Skip to content

Weftra — Complete Setup & Configuration Guide

Using FA day to day? The full feature reference is the User Guide. Running it in production? See the Operations Runbook — systemd services, Cloudflare Access tunnel, health-watcher auto-heal, the self-build loop, and recovery playbook. Browsable docs site: run npm run docs:build to generate a static documentation site from these docs. See docs-site/README.md for build and self-hosting instructions, and the Operations Runbook §"Docs site — publishing" for the deploy runbook (Cloudflare Pages via CI, or self-hosting the static output). This guide covers everything you need to set up Weftra from scratch, including Claude Code configuration, agent permissions, model selection, and integrations.

Tenant/org scope: FA now models an internal tenant/org scope one level above projects — a single default org today, not yet user-configurable. The tenant_id field appears on project reads; multi-org management arrives with a future increment.


Table of Contents

  1. Prerequisites
  2. Installation
  3. Claude Code Setup
  4. Agent Configuration
  5. Permissions & Safety
  6. Model Selection
  7. WhatsApp Notifications (Twilio)
  8. GitHub Integration — incl. PR-merge tracking, manual PR creation, revisions
  9. GitLab Integration — draft MRs, merge detection, revisions on gitlab.com or self-hosted
  10. Bitbucket Cloud Integration — PRs, merge detection, revisions on bitbucket.org
  11. Spec-Kit Pipeline
  12. Workspace Confinement & Data Provisioning
  13. Inbound Triggers — receive webhooks from GitHub/GitLab/Linear/Sentry/Slack
  14. Webhook Configuration
  15. Users & Roles
  16. Protected Projects
  17. Self-Managed Development
  18. Diagnostic Tool
  19. Production Deployment
  20. Troubleshooting

Prerequisites

  • Node.js 18+Download
  • Git — for cloning target repos
  • Claude Code CLI — the autonomous agent engine
  • SSH key on the FA host — FA clones project repositories over SSH using the host machine's SSH key. HTTPS remotes are not supported (FA has no credential model for HTTPS and will reject them at enrollment). Make sure the SSH key on the FA host has read and write access to every enrolled repository. For GitHub this typically means adding the host's public key (~/.ssh/id_ed25519.pub or similar) as a deploy key or to an account with repo access.

Installation

bash
# Clone the repository
git clone https://github.com/scottallan/featureagent.git
cd featureagent

# Install dependencies
npm install

# Create your environment file
cp .env.example .env

Edit .env with your configuration (detailed in sections below).

bash
# Start in development mode (hot-reload)
npm run dev

# Or build for production
npm run build
npm start

Claude Code Setup

Weftra uses the Claude Code CLI to autonomously implement features. You need Claude Code installed and authenticated on the machine running Weftra.

Install Claude Code

bash
# Install globally via npm
npm install -g @anthropic-ai/claude-code

# Verify installation
claude --version

Authenticate

bash
# Interactive login (opens browser)
claude login

# Or set API key directly
export ANTHROPIC_API_KEY=sk-ant-...

Verify It Works

bash
# Quick test — should return a response
claude --print "Say hello"

Custom CLI Path

If Claude Code is installed in a non-standard location:

env
CLAUDE_CLI_PATH=/usr/local/bin/claude

Agent Configuration

Core Settings

env
# How often the agent checks for new work (milliseconds)
AGENT_POLL_INTERVAL_MS=30000        # Default: 30 seconds

# Maximum parallel feature implementations
AGENT_MAX_CONCURRENCY=3             # Default: 1

# Concurrency slots reserved for review/Fixer dispatch (spec-conformance review,
# adversarial security review, designer review, autonomous security fix) that
# build-class dispatch (analyze, implement, revise, retry, spec-kit) may not consume
# (spec 423 / RM-211). Clamped at read time to AGENT_MAX_CONCURRENCY - 1, so the
# documented default (AGENT_MAX_CONCURRENCY=1) always has an EFFECTIVE reservation of
# 0 regardless of this value — a default install is unchanged. See
# docs/OPERATIONS.md §"Reserved review capacity".
REVIEW_RESERVED_SLOTS=1             # Default: 1

# Maximum agent turns per implementation session
AGENT_MAX_TURNS=100                 # Default: 100

# Prior-decisions submission gate (spec 222) — bounds the ONE model call a submission
# may make on the request path. AGENT_MAX_CONCURRENCY does NOT cover this path.
# Hitting any of these bounds raises the "check could not be completed" clarification.
# Each must be a BARE integer in range, or the default is used (fallback, never
# disablement): 'unlimited' would parse to NaN and remove the bound entirely, and
# '30s'/'8 checks' would silently truncate to a number nobody configured.
PRIOR_DECISION_MAX_CONCURRENT=2     # Default: 2   — checks running at once (1–64)
PRIOR_DECISION_QUEUE_LIMIT=8        # Default: 8   — submissions allowed to wait for a slot (0–10000; 0 = never queue)
PRIOR_DECISION_TIMEOUT_MS=120000    # Default: 2 min — wall clock before the request gives up (1000–3600000)

# Idle-output watchdog for the default Claude-CLI engine (spec 247): the Claude
# CLI is killed if it goes this long with NO stdout/stderr output. In stream-json
# mode a single agent tool call (a test suite, an install, a build) is silent for
# its whole duration, so this must comfortably outlast FA's own "run the tests"
# instruction. Malformed/negative values fall back to the default (never disabled).
# This is a host/operator knob only — not project- or feature-settable.
AGENT_IDLE_TIMEOUT_MS=900000        # Default: 900000 (15 min)

# Authoritative tool-call cross-check ceiling (spec 250): a heuristic (the idle
# timer above) may raise a suspicion; only an authoritative signal may terminate.
# When the idle timer fires WHILE a tool call is outstanding (tracked from the
# stream's own tool_use/tool_result events), FA does not kill on the idle bound —
# it re-arms against this larger ceiling instead. Only a SINGLE tool call that
# stays outstanding continuously past this bound, with zero interim output, is
# killed (cause tool_stall). Keep well above AGENT_IDLE_TIMEOUT_MS. Host/operator
# knob only — not project- or feature-settable.
#
# Must be a BARE integer in milliseconds, 1–2147483647; anything else falls back to
# the default (fallback, never disablement — `agentToolStallTimeoutMs`,
# src/config.ts:530, parses it with strictBoundedInt, src/config.ts:214).
# Write 2700000, NOT '45m' or '45 min': a
# suffixed value would truncate to 45 MILLISECONDS, and '0' would arm a 0ms
# deadline — either one turns this ceiling into an instant kill of the long tool
# call it exists to protect, so both are rejected in favour of the default instead.
AGENT_TOOL_STALL_TIMEOUT_MS=2700000 # Default: 2700000 (45 min)

Runtime-tunable without a restart (spec 148 inc-1): AGENT_MAX_CONCURRENCY, AGENT_MAX_RETRIES, AGENT_MODEL, AGENT_MAX_TURNS, PR_CHECK_INTERVAL_MS, and (spec 423) REVIEW_RESERVED_SLOTS can all be overridden live from the admin dashboard (Settings → Runtime Settings) or via GET/PUT /api/admin/settings/runtime — the override takes effect on the very next agent-engine tick, no restart needed. The env vars above remain the fallback when no override is set. AGENT_POLL_INTERVAL_MS is the one exception: it's bound into a timer at startup, so changing it still requires editing .env and restarting FA. See docs/OPERATIONS.md §"Admin Runtime Settings" for the full classification of every config.ts option.

Concurrency

AGENT_MAX_CONCURRENCY controls how many features the agent works on simultaneously. Consider:

  • 1 — Safe default. Features are processed one at a time, in order.
  • 2-3 — Good for machines with ample CPU/RAM. Each implementation clones a repo and runs Claude Code.
  • 5+ — For dedicated servers. Each concurrent job uses significant resources.

REVIEW_RESERVED_SLOTS (default 1, spec 423 / RM-211) reserves that many of those slots for review/Fixer dispatch — implement/analyze/revise/retry/spec-kit dispatch may never consume the reserved lane, so a long-running implement queue can no longer starve the spec-conformance reviewer, the adversarial security reviewer, the designer review, or the autonomous Security Fixer. See docs/OPERATIONS.md §"Reserved review capacity" for the trade-off and the review_capacity_starved ledger signal.

Retry

env
AGENT_MAX_RETRIES=3                  # Default: 3 (set to 0 to disable auto-retry)

When a feature fails, the agent will automatically retry it up to AGENT_MAX_RETRIES times. Each retry resets the feature to queued, clears previous artifacts, and increments retry_count. You can also manually retry via the API (POST /api/features/:id/retry) or the dashboard's Retry button.

Spec-conformance reviewer auto-revise loop

env
REVIEWER_MAX_ROUNDS=2                # Default: 2 (set to 0 to disable auto-revise loop)

When reviewer: spec_conformance is enabled, REVIEWER_MAX_ROUNDS controls how many automatic revise-and-re-review rounds FA drives before escalating to a human. With the default of 2, FA will auto-revise up to twice before leaving the feature at implemented with a standing REQUEST_CHANGES review. Set to 0 to disable the loop entirely (FA posts the verdict once and stops — the original post-and-stop behavior).


How the Agent Works

Understanding what happens when the agent processes a feature helps with debugging, monitoring, and configuring your environment.

The Agent Loop

Weftra runs a polling loop inside the Node.js server process (no separate daemon or container). Every AGENT_POLL_INTERVAL_MS milliseconds (default: 30s), the loop:

  1. Picks up features in analyzing status and runs a single-shot Claude Code call to evaluate them
  2. Picks up features in queued status (up to AGENT_MAX_CONCURRENCY) and starts implementation
  3. Picks up failed features with retry_count < AGENT_MAX_RETRIES and re-queues them

What Happens During Implementation

Each feature job runs through these steps:

1. Clone          git clone --depth 50 -b main <repo_url>
                  into: WORKSPACE_DIR/<feature-id>/

2. Branch         git checkout -b feature/<slug>-<id>

3. Implement      claude --print --model sonnet --max-turns 100 \
                    --dangerously-skip-permissions \
                    "<implementation prompt>"
                  (spawned as a child process in the workspace dir)

