serve-config.md — configuring a network-facing fak serve
A single reference for the environment variables and fak serve flags that matter
when you put the kernel gateway in front of a model and expose it beyond loopback.
Every var and default below is read directly from the code (see the Source
column / pointers); none is guessed.
fak serve is a drop-in adjudication proxy: it fronts a model (an upstream
OpenAI-compatible/Anthropic provider, or the in-kernel engine) and runs every
proposed tool call through the kernel before the client sees it. By default it
binds loopback with no authentication — fine for local dogfood, not for a
network-reachable deployment. The three things you almost always set for a
network-facing deploy are covered below: auth (--require-key-env), the
policy floor (and how to reload it live), and timeouts (so a slow backend
or a slow client can’t pin or trip a connection).
Env-var reference
| Variable | Default | Units | Scope | Pre-raised by dogfood-claude.sh? |
Source |
|---|---|---|---|---|---|
FAK_HTTP_READ_TIMEOUT_S |
30 |
seconds | global (per-connection) | no | internal/gateway/http.go |
FAK_HTTP_WRITE_TIMEOUT_S |
90 |
seconds | global (per-connection / whole handler) | yes → FAK_DOGFOOD_TIMEOUT_S (default 300s, 900s for openai) |
internal/gateway/http.go |
FAK_HTTP_IDLE_TIMEOUT_S |
120 |
seconds | global (per-connection keep-alive) | no | internal/gateway/http.go |
FAK_PLANNER_TIMEOUT_S |
60 |
seconds (clamped [5, 3600]) |
global (per upstream request) | yes → 300s (ollama/shim), 900s (openai) |
internal/agent/chat.go |
FAK_PROVIDER_EXTRA_BODY_JSON |
unset | JSON object | global (per upstream request) | yes, from FAK_DOGFOOD_PROVIDER_EXTRA_BODY_JSON |
internal/agent/chat.go |
FAK_MODEL_DIR |
unset (synthetic checkpoint) | filesystem path | global (process start) | no | internal/modelengine/modelengine.go |
FAK_Q4K |
unset (lean-Q8 path) | flag (set/unset) | global (process start) | no | cmd/fak/main.go |
FAK_RATELIMIT_MAX_CALLS |
0 (unlimited / inert) |
call count | global (process start) | no | internal/ratelimit/ratelimit.go |
FAK_RATELIMIT_MAX_COST |
0 (unlimited / inert) |
cost units (~arg bytes) | global (process start) | no | internal/ratelimit/ratelimit.go |
FAK_RATELIMIT_KEY |
trace |
enum: trace|tool|global |
global (process start) | no | internal/ratelimit/ratelimit.go |
FAK_AUDIT_JOURNAL |
unset (no journal) | filesystem path (.jsonl) |
global (process start) | no | internal/journal/journal.go |
FAK_IFC |
enabled | toggle (off disables) |
global (process start) | no | internal/ifc/ifc.go |
Notes on the table:
- Scope. None of these is a per-HTTP-request override — there is no header or
query knob that changes them per call. The HTTP timeouts apply per connection;
the planner timeout applies per upstream model request; the rest are read once
at process start. “global” means “set in the environment before
fak servestarts.” ReadHeaderTimeoutis fixed at 10s in code and has no env override.- Disabling a timeout. For the three
FAK_HTTP_*_TIMEOUT_Svars,0selects Go’s “no timeout” semantics (an explicit opt-out for a long-running local backend). A negative value is rejected and the default is kept (durEnvininternal/gateway/http.go). FAK_PLANNER_TIMEOUT_Sclamping. Values outside[5, 3600]seconds are ignored and the 60s default is kept (plannerTimeoutininternal/agent/chat.go).
Auth: requiring a bearer key on a network-facing gateway
With no key configured the gateway is a pass-through — every route is open. That is
the loopback default. On a non-loopback bind with no key, the gateway logs a
WARNING: binding ... with NO --require-key set line but still serves.
Turn on auth by naming an environment variable that holds the secret — the secret value is never a command-line argument:
export FAK_GATEWAY_KEY="$(openssl rand -hex 32)"
fak serve --addr 0.0.0.0:8080 --require-key-env FAK_GATEWAY_KEY --policy policy.json
--require-key-env VARreads the secret from$VARat startup. If$VARis empty,fak serveprints--require-key-env <VAR> is empty — starting with NO authenticationand continues unauthenticated, so make sure the var is exported and non-empty in the serving process’s environment.- Once set, every route except
/healthzrequires the secret. The gateway accepts it over either header:Authorization: Bearer <tok>— OpenAI-compatible and fak-native clients.x-api-key: <tok>— what Claude Code and the Anthropic SDKs send to/v1/messages. Both are compared against the same secret in constant time (SHA-256 digests), so the reject latency leaks neither the secret’s bytes nor its length. A bareAuthorizationvalue with noBearerscheme is rejected.
- The policy/lifecycle routes under
/v1/fak/*(reload, trace reset) require the same bearer token when--require-key-envis set.
Source: withAuth / gatewayCredential in internal/gateway/http.go; the
--require-key-env flag in cmd/fak/main.go; the dual-header note in
DOGFOOD-CLAUDE.md.
Policy: the default-deny floor and reloading it live
The kernel adjudicates every proposed tool call against a capability-floor
manifest. Anything not affirmatively allowed and not explicitly denied resolves
to the fail-closed DEFAULT_DENY — an empty manifest ({}) denies every call.
With no --policy flag the kernel uses its built-in default floor; pass --policy
FILE to deploy your own. (Full manifest schema and refusal vocabulary live in
POLICY.md — not repeated here.)
Workflow:
fak policy --dump > policy.json # start from the built-in default
# edit policy.json: allow the tools your agent needs, deny the irreversible ones
fak policy --check policy.json # validate before it gates a run
fak serve --addr 0.0.0.0:8080 --policy policy.json --require-key-env FAK_GATEWAY_KEY
You can also validate at boot without binding a listener: fak serve --policy
policy.json --policy-check exits after validating the manifest.
Switching/reloading a policy on a running gateway — no process restart, the warm vDSO cache and IFC ledger survive:
# edit policy.json in place, then:
curl -X POST http://HOST:8080/v1/fak/policy/reload \
-H "Authorization: Bearer $FAK_GATEWAY_KEY"
The reload re-reads the same file that was passed to --policy at startup, so
“switch policy” means “rewrite that file, then POST reload.” If --require-key-env
is set, the reload route requires the bearer token like every other /v1/fak/*
route. A related lifecycle route clears one trace’s IFC high-water mark after an
operator-approved session boundary:
curl -X POST http://HOST:8080/v1/fak/trace/reset \
-H "Authorization: Bearer $FAK_GATEWAY_KEY" \
-H 'Content-Type: application/json' \
-d '{"trace_id":"gw-123"}'
Source: POLICY.md; --policy / --policy-check flags and the reload route in
cmd/fak/main.go.
Timeout tuning: remote upstream vs slow local model
The two timeouts that interact with a model round-trip are FAK_HTTP_WRITE_TIMEOUT_S
(bounds the whole HTTP handler, and a live upstream model round-trip rides it)
and FAK_PLANNER_TIMEOUT_S (bounds the upstream provider request itself). The
write timeout must be at least as large as the planner timeout, or the handler
will be cut off while the upstream request is still legitimately in flight.
Remote hosted upstream (fast first token). Keep the conservative
network-exposed defaults. A hosted API answers in seconds, so 90s write / 60s
planner is plenty and protects you from a slow-loris client pinning a connection.
Set FAK_PROVIDER_EXTRA_BODY_JSON if the upstream needs provider-specific request
fields (e.g. vLLM/SGLang sampling knobs). Example:
FAK_HTTP_WRITE_TIMEOUT_S=120 FAK_PLANNER_TIMEOUT_S=120 \
fak serve --addr 0.0.0.0:8080 --provider openai \
--base-url https://upstream/v1 --model M --api-key-env UPSTREAM_KEY \
--require-key-env FAK_GATEWAY_KEY --policy policy.json
Slow local CPU model (first token can take minutes). A multi-thousand-token
prefill on a CPU-served model can run for minutes, so the conservative defaults
will trip mid-turn. Raise both timeouts together — this is exactly what
scripts/dogfood-claude.sh does, pre-raising FAK_PLANNER_TIMEOUT_S and
FAK_HTTP_WRITE_TIMEOUT_S to FAK_DOGFOOD_TIMEOUT_S (default 300s, 900s
for the openai backend):
FAK_PLANNER_TIMEOUT_S=600 FAK_HTTP_WRITE_TIMEOUT_S=600 \
fak serve --addr 127.0.0.1:8080 --gguf model.gguf --tokenizer tok/ \
--policy policy.json
If the backend genuinely streams for longer than any sane ceiling, set
FAK_HTTP_WRITE_TIMEOUT_S=0 to disable the write deadline entirely (Go’s
“no timeout”). Note FAK_PLANNER_TIMEOUT_S cannot be disabled — it is clamped to
at most 3600s (1h); pick a value inside [5, 3600].
FAK_HTTP_READ_TIMEOUT_S (30s) bounds how long a client may take to deliver its
request body, and FAK_HTTP_IDLE_TIMEOUT_S (120s) bounds an idle keep-alive
connection. Neither rides the model round-trip, so leave them at the defaults
unless you have unusually slow clients or want longer-lived keep-alives.
Source: timeout semantics and the “slow local backend” rationale in
internal/gateway/http.go; planner timeout in internal/agent/chat.go; the
300s/900s pre-raises in scripts/dogfood-claude.sh.
Feature wiring status
Every fak serve feature, traced from its operator flag through the
gateway.Config field to the load-bearing runtime read that produces the effect.
A green make ci does not tell you a feature is reachable: a Config field
can be set on the struct while the flag that feeds it is missing, so the feature
is dead on the shipped binary. This table is the answer to “is it actually wired?”
The states are: wired (a runtime read consumes the field on a request/turn
path and the operator reaches it by default); off-by-default (fully wired,
inert until a non-default flag value arms it; a deliberate guard, not a defect);
partial (the producer side is wired but a documented consumer step is
deferred); dead-wired (the gateway reads the field but serve.go never
feeds it; unreachable on the shipped binary).
| Feature | Status | Flag | gateway.Config field | Live call site | Note |
|—|—|—|—|—|—|
| inkernelchat | wired | --gguf / --tokenizer | InKernelModel | internal/gateway/gateway.go:861 | with model+tokenizer and no –base-url, /v1/chat/completions and /v1/messages serve the in-kernel model |
| replica | wired | --replica-base-url | ReplicaBaseURLs | internal/gateway/gateway.go:715 | 2+ endpoints -> ReplicaRouter round-robin |
| vdso | wired | --vdso / --invalidation | VDSO | internal/kernel/kernel.go:348 | dedup fast path + tier-2 invalidation granularity |
| vdsoproxyfill | off-by-default (wired) | --vdso-proxy-fill | VDSOProxyFill | internal/gateway/gateway.go:1868 | warms the vDSO tier-2 cache from admitted inbound tool_result blocks; off by default |
| toolfloor | wired | (adjudicator.Default.NeverAdmits) | ToolFloorDenies | internal/gateway/messages.go:392 | prunes provably-unreachable tool defs from the Anthropic passthrough; default-on, fail-safe |
| expose | off-by-default (wired) | --expose | ExposeTools | internal/gateway/mcp.go:exposedToolDescriptors | allowlist of tool-name globs that narrows BOTH tools/list discovery AND tools/call invocation to the named tools (a hidden tool answers “unknown tool”, no existence leak); a malformed or zero-match pattern fails startup loud; empty (default) exposes the full surface |
| decidesession | wired | (host func, default-on) | DecideSession | internal/gateway/session_admit.go:57 | run-state refusal + TurnsLeft debit + budget + pace, before the model turn |
| debitsession | wired | (host func, default-on) | DebitSession | internal/gateway/session_admit.go:157 | debits TokensLeft + context budget after the planner returns |
| nativeserve | off-by-default (wired) | --native | Native | internal/gateway/messages.go:153 | routes non-streaming /v1/messages through fak’s owned agent.RunArm loop; off by default |
| nativeserveturns | off-by-default (wired) | --native-max-turns | NativeMaxTurns | internal/gateway/native_serve.go:33 | caps the owned native serve loop’s model round-trips per request when –native is enabled |
| routemanifest | wired | --route-manifest | RouteManifest | internal/gateway/gateway.go:1127 | binds ToolCall.Engine before Submit; flag wired (was DEAD_WIRED before this pass) |
| routeaccounts | off-by-default (wired) | --route-accounts | RouteAccounts | internal/gateway/gateway.go:resolveRoute | binds the routed model id through the account roster to Target.EngineRoute() before Submit, so the residency PDP adjudicates the account-resolved route (#2528); off when no roster file is named |
| ctxview | wired | --ctx-view-budget | CtxViewBudget | internal/gateway/gateway.go:788 | re-materializes history as an O(1) planned ctxplan view under the budget; DEFAULT-ON at 8000 resident tokens (fail-open, Anthropic cache prefix byte-identical), pass 0 to disable |
| compacthistory | wired | --compact-history-budget | CompactHistoryBudget | internal/gateway/messages.go:compactAnthropicRawWithReason | compacts old turns in the Anthropic outbound body once it sprawls past the budget, cache prefix byte-identical; DEFAULT-ON at ~48k (gateway.DefaultCompactHistoryBudget), pass 0 to disable |
| compactanchorhead | wired | --compact-anchor-head | CompactAnchorHead | internal/gateway/messages.go:compactAnthropicRawWithReason | re-anchors compacthistory’s protected prefix on the stable system/tools head (agent.CompactAnchorHead) instead of the first cache_control breakpoint, the #1407 anchor-starved fix; DEFAULT-ON with every fire still gated on agent.CacheBurstPaysBack (#1408): fires when the live session’s Budget.TurnsLeft horizon repays the one-time burst, or horizon-free when the trace OBSERVABLY idled past the message-breakpoint cache TTL (coldMessageSpanCache — the suffix re-bills cold that turn anyway); a warm un-budgeted session never bursts, pass =false to pin the first-breakpoint anchor |
| assumesessionturns | wired | --assume-session-turns | AssumeSessionTurns | internal/gateway/messages.go:headSessionPrior | the head-anchored burst gate’s presumed session length when NO bounded Budget.TurnsLeft horizon is wired (the fak guard -- claude case): headSessionPrior maps the trace’s served-turn depth to CurrentTurn and this value to TotalTurns, so a WARM un-budgeted long session fires the #1407 head re-anchor shed early (agent.CacheBurstPaysBack, #1408) and refuses near the presumed end; DEFAULT-ON at gateway.DefaultAssumedSessionTurns, a genuine wired Budget.TurnsLeft always wins, pass 0 for the byte-for-byte conservative no-horizon behavior. Inert unless –compact-anchor-head engages the head re-anchor |
| elideresult | wired | --elide-result-bytes | ElideResultBytes | internal/gateway/messages.go:maybeElideAnthropicRaw | shrinks an old oversized tool_result body to a bounded head+tail on BOTH wires — the Anthropic passthrough (req.Raw byte-splice, cache head byte-identical) and the decoded local-model path (req.Messages, for GLM-5.2/Qwen-3.6 served by fak); DEFAULT-ON at gateway.DefaultElideResultBytes (16KB), pass 0 to disable |
| debugstats | off-by-default (wired) | --debug-stats | DebugStatsf | internal/gateway/metrics.go:404 | emits one compact payload-free per-turn cache/compaction/resetScore line to stderr; off by default |
| resetonbudget | off-by-default (wired) | --reset-on-budget | ResetOnBudget | internal/gateway/session_admit.go:108 | distills a carryover seed and continues transparently on budget exhaustion; needs –context-budget-tokens |
| budgetwebhook | off-by-default (wired) | --budget-webhook | (observer seam) | internal/session/usage.go:73 | POSTs a pre-exhaustion warning + exhaustion event; wired via WatchBudget, off when URL empty |
| notifier | wired | --notify-native / --notify-webhook / --notify-slack | (observer seam) | cmd/fak/serve.go (WatchTransitions) | #761 stop-reason push notifier; native default-on (was DEAD_WIRED before this pass) |
| enginecache | off-by-default (wired) | --engine-cache-engine | EngineCacheEngine | internal/gateway/gateway.go:1480 | resets the serving-engine cache after a quarantined proxy turn; off when engine empty |
| backend | off-by-default (wired) | --backend | Backend | internal/agent/inkernel_planner.go:271 | decodes the in-kernel chat through the compute HAL device; off when name empty |
| cpuoffloadexperts | off-by-default (wired) | --cpu-offload-experts | CPUOffloadExperts | internal/agent/inkernel_planner.go:282 | with –gguf –backend, keeps MoE expert GEMMs on host RAM while dense/router/attention run on the device; off by default |
| metal | wired | --metal | Metal | internal/agent/inkernel_planner.go:1067 | with –gguf (no –backend), auto-selects the Apple-Silicon metalgemm GPU when Apple-Silicon+cgo+a device are available; –metal/FAK_METAL=1 requires that path fail-loud; dense-Qwen Q8 only; CPU fallback on non-Metal builds or unavailable devices |
| expertparallel | off-by-default (wired) | --expert-parallel | ExpertParallelRanks | internal/gateway/gateway.go:817 | sets expert-parallel MoE ranks on the in-kernel model before planner construction; 0/1 leave the monolith path unchanged |
| steersession | partial | (host func, default-on) | SteerSession | internal/agent/loop_session.go:297 (drainSteer) | POST /session/{id}/steer enqueues onto the a2achan Session bus; the native RunArm loop drains it at its turn boundary and folds it into the next turn as a user message (drainSteer, #850 — the consumer half #760 deferred). PARTIAL because only the native serve path owns that loop: the default proxy serve forwards a single upstream turn and owns none, so a steer to a proxy-served session is refused at ingress with the closed STEER_NO_OWNED_LOOP reason (409) rather than falsely acked as delivered (#3528). |
| elidestale | wired | --elide-stale-reads | ElideStaleReads | internal/gateway/messages.go:865 (maybeElideStaleReads) | the restorable sibling of –elide-result-bytes: on the Anthropic passthrough, replaces a Read tool_result superseded by a LATER in-session Edit/Write with a compact fak_context_restore marker (pre-edit body stashed behind a restore handle), same cache-safe working-set band, cache prefix byte-identical; DEFAULT-ON (gateway.DefaultElideStaleReads), pass =false to opt out |
| positiveresidual | wired | --positive-residual-substitution | PositiveResidualSubstitution | internal/gateway/messages.go (positive residual substitution before provider dispatch) | opt-in conservative replacement of compacted negated or stale history with a positive residual; original bytes remain restorable through ctxrestore; covered by internal/gateway/positive_residue_test.go |
| vcacheanchor | wired | --vcache-anchor | VCacheAnchor | internal/gateway/messages.go:796 | M2 star-anchor pre-flight gate (#1493): on the Anthropic passthrough, applies cachemeta.RecommendLayout before send — hoists volatile system blocks behind a byte-stable cacheable anchor and splices a cache_control breakpoint onto the stable head so the first request warms provider prefix caching and siblings read it; DEFAULT-ON (gateway.DefaultVCacheAnchor), DECOUPLED from –compact-history-budget, fail-safe identity on any ambiguity, pass =false to opt out |
| defercoldtools | off-by-default (wired) | --defer-cold-tools | DeferColdTools | internal/gateway/messages_tooldefer.go:87 | the 10x floor lever (#3232, epic #3229): on the outbound Anthropic body marks every allowed-but-COLD custom tool defer_loading:true and injects one tool_search_tool, so the provider loads only the HOT core and faults a cold schema in on demand; deterministic + cache-safe, DEFAULT OFF (also FAK_DEFER_COLD_TOOLS=1), Anthropic passthrough only |
| streamprogress | wired | --stream-progress-timeout | StreamProgressTimeout | internal/agent/stream_stall.go:streamProgressWindow (armed at internal/agent/stream.go:newStallReader and internal/agent/anthropic_stream.go) | the streaming CONTENT-progress deadline (#5486): how long a proxied stream may stay WARM — keepalive/ping frames re-arming the inter-byte deadline — without one frame that advances the turn, after which the turn ends as a no-progress stall (504 upstream_stalled) instead of riding the 600s whole-request ceiling. newConfiguredHTTPPlanner carries the Config value verbatim onto every proxy planner (lone upstream AND each replica) and streamProgressWindow resolves it: DEFAULT-ON at agent.DefaultStreamProgressTimeout (300s) when unset, a positive value outside [5s, 600s] falls back to that default rather than being clamped, and a NEGATIVE value disables the deadline. --stream-progress-timeout 0 is the operator’s off switch (the house 0-is-off spelling, as with –ctx-view-budget/–elide-result-bytes) and cmd/fak/serve.go:serveStreamProgressTimeout translates that 0 into the negative encoding — the escape hatch for a provider whose prefill legitimately outlasts the window. Streaming proxy path only; inert on the buffered turn and on the offline mock planner |
| keyprincipals | off-by-default (wired) | --key-principal | KeyPrincipals | internal/gateway/http.go:359 (withAuth -> keyset.lookup) | the multi-tenant KEYSET (#5332): serve resolves each PRINCIPAL=ENV_VAR spec through gateway.ParseKeyPrincipals (env var NAMES only, never a secret at rest) and gateway.New hashes the keys to SHA-256 digests, so a matching inbound x-api-key / Bearer both AUTHENTICATES the caller and stamps its tenant principal via WithPrincipal — which is what makes principalFor AUTHORITATIVE-from-context instead of falling through to the caller-supplied X-Fak-Principal header, and therefore what makes the modelroute Account.Principals allowlist (Target.Admits) a real isolation boundary. OFF by default: no –key-principal leaves the map nil, newKeyset returns a nil keyset, and the –require-key-env single-bearer path is byte-for-byte unchanged. A malformed spec, an unset/empty env var, or two tenants sharing one key REFUSES to boot (serveKeyPrincipals -> exit 2) |
This table is kept honest by the trunk, not by hand. fak serve-wiring
re-derives the wiring on each run: it reads the real serve.go and gateway.go
and cross-checks that every audited row’s Config field still exists and is
still set in serve.go’s gateway.New(Config{...}) literal, and that no Config
feature is left unaudited. A field serve.go stops feeding flips its row to
dead-wired and reds the gate:
fak serve-wiring # the summary (counts + per-feature call sites)
fak serve-wiring --md # regenerate the table above
fak serve-wiring --check # CI gate: exit 1 on wiring drift
The verdicts come from an audited baseline (each row traced flag -> Config field
-> runtime read, then adversarially verified) in cmd/fak/servewiring.go; the
drift cross-check is in the same file, exercised by cmd/fak/servewiring_test.go.
When you wire a new serve feature, add its row there and the gate keeps it true.
One gateway.Config feature is absent from the table and cannot be added to it:
--compact-solvency-floor (CompactSolvencyFloorTokens) is a fak guard flag —
cmd/fak/guard.go sets that Config field and serve.go never does — and
fak serve-wiring audits only serve.go’s gateway.New(Config{...}) literal, so a
row for it would read as dead-wired and red --check. It was carried inside the
generated block by hand for a while, which the next --md regeneration silently
dropped; it is documented with the guard flag surface instead (fak guard --help,
“Token economy”).