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:buildto generate a static documentation site from these docs. Seedocs-site/README.mdfor 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_idfield appears on project reads; multi-org management arrives with a future increment.
Table of Contents
- Prerequisites
- Installation
- Claude Code Setup
- Agent Configuration
- Permissions & Safety
- Model Selection
- WhatsApp Notifications (Twilio)
- GitHub Integration — incl. PR-merge tracking, manual PR creation, revisions
- GitLab Integration — draft MRs, merge detection, revisions on gitlab.com or self-hosted
- Bitbucket Cloud Integration — PRs, merge detection, revisions on bitbucket.org
- Spec-Kit Pipeline
- Workspace Confinement & Data Provisioning
- Inbound Triggers — receive webhooks from GitHub/GitLab/Linear/Sentry/Slack
- Webhook Configuration
- Users & Roles
- Protected Projects
- Self-Managed Development
- Diagnostic Tool
- Production Deployment
- 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.pubor similar) as a deploy key or to an account with repo access.
Installation
# Clone the repository
git clone https://github.com/scottallan/featureagent.git
cd featureagent
# Install dependencies
npm install
# Create your environment file
cp .env.example .envEdit .env with your configuration (detailed in sections below).
# Start in development mode (hot-reload)
npm run dev
# Or build for production
npm run build
npm startClaude 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
# Install globally via npm
npm install -g @anthropic-ai/claude-code
# Verify installation
claude --versionAuthenticate
# Interactive login (opens browser)
claude login
# Or set API key directly
export ANTHROPIC_API_KEY=sk-ant-...Verify It Works
# Quick test — should return a response
claude --print "Say hello"Custom CLI Path
If Claude Code is installed in a non-standard location:
CLAUDE_CLI_PATH=/usr/local/bin/claudeAgent Configuration
Core Settings
# 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_SLOTScan all be overridden live from the admin dashboard (Settings → Runtime Settings) or viaGET/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_MSis the one exception: it's bound into a timer at startup, so changing it still requires editing.envand restarting FA. Seedocs/OPERATIONS.md§"Admin Runtime Settings" for the full classification of everyconfig.tsoption.
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
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
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:
- Picks up features in
analyzingstatus and runs a single-shot Claude Code call to evaluate them - Picks up features in
queuedstatus (up toAGENT_MAX_CONCURRENCY) and starts implementation - Picks up
failedfeatures withretry_count < AGENT_MAX_RETRIESand 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:
--printmode (non-interactive, outputs text)--output-format text--modelset to yourAGENT_MODEL(default:sonnet)--max-turnsset to yourAGENT_MAX_TURNS(default: 100)--dangerously-skip-permissionsif configured- An idle-output watchdog: killed after
AGENT_IDLE_TIMEOUT_MS(default 15 min) of no stdout/stderr, reset by any output — seeAGENT_IDLE_TIMEOUT_MSabove 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 largerAGENT_TOOL_STALL_TIMEOUT_MSceiling 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
| Path | Purpose | Lifetime |
|---|---|---|
WORKSPACE_DIR/<feature-id>/ | Cloned repo for implementation | Temporary — deleted after job completes or fails |
data/featureagent.db | SQLite database with all features, projects, users | Persistent |
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/3An 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:
# 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:
# 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:
ps aux | grep claudePermissions & Safety
This is the most important configuration decision. It determines how much autonomy the Claude Code agent has when implementing features.
Default Mode (Recommended for Getting Started)
AGENT_PERMISSIONS=defaultIn 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)
AGENT_PERMISSIONS=dangerously-skip-permissionsWARNING: 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:
Run in a container — Use Docker to isolate the agent:
bashdocker run -d \ -e AGENT_PERMISSIONS=dangerously-skip-permissions \ -e ANTHROPIC_API_KEY=sk-ant-... \ -v /var/featureagent/data:/app/data \ featureagentLimit 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)
Use fine-grained GitHub tokens — Only grant access to specific repositories the agent should modify. Never use tokens with admin/org-level access.
Restrict workspace directory — Keep
WORKSPACE_DIRon a separate partition or volume.
Allowlisted Tools (Future)
Claude Code also supports granular tool permissions via --allowedTools. This is a middle ground:
# 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
AGENT_MODEL=sonnet # DefaultAvailable models:
| Model | Setting | Best For |
|---|---|---|
| Claude Sonnet 4.6 | sonnet | Balanced speed and quality. Good default for most features. |
| Claude Opus 5 | opus | Complex features requiring deep reasoning. Slower, higher cost. |
| Claude Haiku 4.5 | haiku | Simple features, fast turnaround. Lower cost. |
Recommendations
- Start with
sonnet— Good balance of capability and cost for most features - Use
opusfor — Large architectural changes, complex multi-file features, features requiring deep understanding of the codebase - Use
haikufor — Simple bug fixes, config changes, documentation updates
Max Turns
AGENT_MAX_TURNS=100Controls 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
| Channel | Cost | Setup | Best For |
|---|---|---|---|
| Telegram | Free | 5 min | Best free option. No limits, no verification. |
| Discord | Free | 2 min | Teams already using Discord. |
| Slack | Free tier | 5 min | Teams already using Slack. |
| Paid (Twilio) or free tier (Meta) | 15-60 min | Direct mobile notifications. | |
| Free (SMTP) | 10 min | Universal fallback. |
Notification Rules
Each channel has a notify array controlling who receives messages:
| Status Change | Default Recipient |
|---|---|
→ awaiting_approval | PO |
→ clarification_needed | Submitter (or PO in po_approval mode) |
→ in_progress | Submitter |
→ implemented | Submitter + PO |
→ failed | Submitter + PO |
Set notifications_enabled: false on a project to disable all notifications.
Telegram Setup (Recommended — Free)
- Message @BotFather on Telegram
- Send
/newbot, follow prompts, get your bot token - Message your bot, then visit
https://api.telegram.org/bot<TOKEN>/getUpdatesto find your chat_id - Configure:
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
- In your Discord server: Server Settings → Integrations → Webhooks → New Webhook
- Copy the webhook URL
- Configure:
{
"type": "discord",
"webhook_url": "https://discord.com/api/webhooks/123456/abcdef...",
"notify": ["all"]
}Discord notifications include rich embeds with color-coded status.
Slack Setup
- Create a Slack app at api.slack.com/apps
- Enable Incoming Webhooks, add to a channel
- Copy the webhook URL
- Configure:
{
"type": "slack",
"webhook_url": "https://hooks.slack.com/services/T00/B00/xxxx",
"notify": ["all"]
}WhatsApp Setup
Via Twilio:
{
"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):
{
"type": "whatsapp",
"provider": "meta",
"access_token": "EAAxxxxxxx",
"phone_number_id": "123456789",
"po_number": "15551234567",
"submitter_number": "15559876543",
"notify": ["all"]
}Email Setup
{
"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:
"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
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
Configure:
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx- Enable per-project:
# 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 projectWhat 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'sbase_branchoverride)
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_TOKENis 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 thePUTrejects with400and 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 underRUNTIME=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:
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:
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
Create a GitLab personal access token (or a project access token) with at minimum:
- api scope (needed for MR creation, notes, and approvals)
Configure:
GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx- For self-hosted GitLab (optional):
# Hostname only — no https:// prefix, no trailing slash
GITLAB_HOST=gitlab.mycompany.comWhen 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 URL | Provider used |
|---|---|
github.com | GitHub (Octokit, GITHUB_TOKEN) |
gitlab.com | GitLab (REST v4, GITLAB_TOKEN) |
value of GITLAB_HOST | GitLab (REST v4, GITLAB_TOKEN) |
| anything else | GitHub (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'sbase_branchoverride)
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:
# 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_TOKENis 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. Onlybitbucket.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.
- Go to Bitbucket Cloud → Personal settings → App passwords → Create app password
- Name it (e.g.
feature-agent) and grant these scopes:- Repositories:
Read - Pull requests:
Write
- Repositories:
- Copy the generated password (shown once).
- Configure:
BITBUCKET_TOKEN=your-app-password
BITBUCKET_USERNAME=your-bitbucket-usernameWhen 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):
- Go to Workspace settings → Security → Access tokens (or Repository settings → Access tokens)
- Create a token with
repository:readandpullrequest:writescopes. - Configure:
BITBUCKET_TOKEN=your-access-token
# BITBUCKET_USERNAME — leave unset when using a workspace/repo access tokenWithout 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 URL | Provider used |
|---|---|
github.com | GitHub (Octokit, GITHUB_TOKEN) |
gitlab.com | GitLab (REST v4, GITLAB_TOKEN) |
value of GITLAB_HOST | GitLab (REST v4, GITLAB_TOKEN) |
bitbucket.org | Bitbucket Cloud (REST v2.0, BITBUCKET_TOKEN) |
| anything else | GitHub (backward-compatible default) |
Both HTTPS and SSH repo URLs are recognized:
https://bitbucket.org/myworkspace/myrepo.gitgit@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'sbase_branchoverride)
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:
# 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_TOKENandBITBUCKET_USERNAMEare 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
FA_BASE_URL=http://127.0.0.1:3100 \
FA_PROJECT_API_KEY=fa_your_project_key \
npm run mcp-server| Variable | Default | Purpose |
|---|---|---|
FA_BASE_URL | http://127.0.0.1:3100 | Base 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)
{
"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 byrequireProjectAuth— the same guard every other tenant HTTP route in FA uses (no separate/weaker check). A missing or invalid key is rejected with401before 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— seedocs/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 orADMIN_API_KEY— enforced both by the tool set and by an allowlist of exactly those routes inside the sharedcallFAhelper (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 hostsrequireAdminAuthroutes that do not (/api/builder/admin/*,/api/builder/new-project). - The spec-drafting tools are rate-limited.
draft_spec,clarify_specandrefine_speceach 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 — seedocs/USER_GUIDE.md§ "Bounded model spend".estimate_specmakes no model call and is not capped. - Off by default. With
FA_MCP_HTTP_ENABLEDunset,POST /api/mcp(andGET/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_KEYunset andFA_ALLOW_OPEN_ADMIN=1, every request to this endpoint gets503instead — setADMIN_API_KEY(recommended) or unsetFA_ALLOW_OPEN_ADMINto bring it back. Seedocs/OPERATIONS.md§13 for why.
Enabling it
FA_MCP_HTTP_ENABLED=trueSet 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.
| Variable | Default | Purpose |
|---|---|---|
FA_MCP_HTTP_ENABLED | unset (disabled) | Opt-in flag. Set to true to mount the endpoint. |
FA_MCP_HTTP_BASE_URL | this 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.0 → 127.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:
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
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| Variable | Default | Purpose |
|---|---|---|
FA_MCP_OAUTH_ENABLED | unset (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_ALLOWLIST | unset (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_SEC | 3600 (1 hour) | Minted access token lifetime. Bounded 1–86400s. Tokens remain individually revocable regardless of TTL. |
FA_MCP_OAUTH_CODE_TTL_SEC | 60 | Authorization code lifetime — short by design (single-use, exchanged immediately after the browser redirect). Bounded 1–600s. |
FA_MCP_OAUTH_MAX_CLIENTS | 100 | Hard 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_SEC | 2592000 (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_MS | 60000 (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_WINDOW | 10 | Max 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_url | http://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
- The MCP client fetches
GET /.well-known/oauth-authorization-serverandGET /.well-known/oauth-protected-resourceto discover FA's endpoints (RFC 8414 / RFC 9728). - It registers itself via
POST /oauth/mcp/registerwith itsredirect_uris(rejected outright if any aren't on your allowlist above) — a public client, noclient_secret(PKCE, S256 only, is the sole proof-of-possession; there is no plain-PKCE or implicit-grant fallback). - It opens the human's browser at
GET /oauth/mcp/authorizewith 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 theclient_idand the exactredirect_urithe 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. - On approval, the browser is redirected back to the client's (already-vetted)
redirect_uriwith a single-use authorization code. - The client exchanges that code — with its PKCE verifier — at
POST /oauth/mcp/tokenfor a short-lived access token scoped to that one project. - The client calls
POST /api/mcpwithAuthorization: Bearer <access_token>exactly as it would with a pastedfa_...key —src/routes/mcp-http.tsaccepts either credential kind and resolves to the identical project scope. POST /oauth/mcp/revokeinvalidates a token immediately (single call, idempotent — always200, 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-256token_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 underFA_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
# Clone, run `specify init --ai claude`, generate a constitution, open a bootstrap PR
curl -X POST http://localhost:3100/api/projects/:id/spec-kit/enableStatus flows disabled → enrolling → awaiting_merge. Watch live enrollment logs:
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:
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:
curl -X POST http://localhost:3100/api/projects/:id/spec-kit/checkTo 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:
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()):
- Validates
data_dirsexist (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. - Runs
git lfs pull(best-effort — fine if the repo has no LFS). - Symlinks each
data_dirsentry read-only into the workspace and adds it to.git/info/excludeso it's never committed (path-traversal guarded). - Runs
setup_commandin 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:
FA_WORKER_REJECTED_CLASS_COOLDOWN_HOURS=168| Variable | Default | Meaning |
|---|---|---|
FA_WORKER_REJECTED_CLASS_COOLDOWN_HOURS | 168 (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):
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/services — PATCH /api/project or admin's PATCH /api/projects/:id):
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):
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.ts → checkEgressTarget, 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).
# .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: falseGate execution:
- FA runs each gate via
bash -lc "<command> 2>&1"inside the sandbox Runtime (the same isolated environment the agent used). IfRUNTIME=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_ITERATIONScycles, 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 tofailedand the workspace is preserved. No commit is pushed and no PR is created. - Set
FA_MAX_GATE_ITERATIONS=0to 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 aswarnbut do not block and never trigger iteration.
Gate shape:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | yes | — | Non-empty label used in logs, run-events, and the provenance artifact. |
command | string | yes | — | Shell command to run. FA runs it and reads only the exit code. |
block | boolean | no | true | true = 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:
FA_INBOUND_TRIGGERS_ENABLED=trueWithout 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:
curl http://localhost:3100/api/projects/<projectId>/triggers \
-H "Authorization: Bearer <project-api-key>"Response:
{
"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
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:
{
"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-tokenheader to your project's dedicated trigger secret, or its API key as a fallback (or include"token": "..."in the body) - Optionally set
x-fa-timestampto the current Unix epoch seconds for replay-window tolerance (FA_TRIGGER_TIMESTAMP_TOLERANCE_SECONDS, default 300s) — seedocs/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:
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
{
"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:
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_RETRIESandWEBHOOK_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_SECRETmust 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).
Configuring SMTP — portal (recommended)
SMTP can be configured without editing .env or restarting FA. In the admin dashboard go to Settings → Email (SMTP):
| Field | Description |
|---|---|
| SMTP host | Your SMTP server hostname (e.g. smtp.gmail.com) |
| Port | Typically 587 (STARTTLS) or 465 (SSL) |
| Security mode | STARTTLS (recommended), SSL / Implicit TLS, or None — set explicitly, never inferred from port |
| Username | SMTP authentication username |
| Password | Write-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 address | The From: header on outgoing emails |
| Reset-link base URL | The 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):
# 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.comNote: 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:
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:
Create the user account (admin API):
bashcurl -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"}'Send Alice the login page URL:
https://your-fa-domain.com/login.htmlAlice 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}wherefingerprintis derived from the currentpassword_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_ATTEMPTSandLOCAL_AUTH_LOCKOUT_MS).
API endpoints
| Endpoint | Method | Body | Description |
|---|---|---|---|
/auth/local/login | POST | {email, password} | Issue a session cookie. 401 on wrong credentials (uniform, no enumeration). |
/auth/local/reset-request | POST | {email} | Send reset email. Always returns 200 (no enumeration). |
/auth/local/reset | POST | {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
- A Google Cloud project with the OAuth 2.0 API enabled.
- An OAuth 2.0 Client ID of type "Web application".
Step-by-step
- Go to Google Cloud Console → APIs & Services → Credentials.
- Click "Create Credentials" → "OAuth 2.0 Client ID". Choose "Web application".
- Under "Authorized redirect URIs", add your redirect URI:
- Development:
http://localhost:3100/auth/google/callback - Production:
https://your-fa-domain.com/auth/google/callback
- Development:
- Click Create. Note the Client ID and Client Secret.
Environment variables
Add these three variables to your .env (or system environment — never commit secrets):
# 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:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"How it works
- FA implements OAuth 2.0 authorization code + PKCE (RFC 7636) with
statefor CSRF protection on the handshake. - The returned
id_tokenis 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 (+
Securein production) signed session cookie valid for 7 days. State-changing requests via the session cookie require anX-CSRF-Tokenheader (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:
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
- Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
- Set the Authorization callback URL:
- Development:
http://localhost:3100/auth/github/callback - Production:
https://your-fa-domain.com/auth/github/callback
- Development:
- Register the app. Note the Client ID, then generate and note a Client Secret.
Step-by-step — GitLab
- Go to GitLab → User Settings → Applications → Add new application.
- Set the Redirect URI:
- Development:
http://localhost:3100/auth/gitlab/callback - Production:
https://your-fa-domain.com/auth/gitlab/callback
- Development:
- Under Scopes, check
read_useronly (least-privilege — FA only reads the profile/email). - Save. Note the Application ID and Secret.
Environment variables
# 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=falseSESSION_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
statefor CSRF protection on the handshake: thestatevalue is held in a signed, HttpOnlyfa_oauth_statecookie 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/S256go out on the authorize URL andcode_verifierin 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 enforcedcode_verifierproves that, on GitHub nothing does. So on the GitHub path the binding isstatealone: 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 intooauth_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 secondidentity.linkrow. 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 signedstateverified again and again untilexp. What it does NOT close: a GitHub OAuth App still ignores PKCE, so the ONE attemptstatenow buys is still bound by nothing cryptographic on that path — only bystate'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, seedocs/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-usestate, link through Google or GitLab, whose token endpoints enforce PKCE. - The access token is exchanged server-side only: it is a local in
exchange*Code→fetch*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 theidentitiestable, whereproviderisgithub/gitlabfor the public hosts andgithub:<host[:port]>/gitlab:<host[:port]>for a self-hosted base URL (githubIdentityProvider/gitlabIdentityProviderin 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 inhandleFederatedCallback(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_URLstarts 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 fromidentities(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 (connectionTargetin 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 ashttps://gitlab.internal:8443is dialled on that port. - Only a verified email maps to an account: GitHub — the
GET /user/emailsentry withprimary && verified; GitLab — the profile email, only whenGET /userreports aconfirmed_attimestamp for the address. GitLab's accountstate(active,blocked, …) is not accepted as a stand-in for email confirmation — an unconfirmed address is refused infetchGitlabProfile(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/statusexposesgithubLoginEnabled/gitlabLoginEnabledas 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:
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_SECRETmust 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_tokenwithemailandemail_verifiedclaims (standard OIDC scopeopenid email profile).
Step-by-step (generic)
- Create an OIDC application (called "Web application", "Regular Web App", or similar) in your IdP's admin console.
- Set the Allowed Redirect URI / Callback URL to:
- Development:
http://localhost:3100/auth/oidc/callback - Production:
https://your-fa-domain.com/auth/oidc/callback
- Development:
- Note the Client ID and Client Secret issued by the IdP.
- Note the Issuer URL (e.g.
https://login.microsoftonline.com/<tenant-id>/v2.0for Entra ID, orhttps://your-org.okta.comfor Okta). FA appends/.well-known/openid-configurationto this URL for discovery.
Environment variables
# 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_SECRETis a secret. Inject it via an environment variable, a secret manager (Vault, Doppler, AWS SSM, K8s Secret), or a.envfile that is never committed. FA never logs or returns it via any API.
IdP-specific notes
| IdP | Issuer URL pattern | Notes |
|---|---|---|
| Okta | https://your-org.okta.com or https://your-org.okta.com/oauth2/default | Use the Authorization Server issuer, not just the org domain. |
| Azure AD / Entra ID | https://login.microsoftonline.com/<tenant-id>/v2.0 | Enable email claim via "Token configuration" in Azure Portal. |
| Auth0 | https://your-tenant.auth0.com | Enable OIDC-conformant mode; add email to the ID token claims. |
| Keycloak | https://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.com | Works 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→ fetchesauthorization_endpoint,token_endpoint, andjwks_uri. - The
id_tokenis verified locally (RSA-SHA256 signature against the IdP's JWKS, plusiss,aud,exp,nonce, andemail_verified). No secret is ever sent to a verification service. - Nonce is used for anti-replay (state cookie + signed
noncein the token). - OIDC is enabled iff
OIDC_ISSUER,OIDC_CLIENT_ID,OIDC_CLIENT_SECRET, andOIDC_REDIRECT_URIare 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. SetOIDC_ALLOW_SIGNUP=trueto let any IdP-authenticated user self-provision an FA account with the least-privilegeapproverrole.
User provisioning
By default (OIDC_ALLOW_SIGNUP=false), an admin must create each FA user account before they can sign in via SSO:
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):
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
# 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 Type | Who Uses It | What It Can Do |
|---|---|---|
Project API key (fa_...) | External systems (CI, apps) | Submit features, list features, poke for updates |
Admin user key (fa_...) | Human admins | Everything — project CRUD, user CRUD, approve any feature |
Approver user key (fa_...) | Product owners / approvers | Approve features for linked projects, view scoped features |
Legacy ADMIN_API_KEY env var | Backwards compatibility | Same 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:
# 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:
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:
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
# 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_KEY | Exposed? | Behavior |
|---|---|---|
| set (plausible) | either | No change — starts normally, binds the configured host. |
| set, but not a plausible value (e.g. a leftover placeholder) | either | Refuses 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. |
| unset | no (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). |
| unset | yes | Starts, 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. |
| unset | yes, with FA_ALLOW_OPEN_ADMIN=1 | Starts 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
# /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.targetsudo systemctl enable featureagent
sudo systemctl start featureagentDeploy 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).
# 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:3100Why 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 state —
data/(SQLite DB) andworkspaces/stored in named Docker volumes; they survivedocker compose restartand 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:
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:
npm run rehearse:installThis 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:
- Generates a fresh, throwaway
.env(no real secrets —ANTHROPIC_API_KEY/GITHUB_TOKENare left blank; the golden path below never needs them). docker compose buildthenup -d, scoped to an isolatedfa-rehearsalcompose 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.- Polls
GET /healthuntil it's reachable (proves the published port actually works, not just the in-container healthcheck). - Runs a golden-path HTTP smoke test: admin auth,
POST /api/projects(creates a project and gets back anfa_key),POST /api/features(submits a feature and confirms it reaches a valid pre-agent state),GET /(the dashboard). - 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:
| Mode | Behaviour |
|---|---|
file (default) | FA self-provisions ADMIN_API_KEY at startup if absent and writes it to .env. Back-compat; existing installs unaffected. |
managed | FA 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:
- Set
FA_SECRET_MODE=managedin your deployment config (systemdEnvironmentFile, K8senvFrom, etc.). - Inject
ADMIN_API_KEY=<your-key>via your secret manager before FA starts. - FA reads the injected value on startup — no
.envwrite, no self-provisioning.
Admin key rotation (see OPERATIONS.md for the full runbook):
# 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_KEYas 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_DIRon 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.jsonIt 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):
- 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).
- 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. - 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 inauthored_viawhen 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
- Check agent status:
curl http://localhost:3100/api/status - Ensure Claude Code is authenticated:
claude --print "hello" - Check logs for errors
- Verify features are in
queuedstatus (not stuck inanalyzingorclarification_needed)
Claude Code permission errors
If features fail with permission errors:
- Use
AGENT_PERMISSIONS=dangerously-skip-permissionsin isolated environments - Or configure Claude Code's
.claude/settings.jsonto auto-approve tools
Webhook delivery failures
- Check your endpoint is reachable from the Weftra server
- Verify HTTPS certificates are valid
- Increase
WEBHOOK_TIMEOUT_MSfor 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:+15551234567format - Verify
notifications_enabledis true on the project
Database issues
# Reset the database (dev only!)
npm run db:resetThe SQLite database auto-creates on first run. If corrupted, delete the .db file and restart.