4. Test           npm test
                  (runs the project's test suite)

5. Document       claude --print "<doc prompt>"
                  (generates docs/features/<slug>.md)

6. Push           git add -A && git commit && git push -u origin <branch>

7. PR (optional)  Creates a draft PR via GitHub API

8. Cleanup        rm -rf WORKSPACE_DIR/<feature-id>/

The Claude Code CLI is spawned as a child process using Node's child_process.spawn(). It runs with:

  • --print mode (non-interactive, outputs text)
  • --output-format text
  • --model set to your AGENT_MODEL (default: sonnet)
  • --max-turns set to your AGENT_MAX_TURNS (default: 100)
  • --dangerously-skip-permissions if configured
  • An idle-output watchdog: killed after AGENT_IDLE_TIMEOUT_MS (default 15 min) of no stdout/stderr, reset by any output — see AGENT_IDLE_TIMEOUT_MS above and "Monitoring the Agent" below for what an idle kill looks like when it fires. A heuristic may raise a suspicion; only an authoritative signal may terminate: if the idle timer fires while a tool call is outstanding, FA does not kill — it re-arms against the larger AGENT_TOOL_STALL_TIMEOUT_MS ceiling instead (spec 250)
  • Working directory set to the cloned repo

The stdout/stderr from Claude Code is captured and stored in the feature's implementation_log field.

Where Code Lives

PathPurposeLifetime
WORKSPACE_DIR/<feature-id>/Cloned repo for implementationTemporary — deleted after job completes or fails
data/featureagent.dbSQLite database with all features, projects, usersPersistent

Default WORKSPACE_DIR is ./workspaces relative to the Weftra install directory.

Monitoring the Agent

Server logs — The Weftra server logs all agent activity to stdout:

[agent] Starting agent loop (poll: 30000ms, concurrency: 1)
[agent] Processing feature: Build chess game (a1b2c3d4) [1/1 slots]
[agent] Feature implemented: a1b2c3d4 on feature/build-chess-game-a1b2c3d4 (PR: https://...)

Or on failure:

[agent] Implementation failed for a1b2c3d4: Claude Code exited with code 1: ...
[agent] Retrying failed feature: Build chess game (a1b2c3d4) attempt 2/3

An idle-timeout kill (spec 247) is a distinct, legible failure, not a generic exit — the log and run-event ledger say so explicitly instead of leaving it to look like unexplained flakiness:

[claude:a1b2c3d4] Idle timeout (900s no output) — killing process
[agent] Implementation failed for a1b2c3d4: Agent killed after 900s with no agent output (idle timeout) — ...

This means the CLI itself went silent past AGENT_IDLE_TIMEOUT_MS with no tool call outstanding — genuine idle. If a tool call WAS outstanding when the idle timer fired (spec 250), FA does not kill on the idle bound alone: it re-arms against the larger, separate AGENT_TOOL_STALL_TIMEOUT_MS ceiling instead, so a single long-running tool call (a large test suite, an install, a build) is not killed just for being silent. Only a tool call that stays outstanding continuously past THAT ceiling, with zero interim output, is killed — a distinct tool_stall cause:

[claude:a1b2c3d4] Tool stall (2700s no output with a tool call outstanding) — killing process
[agent] Implementation failed for a1b2c3d4: Agent killed after a tool call ran silently for 2700s (tool stall) — ...

If either kill is expected for your project, raise the corresponding env var (AGENT_IDLE_TIMEOUT_MS or AGENT_TOOL_STALL_TIMEOUT_MS) rather than treating it as flakiness. See docs/OPERATIONS.md §4e for the idle_timeout/tool_stall run-failure causes recorded in the ledger.

Dashboard — The header shows active jobs / max concurrency. Feature cards show real-time status, and the detail view shows the full implementation log and test results after completion.

API — Poll the status endpoint and feature detail:

bash
# Agent status (active jobs, model, concurrency)
curl http://localhost:3100/api/status

# Feature detail (status, implementation_log, test_results, retry_count)
curl http://localhost:3100/api/features/:id \
  -H "Authorization: Bearer $API_KEY"

Watch the workspace — While a feature is being implemented, you can watch the workspace directory to see files being created in real-time:

bash
# Watch for new files
watch -n 2 "find ./workspaces -type f | head -30"

# Or tail the workspace for a specific feature
ls ./workspaces/
# Then watch the active one:
watch -n 2 "ls -la ./workspaces/<feature-id>/"

Watch Claude Code process — Since Claude Code runs as a child process, you can see it in your process list:

bash
ps aux | grep claude

Permissions & Safety

This is the most important configuration decision. It determines how much autonomy the Claude Code agent has when implementing features.

env
AGENT_PERMISSIONS=default

In default mode, Claude Code runs with standard permissions. The agent can:

  • Read and write files in the cloned workspace
  • Run commands (npm test, git, etc.)

The agent will prompt for permission on certain operations, which in a headless environment may cause the implementation to stall. For fully autonomous operation, see the next section.

Dangerously Skip Permissions (Full Autonomy)

env
AGENT_PERMISSIONS=dangerously-skip-permissions

WARNING: This flag gives Claude Code unrestricted access to execute any command without confirmation. Only use this when:

  • The agent runs in an isolated environment (container, VM, dedicated server)
  • You trust the feature descriptions being submitted
  • You have proper network isolation (the agent won't access sensitive internal systems)

What --dangerously-skip-permissions enables:

  • File read/write without confirmation
  • Shell command execution without confirmation
  • Package installation without confirmation
  • Any tool use without user approval prompts

Security recommendations when using full autonomy:

  1. Run in a container — Use Docker to isolate the agent:

    bash
    docker run -d \
      -e AGENT_PERMISSIONS=dangerously-skip-permissions \
      -e ANTHROPIC_API_KEY=sk-ant-... \
      -v /var/featureagent/data:/app/data \
      featureagent
  2. Limit network access — The agent only needs to reach:

    • api.anthropic.com (Claude API)
    • github.com (repo clone + push)
    • Notification endpoints: api.telegram.org, discord.com, hooks.slack.com, api.twilio.com, graph.facebook.com, SMTP host (depending on configured channels)
  3. Use fine-grained GitHub tokens — Only grant access to specific repositories the agent should modify. Never use tokens with admin/org-level access.

  4. Restrict workspace directory — Keep WORKSPACE_DIR on a separate partition or volume.

Allowlisted Tools (Future)

Claude Code also supports granular tool permissions via --allowedTools. This is a middle ground:

bash
# Example: allow file ops and bash, but not network
claude --allowedTools "Read,Write,Edit,Bash" --print "..."

This is not yet configurable via Weftra's .env but can be added to the CLI args in src/services/agent-engine.ts.


Model Selection

env
AGENT_MODEL=sonnet                  # Default

Available models:

ModelSettingBest For
Claude Sonnet 4.6sonnetBalanced speed and quality. Good default for most features.
Claude Opus 5opusComplex features requiring deep reasoning. Slower, higher cost.
Claude Haiku 4.5haikuSimple features, fast turnaround. Lower cost.

Recommendations

  • Start with sonnet — Good balance of capability and cost for most features
  • Use opus for — Large architectural changes, complex multi-file features, features requiring deep understanding of the codebase
  • Use haiku for — Simple bug fixes, config changes, documentation updates

Max Turns

env
AGENT_MAX_TURNS=100

Controls how many "turns" (tool calls) the agent can make per implementation. Higher values allow the agent to handle more complex features but increase cost and time.

  • 50 — Simple features (add a config option, small UI change)
  • 100 — Medium features (new API endpoint, component)
  • 200+ — Complex features (new subsystem, large refactor)

Notification Channels

Weftra supports multiple notification channels, configured per-project. Channels are stored in the project's notification_channels JSON array.

Supported Channels

ChannelCostSetupBest For
TelegramFree5 minBest free option. No limits, no verification.
DiscordFree2 minTeams already using Discord.
SlackFree tier5 minTeams already using Slack.
WhatsAppPaid (Twilio) or free tier (Meta)15-60 minDirect mobile notifications.
EmailFree (SMTP)10 minUniversal fallback.

Notification Rules

Each channel has a notify array controlling who receives messages:

Status ChangeDefault Recipient
awaiting_approvalPO
clarification_neededSubmitter (or PO in po_approval mode)
in_progressSubmitter
implementedSubmitter + PO
failedSubmitter + PO

Set notifications_enabled: false on a project to disable all notifications.

  1. Message @BotFather on Telegram
  2. Send /newbot, follow prompts, get your bot token
  3. Message your bot, then visit https://api.telegram.org/bot<TOKEN>/getUpdates to find your chat_id
  4. Configure:
bash
curl -X POST http://localhost:3100/api/projects \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-app",
    "repo_url": "git@github.com:org/my-app.git",
    "autonomy_mode": "auto_safe",
    "notification_channels": [
      {
        "type": "telegram",
        "bot_token": "123456:ABC-DEF1234ghIkl-zyx57W2v",
        "chat_id": "987654321",
        "notify": ["all"]
      }
    ]
  }'

For group notifications, add the bot to a group and use the group's chat_id (negative number).

Discord Setup

  1. In your Discord server: Server Settings → Integrations → Webhooks → New Webhook
  2. Copy the webhook URL
  3. Configure:
json
{
  "type": "discord",
  "webhook_url": "https://discord.com/api/webhooks/123456/abcdef...",
  "notify": ["all"]
}

Discord notifications include rich embeds with color-coded status.

Slack Setup

  1. Create a Slack app at api.slack.com/apps
  2. Enable Incoming Webhooks, add to a channel
  3. Copy the webhook URL
  4. Configure:
json
{
  "type": "slack",
  "webhook_url": "https://hooks.slack.com/services/T00/B00/xxxx",
  "notify": ["all"]
}

WhatsApp Setup

Via Twilio:

json
{
  "type": "whatsapp",
  "provider": "twilio",
  "account_sid": "ACxxxxxxxx",
  "auth_token": "your_token",
  "from_number": "whatsapp:+14155238886",
  "po_number": "whatsapp:+15551234567",
  "submitter_number": "whatsapp:+15559876543",
  "notify": ["all"]
}

Via Meta Cloud API (free tier — 1,000 conversations/month):

json
{
  "type": "whatsapp",
  "provider": "meta",
  "access_token": "EAAxxxxxxx",
  "phone_number_id": "123456789",
  "po_number": "15551234567",
  "submitter_number": "15559876543",
  "notify": ["all"]
}

Email Setup

json
{
  "type": "email",
  "smtp_host": "smtp.gmail.com",
  "smtp_port": 587,
  "smtp_user": "you@gmail.com",
  "smtp_pass": "app-specific-password",
  "from": "featureagent@example.com",
  "po_address": "po@company.com",
  "submitter_address": "dev@company.com",
  "notify": ["all"]
}

For Gmail, use an App Password (not your regular password).

Multiple Channels

You can combine any number of channels per project:

json
"notification_channels": [
  { "type": "telegram", "bot_token": "...", "chat_id": "...", "notify": ["all"] },
  { "type": "discord", "webhook_url": "...", "notify": ["po"] },
  { "type": "email", "smtp_host": "...", "po_address": "...", "notify": ["po", "submitter"] }
]

GitHub Integration

Weftra can automatically create draft pull requests when a feature is implemented.

Setup

  1. Create a GitHub Personal Access Token (or fine-grained token):

    • Go to GitHub → Settings → Developer settings → Personal access tokens
    • For fine-grained tokens, grant access to specific repos with:
      • Contents: Read and write
      • Pull requests: Read and write
  2. Configure:

env
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  1. Enable per-project:
bash
# Via API
curl -X PATCH http://localhost:3100/api/projects/:id \
  -H "Content-Type: application/json" \
  -d '{"auto_create_pr": true}'

# Or via the dashboard when enrolling/editing a project

What the Agent Creates

When a feature is successfully implemented with auto_create_pr enabled:

  • A draft pull request is created (not ready for review)
  • PR title: feat: <feature title>
  • PR body includes: feature description, test results, doc path, agent attribution
  • The PR targets the project's default_branch (or the feature's base_branch override)

Draft PRs allow your team to review, request changes, or merge at their discretion.

PR-Merge & Review Tracking

With GITHUB_TOKEN set, the agent polls open PRs for implemented features each tick (up to 5 at a time, throttled per-feature by PR_CHECK_INTERVAL_MS, default 10 min):

  • Merged PR → the feature auto-transitions to merged (a terminal state).
  • Review comments → the count is recorded and surfaced in the dashboard as a ! next to the PR, so you know there's feedback to address.

Polling is per-project: a project with its own configured VCS credential (see below) is still polled even when the operator hasn't set GITHUB_TOKEN. A project with neither fails soft (that feature is skipped for the tick) rather than disabling polling for everyone.

GITHUB_TOKEN is read once at startup. After rotating the token, restart the server or PR creation/polling will keep using the old (expired) token.

Project-level VCS credentials

By default every project reaches its repo with the operator's own GITHUB_TOKEN / GITLAB_TOKEN / BITBUCKET_TOKEN above. If a project's repository lives under a different GitHub/GitLab/Bitbucket account or organization than the one that token reaches, an admin can give that project its own narrowly-scoped credential instead of widening the operator's token. This is configured through the admin tenant-credentials API — see docs/OPERATIONS.md § "Project-level VCS credentials (spec 215)" for how to set, view, and revert it, and the isolation/authorization guarantees. There is no dashboard UI for this yet; unconfigured projects keep working unchanged (operator-default fallback).

Two things to check in that section before relying on this:

  • A secret backend must be wired first. A project credential is stored as an opaque ref, and no adapter shipped with FA can dereference one (the AWS Secrets Manager adapter throws not yet implemented; the broker registry is per-process and nothing populates it in production). Until one is wired the PUT rejects with 400 and stores nothing, so the operator-default fallback is the only working configuration.
  • Coverage residuals. The Security Fixer's, the spec-kit flows', and the environment- scaffold inventory clone all run host git and reach the repo with the host's ambient git/SSH identity, not the project credential — and under RUNTIME=local (the dev opt-out) so do the implement and revise flows' git operations. Even under RUNTIME=docker, the sandboxed path only offers the resolved credential for a repo host it actually routes (github.com, gitlab.com/GITLAB_HOST, bitbucket.org) — a self-hosted/unrecognized host clones tokenlessly (works only for a public repo) rather than being handed the wrong credential.

Run the provision-check once repo_url and any project credential are set. The POST /api/projects/:id/env/check dry-run (docs/USER_GUIDE.md § "Dry-run before you pay: the provision-check") clones the repository through this same credential ladder before it runs a declared setup_command/verify_command (RM-262 c) — so it's the fastest way to confirm the credential you just configured actually reaches the repo, before the first real feature run depends on it. If the clone fails, the check reports setup/verify as NOT verified rather than a false pass.

Manual PR Creation

If the automatic PR creation failed (e.g. an expired token at implementation time), the branch is already pushed — recover with a pure API call once the token is fixed:

bash
curl -X POST http://localhost:3100/api/features/:id/create-pr \
  -H "Authorization: Bearer fa_your_api_key"

This opens a draft PR for an implemented feature that has a branch_name but no pr_url. Failures surface as 502 (unlike the auto-flow, which swallows errors).

Addressing Review Comments (Revisions)

After a PR is reviewed, nudge the agent to apply the requested changes on the same branch:

bash
curl -X POST http://localhost:3100/api/features/:id/revise \
  -H "Authorization: Bearer fa_your_api_key"

This flips the feature to revising. The agent fetches the PR's review feedback (review summaries + inline code comments + conversation comments), re-clones the branch, applies the changes, runs tests, and pushes to the same branch — updating the existing PR (no new PR). It returns to implemented. If there are no comments, or on error, it reverts to implemented with the PR left intact.


GitLab Integration

Weftra supports GitLab-hosted projects natively. When a project's repo_url points to gitlab.com (or a self-hosted GitLab instance), FA automatically creates draft Merge Requests, polls for merges, surfaces MR review comments, and posts spec-conformance reviews — all without any per-project configuration beyond the credentials below.

Setup

  1. Create a GitLab personal access token (or a project access token) with at minimum:

    • api scope (needed for MR creation, notes, and approvals)
  2. Configure:

env
GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
  1. For self-hosted GitLab (optional):
env
# Hostname only — no https:// prefix, no trailing slash
GITLAB_HOST=gitlab.mycompany.com

When GITLAB_HOST is set, any project whose repo_url matches that hostname is routed to the GitLab provider automatically.

How provider selection works

FA selects the VCS provider solely from the hostname in the project's repo_url (or pr_url). No per-project configuration is required:

Host in URLProvider used
github.comGitHub (Octokit, GITHUB_TOKEN)
gitlab.comGitLab (REST v4, GITLAB_TOKEN)
value of GITLAB_HOSTGitLab (REST v4, GITLAB_TOKEN)
anything elseGitHub (backward-compatible default)

What FA creates on GitLab

When a feature is implemented with auto_create_pr enabled on a GitLab-hosted project:

  • A draft Merge Request is created (Draft: feat: <title>)
  • MR description includes: feature description, test results, doc path, agent attribution
  • The MR targets the project's default_branch (or the feature's base_branch override)

MR-Merge & Review Tracking

With GITLAB_TOKEN set, FA polls open MRs for implemented features (same cadence as GitHub: up to 5 at a time, throttled per-feature by PR_CHECK_INTERVAL_MS, default 10 min):

  • Merged MR → feature auto-transitions to merged.
  • Review comments → count is recorded and surfaced in the dashboard as a ! next to the MR link.

Manual MR Creation & Revisions

The same /create-pr and /revise endpoints work for GitLab MRs:

bash
# Recover a failed auto-MR
curl -X POST http://localhost:3100/api/features/:id/create-pr \
  -H "Authorization: Bearer fa_your_api_key"

# Address MR review comments
curl -X POST http://localhost:3100/api/features/:id/revise \
  -H "Authorization: Bearer fa_your_api_key"

GITLAB_TOKEN is read once at startup. After rotating the token, restart the server.


Bitbucket Cloud Integration

Weftra supports Bitbucket Cloud (bitbucket.org) natively. When a project's repo_url points to bitbucket.org, FA automatically creates PRs, polls for merges, surfaces PR review comments, and posts spec-conformance reviews — all without any per-project configuration beyond the credentials below.

Bitbucket Server / Data Center is NOT supported. These products use a different REST API (v1.0, /rest/api/1.0) and are out of scope. Only bitbucket.org (Bitbucket Cloud) is supported.

Setup

Option A — App password (most common)

App passwords are per-user credentials that scope permissions without sharing your account password.

  1. Go to Bitbucket Cloud → Personal settings → App passwords → Create app password
  2. Name it (e.g. feature-agent) and grant these scopes:
    • Repositories: Read
    • Pull requests: Write
  3. Copy the generated password (shown once).
  4. Configure:
env
BITBUCKET_TOKEN=your-app-password
BITBUCKET_USERNAME=your-bitbucket-username

When BITBUCKET_USERNAME is set, FA uses HTTP Basic auth (username:app-password) — the standard Bitbucket Cloud app-password flow.

