Weftra — Complete Setup & Configuration Guide
Using Weftra 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: Weftra 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 Weftra host — Weftra clones project repositories over SSH using the host machine's SSH key. HTTPS remotes are not supported (Weftra has no credential model for HTTPS and will reject them at enrollment). Make sure the SSH key on the Weftra 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/weftra/weftra.git
cd weftra
# 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 startStoring a per-tenant model key in the local encrypted backend (optional)
By default every run uses the operator's own ANTHROPIC_API_KEY (AGENT_MODEL / the engine config below). To give a tenant its own model credential instead (spec 479 inc-2), do this once, in order:
- Confirm
FA_INSTANCE_SECRET_KEYis set —filemode self-provisions it into.envat first startup;managedmode requires it from your secret manager (seedocs/OPERATIONS.md§ "Data at rest"). - Create a named admin user (
POST /api/users, roleadmin) — the write-through entry requires a named admin identity; the sharedADMIN_API_KEYcannot use it. - Sign in (or present that admin's own API key) and store the key from the dashboard's Tenant Credentials tab, under "Store a key in this instance" — see
docs/OPERATIONS.md§ "Local encrypted backend" for the equivalentcurlform.
Storing a key on an engine account in the local encrypted backend (optional)
An engine account's key does not have to live in a host environment variable (ANTHROPIC_API_KEY_<SUFFIX>, see "Engine Accounts" in docs/USER_GUIDE.md) — it can be entered directly, encrypted at rest, from the dashboard (spec 479 inc-3):
- Confirm
FA_INSTANCE_SECRET_KEYis set, as above. - Create a named admin user, as above — the same bar applies here.
- On the Engine Accounts tab's registration form, choose "Store a key in this instance" instead of "Reference a key held elsewhere" — see
docs/OPERATIONS.md§ "Local encrypted backend" for the equivalentcurlform againstPOST /api/admin/engine-credentials/stored-key.
This takes the key out of the host environment: it is encrypted in this instance's own database, and the registry row keeps only a handle. It does not take the key off the host. The ciphertext sits in Weftra's SQLite file and FA_INSTANCE_SECRET_KEY, which decrypts it, lives on the same host — so this protects the database file and its backups, not a compromised host. Anyone who can read both the database and the instance key can recover the value; keep protecting the host as before.
To obtain a Claude oauth token from the instance itself (the connect flow), set FA_CLAUDE_CONNECT_EGRESS_HOSTS (comma-separated hosts the throwaway sign-in container may reach; the default covers the Claude sign-in and API hosts) and run with RUNTIME=docker. See docs/OPERATIONS.md § "Local encrypted backend".
A stored-key account is used by default-engine runs in api mode and by the account's Check action, in place of an ANTHROPIC_API_KEY_<SUFFIX> host variable. If its key is revoked, a run that selects it fails; it never falls back to the host ANTHROPIC_API_KEY. Storing an OAuth token this way is not supported.
Claude Code Setup
Weftra uses the Claude Code CLI to autonomously implement features. You need Claude Code installed and authenticated on the machine running Weftra.
Install Claude Code
# 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 (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
# How many features may be in automated analysis at once (spec 483 / RM-364). A
# short, tool-less model call dispatched on its OWN lane, in ADDITION to
# AGENT_MAX_CONCURRENCY — never carved out of it, and never bounded by
# REVIEW_RESERVED_SLOTS. Minimum 1: a value of 0 would park every feature entering
# analysis with no error. See docs/OPERATIONS.md §"Reserved review capacity".
ANALYSIS_MAX_CONCURRENCY=2 # Default: 2
# 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 Weftra'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), Weftra 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, (spec 423)REVIEW_RESERVED_SLOTS, and (spec 483)ANALYSIS_MAX_CONCURRENCYcan 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 Weftra. Seedocs/OPERATIONS.md§"Admin Runtime Settings" for the full classification of everyconfig.tsoption.
Environment variable names
Every FA_<NAME> environment variable may also be written WEFTRA_<NAME> — for example WEFTRA_RUNTIME_IMAGE for FA_RUNTIME_IMAGE, or WEFTRA_BASE_URL and WEFTRA_API_KEY for the fa CLI. WEFTRA_* is the documented name going forward; existing FA_* settings in a .env, Compose file or Helm values file keep working unchanged, so nothing has to be renamed.
- Only the prefix differs. The rest of the name is identical, and the rule covers every variable, including ones added later.
- If both names of a pair are set to the same value, nothing changes.
- If both names of a pair are set to different values, Weftra refuses to start. The error names every conflicting pair and never prints a value. Remove one of the two, or give them the same value.
- An empty or whitespace-only value counts as unset, so a
.envcopied from an older.env.example(which carried emptyFA_*lines) does not conflict with a realWEFTRA_*value. - The
faCLI and the MCP adapter apply the same rule to their own environment and exit non-zero on a conflict. - Variables without the
FA_prefix (ADMIN_API_KEY,GITHUB_TOKEN,AGENT_*,RUNTIME,PORT,MCP_*) are unchanged.
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/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.
ANALYSIS_MAX_CONCURRENCY (default 2, spec 483 / RM-364) is a separate budget again: analysis (the agent's first look at a feature, before it is queued) is a short, tool-less model call, not a build — it no longer waits for AGENT_MAX_CONCURRENCY at all, and this limit bounds it on its own lane, in addition to AGENT_MAX_CONCURRENCY rather than carved out of it. Size AGENT_MAX_CONCURRENCY for the host's build/test capacity (each build or revise run's test gate runs the project's full suite); size ANALYSIS_MAX_CONCURRENCY for model-call quota, since an analysis costs no host CPU beyond one host-spawned Claude CLI call.
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)
REVIEWER_MAX_REGRADES_PER_FEATURE=5 # Default: 5 (set to 0 to disable head-tracking re-grade)When reviewer: spec_conformance is enabled, REVIEWER_MAX_ROUNDS controls how many automatic revise-and-re-review rounds Weftra drives before escalating to a human. With the default of 2, Weftra 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 (Weftra posts the verdict once and stops — the original post-and-stop behavior).
REVIEWER_MAX_REGRADES_PER_FEATURE (spec 443 inc-12, RM-266) bounds a SEPARATE thing: how many times an already-APPROVED verdict may be re-graded after the branch head moves past the commit it graded. With the default of 5, up to five reviewer_final(approved) rows may accumulate for one feature as the head advances; at the cap Weftra stops offering a re-grade and records one reviewer_regrade_capped event. Set to 0 to restore the pre-inc-12 behavior exactly: a spec-conformance APPROVE is single-shot and never tracks a later push (no live-head read, no extra event) — see docs/USER_GUIDE.md's conformance-reviewer section for what re-grades and what does not, and the admin {"reopen": true} escape hatch for the cases it cannot (an escalation, an unproven head binding, a patch-mode feature).
Enrolment survey (FA_PROPOSE_*, spec 443 inc-38 + inc-39, RM-287 pt.2-3)
# Bounds on the CLONE the survey performs (inc-38):
FA_PROPOSE_CLONE_DEADLINE_MS=180000 # Default: 180000 (3 min). Range: 1000-3600000.
FA_PROPOSE_MAX_CONCURRENT=2 # Default: 2. Range: 1-32.
FA_PROPOSE_SWEEP_AGE_MS=21600000 # Default: 21600000 (6 h). Range: 60000-2592000000 (30 days).
# Bounds on the model PROPOSAL pass over that evidence (inc-39):
FA_PROPOSE_MODEL_PASS=on # Default: on. 'off' skips the pass entirely (no engine
# call is made) and the report says so
# (model_pass.status: "skipped").
FA_PROPOSE_MODEL_DEADLINE_MS=180000 # Default: 180000 (3 min). Range: 1000-3600000. Bounds
# the MODEL call, separately from the clone deadline above.
# On expiry the call is cancelled and SIGTERMed, not just
# abandoned — the concurrency slot above is never handed
# back while a model call is still running.See docs/USER_GUIDE.md's "The enrolment survey" section for what each phase does. All five settings are parsed strictly (strictBoundedInt) — an unparseable or out-of-range value falls back to the documented default rather than silently disabling the bound; only FA_PROPOSE_MODEL_PASS is a switch, and only the literal value off turns it off. Turning the model pass off costs nothing but its two extra fields per report: the deterministic half (evidence, omitted, default_branch/runtime_image, not_proposed) is unaffected either way.
Since spec 443 inc-40 (RM-287 pt.4), FA_PROPOSE_MODEL_PASS also gates the model-derived readiness classes in the same report's readiness block (ci_runs_no_tests, toolchain_pin_unverified, package_manager_unrecognised, external_service_required, secret_required_by_tests, formatter_scope_exceeds_ci, proxy_sensitive_tests) — they ride the SAME pass, not a second model call. With the switch off, every one of those 7 classes lands in readiness.unevaluated[] rather than being silently skipped, so readiness.summary is incomplete by construction (or not_ready, if a Weftra-computed blocker also fires) — it can never read ready or ready_with_findings on the strength of a check that never ran. No new environment variable is introduced for this.
Scheduled repository sweeps (FA_REPO_SWEEP_SCHEDULER_TICK_MS)
FA_REPO_SWEEP_SCHEDULER_TICK_MS=900000 # Default: 900000 (15 min). Range: 0-86400000.
# 0 turns the scheduler off entirely.How often the scheduler looks for a project whose whole-repository security sweep is due. It starts nothing unless an administrator has set a project's repo_sweep_interval_hours (off by default), so leaving the default costs nothing. It is a host setting only: no request, project key or project field can set it. Parsed strictly; an unparseable or out-of-range value falls back to the default. The scheduler also starts nothing unless RUNTIME=docker. See docs/OPERATIONS.md's "Whole-repository security sweep" section for the cadence, the budget and the audit record.
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, Weftra 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/weftra.db | SQLite database with all features, projects, users. A new install creates this name. An existing install that has only data/featureagent.db keeps opening that file when DATABASE_PATH is not set | 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), Weftra 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/weftra/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)
Effort per predicted complexity (optional)
FA_COMPLEXITY_EFFORT={"claude-code":{"light":{"model":"haiku","max_turns":25},"deep":{"model":"opus","max_turns":150}}}Host-only. Declares, per engine and predicted complexity tier (light, standard, deep), the model, turn ceiling and token-budget ceiling for the implementation turn. Unset means no change. See docs/ENGINES.md.
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": "weftra@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. The GitHub App's private key file (below) is different: it is read fresh at mint time, so rotating that file takes effect without a restart.
VCS_CHECK_WRITE_REPROBE_INTERVAL_MS (default 3600000 = 1 hour) — a personal access token cannot write GitHub check-runs at all (see "Using a GitHub App instead of GITHUB_TOKEN" below), so Weftra remembers that denial per credential instead of asking again on every PR poll. This setting controls how long that denial is trusted before Weftra retries checks.create — never below PR_CHECK_INTERVAL_MS, and a rotated credential is retried on the very next poll regardless of this setting. See docs/OPERATIONS.md §4n-3.
VCS_RATE_LIMIT_PAUSE_MAX_MS (default 3600000 = 1 hour) — once a VCS credential's rate-limit allowance is spent, the PR-merge poll pauses its background calls until the provider's own stated reset time (x-ratelimit-reset / retry-after) instead of calling it again every tick and failing. This setting is the CEILING that reset time is clamped to — a project's VCS host can be a forge the tenant controls, so the reset time it reports is untrusted input, and without a cap one response could pause that project's polling indefinitely. Never below PR_CHECK_INTERVAL_MS; foreground calls (a route, a review stage, a revise) are never held by this. See docs/OPERATIONS.md §4n-4.
Default-branch landings (record, do not verify)
Beside the PR-merge poll, Weftra also observes each project's default branch and RECORDS every commit that lands there — a direct push, or a PR merged that Weftra never gated — classifying it governed_here / governed_elsewhere / direct / unknown / history_rewritten. It never verifies, tests or executes anything for a landing. Set FA_DEFAULT_BRANCH_OBSERVER=off to disable it entirely (read once at startup — restart Weftra to apply a change). See docs/OPERATIONS.md §"Default-branch landings (record, do not verify)" and GET /api/projects/:id/default-branch-landings (admin-only).
Using a GitHub App instead of GITHUB_TOKEN
A fine-grained personal access token cannot read or write GitHub's Checks API (check-runs are App-only), and the operator's own GITHUB_TOKEN shares its 5,000 requests/hour quota with every other tool the operator runs. A GitHub App installation has its own rate limit and can be scoped to exactly the repositories it's installed on. This increment lets Weftra use one as the operator-default github.com credential — Weftra mints a short-lived installation token per repository and caches it until shortly before it expires.
Create the GitHub App (GitHub → Settings → Developer settings → GitHub Apps → New GitHub App) with these permissions:
- Contents: Read and write
- Pull requests: Read and write
- Commit statuses: Read and write
- Checks: Read and write
- Metadata: Read-only
- Workflows: Read and write — only needed if Weftra will build changes to files under
.github/workflows/. GitHub refuses any push that creates or updates a workflow file unless the credential has this permission (the same applies to a fine-grained PAT).
Generate a private key for the App and download the
.pemfile. Install the App on the GitHub account/organization that owns every project you want it to act for (you can install it on all repositories, or select specific ones — either way, Weftra always mints a token scoped to just the one repository it's touching).Configure on the Weftra host:
GITHUB_APP_ID=123456
GITHUB_APP_PRIVATE_KEY_PATH=/etc/weftra/github-app.pem
# Optional — pins one installation instead of resolving it per-repository via the GitHub API:
GITHUB_APP_INSTALLATION_ID=987654GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY_PATH must be set together — setting only one is a misconfiguration. The private key itself is never accepted inline in an env var, in a request body, or in project config; Weftra only ever reads it from the file at that path, and only at the moment it mints a token.
What this gives you: a separate rate limit from every other tool sharing
GITHUB_TOKEN, per-repository scoped tokens (each minted token is valid for exactly the one repository Weftra is about to touch, and expires within the hour) — and Weftra now writes its provenance check as a check-run (not a commit status) and reads GitHub Actions results through the Checks API. This needs the Checks: Read & write permission above. Without it, Weftra falls back to commit statuses exactly as it does under a PAT — nothing breaks, but the "GitHub credential" dashboard card (see step 5) shows "permission missing" so you know to grant it. Once granted, the card returns to "working" at the next successful check-run (it shows whichever of the last success and the last denial is more recent) — no restart. The Checks line is one instance-wide signal across every installation of the App, not a per-repository verdict: if one installation has not accepted the permission while others have, the line reflects whichever outcome happened last.Fail-closed behavior. Once
GITHUB_APP_IDis set, Weftra never silently falls back toGITHUB_TOKENfor a github.com project. A half-configured App, an App not installed on the target repository, an unreadable key file, or a non-2xx response while minting all fail the resolution outright with a named error — they never quietly reach for the PAT. A misconfiguration (exactly one of the two required env vars set) also logs agithub_app_misconfiguredline at startup naming which variable is missing.One exception, for a repository the App can never be installed on: if your project's repository is public and you only need Weftra to read it (a run declared
run_mode: "patch"— it never pushes a branch or opens a PR), Weftra can clone it with no credential at all instead of failing the run. This is narrow by design: it applies only to a run that cannot write, only when the project has no VCS credential of its own, only ongithub.com, and only once Weftra itself has confirmed (with one unauthenticated, uncredentialed GitHub API call) that the repository is actually public. It never falls back toGITHUB_TOKENor any minted App token — the clone genuinely carries nothing. Arun_mode: "pr"project gets no such exception: a run that would push still fails at the start with the same named error as before. Seedocs/OPERATIONS.md§ "An operator GitHub App" for the full rule, the anonymous rate limit this shares with every other unauthenticated GitHub caller, and the pinned-installation caveat (leaveGITHUB_APP_INSTALLATION_IDunset for this exception to apply).The same exception lets five read-only GitHub API calls (a PR's diff and details, a file at a branch, a branch's head SHA, and the commits between two refs) work with no credential on such a repository; every write still fails closed. All of Weftra's unauthenticated GitHub calls — those reads and the public-repository check above — share one budget, because GitHub allows only about 60 per hour per source address:
# Optional — unauthenticated GitHub API requests per rolling hour, instance-wide.
# Bare integer 0-10000. Default 45. 0 refuses every anonymous call. Per process.
FA_GITHUB_ANONYMOUS_READ_BUDGET_PER_HOUR=45Confirm the App is in use:
GET /api/admin/vcs-credential-health(admin-only) returns anoperator_default_githubobject —kind: "github_app"once both env vars are set, plusconfigured,misconfigured,app_id,installation_pinned,last_mint_at, a redactedlast_mint_error, and achecks: { last_ok_at, last_denied_at, last_denied_operation }object recording the last time Weftra wrote or read a check-run (postCheck/fetchPrChecks/fetchCommitChecks) and whether it succeeded. Thevcs_credential_resolvedrun event also carriescredential_kind: "github_app"(plus the app/installation ids) for any run that used it.The admin dashboard's Settings tab has a "GitHub credential" card showing the same information — kind, App id, mint status, and the Checks line — next to the proven-handle enforcement card. See
docs/OPERATIONS.md§ "Project-level VCS credentials (spec 215)" for where an operator App sits relative to a project's own credential.A project may also bring its own App instead of relying on this operator-default one — see
docs/OPERATIONS.md§ "A project's own GitHub App (spec 458 inc-1b, RM-323)" for the admin API that configures it.
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:
- There is still no DEDICATED way to store a VCS credential value here. A project credential is stored as an opaque ref: the AWS Secrets Manager adapter throws
not yet implemented, the broker registry is per-process and nothing populates it in production, and the local encrypted backend (docs/OPERATIONS.md§ "Local encrypted backend") CAN dereference afa-local:handle — spec 479 inc-2 shipped an admin write-through entry that mints one, but that entry acts on a tenant'smodelcredential row only, not a project'svcsrow. ThePUThere still rejects with400and stores nothing for an aws-sm/vault/brokered ref it cannot dereference, so the operator-default fallback remains the practical configuration for a project's VCS credential. - 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), Weftra 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
Weftra 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 Weftra 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, Weftra 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, Weftra 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, Weftra 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, Weftra sends the token as Authorization: Bearer <token>.
How provider selection works
Weftra 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 Weftra 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. Weftra prefixes the title with
Draft:to signal intent — the same approach used for GitLab. Merge or close the PR as usual; Weftra detects the merge via polling.
PR-Merge & Review Tracking
With BITBUCKET_TOKEN set, Weftra 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 Weftra acting as the governed backend — the client supplies the intent, Weftra 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 Weftra'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 Weftra'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 Weftra instance the adapter calls |
FA_PROJECT_API_KEY | (one of this or FA_USER_API_KEY required) | A tenant fa_... project API key — see USER_GUIDE.md's "Authentication & API Keys" section. Starts a project session (the project tools). |
FA_USER_API_KEY | (one of this or FA_PROJECT_API_KEY required) | A named admin user's own fa_... key — see USER_GUIDE.md's "Creating a project from an MCP client" section. Starts a user session (create_project, issue_project_key, list_own_projects), after a start-up check confirms the key names an admin. Set only one of the two; setting both, or neither, exits non-zero with a fixed message naming the problem — never the key's value. |
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 Weftra-host secret. If neither key is set (or both are), the process prints an actionable error to stderr and exits immediately rather than starting an unauthenticated — or ambiguous — server. FA_MCP_USER_SESSIONS_ENABLED (below, Remote / Streamable HTTP section) does not govern FA_USER_API_KEY — that flag gates admission at the network front door only; a locally spawned stdio process holding the admin's own key is indistinguishable from curl sending the same key straight at Weftra's HTTP routes, so there is nothing server-side here for the flag to gate.
Connecting a client (Claude Desktop example)
{
"mcpServers": {
"weftra": {
"command": "npm",
"args": ["run", "mcp-server", "--prefix", "/path/to/weftra"],
"env": {
"FA_BASE_URL": "http://127.0.0.1:3100",
"FA_PROJECT_API_KEY": "fa_your_project_key"
}
}
}
}Or, for a named admin's user session (create_project, issue_project_key, list_own_projects) instead of a project session — set FA_USER_API_KEY and omit FA_PROJECT_API_KEY:
{
"mcpServers": {
"weftra-admin": {
"command": "npm",
"args": ["run", "mcp-server", "--prefix", "/path/to/weftra"],
"env": {
"FA_BASE_URL": "http://127.0.0.1:3100",
"FA_USER_API_KEY": "fa_your_admin_user_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 Weftra 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 by default. A project session's only routes are the same project-scoped tools as the stdio server (
draft_spec,clarify_spec,refine_spec,estimate_spec,submit_feature,get_feature_status,get_feature_progress,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. With the default configuration there is no path from this endpoint to any admin-guarded route or toADMIN_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 one opt-in exception (FA_MCP_USER_SESSIONS_ENABLED, table below) admits a named admin's own key to a session with three tools,create_project,issue_project_keyandlist_own_projects, which use their own four-pair allowlist (assertAllowedUserPath) and forward only the bearer the caller presented — neverADMIN_API_KEY. The allowlist replaced an/api/admin-prefix denylist, which could only ever cover admin routes that carry that prefix — Weftra'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 Weftra 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.
Changing the instance base URL (app_base_url / APP_BASE_URL) invalidates every outstanding MCP OAuth token; clients must re-authorize.
Enabling it
FA_MCP_HTTP_ENABLED=trueSet in the environment (or .env in file mode) before starting Weftra. 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). Weftra 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 Weftra sits behind a reverse proxy that remaps the port Weftra itself listens on. |
FA_MCP_USER_SESSIONS_ENABLED | unset (disabled) | Opt-in, and requires the front door above (FA_MCP_HTTP_ENABLED=true). Set to true to admit a named admin user's own fa_ key at POST /api/mcp as a session exposing three tools, create_project, issue_project_key and list_own_projects (docs/USER_GUIDE.md § "Creating a project from an MCP client"). Off, a user key at /api/mcp gets the same 401 as any unknown key. Governs HTTP admission only — it does not gate the stdio user mode (FA_USER_API_KEY, above), which reaches the same three tools directly. This is a new admin-capable network door: enable it deliberately. |
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);Protocol revision 2026-07-28 (stateless requests)
The door answers server/discover itself, and it accepts requests that declare protocol revision 2026-07-28 in params._meta (io.modelcontextprotocol/protocolVersion). Those requests get a resultType on every result, and a declared version of any other revision is refused with JSON-RPC error -32022. A request whose Mcp-Method or Mcp-Name header disagrees with its body is refused with error -32020 before any tool runs. A request that declares no version and sends neither header is handled exactly as before, so existing clients are unaffected. No session is issued. The per-requirement status of this door, and of the other MCP surfaces, is in docs/security/mcp-conformance.md.
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).
MCP clients must send the resource parameter (this server's /api/mcp address) on both the authorize and the token request.
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; Weftra does not special-case any client by name. |
FA_MCP_OAUTH_CLIENT_METADATA_HOSTS | unset (feature off — Weftra makes no such request) | Comma-separated exact hosts (host or host:port, lower-case) whose Client ID Metadata Documents Weftra will read. A client whose client_id is an HTTPS URL on one of these hosts can connect without registering. There are no wildcards: an entry containing *, /, whitespace or :// is dropped, and *.example.com lists nothing. Read fresh on every use, so removing a host stops new connections from it at once (see the section below for what that does not stop). |
WEFTRA_MCP_OAUTH_ALLOW_MISSING_RESOURCE | unset (the resource parameter is required) | Set to exactly true to accept an authorize or token request that omits the RFC 8707 resource parameter, for an older client that cannot send it. A wrong resource is still refused. Read fresh on every request, so unsetting it takes effect at once; Weftra logs a warning at startup while it is on. Operator environment only. |
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 Weftra 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 Weftra 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 Weftra'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 Weftra 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 401 challenge and the resource parameter
A request to POST /api/mcp without a valid credential is answered 401 with a WWW-Authenticate header, so a client can find out where to authenticate:
- OAuth on (
FA_MCP_OAUTH_ENABLED=true):WWW-Authenticate: Bearer resource_metadata="<base>/.well-known/oauth-protected-resource", scope="mcp". - OAuth off:
WWW-Authenticate: Bearer. The metadata document answers404in that state, so the header does not name it. error="invalid_token"is appended when the request sent anAuthorization: Bearerheader that Weftra refused. A request with no credentials gets noerrorparameter.
<base> is the instance's app_base_url (or APP_BASE_URL when app_base_url is unset). Weftra never takes it from the request's Host, X-Forwarded-* or Origin headers, so a wrong value sends clients to the wrong place: set it to the address clients actually reach. If the value contains a double quote, a backslash or a character outside printable ASCII, the header is the bare Bearer form (or Bearer error="invalid_token") and carries no metadata pointer.
The protected-resource metadata document lists scopes_supported: ["mcp"], and the token response's scope is always mcp.
The authorization endpoints accept an RFC 8707 resource parameter, but only one that names this server: <base>/api/mcp, with an optional trailing / and any letter case in the host. Omitting the parameter is also accepted. Any other value is refused with invalid_target:
GET /oauth/mcp/authorize: a redirect to the registeredredirect_uricarryingerror=invalid_target. No consent page is shown.POST /oauth/mcp/authorize:400 {"error":"invalid_target"}. No authorization code is issued.POST /oauth/mcp/token:400 {"error":"invalid_target"}. No token is minted, and the authorization code is not used up, so the same code can be exchanged again without the bad parameter.
A resource that is repeated, empty, or carries a fragment or a query is refused the same way. The refusal never repeats the value that was sent.
The flow
- The MCP client fetches
GET /.well-known/oauth-authorization-serverandGET /.well-known/oauth-protected-resourceto discover Weftra'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 Weftra (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. A denial, and any error the authorization request hits after itsredirect_uriis verified, redirects back the same way. Every such redirect also carries anissquery parameter (RFC 9207). A client compares thatisswith theissuerin the metadata document by exact string match, and refuses the response if they differ. Theissueris the instance's configured public base URL (the admin portal'sapp_base_url, elseAPP_BASE_URL, else the loopback default), so changing that setting changes the issuer. A client that cached the old metadata must fetch it again before its next authorization. - 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).
Client ID Metadata Documents (a URL client_id)
An MCP client may identify itself with an HTTPS URL as its client_id, and Weftra then reads the client's name and redirect URIs from the JSON document at that URL. Dynamic registration (POST /oauth/mcp/register) is still supported beside it. The URL form is off until you list a host in FA_MCP_OAUTH_CLIENT_METADATA_HOSTS. The read is bounded four ways:
- Listed hosts only. A URL on any other host is refused before Weftra makes any request. An unset list means Weftra never makes this request.
- Only after an admin signs in. Anonymous and approver requests get the sign-in page or the admin-required page, and cause no outbound request. The check runs before the read. The read happens when an admin's browser loads the authorization page, a plain GET carrying the session cookie. That cookie is
SameSite=Lax, so a link on another site that a signed-in admin follows also loads the page: Weftra then reads the document at whatever path on a listed host the link names, and stores or refreshes the client row, before the admin decides anything. No code or token is issued until the admin approves on the consent page, which is CSRF-protected. List only hosts where every path is published by a client you would connect. On a host that serves content from several publishers, any of them can get a client row stored this way, which counts towardFA_MCP_OAUTH_MAX_CLIENTSand can evict clients that never completed a flow. - Through the egress guard. HTTPS only. The address is validated and pinned, and private, loopback and link-local targets are refused. Redirects are never followed. The whole exchange, name resolution included, is limited to 5,000 ms, and at most 16,384 body bytes are read. Weftra's code adds one header,
Accept: application/json. The HTTP library (undici) adds its own defaults:user-agent,accept-encoding,accept-languageandsec-fetch-mode, so the listed host can tell which HTTP library made the request. No header, cookie or credential from the inbound request or from Weftra's environment goes with it. - The redirect allowlist still decides where a code goes. The document's
redirect_urisare intersected withFA_MCP_OAUTH_REDIRECT_ALLOWLISTwhen they are stored and on every read. A document cannot add a redirect target you did not list.
A document is accepted only if it is served as application/json with status 200, its client_id equals the URL exactly, and it lists 1 to 20 redirect_uris. It must not carry a client_secret, must use token_endpoint_auth_method none (or omit it), and, if it lists grant_types or response_types, must include authorization_code and code. Every other member is ignored. Logos, JWKS and similar links are never fetched or shown.
There is no cache. Weftra reads the document once per consent-page load by a signed-in admin, and nowhere else. The client row is stored with the URL as its client_id. A reload refreshes the name and redirect URIs and keeps the row's timestamps.
What the consent page shows. For a URL client, one extra row names the host the document was read from, and says that the client name is what that document states and that Weftra has not verified who publishes it. The page for a registered client is unchanged.
When the list is non-empty, the discovery document carries client_id_metadata_document_supported: true. When it is empty, the member is absent.
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.
Where enrollment runs. Under RUNTIME=docker (the default), specify init and the constitution agent turn run in the project's sandbox image, not on the Weftra host — so under RUNTIME=docker the Weftra host no longer needs the specify CLI. The image is resolved the way a feature run resolves it: the project's runtime_image if set, else a curated language image auto-detected from the repository's contents (fa-runtime-python, fa-runtime-go, …), else the default fa-runtime:latest. The image must provide specify, and only the default fa-runtime image does: it installs spec-kit 0.9.4 from the upstream repository at a pinned commit, with hash-locked dependencies. The auto-detected language images do not carry it, so a repository that auto-detects one fails enrollment until the project's runtime_image is set explicitly to fa-runtime:latest or to a custom image that installs the CLI itself (Python 3.11+). An image without it fails enrollment (failed), and the enrollment log names the image. That the pinned CLI builds into the image and scaffolds without network access has been read from its source, not yet observed in a built image — docs/OPERATIONS.md §4h-4c lists the operator check. Under RUNTIME=local (development only) both run on the host, where specify must be on PATH. Enrollment uses the project's own runtime-egress policy; see docs/OPERATIONS.md §4h-4c for what to allow.
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" }.
tool_proxy_env (spec 443 inc-59, RM-288). Another project-level, self-configurable environment field: whether test_command, verify_command and pinned_test_command run with the proxy-shaped variables HTTPS_PROXY/https_proxy/HTTP_PROXY/http_proxy/NO_PROXY/no_proxy present (inherit, default) or unset (strip_for_tests). strip_for_tests unsets all six names whoever set them — Weftra's runtime-egress grant sets four, but an image, a login profile or your own env: declaration can set any of them — and it applies at every Weftra path that runs those three commands (both test gates, the pin precheck, a gate re-run, a security-fixer round and its build repair, the merge/canary gate set), so one declared command never runs in two environments. setup_command and every other declared command always keep them. Set it via the same PATCH /api/projects/:id (or PATCH /api/project with your project key) call as data_dirs/setup_command above. See docs/USER_GUIDE.md §"The test gate" for the full description.
product_path (RM-143). A project-level, self-configurable field: the repository directory that "Publish to repo" writes definition.json into. Unset (or null) means .fa/product. It must be a relative path of /-separated segments made only of letters, digits, ., _ and - (at most 200 characters; no leading / or -, no empty, . or .. segment, no backslash, no .git segment) and its first segment may not be a CI or hook directory (.github, .gitlab, .circleci, .buildkite, .azuredevops, .azure-pipelines, .husky). A trailing / is dropped. The same check runs on create, PATCH /api/projects/:id and PATCH /api/project; an invalid value is a 400. Publishing is an approver/admin action — see docs/USER_GUIDE.md §"Publish the definition to your repo".
Agent Factory — Rejected-Class Cooldown (spec 435, RM-250)
A recommend-step worker's propose_recommendation calls can optionally name a finding_class so Weftra 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 Weftra reaches on the project's behalf, with the project's own credential).
1. There is still no DEDICATED way to store one. Same residual as the VCS credential section above: a connector credential is stored as an opaque ref, and the AWS Secrets Manager adapter throws not yet implemented for a byo ref out of the box. 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). The local encrypted backend (docs/OPERATIONS.md § "Local encrypted backend") can dereference a fa-local: handle — spec 479 inc-2 shipped an admin write-through entry that mints one, but that entry acts on a tenant's model credential row only, with no equivalent entry for a named connector credential. Until a connector-specific write path exists, PUT .../connector-credentials/:name rejects with 400 and stores nothing for a ref it cannot dereference (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 Weftra 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, design-export or http-json — 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 Weftra. 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.
design-export reads a design document's JSON node tree instead of crawling pages. Each allow_paths entry is an EXACT document path (fetched once, never crawled), and the connector adds a required design object (refused on wiki-markdown; listing is refused on design-export):
{ "id": "screens", "kind": "design-export", "base_url": "https://design.example.com",
"credential_ref": "design-cred", "allow_paths": ["/v1/files/abc123"],
"design": { "root_pointer": "/document", "children_key": "children",
"name_key": "name", "text_key": "characters", "type_key": "type", "id_key": "id" } }design key | Required | Meaning |
|---|---|---|
root_pointer | yes | "" or an RFC 6901 pointer (starts with /, at most 200 chars, only ~0/~1 escapes) to the root node object |
children_key | yes | key holding a node's child array |
name_key | yes | key holding a node's display name |
text_key | yes | key holding a text layer's characters |
type_key | no | key holding a node's type label |
id_key | no | key holding a node's id |
images | no | [{ "document", "path" }] — one rendered image per document. document must equal an allow_paths entry (at most one image per document, at most as many entries as allow_paths); path is a path on base_url's own host (a query string is allowed, no scheme, host or port; at most 2,000 chars). Any other key in an entry is refused |
max_image_bytes | no | largest image accepted, in bytes (default 4,194,304 = 4 MiB, hard cap 8,388,608 = 8 MiB) |
Every *_key matches ^[A-Za-z_][A-Za-z0-9_]{0,63}$; unknown design keys are refused with a 400 naming the field. There are no defaults: Weftra ships no preset mapping for any tool, and no default image path.
An image is stored only if its first bytes say PNG, JPEG or WebP — the server's Content-Type is never trusted, and SVG, HTML and GIF are skipped. The stored image is read with GET /api/projects/:id/product/sources/:lineageId/image[?version=N].
http-json is the generic fallback: each allow_paths entry is an EXACT JSON document (fetched once, no pagination, no link following), and each item in it becomes one text snapshot. The connector adds a required json object (refused on the other kinds; design and listing are refused on http-json):
{ "id": "tickets", "kind": "http-json", "base_url": "https://items.example.com",
"credential_ref": "items-cred", "allow_paths": ["/v1/items"],
"json": { "items_pointer": "/data/items", "id_key": "id",
"body_key": "text", "title_key": "name", "body_format": "markdown" } }json key | Required | Meaning |
|---|---|---|
items_pointer | yes | "" or an RFC 6901 pointer (starts with /, at most 200 chars, only ~0/~1 escapes) to an array of item objects, or to one object (a single item) |
id_key | yes | key holding the item's id (a non-empty string of at most 200 chars, or a safe integer) |
body_key | yes | key holding the item's body (must be a string) |
title_key | no | key holding the item's title |
body_format | no | markdown (default, body kept as text) or html (converted to Markdown) |
Every *_key matches ^[A-Za-z_][A-Za-z0-9_]{0,63}$; unknown json keys are refused with a 400 naming the field. Each item is stored under external_ref <path>?<query>#<id>. An item that is not an object, has a missing/duplicate id or a non-string body is skipped as invalid_document; at most 2000 entries per document are examined, and max_pages caps the total snapshots.
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. Weftra 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 Weftra 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.
Host data roots (FA_DATA_DIRS_ALLOWED_ROOTS)
A project can declare data_dirs: host directories mounted read-only into its sandbox (frozen datasets, caches, model weights). A project sets that list itself (PATCH /api/project, or the repo's own .fa/environment.yml data: key), so the operator decides which host paths count as data:
FA_DATA_DIRS_ALLOWED_ROOTS=/srv/datasets,/home/me/src/my-project/signal_cache- Comma- or colon-separated absolute paths. A declared source must be one of them or inside one, judged by its real path, so a symlink cannot point out of an allowed root.
- Unset means no host path can be mounted. A run that declares
data_dirsthen fails at the start with a message naming this variable. - Whatever the roots, a source may never be
/, Weftra's own working directory, database directory or workspaces, a directory that contains any of them, or a credential/control location (~/.claude*,~/.ssh,~/.aws,~/.config,~/.docker, the Docker socket,/etc,/proc,/sys,/dev,/root). - Restart Weftra after changing it.
Declared Gate Commands (gates: in .fa/environment.yml)
Declare an ordered list of shell commands in the committed .fa/environment.yml that Weftra runs inside the sandbox after implementation, after tests, and after the verify step — and before creating any commit or PR. Weftra 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:
- Weftra runs each gate via
loginShellArgs— a login shell (so a project's own profile still runs) that keeps the runtime image'sENV PATHin front (spec 443 inc-15) — with"<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): Weftra 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. Weftra 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-weftra-tokenheader to your project's dedicated trigger secret, or its API key as a fallback (or include"token": "..."in the body) - Optionally set
x-weftra-timestampto the current Unix epoch seconds for replay-window tolerance (FA_TRIGGER_TIMESTAMP_TOLERANCE_SECONDS, default 300s) — seedocs/USER_GUIDE.md - The legacy
x-fa-token/x-fa-timestampnames are still accepted; sending both names with different values is refused (see "Header names" indocs/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/weftra"
}'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-Weftra-Signature header, using your project's API key as the secret:
X-Weftra-Signature: sha256=abc123...The legacy X-FeatureAgent-Event / X-FeatureAgent-Signature names are still sent alongside the Weftra names, with identical values; see "Header names" in docs/USER_GUIDE.md.
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
Weftra 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,
POST /auth/local/reset-requeststill answers200, but withok: falseand a message that this instance has no email configured and an administrator can issue a password link instead — no token is minted and no email is attempted. See "Inviting a user" below for the no-SMTP path.
Configuring SMTP — portal (recommended)
SMTP can be configured without editing .env or restarting Weftra. 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 Weftra instance (e.g. https://weftra.example.com). Used to build the password-reset link in emails, and — since spec 467 inc-3 — the dashboard_url/logs_url links POST /api/features, GET /api/features/:id, and the MCP door's submit_feature/get_feature_status/await_feature/get_feature_progress tools hand back. Defaults to http://localhost:3100 if unset — recipients can't click an http://localhost link from outside the server, and MCP clients get links_base: "loopback_default" instead of "configured". |
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. A Google Workspace account can create an App Password only once 2-Step Verification is turned on (Google Account → Security → App Passwords, or myaccount.google.com/apppasswords). Use that 16-character password — not your Google account password.
Gmail example — both working port/security pairs:
| Field | Value |
|---|---|
| SMTP host | smtp.gmail.com |
| Port | 587 |
| Security mode | STARTTLS |
| Username | you@gmail.com |
| Password | the App Password |
If the test email times out on 587 (some networks block it outbound), switch to the alternative pair instead of giving up on SMTP:
| Field | Value |
|---|---|
| SMTP host | smtp.gmail.com |
| Port | 465 |
| Security mode | SSL / Implicit TLS |
| Username | you@gmail.com |
| Password | the App 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, and in the dashboard_url/logs_url links handed back
# to API callers and MCP clients (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:
POST /auth/local/reset-requesttells Alice this instance has no email configured instead of claiming a link was sent. Issue her a one-time link yourself with the admin routePOST /api/users/:id/password-invite, which returns the link directly in the response (it branches on the sameisSmtpConfigured()check).
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 slow an attacker down with a growing wait, not a lockout. Two in-memory counters are kept per normalised email, whether or not an account exists (so a throttle reveals nothing about existence):
- Per email and client address: the first 5 failures are free. After the 5th, the next attempt from that client must wait 1 s, then 2 s, 4 s, and so on (doubling per failure), up to 15 minutes.
- Per email, all clients (the spray cap): the first 20 failures inside a 15-minute idle window are free. After that, any attempt on the account waits 1 s, 2 s, 4 s, ... capped at 60 s, so a guess spread over many addresses is slowed and each refusal of the real owner is at most a minute. Sign-in links and federated sign-in are unaffected, and the record expires 15 minutes after the last failure.
- Each attempt is counted the moment it is admitted, before the password is checked (and un-counted if it succeeds), so a burst of concurrent requests gets no extra guesses: once the free attempts are spent, at most one guess per wait is ever checked. An attempt refused inside a wait is not counted and does not lengthen it.
- A successful sign-in clears only that email + client record.
- State is in memory only: a restart resets it.
- "Client address" is the direct TCP peer unless you set
FA_TRUSTED_PROXY_HOPS(below). Only the password login and password reset-request routes use it; Expresstrust proxyis not enabled and every other limiter still keys on the direct peer. POST /auth/local/reset-requestis rate-limited: at most 3 requests per email and 20 per client address per 15 minutes (a fixed window), counted whether or not the email has an account. Over the limit the answer is429+Retry-Afterand no email is sent.- Both limiters' maps are hard-capped (
LOCAL_AUTH_MAP_MAX_ENTRIES, default 10000, per map). At the cap, expired records are evicted first and a live record is never evicted. For sign-in, one address may create at mostLOCAL_AUTH_CLIENT_MAX_NEW_KEYS(default 50) new per-email records per 15-minute idle window, so one address cannot fill the email maps. An attempt on an email that cannot get a record of its own from that address (budget spent, or an email map full of live records) is counted on a per-address overflow record instead, with the same free attempts and doubling wait, so it is still counted before the password is checked and still throttled, and a full email map never refuses a correct password (reserveLoginAttemptinsrc/services/local-auth.ts). Only when the per-address map itself is full of live records (10000 distinct addresses that failed in the last 15 minutes, by default) is an address with no record refused with429before the check, until a record expires. For reset-request, a request that would need a new record in a full map is refused with429until some record expires.
Local-auth settings
| Variable | Default | Meaning |
|---|---|---|
LOCAL_AUTH_FREE_ATTEMPTS | 5 (falls back to LOCAL_AUTH_MAX_ATTEMPTS) | Failures per email + client before the first wait. |
LOCAL_AUTH_BASE_DELAY_MS | 1000 | First wait; doubles with each further failure. |
LOCAL_AUTH_MAX_DELAY_MS | 900000 (falls back to LOCAL_AUTH_LOCKOUT_MS) | Longest per-client wait. |
LOCAL_AUTH_ACCOUNT_FREE_ATTEMPTS | 20 | Failures on one email, from all clients, in a 15-minute idle window before the account-wide wait starts. |
LOCAL_AUTH_ACCOUNT_MAX_DELAY_MS | 60000 | Longest account-wide wait. |
LOCAL_AUTH_RESET_PER_EMAIL | 3 | Reset-requests per email per 15 minutes. |
LOCAL_AUTH_RESET_PER_CLIENT | 20 | Reset-requests per client address per 15 minutes. |
LOCAL_AUTH_MAP_MAX_ENTRIES | 10000 | Hard cap on each limiter map. |
LOCAL_AUTH_CLIENT_MAX_NEW_KEYS | 50 | New per-email failure records one client address may create per 15-minute idle window; further emails from it share its overflow record. |
FA_TRUSTED_PROXY_HOPS | 0 | Number of reverse proxies you operate in front of Weftra (integer 0-10). See below. |
LOCAL_AUTH_MAX_ATTEMPTS and LOCAL_AUTH_LOCKOUT_MS keep working: they are read as the fallbacks for LOCAL_AUTH_FREE_ATTEMPTS and LOCAL_AUTH_MAX_DELAY_MS when the new names are unset, so an existing .env keeps meaning something (the lockout it described is now the longest wait). A value that is not a bare non-negative integer in range is ignored in favour of the next fallback or the default.
FA_TRUSTED_PROXY_HOPS — behind a proxy or tunnel
With the default 0, "client address" is the TCP peer and X-Forwarded-For is ignored. That is safe against forgery but, behind a reverse proxy or tunnel, every caller then arrives from the proxy's address, so all callers share one client key. The per-email-and-client wait (up to 15 minutes) then applies to every caller of that email, the real owner included — anyone on the internet can put any account into that wait — so the 60 s account-wide bound does not protect the owner until the hop count is set. The per-client new-record limit is likewise shared by everyone. Set FA_TRUSTED_PROXY_HOPS=N to the number of proxies you control between the internet and Weftra (one Nginx or one Cloudflare Tunnel is 1). Weftra then takes the Nth address counted from the right of X-Forwarded-For (the one your own proxy appended), so a client-supplied prefix does not matter. Never set it higher than the proxies you actually run: each extra hop makes Weftra read an entry further left, which the caller wrote, and a client can then pick its own throttle key (a fresh one per request defeats the throttle). A value that is not an integer from 0 to 10 is logged at startup (naming the variable) and treated as 0. Check what your proxy appends before relying on it.
Sign in with a one-time email link
Alongside email+password, a person can sign in with a link emailed to their address instead of typing a password. On the login page, click "Email me a sign-in link", enter your email, and (when SMTP is configured) you'll receive a link. Clicking it opens the login page with a Sign In button — the link itself never signs you in on load; you still have to click.
- The link is valid for 15 minutes and works once. A second click, or a click after it expires, shows the same "invalid or expired" message.
- Requires
SESSION_SECRETand SMTP to be configured, exactly like the password-reset flow above. With no SMTP configured, the request form says so instead of claiming a link was sent. - Requests are rate-limited: at most 3 per email and 10 per source address per 15-minute window (a fixed window, not a rolling one — a burst that straddles a window boundary can reach twice the stated count). An email longer than 320 characters is refused. Failed redemption attempts are separately rate-limited per source address.
- Behind a reverse proxy, "source address" is the proxy. Both limits key on the direct TCP peer, never
X-Forwarded-For(which a caller can forge). Behind Nginx, a Cloudflare Tunnel or any other proxy every caller arrives from the same address and shares ONE bucket: anyone can spend the instance's 10 link requests per window, and 10 failed redemptions by anyone refuse every redemption — valid links included — until the window ends. This affects only this sign-in method; password, Google and OIDC sign-in are unaffected. - The response status and body never reveal whether an email has an account; response time is not equalized (a known email does a little more work before the reply), bounded by the same limits.
- A successful sign-in issues the same session cookie every other method issues, and is recorded on the actor-events ledger as
user.sign_in_link.redeem— never the token or its hash. - The link is only ever minted for a user who already exists; it creates no account.
API endpoints
| Endpoint | Method | Body | Description |
|---|---|---|---|
/auth/local/login | POST | {email, password} | Issue a session cookie. 401 on wrong credentials (uniform, no enumeration). 400 unless both are non-empty strings. 429 + Retry-After while a failure delay is running. |
/auth/local/reset-request | POST | {email} | Always returns 200 with an identical body for an existing and an unknown email (no enumeration). Sends a reset email when SMTP is configured; otherwise answers ok: false, email_configured: false pointing to an administrator, without looking the email up. 400 unless email is a non-empty string. Rate-limited: 429 + Retry-After past 3 per email / 20 per client per 15 minutes. |
/auth/local/reset | POST | {token, new_password} | Set password with a valid single-use token. |
/auth/local/link-request | POST | {email} | Always returns 200 with an identical body for an existing and an unknown email (no enumeration). Emails a one-time sign-in link when SMTP is configured; otherwise answers ok: false, email_configured: false. Rate-limited. |
/auth/local/link-redeem | POST | {token} | Consume a one-time sign-in link and issue a session cookie. POST only — a GET never signs in. |
Configuring Google OAuth
Google Sign-In lets human users log into the Weftra 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
- Weftra 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 Weftra user (
POST /api/users). Unknown emails are rejected with a clear message. No auto-provisioning for self-hosted. - A returning sign-in resolves the account by the Google account id (
sub) already linked in theidentitiestable — never by email. The verified email only makes a first link, and only to an account that has no Google link yet and is not an administrator. A refusal is recorded asidentity.sign_in_refusedand the login page names the fix.
Upgrade note — administrators must link Google explicitly. An existing administrator who has never signed in with Google can no longer be linked automatically by email. Such an administrator signs in another way first (API key or password), then clicks Link Google in the Account panel; from then on Google sign-in works by the linked account id. Administrators who have already signed in with Google are unaffected. Non-administrator accounts still link on their first Google sign-in. The same applies to GitHub and GitLab sign-in.
- On success, Weftra 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). - Sessions are stateless, but each carries the user's session generation, and a session works only while that matches the user's current one. These end a user's sessions in every browser: Sign Out, a password reset, and an admin rotating the user's API key (this also ends sessions that user minted through the API-key login). Each sign-in and each of these is recorded on the audit ledger (
user.session_issued,user.sessions_revoked,user.regenerate_key). ChangingSESSION_SECRETends everyone's sessions. - One-time sign-out on deploy: cookies issued before this mechanism existed carry no generation and are refused, so the first deploy of this change signs every user out once. They sign in again as normal; nothing else is needed.
Redirect URI in production
Set GOOGLE_REDIRECT_URI to the exact URI registered in Google Cloud Console, including the scheme and path. If your Weftra 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 Weftra 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 Weftra 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 Weftra has verified end to end — Weftra 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.
Repository access (optional per user). A signed-in user can link their GitHub account for repository access from the Account panel. It uses the same OAuth App and the same callback URL as sign-in, so there is nothing new to register. It requires FA_INSTANCE_SECRET_KEY (the secret store encrypts the token); without it the link button is refused with a 409 before any redirect.
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 — Weftra 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 Weftra 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 Weftra 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, Weftra 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 Weftra: if the rebuilt instance hands out numeric user ids again, an existing row for that id would log in the Weftra 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 Weftra account. - Sign-in is invite-only, with no exception: the verified email must match a pre-created Weftra 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)
Weftra 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). Weftra 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. Weftra 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 Weftra-native Google OAuth path. |
How it works
- Weftra 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. - A returning sign-in resolves the account by the IdP's stable
subalready linked in theidentitiestable, never by email. The IdP-verified email only makes a first link, and only to an account with no SSO identity yet that is not an administrator; otherwise the sign-in is refused and recorded asidentity.sign_in_refused. - Sign-in is invite-only by default (
OIDC_ALLOW_SIGNUP=false): the IdP-verified email must match a pre-created Weftra 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 a Weftra account with the least-privilegeapproverrole.
User provisioning
By default (OIDC_ALLOW_SIGNUP=false), an admin must create each Weftra 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 makes her first link only; every later sign-in goes by the IdP's sub.
Upgrade note — administrators and SSO. An existing administrator who has never signed in with SSO can no longer be linked automatically by email, and the dashboard has no Account-panel Link control for SSO. An administrator keeps any SSO identity already linked; one who has not linked SSO signs in another way (API key, password, Google, GitHub or GitLab). Non-administrators are unaffected.
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 first SSO login, Weftra records an oidc entry in the identities table (provider='oidc', provider_subject=<sub>) and an identity.link event (via: first_sign_in). 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. A sub whose linked account no longer exists is refused (the email is not consulted).
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 main branch with po_approval mode.
The featureagent project is protected and configured with:
- Autonomy mode:
po_approval— features require approval before implementation - Target branch:
main - 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/weftra/data/weftra.db
WORKSPACE_DIR=/var/weftra/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, the default — config.ts). RUNTIME=local is NOT
# sandboxed: there is no container boundary at all. Spec 462
# inc-2 (RM-332) bounds what that costs rather than claiming
# it is safe — two separate things:
# 1. A tenant-declared command (setup_command/
# test_command/verify_command/the generated-path
# regenerate commands/gate providers/environment
# provision checks/eval replay scoring) now runs with an
# environment built from NOTHING plus a fixed allowlist
# (PATH/HOME/USER/LOGNAME/SHELL/LANG/LC_ALL/LC_CTYPE/
# TERM/TMPDIR/TZ/the proxy vars — see
# LOCAL_TENANT_ENV_ALLOWLIST,
# src/services/runtime/local-runtime.ts) — never Weftra's
# process.env, so a declared `verify_command: "env"`
# can no longer print ADMIN_API_KEY/GITHUB_TOKEN/
# ANTHROPIC_API_KEY/SESSION_SECRET. The agent CLI itself
# is NOT covered by this allowlist — it still needs,
# and still sees, its own engine credential; it also
# still runs completely UNSANDBOXED on this host (no
# container, no network/filesystem confinement).
# 2. Weftra now REFUSES to start in local mode on an instance
# that holds a real operator secret (ADMIN_API_KEY set)
# or any project other than its own self-managed
# one(s) — i.e. an instance with tenants — unless you
# set FA_LOCAL_RUNTIME_ACKNOWLEDGED=host-unsandboxed
# below. Weftra self-provisions ADMIN_API_KEY at boot
# (loaded from .env, or generated and saved there)
# before this check runs — so IN PRACTICE EVERY
# RUNTIME=local START NEEDS THE OVERRIDE, including a
# fresh dev box with zero tenants. The tenant-project
# condition is a second, independent reason named in
# the refusal message. UPGRADE NOTE: every existing
# RUNTIME=local instance will NOT START after upgrading
# to this version until it either moves to
# RUNTIME=docker or sets that override. The override
# is logged at startup (a WARN line) and at every job's
# own log — it is NOT written to the tamper-evident
# run-event ledger. See docs/OPERATIONS.md §39.
FA_LOCAL_RUNTIME_ACKNOWLEDGED= # Set to EXACTLY 'host-unsandboxed' to start RUNTIME=local
# (needed in practice for every local start — Weftra always has
# ADMIN_API_KEY set by the time it checks; see above). Any other value (including '1', 'true', a
# different casing) is treated as unset and Weftra still
# refuses. Operator process-environment configuration ONLY —
# never read from a project, a feature field, or a
# `.fa/environment.yml` value of the same name.
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).
# Build your own for another toolchain: docs/RUNTIME_IMAGES.md
# A missing image is NOT a manual step: Weftra 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.
# Image hygiene (pinned npm, apt upgrade, the curated
# build's --pull, and the periodic operator rebuild):
# docs/OPERATIONS.md §38a.
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 (Weftra-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
# Weftra-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 Weftra's own shipped profile,
# containers/seccomp/fa-default.json. Operator config only
# — never a project/feature field.
FA_SANDBOX_NPM_CACHE=off # 'off' (default) | 'project'. Spec 475 inc-1: a
# per-project npm cache for agent sandboxes, mounted
# read-write at /fa-cache/npm with patient
# fetch-retry settings. 'project' mounts
# <FA_SANDBOX_NPM_CACHE_DIR>/<project_id> for the
# six implementation/revise/fixer/gate-rerun flows
# only. Operator-only — no project/feature/
# request-body field or repo file can change it. An
# unknown value stops startup. See
# docs/OPERATIONS.md §4h-15.
FA_SANDBOX_NPM_CACHE_DIR= # Host root for the per-project directories above.
# Empty (default) resolves to sandbox-cache/npm
# beside the database file's directory — never
# inside WORKSPACE_DIR. Swept down to
# FA_SANDBOX_NPM_CACHE_MAX_MB, but that cap is
# enforced at sweep time, not a live quota, and
# the default shares a filesystem with the
# database: when turning the cache on, set this
# to a volume whose exhaustion cannot stop the
# database.
FA_SANDBOX_NPM_CACHE_MAX_MB=10240 # Size cap (MiB) for the root above. '0' = no cap.
# Any other non-integer/negative/fractional value
# stops startup, naming the accepted form.
# Operator-only. See docs/OPERATIONS.md §4h-15.
# FA_RUNTIME_NETWORK_ALLOWED=net-a,net-b # extra Docker networks a run (feature or project network_policy) may
# join, besides none, bridge and FA_RUNTIME_NETWORK. host, container:* and
# Weftra's own per-run networks (fa-net-*, fa-git-egress-*,
# fa-runtime-egress-*) are never allowed, even if listed here (ignored and
# logged). Upgrading: a stored network_policy naming an undeclared network
# now fails its run as configuration_refused — add the name here.
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
# Weftra-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 Weftra 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.
FA_EGRESS_PRIVATE_HOSTS= # Comma-separated names (or IPv4 literals) that are ALREADY on an egress
# allowlist and may resolve to a private/loopback/CGNAT address (e.g. a
# self-hosted Git server: "git.corp.example"). Empty by default: with
# FA_RUNTIME_EGRESS=allowlist the forwarder refuses any allowlisted name
# that resolves privately (job log: "refuse CONNECT ... private_address").
# Never exempts link-local (169.254/16, fe80::/10). Adds nothing to any
# allowlist. Operator-only: not settable per project or feature. See
# docs/OPERATIONS.md §4h-8b.
# Also the second half of an engine profile that points at a model on
# your own machine (an http:// openaiCompatible.baseUrl): the host must be
# listed here or startup refuses the profile. Loopback, localhost,
# link-local, 0.0.0.0 and IPv6 are refused even when listed. See
# docs/ENGINES.md "A model on your own machine" and OPERATIONS §4h-8f.
FA_EGRESS_TUNNEL_IDLE_TIMEOUT_MS=900000 # Bounds on every tunnel the egress forwarder admits (idle: no byte either way;
FA_EGRESS_TUNNEL_MAX_LIFETIME_MS=14400000 # lifetime: since the 200; bytes: both directions together). Defaults shown
FA_EGRESS_TUNNEL_MAX_BYTES=17179869184 # (15 min, 4 h, 16 GiB). Each must be a positive whole number; there is no value
# that disables a bound, and an invalid value fails the run at provisioning.
# Operator-only: not settable per project or feature. See docs/OPERATIONS.md §4h-8d.
# Sandbox container reaper (spec 172) — see docs/OPERATIONS.md § Sandbox container
# reaper. Every container Weftra starts is labeled weftra.managed/instance(/job) (and, during the compatibility window, the legacy featureagent.* twins);
# a periodic sweep force-removes ONLY Weftra'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 Weftra container is reaped (3 h default)
SANDBOX_ORPHAN_TTL_MS=900000 # Age (ms) after which an ORPHANED Weftra container (missing/empty/"undefined"
# instance label (either family) — 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 Weftra-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)
FA_DEFAULT_BRANCH_OBSERVER=on # 'off' disables the default-branch observer (record, do not verify — spec 463 inc-1a)
FA_FOREIGN_LANDING_VERIFIER=on # 'off' disables the opt-in foreign-landing verifier (spec 463 inc-3a) — still requires a per-project policy to run anything
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, Weftra 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. Weftra 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 Weftra'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 Weftra 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 Weftra 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 Weftra sits behind a proxy.
Running with systemd
# /etc/systemd/system/weftra.service
[Unit]
Description=Weftra
After=network.target
[Service]
Type=simple
User=weftra
WorkingDirectory=/opt/weftra
ExecStart=/usr/bin/node dist/index.js
EnvironmentFile=/opt/weftra/.env
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.targetsudo systemctl enable weftra
sudo systemctl start weftraDeploy with Docker Compose (one command)
The fastest path from a fresh clone to a running, persistent Weftra 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 Weftra 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 Weftra 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 Weftra 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.
Weftra 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 — Weftra 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 Weftra container so Weftra (the trusted Governor/orchestrator) can spawn per-run sandbox containers on the host daemon. This grants the Weftra process the ability to control Docker on the host, which is effectively host-root access. This is acceptable only because Weftra 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.
Sandbox identity (spec 462, RM-332 inc-1). The Weftra image itself now runs as a non-root user (node, uid 1000), and the agent job, sandboxed-clone and git-egress containers refuse to launch as uid 0 (resolveSandboxUser() at src/services/runtime/docker-runtime.ts:1314, sandbox-git.ts:480, git-egress.ts:602) unless you explicitly acknowledge a user-namespace-remapped Docker daemon (FA_SANDBOX_ROOT_ACKNOWLEDGED=userns-remap). This does not cover every container Weftra launches: the governed deploy container, project service containers and the eval harness pass no --user and run as their image's user (root for the fa-runtime* images) — see docs/OPERATIONS.md §"No root sandbox in a containerised Weftra" for what that means and when it's honest to set it. Because the container is non-root, it needs GROUP membership on the Docker socket to reach it: set DOCKER_GID in .env to the socket's actual group — stat -c %g /var/run/docker.sock — before docker compose up; the compose file's default (999) is only a common convention, not a guarantee for your host. Upgrading an existing compose install whose named volumes are root-owned from before this spec needs a one-time chown -R 1000:1000 — see docs/OPERATIONS.md for the exact command.
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. Weftra will mount your local ~/.claude creds directory read-only into each agent container.
Useful operations:
docker compose logs -f fa # Tail live Weftra 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).
Deploy with Helm (Kubernetes)
For a Kubernetes deployment, use the chart at deploy/helm/weftra/ — see deploy/helm/README.md for the full quick start. It ships the same hardened-by-default posture as compose (spec 462, RM-332 inc-1): a non-root pod (runAsUser: 1000), readOnlyRootFilesystem: true, and a runtime.dockerSocket.gid value you must set to your K8s node's actual Docker socket group (stat -c %g /var/run/docker.sock on the node — do not assume the chart's default is correct for your cluster). Because the root filesystem is read-only by default, ADMIN_API_KEY must come from envFromSecret rather than relying on Weftra's usual self-provisioning fallback — see deploy/helm/README.md's "Hardened defaults" section.
Put Weftra on the internet with Cloudflare Tunnel and Access
Use this when you want to reach Weftra from anywhere (your phone, claude.ai, ChatGPT, GitHub webhooks) without opening a port on your machine. A Cloudflare Tunnel makes an outbound connection from your host to Cloudflare and serves a hostname through it; Cloudflare Access puts a login in front of that hostname. Weftra keeps listening on loopback the whole time.
You need a domain on Cloudflare and a free Zero Trust account. The examples use weftra.example.com.
1. Create the Access application first, so the hostname is never open. In the Cloudflare dashboard, go to Zero Trust → Access → Applications → Add an application → Self-hosted, set the domain to weftra.example.com, and add a policy Allow → Emails → your address (or your domain). Do this before step 2.
2. Create the tunnel on the Weftra host.
cloudflared tunnel login # writes ~/.cloudflared/cert.pem — keep it chmod 600
cloudflared tunnel create weftra # writes ~/.cloudflared/<tunnel-id>.json, the tunnel's credential
cloudflared tunnel route dns weftra weftra.example.com~/.cloudflared/weftra.yml:
tunnel: <tunnel-id>
credentials-file: /home/<you>/.cloudflared/<tunnel-id>.json
ingress:
- hostname: weftra.example.com
service: http://localhost:3100
- service: http_status:404Run it under systemd so it survives reboots (/etc/systemd/system/cloudflared-weftra.service, running as your user):
[Service]
User=<you>
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --config /home/<you>/.cloudflared/weftra.yml run weftra
Restart=alwaysThen sudo systemctl enable --now cloudflared-weftra.
3. Tell Weftra where it lives. In Weftra's environment:
FA_BIND=127.0.0.1 # unchanged: the tunnel connects to localhost
FA_EXPOSED=true # the bind looks local, but the instance is reachable from the internet
ADMIN_API_KEY=<a long random value> # required once exposed
APP_BASE_URL=https://weftra.example.com # used for OAuth discovery, reset links and links handed to assistantsFA_EXPOSED=true matters because the startup guard decides "exposed" from the bind address, and a tunnel looks like loopback to it (see the startup guard table above). Restart Weftra after changing these.
4. Let machines through where they cannot log in, and nowhere else. People pass the Access login in their browser. Some callers cannot, so give them a path-scoped Access application with a Bypass policy (a more specific path takes precedence over the host-wide application). Weftra's own authentication still applies on every one of these paths.
| Caller | Paths to bypass | What still authenticates it |
|---|---|---|
| MCP connectors (claude.ai, ChatGPT) | /api/mcp, /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource, /oauth/mcp/register, /oauth/mcp/token, /oauth/mcp/revoke | A project key, or an OAuth token an admin approved |
| Inbound webhooks (GitHub, GitLab, Linear, Sentry, Slack) | /api/projects/*/triggers/* | The trigger's HMAC secret |
Leave /oauth/mcp/authorize behind Access: it opens in the person's own browser, which can log in. Scripts and other services that can set headers should use an Access service token instead of a bypass: create one under Access → Service Auth → Service Tokens, add a policy with action Service Auth that includes it, and send CF-Access-Client-Id and CF-Access-Client-Secret with each request alongside Weftra's own Authorization header.
5. Check it.
curl -s -o /dev/null -w '%{http_code}\n' https://weftra.example.com/api/status # 302: the Access login is in front
curl -s -o /dev/null -w '%{http_code}\n' https://weftra.example.com/api/mcp # 401 from Weftra: bypassed, then refused without a key (404 if FA_MCP_HTTP_ENABLED is off)If Cloudflare shows a 502, the tunnel reached your host but Weftra did not answer on localhost:3100: journalctl -u cloudflared-weftra shows connection refused while Weftra restarts, and a host network change drops and re-registers the tunnel's connections within seconds. See Connecting assistants for the claude.ai and ChatGPT steps that come after this.
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 Weftra 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 Weftra 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)
Weftra supports two modes for the admin API key:
| Mode | Behaviour |
|---|---|
file (default) | Weftra self-provisions ADMIN_API_KEY at startup if absent and writes it to .env. Back-compat; existing installs unaffected. |
managed | Weftra reads ADMIN_API_KEY from the environment only. If the key is absent at startup, Weftra 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 Weftra starts. - Weftra 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, Weftra rewrites the .env file immediately and the new key is active in-process. In managed mode, Weftra 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 Weftra'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 Weftra 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-Weftra-Door: openapi-action. This is optional and purely informational — Weftra 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-Weftra-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. (If both data/weftra.db and data/featureagent.db exist and DATABASE_PATH is not set, the server refuses to start and names both files; set DATABASE_PATH to the one you mean.)