Skip to the content.

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

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

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