Option B — Repository or workspace access token

If your Bitbucket workspace has workspace/repository access tokens enabled (Bitbucket Cloud Premium):

  1. Go to Workspace settings → Security → Access tokens (or Repository settings → Access tokens)
  2. Create a token with repository:read and pullrequest:write scopes.
  3. Configure:
env
BITBUCKET_TOKEN=your-access-token
# BITBUCKET_USERNAME — leave unset when using a workspace/repo access token

Without BITBUCKET_USERNAME, FA sends the token as Authorization: Bearer <token>.

How provider selection works

FA selects the VCS provider solely from the hostname in the project's repo_url (or pr_url). No per-project configuration is required:

Host in URLProvider used
github.comGitHub (Octokit, GITHUB_TOKEN)
gitlab.comGitLab (REST v4, GITLAB_TOKEN)
value of GITLAB_HOSTGitLab (REST v4, GITLAB_TOKEN)
bitbucket.orgBitbucket Cloud (REST v2.0, BITBUCKET_TOKEN)
anything elseGitHub (backward-compatible default)

Both HTTPS and SSH repo URLs are recognized:

  • https://bitbucket.org/myworkspace/myrepo.git
  • git@bitbucket.org:myworkspace/myrepo.git

What FA creates on Bitbucket

When a feature is implemented with auto_create_pr enabled on a Bitbucket-hosted project:

  • A PR is created with the title Draft: feat: <title>
  • PR description includes: feature description, test results, doc path, agent attribution
  • The PR targets the project's default_branch (or the feature's base_branch override)

Draft PR limitation: Bitbucket Cloud's REST API does not expose a native draft PR flag. FA prefixes the title with Draft: to signal intent — the same approach used for GitLab. Merge or close the PR as usual; FA detects the merge via polling.

PR-Merge & Review Tracking

With BITBUCKET_TOKEN set, FA polls open PRs for implemented features (same cadence as GitHub/GitLab: up to 5 at a time, throttled per-feature by PR_CHECK_INTERVAL_MS, default 10 min):

  • Merged PR → feature auto-transitions to merged.
  • Review comments → count is recorded and surfaced in the dashboard as a ! next to the PR link.

Manual PR Creation & Revisions

The same /create-pr and /revise endpoints work for Bitbucket PRs:

bash
# Recover a failed auto-PR
curl -X POST http://localhost:3100/api/features/:id/create-pr \
  -H "Authorization: Bearer fa_your_api_key"

# Address PR review comments
curl -X POST http://localhost:3100/api/features/:id/revise \
  -H "Authorization: Bearer fa_your_api_key"

BITBUCKET_TOKEN and BITBUCKET_USERNAME are read once at startup. After rotating credentials, restart the server.


MCP Server (stdio Interface)

Weftra ships a local stdio MCP server so any MCP-capable client (Claude Desktop, Claude Code, etc.) can submit and track features directly, with FA acting as the governed backend — the client supplies the intent, FA governs the implementation (analyze → clarify → approve → gate → PR).

This is a different feature from the mcp: field documented in USER_GUIDE.md § Configuring a Project — that field grants the agent extra tools inside its own sandbox. The MCP server described here runs the other direction: it lets an external MCP client call FA's API.

What it is

src/tools/mcp-server.ts is a thin protocol adapter: every MCP tool call it exposes is a plain HTTP request to FA's existing project-key routes (Authorization: Bearer <project key>), so the same auth middleware, validation, and forbidden-fields guards that gate every other tenant request also gate MCP tool calls. It never touches the database directly and never reads ADMIN_API_KEY — a project API key is the only credential it ever holds.

Running it

bash
FA_BASE_URL=http://127.0.0.1:3100 \
FA_PROJECT_API_KEY=fa_your_project_key \
npm run mcp-server
VariableDefaultPurpose
FA_BASE_URLhttp://127.0.0.1:3100Base URL of the running FA instance the adapter calls
FA_PROJECT_API_KEY(required)A tenant fa_... project API key — see USER_GUIDE.md's "Authentication & API Keys" section

Both are read only from the process environment the MCP client launches the server with — there is no config file and no fallback to any FA-host secret. If FA_PROJECT_API_KEY is missing, the process prints an actionable error to stderr and exits immediately rather than starting an unauthenticated server.

Connecting a client (Claude Desktop example)

json
{
  "mcpServers": {
    "feature-agent": {
      "command": "npm",
      "args": ["run", "mcp-server", "--prefix", "/path/to/featureagent"],
      "env": {
        "FA_BASE_URL": "http://127.0.0.1:3100",
        "FA_PROJECT_API_KEY": "fa_your_project_key"
      }
    }
  }
}

See USER_GUIDE.md § MCP Server Interface for the full tool reference and governance notes.


MCP Server (Remote / Streamable HTTP Interface)

Spec 167 inc-2 adds a network transport for the exact same tools the stdio server above exposes — for MCP clients that connect over HTTP (ChatGPT/Copilot/Claude-web connectors, or any Streamable HTTP MCP client) instead of spawning a local process. It is off by default.

What it is (and isn't)

src/routes/mcp-http.ts connects createFeatureAgentMcpServer — the SAME builder the stdio server uses, byte-identical tools/schemas — to the MCP SDK's Streamable HTTP server transport instead of stdio. Nothing about the tool set changes; only the wire transport and how the credential arrives.

  • Auth is per-request, not global. Each HTTP request carries its own Authorization: Bearer fa_... project key, validated by requireProjectAuth — the same guard every other tenant HTTP route in FA uses (no separate/weaker check). A missing or invalid key is rejected with 401 before any MCP server object is even constructed.
  • No shared state across connections. A fresh McpServer + transport is built for every request (stateless Streamable HTTP mode), scoped to that request's key only. Two clients with different project keys never share any process-global state — each acts strictly as its own tenant.
  • No admin surface, ever. The only routes reachable through this endpoint are the same project-scoped tools as the stdio server (draft_spec, clarify_spec, refine_spec, estimate_spec, submit_feature, get_feature_status, list_features, list_projects, get_clarifications, answer_clarification, await_feature — see docs/USER_GUIDE.md § "MCP Server Interface" for the full reference), never the Builder's /admin/* route variants. There is no path from this endpoint to any admin-guarded route or ADMIN_API_KEY — enforced both by the tool set and by an allowlist of exactly those routes inside the shared callFA helper (assertAllowedPath, src/tools/mcp-server.ts). The allowlist replaced an /api/admin-prefix denylist, which could only ever cover admin routes that carry that prefix — FA's Builder router hosts requireAdminAuth routes that do not (/api/builder/admin/*, /api/builder/new-project).
  • The spec-drafting tools are rate-limited. draft_spec, clarify_spec and refine_spec each cost one model call on the operator's own credential, attributed to no feature budget, so each is capped per project and instance-wide per rolling window — see docs/USER_GUIDE.md § "Bounded model spend". estimate_spec makes no model call and is not capped.
  • Off by default. With FA_MCP_HTTP_ENABLED unset, POST /api/mcp (and GET/DELETE) fall through to Express's own default 404 — byte-identical to the route not existing, regardless of credential.
  • Refuses to serve if admin would be open. If this FA instance is exposed with ADMIN_API_KEY unset and FA_ALLOW_OPEN_ADMIN=1, every request to this endpoint gets 503 instead — set ADMIN_API_KEY (recommended) or unset FA_ALLOW_OPEN_ADMIN to bring it back. See docs/OPERATIONS.md §13 for why.

Enabling it

bash
FA_MCP_HTTP_ENABLED=true

Set in the environment (or .env in file mode) before starting FA. No other flag is required for a same-host client — the endpoint is POST http://<host>:<port>/api/mcp.

VariableDefaultPurpose
FA_MCP_HTTP_ENABLEDunset (disabled)Opt-in flag. Set to true to mount the endpoint.
FA_MCP_HTTP_BASE_URLthis process's own bind address, e.g. http://127.0.0.1:<PORT>Base URL the handler calls back into for every tool (the same round-trip the stdio server makes to an external FA_BASE_URL). FA infers this from FA_BIND/HOST when unset (loopback/0.0.0.0127.0.0.1; a specific bind address → that address), so you only need to set it if FA sits behind a reverse proxy that remaps the port FA itself listens on.

Connecting a client

Any Streamable HTTP MCP client works — point it at https://<your-fa-host>/api/mcp with header Authorization: Bearer fa_your_project_key. Example using the TypeScript SDK:

ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const transport = new StreamableHTTPClientTransport(new URL('https://your-fa-host/api/mcp'), {
  requestInit: { headers: { Authorization: 'Bearer fa_your_project_key' } },
});
const client = new Client({ name: 'my-client', version: '1.0.0' });
await client.connect(transport);

See docs/OPERATIONS.md § MCP HTTP front door — production bind/security before exposing this beyond a trusted network, and USER_GUIDE.md § MCP Server Interface for the tool reference (identical for both transports).


MCP Client OAuth (spec 199 inc-1)

Lets an MCP client connect to the remote HTTP interface above via a browser consent flow instead of a human pasting a raw fa_... project key into client config. This is convenience, not capability — the minted access token's blast radius is EXACTLY a raw project key: same tool set, same requireProjectAuth scope, same forbidden-fields guard. It is off by default, independent of FA_MCP_HTTP_ENABLED (though you'll normally enable both).

Enabling it

bash
FA_MCP_OAUTH_ENABLED=true
FA_MCP_OAUTH_REDIRECT_ALLOWLIST=https://claude.ai/api/mcp/auth_callback,https://chatgpt.com/connector_platform_oauth_redirect
APP_BASE_URL=https://your-fa-host   # or set app_base_url in the admin portal — same setting SMTP reset links use
VariableDefaultPurpose
FA_MCP_OAUTH_ENABLEDunset (disabled)Opt-in flag and kill-switch. Set to true to mount the OAuth endpoints (404 otherwise, identical to the route not existing). Setting it back to false also immediately rejects every already-minted access token at the MCP front door — disabling the feature disables the whole credential kind, not just the mint/revoke endpoints. Token records stay listable and revocable through the admin token registry (below) while the flag is off.
FA_MCP_OAUTH_REDIRECT_ALLOWLISTunset (empty — no client can register)Comma-separated exact redirect URIs your MCP clients will use. A client can only register a redirect_uri that appears here verbatim — never derived from a request, and re-checked against the live list at every step of the flow (authorize, consent decision, and token exchange), so removing an entry disables it immediately, including for clients that registered it earlier. Add the redirect URI your MCP client's own documentation specifies (e.g. Claude/ChatGPT/Copilot's connector callback URL) — consult that client's docs for the exact value; FA does not special-case any client by name.
FA_MCP_OAUTH_TOKEN_TTL_SEC3600 (1 hour)Minted access token lifetime. Bounded 1–86400s. Tokens remain individually revocable regardless of TTL.
FA_MCP_OAUTH_CODE_TTL_SEC60Authorization code lifetime — short by design (single-use, exchanged immediately after the browser redirect). Bounded 1–600s.
FA_MCP_OAUTH_MAX_CLIENTS100Hard cap on total registered OAuth clients — a storage bound, not a lockout (see the note below on reclamation). Registration is unauthenticated by protocol design (an MCP client registers before it holds any credential), so this bounds database growth from anonymous registrations (registration is also rate-limited, keyed on the direct TCP peer address — never X-Forwarded-For, which a caller supplies and could rotate to buy fresh buckets; behind a reverse proxy that means one shared bucket for all proxied traffic, so raise this cap rather than relying on the rate limit alone). Raise it if you legitimately connect more clients simultaneously.
FA_MCP_OAUTH_CLIENT_TTL_SEC2592000 (30 days)How long a registered client row survives with no activity before FA reclaims it. Measured from last use (a code issued or token minted through it), not from registration, so a client that keeps connecting is never swept. Bounded 1–31536000s.
FA_MCP_OAUTH_REGISTER_WINDOW_MS60000 (60s)The registration rate limiter's window, in milliseconds. A malformed or out-of-range value (non-numeric, or below 1000ms — e.g. a units typo like 60s, which parseInts to 60, a 60-millisecond window) falls back to the default rather than silently disabling the limiter. Bounded 1000–3600000ms.
FA_MCP_OAUTH_REGISTER_MAX_PER_WINDOW10Max registrations accepted per peer address per window. A malformed value (e.g. unlimited, which parseInts to NaN) falls back to the default rather than silently disabling the limiter. Bounded 1–10000.
APP_BASE_URL / admin portal app_base_urlhttp://localhost:<PORT>The externally-reachable URL this instance publishes in its OAuth discovery metadata (issuer, authorization_endpoint, etc.) — set this to your real public hostname before enabling this feature beyond local testing.

FA_MCP_OAUTH_ENABLED and FA_MCP_OAUTH_REDIRECT_ALLOWLIST are read fresh from the process environment on every use (src/config.ts exposes both as getters, like AGENT_MAX_CONCURRENCY), which is what makes "immediately" above literal rather than aspirational — the change lands on the very next request, with no restart and no already-minted token surviving it. Note what "the process environment" means: the environment the running FA process holds. Editing .env alone does not reach it (.env is read once at startup), so an incident-response flip means changing the variable wherever FA's environment comes from (systemd unit, container/orchestrator env, shell export) — and if that source is .env, restarting. The other numeric settings above (…_TOKEN_TTL_SEC, …_CODE_TTL_SEC, …_MAX_CLIENTS, …_CLIENT_TTL_SEC, …_REGISTER_WINDOW_MS, …_REGISTER_MAX_PER_WINDOW) are startup-read and always need a restart.

The client cap does not brick registration. A cap with nothing reclaiming it would be a one-way ratchet: once anonymous registrations (or just ordinary clients re-registering per install — RFC 7591 registrations are not deduped) filled the table, every later caller would get too_many_clients forever and the OAuth front door would be dead until you hand-edited SQLite. So FA reclaims client rows on each registration: rows idle past FA_MCP_OAUTH_CLIENT_TTL_SEC are swept regardless of whether they were ever used, and if the table is still at the cap, the oldest clients that have never completed a single flow (no authorization code or token was ever issued through them) are evicted to make room. A client that has ever been used — even once, even if what it held has since expired — is never evicted by that make-room path, so an unauthenticated registration flood cannot cost a previously-consented client its registration; it can only ever displace other rows nothing was ever done with. too_many_clients is therefore returned only when every stored client has completed at least one authorization — a real capacity limit, which you raise with FA_MCP_OAUTH_MAX_CLIENTS. To see and manage that table directly, use GET /api/admin/mcp-oauth/clients (client_id, name, redirect URIs, last use, live token count) and DELETE /api/admin/mcp-oauth/clients/:clientId (removes the client and cascades away its codes and tokens). Both work while FA_MCP_OAUTH_ENABLED=false, like the token registry.

Like the MCP HTTP front door itself, this whole surface refuses to serve (503) whenever the admin surface would be open to a network caller (ADMIN_API_KEY unset + exposed bind + FA_ALLOW_OPEN_ADMIN=1) — the same fail-closed gate, extended to every new endpoint below.

The flow

  1. The MCP client fetches GET /.well-known/oauth-authorization-server and GET /.well-known/oauth-protected-resource to discover FA's endpoints (RFC 8414 / RFC 9728).
  2. It registers itself via POST /oauth/mcp/register with its redirect_uris (rejected outright if any aren't on your allowlist above) — a public client, no client_secret (PKCE, S256 only, is the sole proof-of-possession; there is no plain-PKCE or implicit-grant fallback).
  3. It opens the human's browser at GET /oauth/mcp/authorize with a PKCE challenge. The human signs in to FA (if not already) and picks which ONE project to bind the connection to, then approves or denies. Only an admin may approve — the minted token is a project tenant credential, which only admins control today; an approver signing in sees an explicit "admin required" refusal (letting an approver mint one would grant them credential authority they don't otherwise hold, and would let them approve features they themselves submitted over MCP). The consent screen shows the client_id and the exact redirect_uri the authorization code will be delivered to, alongside the client's self-declared, unverified name — anyone can register a client under any name (step 2 is unauthenticated by protocol design), so treat the destination URI, not the name, as the identity you are approving. Deny anything whose destination you don't recognise.
  4. On approval, the browser is redirected back to the client's (already-vetted) redirect_uri with a single-use authorization code.
  5. The client exchanges that code — with its PKCE verifier — at POST /oauth/mcp/token for a short-lived access token scoped to that one project.
  6. The client calls POST /api/mcp with Authorization: Bearer <access_token> exactly as it would with a pasted fa_... key — src/routes/mcp-http.ts accepts either credential kind and resolves to the identical project scope.
  7. POST /oauth/mcp/revoke invalidates a token immediately (single call, idempotent — always 200, per RFC 7009, so it can never be used to probe token validity).

Token lifetime is bound to what authorized it

Beyond its own TTL and revocation state, a minted token is re-checked live on every use and stops working the moment any of these change: the feature flag is turned off (FA_MCP_OAUTH_ENABLED=false), the authorizing admin is deleted or demoted, or the project's api_key is rotated (rotation is your universal "invalidate every credential for this project" action — it covers OAuth tokens too).

Admin registries (incident response)

POST /oauth/mcp/revoke requires possessing the raw token — which an operator responding to a compromised client does not have. The admin registries close that gap:

  • GET /api/admin/mcp-oauth/tokens — every minted token's metadata (project, client, authorizing user, issued/expires/revoked timestamps) plus its stored SHA-256 token_hash. The raw bearer value is never stored, so it can never appear here.
  • POST /api/admin/mcp-oauth/tokens/:tokenHash/revoke — revoke by that hash, no raw token needed.
  • GET /api/admin/mcp-oauth/clients — every registered client: client_id, self-declared name, redirect URIs, when it was registered, when it was last used, and how many live tokens were minted through it. Client registration is unauthenticated by protocol design, so this is how you see what is actually in that table (automatic reclamation of unused rows is described above under FA_MCP_OAUTH_MAX_CLIENTS).
  • DELETE /api/admin/mcp-oauth/clients/:clientId — remove one client now, cascading away its authorization codes and access tokens.

All are admin-authenticated and stay available while FA_MCP_OAUTH_ENABLED=false — you can flip the kill-switch first and still enumerate/revoke afterwards.

See USER_GUIDE.md § Connecting by OAuth instead of a pasted key for the end-user walkthrough, and docs/OPERATIONS.md §4a / the Trust Boundary core law in CLAUDE.md for why every invariant here (PKCE-mandatory, allowlisted-never-reflected redirect_uri, tenant-scoped tokens, fail-closed admin gate) is non-negotiable.


Spec-Kit Pipeline

Weftra integrates with GitHub Spec Kit for spec-driven development, enabled per project.

Enrolling a Project

bash
# Clone, run `specify init --ai claude`, generate a constitution, open a bootstrap PR
curl -X POST http://localhost:3100/api/projects/:id/spec-kit/enable

Status flows disabled → enrolling → awaiting_merge. Watch live enrollment logs:

bash
curl http://localhost:3100/api/projects/:id/spec-kit/logs
# or in the dashboard log viewer: ?id=enroll-<projectId>

After merging the bootstrap PR, finalize:

bash
curl -X POST http://localhost:3100/api/projects/:id/spec-kit/mark-enabled   # → 'enabled'

Already have Spec Kit configured? Skip enrollment — detection promotes the project straight to enabled if .specify/ and a constitution both exist:

bash
curl -X POST http://localhost:3100/api/projects/:id/spec-kit/check

To turn it off: POST /api/projects/:id/spec-kit/disable.

Running a Feature Through Spec-Kit

Submit a feature with "use_spec_kit": true (silently coerced to false if the project isn't enabled). The agent runs the pipeline in order:

/speckit.specify → /speckit.clarify → /speckit.plan → /speckit.tasks → /speckit.analyze → /speckit.implement

It pauses at clarification gates and resumes from the saved spec/plan/task files once the questions are answered — the same clarification flow as auto_safe mode.


Workspace Confinement & Data Provisioning

Each feature runs in an isolated workspace (workspaces/<feature_id>/), and the agent is instructed to stay inside it and ignore absolute paths in repo docs. To give features the external data they legitimately need without letting the agent reach outside its workspace, declare it on the project:

bash
curl -X PATCH http://localhost:3100/api/projects/:id \
  -H "Content-Type: application/json" \
  -d '{
    "data_dirs": ["/data/signal_cache", {"src": "/data/fixtures", "dest": "test/fixtures"}],
    "setup_command": "npm ci"
  }'

After cloning each workspace, the agent (provisionWorkspace()):

  1. Validates data_dirs exist (step one). Every declared source must exist on the agent host; a missing one fails the run up front with an actionable error instead of silently continuing.
  2. Runs git lfs pull (best-effort — fine if the repo has no LFS).
  3. Symlinks each data_dirs entry read-only into the workspace and adds it to .git/info/exclude so it's never committed (path-traversal guarded).
  4. Runs setup_command in the workspace (a non-zero exit fails the run).

data_dirs entries are either a path string ("/abs/src" — destination defaults to the basename) or { "src": "/abs/src", "dest": "rel/path" }.


Agent Factory — Rejected-Class Cooldown (spec 435, RM-250)

A recommend-step worker's propose_recommendation calls can optionally name a finding_class so FA refuses a re-proposal of a class already decided on the same deployment (see docs/USER_GUIDE.md § "Earning autonomy" for the full behaviour). An accepted/superseded/ effect_observed/outcome_linked class is refused for good; a rejected class is refused only for a cooldown window, since an operator's "no" is to the case as made, not forever:

bash
FA_WORKER_REJECTED_CLASS_COOLDOWN_HOURS=168
VariableDefaultMeaning
FA_WORKER_REJECTED_CLASS_COOLDOWN_HOURS168 (7 days)How long a rejected finding_class stays refused after the rejection. Bounded 0–8760. 0 disables the cooldown entirely — a rejected class becomes proposable again immediately. Operator-only: no project or feature field can raise or lower it.

External Artifact Connectors (spec 374, RM-149)

A project may declare wiki connectors so the Product Definition Plane can pull pages in as versioned, hashed, redacted source snapshots (docs/USER_GUIDE.md § "Bring your wiki in"). This is read-only and runs no model; it is unrelated to data_dirs above except in spirit (both are declarative config for external resources FA reaches on the project's behalf, with the project's own credential).

1. Set up a secret backend first. Same residual as the VCS credential section above: a connector credential is stored as an opaque ref, and no adapter shipped with FA can dereference a byo ref out of the box (the AWS Secrets Manager adapter throws not yet implemented). A brokered ref works once something registers it into the broker registry for that tenant (registerBrokerSecret — currently a programmatic/test seam, not an operator UI). Until a backend is wired, PUT .../connector-credentials/:name rejects with 400 and stores nothing (the dereference preflight — see below).

2. Register a named credential per connector (admin only):

bash
curl -X PUT http://localhost:3100/api/tenants/projects/:projectId/connector-credentials/wiki-cred \
  -H "Content-Type: application/json" \
  -d '{"provider": "brokered", "authMode": "api", "ref": "fa-broker:default:wiki-token-1", "host": "wiki.example.com"}'

Unlike the single VCS credential per project, a project may hold several named connector credentials — each connector's credential_ref names one by this key. authMode must be api (a bearer token); the write is dereference-preflighted (a ref this instance cannot resolve is refused, never stored — same discipline as the VCS credential PUT).

host is required, and it is what keeps this token yours. It names the ONE hostname this credential may ever be sent to. base_url on the connector is project-key self-configurable, so without this binding a tenant could point a connector at a host it controls and FA would deliver your admin-registered token there in an Authorization: Bearer header. The host is compared against the connector's base_url host when the connector is declared (setProjectConnectors, src/models/product-connectors.ts) and again immediately before the token is dereferenced at ingest time (runIngest, src/services/product-ingest/runner.ts) — both through checkConnectorCredentialForHost (src/models/product-connector-credentials.ts), which compares for exact equality and refuses a credential with no declared host. Repointing base_url at a different host is a 400 until an admin registers a credential for that host.

3. Declare the connector on the project (project-key self-configurable, like data_dirs/servicesPATCH /api/project or admin's PATCH /api/projects/:id):

bash
curl -X PATCH http://localhost:3100/api/project \
  -H "Authorization: Bearer fa_your_project_key" -H "Content-Type: application/json" \
  -d '{
    "connectors": [{
      "id": "wiki",
      "kind": "wiki-markdown",
      "base_url": "https://wiki.example.com",
      "credential_ref": "wiki-cred",
      "allow_paths": ["/docs/"],
      "max_pages": 200,
      "max_bytes_per_page": 524288
    }]
  }'

kind is wiki-markdown only today — an EXCHANGE SHAPE ("an HTTPS endpoint returning a page as Markdown/HTML, plus links"), never a vendor name; there is no if confluence branch anywhere in FA. base_url must be https:// and a real hostname (an IP literal is rejected). max_pages (default 200, hard cap 2000) and max_bytes_per_page (default 512 KB, hard cap 4 MB) cannot be exceeded by configuration.

4. Ingest (admin/approver only):

bash
curl -X POST http://localhost:3100/api/projects/:id/product/connectors/wiki/ingest \
  -H "Authorization: Bearer fa_admin_or_approver_key"

Runs as a DockerRuntime job (requires RUNTIME=docker) whose network egress is bound to exactly the one IPv4 address the connector's host resolves to at run start. FA resolves the host once through the spec-208 egress guard (assertConnectorTargetAllowed, src/services/product-ingest/egress-pin.tscheckEgressTarget, src/utils/egress-guard.ts), refuses the run outright if ANY answer is loopback / RFC1918 / link-local (including 169.254.169.254) / CGNAT, and puts that ADDRESS — not the name — in the sandbox's egress allowlist and in the CONNECT target the in-sandbox fetcher asks for, so nothing on the path resolves the name a second time. TLS still validates against the real hostname (servername/Host in src/services/product-ingest/sandbox-fetch.ts). A host that resolves only to IPv6 is refused rather than dialed by name. The operator's own bound applies first — see FA_RUNTIME_EGRESS/ FA_RUNTIME_EGRESS_ALLOWLIST below: if the operator has armed FA_RUNTIME_EGRESS=allowlist instance-wide, the connector's host must ALSO be in that global allowlist or the ingest refuses to run (fail-closed — the ingest is never granted egress the operator's own posture never authorized). With the operator default (FA_RUNTIME_EGRESS=open), no extra step is needed: the connector's own host is what the run is scoped to regardless. FA_INGEST_TIMEOUT_MS (default 10 min) bounds the whole run's wall-clock; an aggregate 64 MiB ceiling (MAX_INGEST_TOTAL_BYTES, src/services/product-ingest/connector-schema.ts) bounds what one run may collect and what FA will read back from the workspace, and a per-project ceiling of 5000 snapshot rows (MAX_PRODUCT_SOURCE_ROWS_PER_PROJECT, src/models/product-sources.ts) bounds what the store may grow to — a run that reaches either stops with truncated: true.


Declared Gate Commands (gates: in .fa/environment.yml)

Declare an ordered list of shell commands in the committed .fa/environment.yml that FA runs inside the sandbox after implementation, after tests, and after the verify step — and before creating any commit or PR. FA reads only the exit code and captured output; it never interprets what the command does (§VII).

yaml
# .fa/environment.yml
image: node:20
test: npm test

gates:
  # Blocking gate (default: block: true) — non-zero exit fails the run,
  # no commit pushed, no PR opened. Workspace preserved for inspection.
  - name: lint
    command: npm run lint

  # Advisory gate — non-zero exit recorded as a warning, run continues.
  - name: advisory-scan
    command: ./my-scanner.sh
    block: false

Gate execution:

  • FA runs each gate via bash -lc "<command> 2>&1" inside the sandbox Runtime (the same isolated environment the agent used). If RUNTIME=docker, gates run inside the container.
  • Gates run in order. All gates execute (even after a blocking failure) so the full picture is captured before failing.
  • A failing blocking gate triggers an agent-fix iterate loop (up to FA_MAX_GATE_ITERATIONS cycles, default 2): FA re-invokes the agent with the failing gate's output, then re-runs all declared gates for a fresh judgement. If all blocking gates pass within the cap, the run proceeds to commit/PR/implemented. If still failing after the cap, the feature transitions to failed and the workspace is preserved. No commit is pushed and no PR is created.
  • Set FA_MAX_GATE_ITERATIONS=0 to disable the iterate loop and revert to immediate failure on the first blocking gate failure (byte-identical to pre-iterate behaviour).
  • Advisory gate failures (block: false) are recorded as warn but do not block and never trigger iteration.

Gate shape:

FieldTypeRequiredDefaultDescription
namestringyesNon-empty label used in logs, run-events, and the provenance artifact.
commandstringyesShell command to run. FA runs it and reads only the exit code.
blockbooleannotruetrue = blocking (fail the run on non-zero); false = advisory (warn, continue).

Unknown keys or malformed gate entries throw ManifestValidationError — the run aborts before any agent spend.

Observability: every gate result is recorded as a gate_result run-event (visible in the dashboard's run-events timeline) and committed into the provenance artifact (.fa/provenance/<id>.md).

No gates declared → strict no-op. A project with no gates: key behaves byte-identically to before — no extra cost, no behavior change.


Inbound Triggers

Weftra can receive webhooks from external tools (GitHub, GitLab, Linear, Sentry, Slack) and automatically create features from them. Each incoming event enters the project's standard autonomy-mode funnel — a po_approval project still requires human sign-off before any agent spend.

Enabling

Set the environment variable before starting the server:

bash
FA_INBOUND_TRIGGERS_ENABLED=true

Without this flag the ingest routes return 404. The discovery endpoint (GET /api/projects/:id/triggers) still returns the provider list with inbound_enabled: false so you can preview what URLs will be active.

Finding your webhook URLs

Open the dashboard, locate your project in the project table, and click Triggers. The modal shows:

  • An enabled/disabled indicator reflecting FA_INBOUND_TRIGGERS_ENABLED.
  • Whether a dedicated trigger secret is set, plus a Rotate trigger secret button (shows the new plaintext value once).
  • A list of all registered providers (GitHub, GitLab, Linear, Sentry, Slack) with their full absolute webhook URLs — copy them directly into the provider's webhook settings.
  • A signing-secret hint: set the provider's webhook secret to this project's dedicated trigger secret (or its API key as a fallback while no dedicated secret has been rotated).

You can also query the endpoint directly:

bash
curl http://localhost:3100/api/projects/<projectId>/triggers \
  -H "Authorization: Bearer <project-api-key>"

Response:

json
{
  "inbound_enabled": true,
  "trigger_secret_set": false,
  "providers": [
    { "key": "github",  "webhook_path": "/api/projects/<id>/triggers/github" },
    { "key": "gitlab",  "webhook_path": "/api/projects/<id>/triggers/gitlab" },
    { "key": "linear",  "webhook_path": "/api/projects/<id>/triggers/linear" },
    { "key": "sentry",  "webhook_path": "/api/projects/<id>/triggers/sentry" },
    { "key": "slack",   "webhook_path": "/api/projects/<id>/triggers/slack" }
  ]
}

trigger_secret_set reports whether a dedicated secret exists — never the value.

Rotating a dedicated trigger secret

bash
curl -X POST http://localhost:3100/api/projects/<projectId>/triggers/rotate-secret \
  -H "Authorization: Bearer <project-api-key-or-admin-key>"
# => { "trigger_secret": "fatrg_...64 hex chars..." }

Guard: the project's own API key, or an admin key. The plaintext secret is returned exactly once — copy it into the provider's webhook config immediately. Until a project rotates one, trigger_secret is null and webhook verification falls back to the project's API key, so existing webhooks keep working unchanged through an upgrade.

Per-provider webhook setup

The signing secret for every provider is the project's dedicated trigger secret (fatrg_..., recommended — rotate one above) or, as a fallback while none has been rotated, its project API key (fa_...). Paste whichever you're using into the provider's "webhook secret" or "signing secret" field.

GitHub — Settings → Webhooks → Add webhook

  • Payload URL: https://your-fa-host/api/projects/<id>/triggers/github
  • Content type: application/json
  • Secret: your project's dedicated trigger secret (or its API key as a fallback)
  • Events: Issues → "Let me select individual events" → check Issues

GitLab — Settings → Webhooks → Add new webhook

  • URL: https://your-fa-host/api/projects/<id>/triggers/gitlab
  • Secret token: your project's dedicated trigger secret (or its API key as a fallback)
  • Trigger: check Issues events

Linear — Settings → API → Webhooks → New webhook

  • URL: https://your-fa-host/api/projects/<id>/triggers/linear
  • Secret: your project's dedicated trigger secret (or its API key as a fallback)
  • Resources: check Issues

Sentry — Settings → Integrations → Webhooks → Add to Project

  • Webhook URL: https://your-fa-host/api/projects/<id>/triggers/sentry
  • Secret: your project's dedicated trigger secret (or its API key as a fallback)
  • Events: check issue (issue-alert created)

Slack — Use a Slack workflow or custom app that POSTs a JSON body:

json
{
  "title": "Feature title",
  "description": "Optional details",
  "event_id": "<unique-id>",
  "channel": "#general",
  "user": "@alice"
}
  • URL: https://your-fa-host/api/projects/<id>/triggers/slack
  • Set the x-fa-token header to your project's dedicated trigger secret, or its API key as a fallback (or include "token": "..." in the body)
  • Optionally set x-fa-timestamp to the current Unix epoch seconds for replay-window tolerance (FA_TRIGGER_TIMESTAMP_TOLERANCE_SECONDS, default 300s) — see docs/USER_GUIDE.md

Webhook Configuration

Weftra fires HTTP POST webhooks to your project's callback_url on every status change.

Setup

Set callback_url when enrolling a project:

bash
curl -X POST http://localhost:3100/api/projects \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-app",
    "repo_url": "...",
    "autonomy_mode": "auto_safe",
    "callback_url": "https://my-app.com/api/webhooks/featureagent"
  }'

Payload Format

json
{
  "event": "feature.status_changed",
  "feature_id": "550e8400-e29b-41d4-a716-446655440000",
  "project_id": "660e8400-e29b-41d4-a716-446655440000",
  "title": "Dark mode support",
  "old_status": "queued",
  "new_status": "in_progress",
  "branch_name": "feature/dark-mode-support-550e8400",
  "pr_url": null,
  "timestamp": "2025-01-15T10:30:00.000Z"
}

Security

Webhooks include an HMAC-SHA256 signature in the X-FeatureAgent-Signature header, using your project's API key as the secret:

X-FeatureAgent-Signature: sha256=abc123...

Verify on your end:

javascript
const crypto = require('crypto');
const expectedSig = 'sha256=' + crypto
  .createHmac('sha256', projectApiKey)
  .update(JSON.stringify(requestBody))
  .digest('hex');

if (receivedSignature !== expectedSig) {
  return res.status(401).send('Invalid signature');
}

Retry Policy

  • 3 retries with exponential backoff (1s, 2s, 4s)
  • Configurable via WEBHOOK_MAX_RETRIES and WEBHOOK_TIMEOUT_MS
  • Non-2xx responses trigger a retry

Local Email + Password Login

FA supports native email/password login as a second human-login method (alongside Google and API keys). It uses the same session cookie infrastructure as Google Sign-In.

Requirements

  • SESSION_SECRET must be set (already required by Google login — same secret).
  • SMTP configuration is needed for password-reset emails; without it, the endpoint still returns 200 and logs a warning (graceful degradation).

SMTP can be configured without editing .env or restarting FA. In the admin dashboard go to Settings → Email (SMTP):

FieldDescription
SMTP hostYour SMTP server hostname (e.g. smtp.gmail.com)
PortTypically 587 (STARTTLS) or 465 (SSL)
Security modeSTARTTLS (recommended), SSL / Implicit TLS, or None — set explicitly, never inferred from port
UsernameSMTP authentication username
PasswordWrite-only. Leave blank to keep the current value; click Clear password to remove it. The value is stored in the DB and never returned in API responses.
From addressThe From: header on outgoing emails
Reset-link base URLThe externally reachable URL of your FA instance (e.g. https://featureagent.example.com). Used to build the password-reset link in emails. Defaults to http://localhost:3100 if unset — recipients can't click an http://localhost link from outside the server.

DB config overrides env vars: if a field is set in the portal it takes priority over the corresponding SMTP_* env var. Env vars remain as a fallback for fields not set in the portal, so existing env-configured installs keep working.

Gmail App Password: Gmail blocks standard account passwords for SMTP. Enable 2-Step Verification on your Google Account, then create an App Password (Google Account → Security → App Passwords → Mail). Use that 16-character password — not your Google account password.

After saving, click Send test email to verify delivery before relying on the configuration for real password-reset flows.

Configuring SMTP — environment variables (alternative)

If you prefer env-var-only configuration (e.g. Docker secrets):

env
# Required for any human login (Google or local)
SESSION_SECRET=<at-least-32-random-bytes-hex>

# Optional: SMTP for password-reset/invite emails (DB config overrides these)
SMTP_HOST=smtp.example.com
SMTP_PORT=587                          # default 587
SMTP_USER=notify@example.com
SMTP_PASS=<app-password>
SMTP_FROM=Weftra <notify@example.com>

# Base URL used in reset-link emails (default: http://localhost:3100)
APP_BASE_URL=https://your-fa-domain.com

Note: security mode is inferred from port when using env vars only (465 → SSL, anything else → STARTTLS). Use the portal for explicit control.

Generate a strong SESSION_SECRET:

bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Inviting a user (setting a first password)

Local login is invite-only — users cannot self-register. The operator creates the account, then the user sets their own password via the reset flow:

  1. Create the user account (admin API):

    bash
    curl -s -X POST https://your-fa-domain.com/api/users \
      -H "Authorization: Bearer $ADMIN_KEY" \
      -H "Content-Type: application/json" \
      -d '{"email": "alice@company.com", "name": "Alice", "role": "admin"}'
  2. Send Alice the login page URL: https://your-fa-domain.com/login.html

  3. Alice clicks "Set / forgot password?", enters her email, and receives a reset link by email (if SMTP is configured). She clicks the link and sets her password.

Without SMTP: Alice can't receive the email automatically. You can instead generate the reset link manually via POST /auth/local/reset-request (which logs a warning and returns 200) and then extract the token from server logs or use the API — or simply set the password directly via the API in a dev environment.

How it works

  • Passwords are hashed with scrypt (N=16384, r=8, p=1, 32-byte output, random per-user salt). The stored form is self-describing: scrypt$N$r$p$<salt>$<hash>.
  • Constant-time compare prevents timing attacks. The login handler always runs scrypt regardless of whether the email exists, keeping timing consistent (prevents user enumeration).
  • Password reset tokens are stateless HMACs over {userId, exp, fingerprint} where fingerprint is derived from the current password_hash. Setting a new password invalidates all prior tokens (single-use).
  • Tokens expire after 24 hours.
  • Failed logins are tracked in-memory per email+IP. After 5 consecutive failures, the account+IP is locked out for 15 minutes (configurable via LOCAL_AUTH_MAX_ATTEMPTS and LOCAL_AUTH_LOCKOUT_MS).

API endpoints

EndpointMethodBodyDescription
/auth/local/loginPOST{email, password}Issue a session cookie. 401 on wrong credentials (uniform, no enumeration).
/auth/local/reset-requestPOST{email}Send reset email. Always returns 200 (no enumeration).
/auth/local/resetPOST{token, new_password}Set password with a valid single-use token.

Configuring Google OAuth

Google Sign-In lets human users log into the FA dashboard with their Google account instead of pasting a raw API key. This is optional — API-key auth always works regardless.

Prerequisites

  1. A Google Cloud project with the OAuth 2.0 API enabled.
  2. An OAuth 2.0 Client ID of type "Web application".

Step-by-step

  1. Go to Google Cloud ConsoleAPIs & Services → Credentials.
  2. Click "Create Credentials" → "OAuth 2.0 Client ID". Choose "Web application".
  3. Under "Authorized redirect URIs", add your redirect URI:
    • Development: http://localhost:3100/auth/google/callback
    • Production: https://your-fa-domain.com/auth/google/callback
  4. Click Create. Note the Client ID and Client Secret.

Environment variables

Add these three variables to your .env (or system environment — never commit secrets):

env
# Google OAuth (for human sign-in to the dashboard)
GOOGLE_CLIENT_ID=123456789-xxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxxx
GOOGLE_REDIRECT_URI=https://your-fa-domain.com/auth/google/callback

# Session secret — signs the browser session cookie (generate a strong random value)
SESSION_SECRET=<at-least-32-random-bytes-hex-or-base64>

SESSION_SECRET must be set for Google sign-in (or API-key-to-session conversion) to work. If it is absent, Bearer-key auth still works but the "Sign in with Google" button returns a configuration error. Generate a strong value with:

bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

How it works

  • FA implements OAuth 2.0 authorization code + PKCE (RFC 7636) with state for CSRF protection on the handshake.
  • The returned id_token is verified locally (signature against Google's JWKS, aud, iss, exp, email_verified). No secret is ever logged.
  • Sign-in is invite-only: the verified Google email must match a pre-created FA user (POST /api/users). Unknown emails are rejected with a clear message. No auto-provisioning for self-hosted.
  • On success, FA issues an httpOnly + SameSite=Lax (+ Secure in production) signed session cookie valid for 7 days. State-changing requests via the session cookie require an X-CSRF-Token header (the dashboard handles this automatically).

Redirect URI in production

Set GOOGLE_REDIRECT_URI to the exact URI registered in Google Cloud Console, including the scheme and path. If your FA instance is behind a reverse proxy (Nginx, Cloudflare Tunnel), use the public-facing HTTPS URL.

User provisioning

Before a Google user can sign in, an admin must create their FA user account with a matching email:

bash
curl -s -X POST https://your-fa-domain.com/api/users \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@company.com", "name": "Alice", "role": "admin"}'

Alice can then sign in with Google using alice@company.com.


Configuring GitHub and GitLab sign-in

Spec 363 inc-1 (Weftra Identity & Access, RM-127). GitHub and GitLab sign-in work exactly like Google Sign-In above: OAuth 2.0 authorization code + PKCE, invite-only, session cookie on success. Both are optional and independent — enable either, both, or neither. This is distinct from GITHUB_TOKEN / GITLAB_TOKEN (used for draft PR/MR creation) — those are VCS credentials, not human-login configuration.

Prerequisites

  • GitHub: an OAuth App (not a GitHub App) at github.com/settings/developers (or your GitHub Enterprise Server instance's equivalent page).
  • GitLab: an OAuth application at User Settings → Applications on gitlab.com (or your self-managed instance).

A GitHub App as the PKCE-honouring alternative. The residual described below (a GitHub OAuth App does not implement RFC 7636) is specific to OAuth Apps. GitHub documents PKCE support for a GitHub App's user-to-server authorization flow, which is otherwise API-compatible with the OAuth App flow FA already speaks — buildGithubAuthUrl (src/services/github-oauth.ts) already sends code_challenge/code_challenge_method=S256 on every request, so no code change is needed to use one. This is stated as what GitHub documents, not as something FA has verified end to end — FA does not check which app type an operator registered, and nothing here claims it does. If you need the residual below closed cryptographically on the GitHub path specifically (rather than by linking through Google/GitLab instead), registering a GitHub App and using its client credentials in GITHUB_OAUTH_CLIENT_ID/GITHUB_OAUTH_CLIENT_SECRET is the documented option to evaluate.

Step-by-step — GitHub

  1. Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
  2. Set the Authorization callback URL:
    • Development: http://localhost:3100/auth/github/callback
    • Production: https://your-fa-domain.com/auth/github/callback
  3. Register the app. Note the Client ID, then generate and note a Client Secret.

Step-by-step — GitLab

  1. Go to GitLab → User Settings → Applications → Add new application.
  2. Set the Redirect URI:
    • Development: http://localhost:3100/auth/gitlab/callback
    • Production: https://your-fa-domain.com/auth/gitlab/callback
  3. Under Scopes, check read_user only (least-privilege — FA only reads the profile/email).
  4. Save. Note the Application ID and Secret.

Environment variables

env
# GitHub OAuth (human sign-in to the dashboard — distinct from GITHUB_TOKEN)
GITHUB_OAUTH_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx
GITHUB_OAUTH_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GITHUB_OAUTH_REDIRECT_URI=https://your-fa-domain.com/auth/github/callback
GITHUB_OAUTH_BASE_URL=https://github.com   # self-hosted GHES: https://ghe.your-company.com[:port] (API served at <base>/api/v3); https:// only

# GitLab OAuth (human sign-in to the dashboard — distinct from GITLAB_TOKEN)
GITLAB_OAUTH_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GITLAB_OAUTH_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GITLAB_OAUTH_REDIRECT_URI=https://your-fa-domain.com/auth/gitlab/callback
GITLAB_OAUTH_BASE_URL=https://gitlab.com   # self-managed: https://gitlab.your-company.com[:port]; https:// only

# SESSION_SECRET (shared with Google/OIDC/local login — see above) must also be set.

# REQUIRE_PROVEN_VCS_HANDLE (spec 363 inc-6 / RM-127) — operator-only, env-only, read once
# at startup (restart required to change). Default: false (unset/empty/'yes'/'0' — only
# 'true'/'1', trimmed and case-insensitive, arm it). When true, every handle-based
# authorization (@<bot> merge, mention dispatch, merger attribution) honors a VCS handle
# only when it is OAuth-proven — a `pending`/`staged`/legacy handle no longer authorizes on
# its own. Not a runtime setting and not a project/feature field — a project key has no path
# to it. See the "Turning on REQUIRE_PROVEN_VCS_HANDLE" runbook in docs/OPERATIONS.md
# (readiness check first, via GET /api/admin/vcs-proof-readiness) before setting this.
REQUIRE_PROVEN_VCS_HANDLE=false

SESSION_SECRET must be set for either flow to work, exactly as with Google — without it the buttons return a configuration error.

How it works

  • Authorization code with state for CSRF protection on the handshake: the state value is held in a signed, HttpOnly fa_oauth_state cookie and compared in the callback (src/routes/auth.ts). The cookie also records which provider minted it, and each callback refuses a cookie naming a different provider (src/routes/auth.ts, src/middleware/session.ts) — GitHub/GitLab require the field to match, and the Google callback refuses a cookie that names another provider while still accepting the older cookie shape that carries no provider at all.
  • PKCE (RFC 7636) is sent on both flows, but only GitLab enforces it. code_challenge/S256 go out on the authorize URL and code_verifier in the token exchange for both providers; GitLab's token endpoint verifies them, GitHub's OAuth App endpoint ignores them. On the GitHub path an intercepted authorization code is therefore protected by the client secret and the single-use code, not by the verifier — do not treat PKCE as a live control there.
  • What that means for linking a provider to an existing account (spec 363 inc-2, POST /api/users/me/identities/link/:provider). The link callback binds the verified profile to the FA user named in the signed state cookie and requires that user to be the live session user (completeIdentityLink, src/routes/auth.ts) — that decides which account a profile may attach to. It does not, by itself, prove the authorization code came from this browser's handshake; on Google and GitLab the enforced code_verifier proves that, on GitHub nothing does. So on the GitHub path the binding is state alone: 128 bits of randomness in an HttpOnly, SameSite=Lax, HMAC-signed cookie FA never renders into a page, valid — in link mode only — for 5 minutes rather than 10 (OAUTH_LINK_STATE_DURATION_S, src/middleware/session.ts). As of spec 363 inc-3, link-mode state is single-use. Right after the cookie verifies and before any provider call, FA hashes the cookie value (sha256) and inserts it into oauth_consumed_states (src/models/oauth-consumed-states.ts). The hash is taken over the same normalised string the verifier HMAC-checks (canonicalOAuthStateCookieValue, src/middleware/session.ts — the Cookie-header token with its percent-encoding removed), not over the header token as sent, so re-encoding a byte of the cookie does not mint a fresh key; the table's PRIMARY KEY on that hash refuses a second insert of the same value, and a replay is redirected back with a fixed "This link request was already used. Please start again." message before any provider call — never the raw query value, and never a second identity.link row. Expired rows are pruned opportunistically on each insert. Login-mode state is unchanged — still redeemable for the rest of its 10-minute window from any client holding it; adopting the same store for login mode is a documented follow-up, not yet done. What single-use closes: the previous "redeemable for the rest of the window" gap, where the same signed state verified again and again until exp. What it does NOT close: a GitHub OAuth App still ignores PKCE, so the ONE attempt state now buys is still bound by nothing cryptographic on that path — only by state's own secrecy for that single use. If that value were disclosed and redeemed before the legitimate holder's own attempt, an injected code could still attach an attacker's GitHub account to the linking user, and the returning-user fast path would then accept it as a sign-in (and would additionally attempt to prove the attacker's account name as a VCS handle for that user — which lands only if you had already declared that exact handle for them, since proving upgrades a declared row and never creates one, and is refused outright if the handle is already proven for someone else; spec 363 inc-3). Every link is recorded (identity.link, see docs/OPERATIONS.md §actor_events) and is reversible (DELETE /api/users/me/identities/:identityId, which also demotes any VCS-handle proof that identity made, or the dashboard's Linked sign-ins panel). If you need this closed cryptographically rather than by the secrecy of a single-use state, link through Google or GitLab, whose token endpoints enforce PKCE.
  • The access token is exchanged server-side only: it is a local in exchange*Codefetch*Profile (src/services/github-oauth.ts, src/services/gitlab-oauth.ts) and is written to no store, no redirect, and no log line.
  • Identity subject is the provider's immutable numeric user id — never the login/username, which can change. Recorded as (provider, provider_subject=<numeric id>) in the identities table, where provider is github / gitlab for the public hosts and github:<host[:port]> / gitlab:<host[:port]> for a self-hosted base URL (githubIdentityProvider / gitlabIdentityProvider in the provider modules). A numeric id is unique only within one host, so the host is part of the row key: the returning-user fast path in handleFederatedCallback (src/routes/auth.ts) looks rows up under the key derived from the current base URL, and a row minted against a different host is not matched.
  • Changing GITHUB_OAUTH_BASE_URL / GITLAB_OAUTH_BASE_URL starts linkage afresh. Rows recorded against the previous host stay in the table but are no longer consulted; each user's next sign-in goes through the invite-only email check again and records a new row under the new host's key. Rebuilding a self-hosted instance at the same hostname is not detectable by FA: if the rebuilt instance hands out numeric user ids again, an existing row for that id would log in the FA user it was linked to. Before re-enabling sign-in against a rebuilt instance, delete that host's rows from identities (provider = 'github:<host>' or 'gitlab:<host>') so every user re-links through the email check.
  • The base URL is used as configured — scheme, host, port and path. Only https:// is accepted (connectionTarget in each provider module refuses anything else before opening a socket), because the token exchange carries the client secret and the profile fetch carries the user's access token. A non-default port such as https://gitlab.internal:8443 is dialled on that port.
  • Only a verified email maps to an account: GitHub — the GET /user/emails entry with primary && verified; GitLab — the profile email, only when GET /user reports a confirmed_at timestamp for the address. GitLab's account state (active, blocked, …) is not accepted as a stand-in for email confirmation — an unconfirmed address is refused in fetchGitlabProfile (src/services/gitlab-oauth.ts) before the callback ever sees it, so a self-managed instance running GitLab's soft email confirmation cannot be used to claim someone else's pre-provisioned FA account.
  • Sign-in is invite-only, with no exception: the verified email must match a pre-created FA user (POST /api/users). Unknown emails are rejected; no account is ever auto-provisioned by either provider.
  • GET /auth/status exposes githubLoginEnabled / gitlabLoginEnabled as booleans only — never a base URL or secret.

User provisioning

Same as Google — an admin creates the account first, with the email that matches the GitHub/GitLab account:

bash
curl -s -X POST https://your-fa-domain.com/api/users \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@company.com", "name": "Alice", "role": "admin"}'

SSO / OIDC — Enterprise Identity (Generic IdP)

FA supports a bring-your-own-IdP SSO option using standards-based OpenID Connect (OIDC). Any OIDC-compliant issuer works — Okta, Azure AD / Entra, Auth0, Keycloak, PingFederate, Google Workspace (via generic OIDC), or any OpenID Connect–compliant provider. This is distinct from the Google-specific integration: the generic OIDC path uses OIDC Discovery (/.well-known/openid-configuration) and is configured entirely by env vars.

Requirements

  • SESSION_SECRET must be set (same secret used by Google and local-password login).
  • An OAuth 2.0 / OIDC application registered at your IdP with the authorization code flow enabled.
  • The IdP must issue an id_token with email and email_verified claims (standard OIDC scope openid email profile).

Step-by-step (generic)

  1. Create an OIDC application (called "Web application", "Regular Web App", or similar) in your IdP's admin console.
  2. Set the Allowed Redirect URI / Callback URL to:
    • Development: http://localhost:3100/auth/oidc/callback
    • Production: https://your-fa-domain.com/auth/oidc/callback
  3. Note the Client ID and Client Secret issued by the IdP.
  4. Note the Issuer URL (e.g. https://login.microsoftonline.com/<tenant-id>/v2.0 for Entra ID, or https://your-org.okta.com for Okta). FA appends /.well-known/openid-configuration to this URL for discovery.

Environment variables

env
# Generic OIDC/SSO (enterprise sign-in — spec 059 inc 2)
OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
OIDC_CLIENT_ID=<your-client-id>
OIDC_CLIENT_SECRET=<your-client-secret>          # secret — never commit to source control
OIDC_REDIRECT_URI=https://your-fa-domain.com/auth/oidc/callback

# Optional:
OIDC_SCOPES=openid email profile                 # default — change only if your IdP needs extra scopes
OIDC_ALLOW_SIGNUP=false                          # default false (least-privilege) — set to true to auto-provision accounts

OIDC_CLIENT_SECRET is a secret. Inject it via an environment variable, a secret manager (Vault, Doppler, AWS SSM, K8s Secret), or a .env file that is never committed. FA never logs or returns it via any API.

IdP-specific notes

IdPIssuer URL patternNotes
Oktahttps://your-org.okta.com or https://your-org.okta.com/oauth2/defaultUse the Authorization Server issuer, not just the org domain.
Azure AD / Entra IDhttps://login.microsoftonline.com/<tenant-id>/v2.0Enable email claim via "Token configuration" in Azure Portal.
Auth0https://your-tenant.auth0.comEnable OIDC-conformant mode; add email to the ID token claims.
Keycloakhttps://your-keycloak/realms/<realm>The realm's discovery URL is https://your-keycloak/realms/<realm>/.well-known/openid-configuration.
Google Workspace (generic OIDC)https://accounts.google.comWorks in addition to the FA-native Google OAuth path.

How it works

  • FA performs OIDC Discovery at startup-per-login (with a 1-hour TTL cache): GET <OIDC_ISSUER>/.well-known/openid-configuration → fetches authorization_endpoint, token_endpoint, and jwks_uri.
  • The id_token is verified locally (RSA-SHA256 signature against the IdP's JWKS, plus iss, aud, exp, nonce, and email_verified). No secret is ever sent to a verification service.
  • Nonce is used for anti-replay (state cookie + signed nonce in the token).
  • OIDC is enabled iff OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URI are all set.
  • Sign-in is invite-only by default (OIDC_ALLOW_SIGNUP=false): the IdP-verified email must match a pre-created FA user account. Unknown emails are rejected with a clear message directing the user to ask an admin. Set OIDC_ALLOW_SIGNUP=true to let any IdP-authenticated user self-provision an FA account with the least-privilege approver role.

User provisioning

By default (OIDC_ALLOW_SIGNUP=false), an admin must create each FA user account before they can sign in via SSO:

bash
curl -s -X POST https://your-fa-domain.com/api/users \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@company.com", "name": "Alice", "role": "approver"}'

Then Alice can sign in with SSO using alice@company.com. The IdP-verified email is the link key.

With OIDC_ALLOW_SIGNUP=true, first-time SSO users are auto-provisioned with role approver. Admins can promote them afterwards via PATCH /api/users/:id.

Identity linking

On a successful SSO login, FA records an oidc entry in the identities table (provider='oidc', provider_subject=<sub>). On subsequent logins with the same sub, the identity row is found directly without an email lookup — making email changes at the IdP safe after first login.


Users & Roles

Weftra has a built-in user system with two roles:

  • Admin — Can manage projects, users, and all features via the dashboard or API
  • Approver — Linked to specific projects; can approve features for those projects

Creating Your First Admin User

After starting the server, create an admin user via the API (using the legacy ADMIN_API_KEY or dev mode):

bash
curl -s -X POST http://localhost:3100/api/users \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@company.com", "name": "Admin", "role": "admin"}' | jq .

Save the returned api_key — use it to log into the dashboard and for all admin API calls going forward.

Creating Approvers

bash
# Create the approver
APPROVER=$(curl -s -X POST http://localhost:3100/api/users \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "po@company.com", "name": "PO Smith", "role": "approver"}')

APPROVER_ID=$(echo $APPROVER | jq -r .id)

# Link them to a project
curl -s -X POST http://localhost:3100/api/users/$APPROVER_ID/projects \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"project_id\": \"$PROJECT_ID\"}"

The approver can now log into the dashboard with their API key and approve features for their linked projects.

Auth Model

Key TypeWho Uses ItWhat It Can Do
Project API key (fa_...)External systems (CI, apps)Submit features, list features, poke for updates
Admin user key (fa_...)Human adminsEverything — project CRUD, user CRUD, approve any feature
Approver user key (fa_...)Product owners / approversApprove features for linked projects, view scoped features
Legacy ADMIN_API_KEY env varBackwards compatibilitySame as admin user key

You can manage all of this from the dashboard's Users tab (admin only), or via the API.


Protected Projects

Projects can be marked as protected to prevent accidental deletion:

bash
# Set on creation
curl -X POST http://localhost:3100/api/projects \
  -H "Content-Type: application/json" \
  -d '{"name": "my-app", "repo_url": "...", "autonomy_mode": "po_approval", "protected": true}'

# Or update an existing project
curl -X PATCH http://localhost:3100/api/projects/:id \
  -H "Content-Type: application/json" \
  -d '{"protected": true}'

Protected projects return 403 on delete attempts. The dashboard shows a "protected" badge instead of a delete button.


Self-Managed Development

Weftra is enrolled as its own project. This means you can submit feature requests for Weftra itself, and the agent will implement them on the feature-agent-self branch with po_approval mode.

The featureagent project is protected and configured with:

  • Autonomy mode: po_approval — features require approval before implementation
  • Target branch: feature-agent-self
  • Auto PR: enabled
  • Notifications: Telegram

To submit a feature for Weftra:

bash
curl -X POST http://localhost:3100/api/features \
  -H "Authorization: Bearer $FEATUREAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Add dark mode to dashboard",
    "description": "Add a light/dark mode toggle to the admin dashboard..."
  }'

The feature will appear as awaiting_approval in the dashboard. After approval, the agent clones the featureagent repo, creates a feature branch, implements the change with tests, and opens a draft PR.


Diagnostic Tool

Verify your environment is correctly configured for headless Claude Code operation:

bash
npm run diagnose
npm run diagnose -- --prompt "Create a hello.txt file"

The diagnostic spawns Claude Code the same way the agent does and reports success/failure with detailed error messages for common issues (auth, permissions, timeout).


Production Deployment

Environment Variables

env
# Server
PORT=3100
FA_BIND=127.0.0.1                         # Bind host. Non-loopback (e.g. 0.0.0.0) = 'exposed' — see below
FA_SECRET_MODE=file                       # 'file' (default) | 'managed' — see "Secrets management" below
ADMIN_API_KEY=a-strong-random-key-here    # Legacy admin key (optional — user-based admin auth is preferred)
FA_ALLOW_OPEN_ADMIN=                      # Set to '1' to explicitly opt into unauthenticated admin on the
                                          # configured exposed bind, instead of the default of narrowing
                                          # the bind to loopback when ADMIN_API_KEY is unset — see below
FA_ADMIN_OPEN_CIDR=                       # Optional, only meaningful with FA_ALLOW_OPEN_ADMIN=1. Comma-separated
                                          # CIDRs (e.g. 10.0.0.0/8,192.168.0.0/16) restricting open admin to
                                          # those source IPs. Unset = open to any source — see below

# Storage
DATABASE_PATH=/var/featureagent/data/featureagent.db
WORKSPACE_DIR=/var/featureagent/workspaces

# Backup (VACUUM INTO snapshots — see docs/OPERATIONS.md §7 for the restore runbook)
BACKUP_DIR=./backups                  # Directory for snapshot files (created automatically)
BACKUP_RETENTION=14                   # Keep N most-recent snapshots; older ones pruned per backup
BACKUP_INTERVAL_MS=0                  # 0 = disabled (default). Set e.g. 3600000 for hourly backups

# Agent
AGENT_POLL_INTERVAL_MS=30000
AGENT_MAX_CONCURRENCY=3
AGENT_MODEL=sonnet
AGENT_MAX_TURNS=100
AGENT_MAX_RETRIES=3
AGENT_ALLOWED_TOOLS=                       # Comma-separated allowlist (empty = all tools)
AGENT_PERMISSIONS=dangerously-skip-permissions
CLAUDE_CLI_PATH=/usr/local/bin/claude

# Engine-transport reliability deadlines (spec 149) — a spawned-but-alive-but-hung
# ACP/headless engine server fails the run instead of wedging it forever. 0 disables
# either deadline. See docs/ENGINES.md §6 for the full explanation.
ENGINE_STARTUP_TIMEOUT_MS=90000            # ACP handshake (initialize+session/new) deadline; 0 disables
ENGINE_IDLE_TIMEOUT_MS=600000              # Inactivity deadline (ACP prompt phase / whole headless run); 0 disables

# Runtime (sandbox) — see docs/design/engine-runtime.md
RUNTIME=local                             # 'local' (host, development only) or 'docker' (isolated container)
FA_RUNTIME_IMAGE=fa-runtime:latest        # Image for RUNTIME=docker (needs git + node + claude)
                                          # Curated images: fa-runtime (Node/TS), fa-runtime-python
                                          # (Python), fa-runtime-browser (Chromium + Playwright).
                                          # A missing image is NOT a manual step: FA resolves
                                          # present -> pull -> build automatically at run start
                                          # (spec 001 inc-B) — present locally wins outright; else
                                          # `docker pull`; else, ONLY for a curated `<name>:latest`
                                          # reference whose ALLOWLIST ENTRY declares no signing key,
                                          # `docker build` from this repo's own
                                          # containers/<name>/Dockerfile (so an offline/air-gapped
                                          # host still works after its first run). An image whose
                                          # entry declares a key, one that is digest-pinned, and one
                                          # that matches no allowlist entry are all REFUSED the
                                          # build fallback — they require reachable registry bytes.
                                          # A signing key also refuses ALREADY-BUILT local bytes:
                                          # a present image with no registry digest recorded for it
                                          # is not started under a keyed entry, so adding a key
                                          # takes effect on the next run rather than only on hosts
                                          # that had not already built the image.
                                          # Manual pre-build is still fine, just
                                          # optional:
                                          # docker build -t <tag> containers/<dir>
                                          # Select per-project/feature via the runtime_image API
                                          # field. See docs/OPERATIONS.md §4h-7 for the full
                                          # resolution order and where the provenance (present/
                                          # pulled/built) shows up in the run log — the `present`
                                          # line also says whether the cached bytes carry a
                                          # registry digest or were built/loaded locally — and
                                          # docs/USER_GUIDE.md §"Governed browser tooling" for the
                                          # full governed-browse + verify_command + verify_gate recipe.
FA_BOOTSTRAP_GIT_IMAGE=alpine/git:latest  # Image for the spec-161 inc-2b in-sandbox clone/fetch
                                          # bootstrap container (needs only git + a shell) — used
                                          # BEFORE the real runtime_image is known. See
                                          # docs/OPERATIONS.md §4h.
FA_BOOTSTRAP_GIT_EGRESS=enforced           # 'enforced' (default) | 'open'. spec 161 inc-2c: the
                                          # bootstrap clone container's egress is topologically
                                          # restricted (FA-owned --internal network + a CONNECT
                                          # forwarder allowing only the run's own repo host on 443).
                                          # 'open' is an explicit operator opt-out restoring the
                                          # pre-inc-2c behavior (config.runtimeNetwork, no proxy env)
                                          # — fail-closed otherwise (provisioning failure fails the
                                          # run, no fallback). See docs/OPERATIONS.md §4h-1.
FA_SANDBOX_HARDENING=strict                # 'strict' (default) | 'compat'. Spec 405 inc-1: kernel-side
                                          # hardening for the agent JOB container, brought to the
                                          # bootstrap git-clone container's existing parity —
                                          # --cap-drop ALL, --security-opt no-new-privileges, an
                                          # FA-shipped seccomp profile, and a read-only rootfs (only
                                          # /work and a /tmp tmpfs are writable). 'compat' is an
                                          # explicit, RECORDED operator opt-out restoring the pre-405
                                          # argv byte-identical — shown on the provenance doc and
                                          # folded into the spec-360 capability snapshot hash, never a
                                          # silent fallback. Governs the JOB container only; the
                                          # bootstrap clone container has carried this hardening
                                          # unconditionally since spec 161 with no opt-out. See
                                          # docs/OPERATIONS.md §4h-13.
FA_SANDBOX_HARDENING_CAP_ADD=             # Comma-separated Linux capabilities to re-grant on top of
                                          # --cap-drop ALL (e.g. 'NET_BIND_SERVICE'). Empty by
                                          # default; consulted only when FA_SANDBOX_HARDENING=strict.
FA_SECCOMP_PROFILE=                       # Path to an operator-supplied seccomp profile, or
                                          # 'unconfined' to disable syscall filtering entirely
                                          # (still an explicit, recorded choice). Empty (default)
                                          # resolves to FA's own shipped profile,
                                          # containers/seccomp/fa-default.json. Operator config only
                                          # — never a project/feature field.
FA_RUNTIME_NETWORK=none                   # 'none' | 'bridge' | <docker network>. Spec 405 inc-2:
                                          # setting this to ANY value (as this sample does) is now
                                          # also the explicit, recorded opt-out from the agent job
                                          # container's default egress allowlist — see the
                                          # FA_RUNTIME_EGRESS entry below and docs/OPERATIONS.md
                                          # §4h-8a. Leave it UNSET (or empty) to get the new default.
FA_RUNTIME_EGRESS=open                    # 'open' (default) | 'allowlist'. spec 329: opt-in egress
                                          # allowlist for the AGENT JOB CONTAINER itself — DISTINCT
                                          # from FA_BOOTSTRAP_GIT_EGRESS above (that one covers only
                                          # the short-lived bootstrap clone container). 'open' is
                                          # byte-identical to pre-329 behavior (no forwarder, no
                                          # proxy env). 'allowlist' attaches the container to an
                                          # FA-owned --internal network behind a CONNECT forwarder
                                          # permitting only FA_RUNTIME_EGRESS_ALLOWLIST's hosts on
                                          # 443 — fail-closed (a provisioning failure fails the run,
                                          # never falls back to an open bridge). A project may
                                          # TIGHTEN this (and the allowlist) per-project via
                                          # `runtime_egress` / `runtime_egress_allowlist`
                                          # (self-configurable, PATCH /api/project or an admin via
                                          # PATCH /api/projects/:id) — resolution is tighten-only:
                                          # a project cannot loosen an operator-enforced 'allowlist'
                                          # back to 'open', and a project allowlist is intersected
                                          # with (never replaces) a non-empty operator allowlist.
                                          # See docs/OPERATIONS.md §4h-8. NOTE — this var's OWN
                                          # literal default stays 'open' (it remains the
                                          # operator-declared CEILING other consumers like
                                          # product-ingest read); it is a SEPARATE thing from what
                                          # the job container defaults to when neither this, a
                                          # project override, FA_RUNTIME_NETWORK, nor
                                          # `network_policy` says anything at all — see
                                          # docs/OPERATIONS.md §4h-8a (spec 405 inc-2, RM-194):
                                          # THAT case now defaults to an allowlist FA computes per
                                          # run (model-endpoint host + this run's VCS host + the
                                          # project's own declared FA_RUNTIME_EGRESS_ALLOWLIST-style
                                          # entries below), not to the open bridge.
FA_RUNTIME_EGRESS_ALLOWLIST=              # Comma-separated hostnames permitted on port 443 when
                                          # FA_RUNTIME_EGRESS=allowlist (e.g.
                                          # "api.anthropic.com,registry.npmjs.org"). Ignored in
                                          # 'open' mode. A project's own equivalent field
                                          # (`runtime_egress_allowlist`, "egress_allow") is also
                                          # unioned into the spec 405 inc-2 default allowlist above —
                                          # declare a package registry there if your build needs one.

# Sandbox container reaper (spec 172) — see docs/OPERATIONS.md § Sandbox container
# reaper. Every container FA starts is labeled featureagent.managed/instance(/job);
# a periodic sweep force-removes ONLY FA's own labeled containers that are older
# than the TTL and have no live job claim. RUNTIME=docker only.
FA_INSTANCE_ID=                           # Optional: pin an explicit instance id (default: hash of DATABASE_PATH).
                                          # Set this if DATABASE_PATH changes across a migration but you want the
                                          # reaper to keep recognizing pre-migration containers as this instance's.
SANDBOX_REAPER_INTERVAL_MS=900000         # How often the reaper sweeps (15 min default)
SANDBOX_REAPER_TTL_MS=10800000            # Age (ms) after which an unclaimed FA container is reaped (3 h default)
SANDBOX_ORPHAN_TTL_MS=900000              # Age (ms) after which an ORPHANED FA container (missing/empty/"undefined"
                                          # featureagent.instance label — see OPERATIONS.md § Sandbox container reaper)
                                          # is reaped (15 min default — shorter than SANDBOX_REAPER_TTL_MS, since an
                                          # unowned container has no run that could still legitimately need it).
SANDBOX_RUNNING_ALERT_THRESHOLD=25        # Running FA-managed container count above which a loud alert is logged

# CORS
CORS_ALLOWED_ORIGINS=                      # Comma-separated global origins (projects can set their own)

# Auth
ANTHROPIC_API_KEY=sk-ant-...

# Integrations
TWILIO_ACCOUNT_SID=AC...
TWILIO_AUTH_TOKEN=...
TWILIO_WHATSAPP_FROM=whatsapp:+1...
GITHUB_TOKEN=ghp_...
PR_CHECK_INTERVAL_MS=600000                # Per-feature throttle for PR merge/review polling (default 10 min)
SECURITY_CHECK_INTERVAL_MS=600000          # Per-feature throttle for security-review selection between rounds (default 10 min)

# Webhooks
WEBHOOK_MAX_RETRIES=3
WEBHOOK_TIMEOUT_MS=10000

# Fleet Attention Queue SLA thresholds (spec 057)
# How long a human-gated feature can wait before it is flagged as a breach in the
# Fleet Overview attention queue (/fleet.html) and the GET /api/features/admin/attention endpoint.
FLEET_APPROVAL_SLA_HOURS=24        # awaiting_approval → operator must approve within N hours
FLEET_CLARIFICATION_SLA_HOURS=24   # clarification_needed → operator must answer within N hours
FLEET_MERGE_SLA_HOURS=48           # implemented+PR → operator must merge within N hours

# Fleet Attention Alerts — push SLA breaches to project notification channels (spec 057 phase 2)
# Opt-in; off by default. A project opts in by configuring notification channels (existing mechanism).
FLEET_ATTENTION_ALERTS_ENABLED=false      # Set to 'true' to enable breach push-notifications
FLEET_ATTENTION_SWEEP_INTERVAL_MS=900000  # How often to check for new breaches (default 15 min)

Startup guard — narrow the bind to loopback with ADMIN_API_KEY unset on an exposed bind (spec 147)

At startup, right after the admin key is bootstrapped and before the HTTP server binds, FA resolves the host it actually binds (src/services/startup-guard.ts) based on whether admin auth would actually be enforced once the process starts accepting requests. 'Exposed' here is the exact same check requireAdminAuth uses at request time — FA_EXPOSED=true, NODE_ENV=production, or FA_BIND/HOST set to a non-loopback address — so the two can never disagree about whether the instance is reachable from the network. src/index.ts binds whatever host this guard resolves to, not FA_BIND/HOST directly.

ADMIN_API_KEYExposed?Behavior
set (plausible)eitherNo change — starts normally, binds the configured host.
set, but not a plausible value (e.g. a leftover placeholder)eitherRefuses to start, regardless of exposure — a malformed key can never authenticate a request, so admin would run silently 401-bricked. Fix: set a real value or unset it entirely.
unsetno (loopback)Starts, but prints a prominent warning: admin endpoints are open to anyone who can reach loopback (including a reverse proxy forwarding to it — the guard cannot see proxies).
unsetyesStarts, but NARROWS the bind to loopback (127.0.0.1) instead of the configured host, with a prominent warning naming the fixes: set ADMIN_API_KEY, inject it via your secret manager in FA_SECRET_MODE=managed, or set FA_ALLOW_OPEN_ADMIN=1 to keep the configured host with unauthenticated admin (not recommended). Admin routes stay fail-closed (401, requireAdminAuth) regardless — this only shrinks who can reach the bind at all. FA does not exit(1) here; an unset key on an exposed bind is a recoverable misconfiguration, not a reason to crash-loop the whole instance (including every tenant-facing route) out from under it.
unsetyes, with FA_ALLOW_OPEN_ADMIN=1Starts anyway, binding the configured host (no narrowing), with a prominent warning. requireAdminAuth honors this flag: admin is genuinely UNAUTHENTICATED — to every source, unless FA_ADMIN_OPEN_CIDR restricts it (see below).

Only two cases remain fatal (process exit(1)): a set-but-implausible ADMIN_API_KEY, and FA_ALLOW_OPEN_ADMIN=1 with a FA_ADMIN_OPEN_CIDR that is set but entirely unparseable (see below) — both are configuration errors the guard cannot resolve safely by narrowing, since a malformed key can never authenticate anything and an unparseable allowlist would otherwise silently open admin to every source. The exposed+no-key+ no-override cell used to also exit(1) (spec 147 inc-1); it now narrows instead, because crashing that cell took the entire tenant-facing API down for a misconfiguration that narrowing already contains. The key value itself is never logged by this guard, only its presence/absence.

When the guard narrows, the loopback address is FA's effective host everywhere it matters in-process, not just at app.listen: the remote MCP front door (FA_MCP_HTTP_ENABLED) builds its self-call base URL from the actually-bound host — never the configured-but-unbound FA_BIND value, which would send tenant-key-bearing requests to an address FA isn't listening on — and the host field of GET /api/admin/settings/runtime reports the narrowed address so the narrowing is visible when inspecting settings. See docs/OPERATIONS.md § "Startup guard" for details.

Note that "unset" above means the env var is genuinely absent/empty. A key that is present but implausible is never eligible for the loopback-open or FA_ALLOW_OPEN_ADMIN paths at request time either — requireAdminAuth treats a set key (even a malformed one) as a configured credential, not "no key", and falls through to a 401 rather than silently discarding it and opening admin.

FA_ALLOW_OPEN_ADMIN actually opens admin — restrict it with FA_ADMIN_OPEN_CIDR

With FA_ALLOW_OPEN_ADMIN=1 on an exposed bind with no key, requireAdminAuth (src/middleware/auth.ts) skips credential checks entirely — every admin route accepts requests with no Authorization header. Set FA_ADMIN_OPEN_CIDR to a comma-separated CIDR allowlist (e.g. FA_ADMIN_OPEN_CIDR=10.0.0.0/8,192.168.0.0/16) to narrow "open" to only those source addresses; requests from any other source still get a 401. Leaving it unset means open to any source that can reach the instance.

A misconfigured FA_ADMIN_OPEN_CIDR fails closed, never open-all. If the variable is set but every entry fails to parse (typo'd delimiter, missing /prefix, malformed address), the startup guard refuses to start rather than silently treat it the same as "unset" — which would open admin to the entire internet on a fat-fingered value. As defense in depth, requireAdminAuth applies the same rule at request time: a set but entirely unparseable FA_ADMIN_OPEN_CIDR falls through to the fail-closed 401, it never falls back to open-all. A partially-valid value (at least one entry parses) is honored for its valid entries; only the fully-unparseable case is treated as a misconfiguration.

This includes two entry shapes that look plausible but must never be accepted as a real restriction: a trailing slash with an empty prefix (FA_ADMIN_OPEN_CIDR=10.0.0.0/, e.g. a typo'd /8) and an explicit /0 (FA_ADMIN_OPEN_CIDR=0.0.0.0/0). Both would otherwise match every address in their family — the parser rejects them outright rather than silently opening admin to the internet under a "restricted to ..." warning. An operator who genuinely wants open-all should unset FA_ADMIN_OPEN_CIDR, not set a /0.

The allowlist matches the direct TCP socket peer (req.socket.remoteAddress), never X-Forwarded-For or req.ip — both are supplied by the client and trivially spoofed, so an allowlist built on them would be meaningless. This means the allowlist only works for direct binds: behind a reverse proxy, the peer FA sees is the proxy itself, not the original client, so FA_ADMIN_OPEN_CIDR cannot distinguish real clients in that setup — use ADMIN_API_KEY (or a proxy-level access control) instead when FA sits behind a proxy.

Running with systemd

ini
# /etc/systemd/system/featureagent.service
[Unit]
Description=Weftra
After=network.target

[Service]
Type=simple
User=featureagent
WorkingDirectory=/opt/featureagent
ExecStart=/usr/bin/node dist/index.js
EnvironmentFile=/opt/featureagent/.env
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
bash
sudo systemctl enable featureagent
sudo systemctl start featureagent

Deploy with Docker Compose (one command)

The fastest path from a fresh clone to a running, persistent FA with per-run agent sandboxing is Docker Compose. The compose file keeps RUNTIME=docker as the default so the safe path (isolated per-run containers) is also the easy path — you never need to opt out of safety to get started.

Prerequisites: Docker 20.10+ and Docker Compose v2 (docker compose version).

bash
# 1. Copy the example env file and fill in your values
cp .env.example .env
#    Required: ANTHROPIC_API_KEY, GITHUB_TOKEN, ADMIN_API_KEY
#    Set RUNTIME=docker (already the compose default — do not set RUNTIME=local here)

# 2. Start FA in the background
docker compose up -d

# 3. Open the dashboard
open http://localhost:3100

Why ADMIN_API_KEY is required here specifically: the compose file binds FA to 0.0.0.0 inside the container (required so the published port is reachable at all — see docker-compose.yml's FA_BIND comment), which the admin-auth "exposed" check (docs/OPERATIONS.md §"The admin credential (file mode)") treats as network-reachable. Leave ADMIN_API_KEY unset and FA either self-provisions a key you have no easy way to read back out of the container, or (in FA_SECRET_MODE=managed) refuses to start — set it explicitly instead.

FA is now running with:

  • Persistent statedata/ (SQLite DB) and workspaces/ stored in named Docker volumes; they survive docker compose restart and image rebuilds.
  • Per-run agent sandboxing — FA launches each agent run inside a fresh container via RUNTIME=docker; the untrusted agent never touches the host.
  • Auto-restart — the service restarts automatically on crash or host reboot.

Docker socket trust posture. The compose file mounts /var/run/docker.sock into the FA container so FA (the trusted Governor/orchestrator) can spawn per-run sandbox containers on the host daemon. This grants the FA process the ability to control Docker on the host, which is effectively host-root access. This is acceptable only because FA is the Governor, not the untrusted agent — keep RUNTIME=docker (the default) so the agents themselves remain sandboxed. Never set RUNTIME=local in a compose deployment; that removes the per-run isolation and defeats the purpose of the socket mount.

oauth auth mode (optional). If you prefer Claude OAuth credentials over an API key, uncomment the FA_AGENT_OAUTH_DIR volume line in docker-compose.yml and set FA_AGENT_AUTH=oauth in .env. FA will mount your local ~/.claude creds directory read-only into each agent container.

Useful operations:

bash
docker compose logs -f fa          # Tail live FA logs
docker compose restart fa          # Restart without rebuilding
docker compose up -d --build       # Rebuild image after a code change
docker compose down                # Stop (volumes are preserved)
docker compose down -v             # Stop AND delete state volumes (destructive!)

See docs/OPERATIONS.md for the full operations runbook (state, backup, and the relationship to the systemd path).

Validate your install

Before you trust a fresh compose install (or before a launch freeze — see docs/OPERATIONS.md's launch-freeze checklist), run the automated cold-start rehearsal:

bash
npm run rehearse:install

This is a separate operator tool (deploy/rehearsal/, not part of the FA server) that proves the one-command deploy actually works end-to-end, in full isolation from any real deployment:

  1. Generates a fresh, throwaway .env (no real secrets — ANTHROPIC_API_KEY/GITHUB_TOKEN are left blank; the golden path below never needs them).
  2. docker compose build then up -d, scoped to an isolated fa-rehearsal compose project name and image tag — never your real deployment's containers, volumes, or image. The container is published on a freshly-picked free host port (never a fixed 3100), so the rehearsal never collides with a real FA already running on this host.
  3. Polls GET /health until it's reachable (proves the published port actually works, not just the in-container healthcheck).
  4. Runs a golden-path HTTP smoke test: admin auth, POST /api/projects (creates a project and gets back an fa_ key), POST /api/features (submits a feature and confirms it reaches a valid pre-agent state), GET / (the dashboard).
  5. Tears down (docker compose down -v), always — pass or fail.

Each step reports PASS/FAIL; the run exits non-zero and names the first failing step if anything breaks, and prints the elapsed time against the ~10-minute claim. Pass --force-clean if a previous rehearsal run was left live and you want it removed first (still scoped to the fa-rehearsal project only — never your real deployment).

Secrets management (FA_SECRET_MODE)

FA supports two modes for the admin API key:

ModeBehaviour
file (default)FA self-provisions ADMIN_API_KEY at startup if absent and writes it to .env. Back-compat; existing installs unaffected.
managedFA reads ADMIN_API_KEY from the environment only. If the key is absent at startup, FA fails closed with an actionable error and writes nothing to disk. Use this with a secret manager (Vault, K8s Secrets, Doppler) that injects env vars at launch.

How to use managed mode:

  1. Set FA_SECRET_MODE=managed in your deployment config (systemd EnvironmentFile, K8s envFrom, etc.).
  2. Inject ADMIN_API_KEY=<your-key> via your secret manager before FA starts.
  3. FA reads the injected value on startup — no .env write, no self-provisioning.

Admin key rotation (see OPERATIONS.md for the full runbook):

bash
# Rotate via the API (admin auth required):
curl -X POST https://your-fa-host/api/admin/rotate-admin-key \
  -H "Authorization: Bearer <current-admin-key>"
# → { "admin_key": "fa_<new-64-hex>" }

In file mode, FA rewrites the .env file immediately and the new key is active in-process. In managed mode, FA updates in-process only — update your secret manager and redeploy to persist.

Security Checklist

  • [ ] Create admin users and use their API keys instead of the legacy ADMIN_API_KEY
  • [ ] Set ADMIN_API_KEY as a fallback to protect project management endpoints
  • [ ] Use fine-grained GitHub tokens with minimal repo access
  • [ ] Run in an isolated environment when using dangerously-skip-permissions
  • [ ] Restrict network egress to required endpoints only
  • [ ] Keep WORKSPACE_DIR on a separate volume
  • [ ] Regularly rotate API keys and tokens
  • [ ] Monitor agent activity via the dashboard and webhooks

Expose the product-definition action to a GPT-class client

Spec 373 inc-2 (RM-146). GET /api/product/openapi.json is a generated, narrower OpenAPI 3.1 document — filtered from FA's own full API document down to exactly the Product Definition Plane's project-key read + propose routes (list/read/create/version/review an outcome, roadmap item, use case, story, or spec link; export the project's bundle). It excludes retiring, proposing readiness, compiling, and every agent-factory route — an action built from it can read and draft, never promote, retire, or execute anything.

The URL:

https://<your-fa-instance>/api/product/openapi.json

It is unauthenticated — like /api-docs itself, it's a schema document, not data — so no FA credential is needed to fetch it. The routes it describes each still require a project API key.

Registering it as a GPT Action (or any OpenAPI-driven tool):

  1. Import the document from the URL above (most "custom action"/"OpenAPI tool" builders accept a URL directly; others need you to paste the JSON body).
  2. Set authentication to Bearer, and supply a project API key (fa_...) — the same key you'd use for any other project-scoped call. Never the admin key: every operation in this document declares the project-key scheme only, and the underlying routes reject anything else the same way they always have.
  3. If your tool lets you set a static header on every call, add X-FA-Door: openapi-action. This is optional and purely informational — FA records it in authored_via when the credential is a project key, so you can tell which door authored a given artifact later (GET /api/projects/:id/product/stats, admin/approver only). It is a self-declared label, not a credential, and grants nothing by itself.

What the action can and cannot do: list and read outcomes/roadmap items/use cases/stories/spec links; create a new one (always status: draft); add a new version; run the Definition-of-Ready review on demand; export the project's canonical bundle. It cannot retire an artifact, set status: ready, compile an agent solution, or reach any /agents/* route — those stay admin/approver-only HTTP calls outside this document. See docs/PRODUCT_DEFINITION_QUICKSTART.md for the artifact shapes each kind expects.

For a terminal instead of a GPT client, see docs/USER_GUIDE.md "Project Sidecar CLI (fa)" → fa product — the same routes, sending X-FA-Door: fa-client.


Troubleshooting

Agent not processing features

  1. Check agent status: curl http://localhost:3100/api/status
  2. Ensure Claude Code is authenticated: claude --print "hello"
  3. Check logs for errors
  4. Verify features are in queued status (not stuck in analyzing or clarification_needed)

Claude Code permission errors

If features fail with permission errors:

  • Use AGENT_PERMISSIONS=dangerously-skip-permissions in isolated environments
  • Or configure Claude Code's .claude/settings.json to auto-approve tools

Webhook delivery failures

  • Check your endpoint is reachable from the Weftra server
  • Verify HTTPS certificates are valid
  • Increase WEBHOOK_TIMEOUT_MS for slow endpoints
  • Check logs for retry messages

WhatsApp messages not sending

  • Verify Twilio credentials in .env
  • Ensure recipients have joined the Twilio sandbox (for testing)
  • Check phone numbers are in whatsapp:+15551234567 format
  • Verify notifications_enabled is true on the project

Database issues

bash
# Reset the database (dev only!)
npm run db:reset

The SQLite database auto-creates on first run. If corrupted, delete the .db file and restart.

Released under the MIT License.