Configuration
The proxy runs with no config file at all out of the box — a config-less first run boots on mainnet, using embedded defaults for the escrow contract id and algod endpoint, and walks you through wallet setup. Everything on this page is for tuning that behavior; you don't need any of it to get started (see Quick start).
How config is loaded
-
File. An explicit
--config <path>wins; otherwise the proxy checks thePROXY_CONFIGenvironment variable, thenconfig.yamlin its state directory (~/Library/Application Support/zerosignal/on macOS,~/.config/zerosignal/on Linux,%AppData%\zerosignal\on Windows), then./config.yamlin the current directory. -
Network.
--network mainnet|testnet|localnetonproxy startselects the embedded defaults row (escrow app id + algod endpoint) without needing a file at all. An explicit--networkis authoritative: it sets both the network and that network's canonical escrow app id, even over a config file (localnet has no canonical id, so there your config's value is kept). Without the flag, file/env values win and the embedded defaults only fill the gaps. -
Environment overrides. Settings can be overridden by an environment variable named after the YAML path with
_separators and aPROXY_prefix — e.g.PROXY_SERVER_LISTENoverridesserver.listen,PROXY_ZS_ESCROW_APP_IDoverrideszs.escrow_app_id. -
Inspecting what's actually active:
zs-proxy config path # the file path that would be loadedzs-proxy config print-effective # the merged result: defaults + file + env + flags
server — where the proxy listens
| Key | Default | Notes |
|---|---|---|
listen | 127.0.0.1:9376 | Loopback by default — the proxy is meant to be reached only from your own machine. |
read_timeout | 30s | Request-read deadline. |
write_timeout | 0s | Keep at 0 — disabled, required for streaming responses. |
idle_timeout | 120s | Keep-alive idle deadline. |
algod — the Algorand connection
| Key | Default | Notes |
|---|---|---|
network | mainnet | mainnet | testnet | localnet. Setting this alone picks a sensible default endpoint and token — usually all you need. |
endpoint | "" | Optional override for a private or paid algod endpoint. |
token | "" | Optional override — prefer the PROXY_ALGOD_TOKEN env var so it doesn't end up in a committed file. |
zs — operator selection and behavior
| Key | Default | Notes |
|---|---|---|
escrow_app_id | — | The deployed escrow contract id. On a public network you can usually omit it — the proxy falls back to the canonical app id for that network. |
operator_ids | all registered | Optional allowlist of specific on-chain operator ids to restrict routing to. Omit to use every currently-registered operator. |
concurrent_slots | 10 | How many requests can be in flight at once, each holding a ticket backed by the prepaid pool. Raise it if you run a lot of requests in parallel (e.g. several agents at once). Easiest changed with zs-proxy slots <n>, which sets this and funds the pool to match — the limit only really moves when both change. |
fallback_max_output_tokens | 32768 | Output ceiling used when a client omits max_tokens — the escrow ticket needs a bound to price against. It isn't applied as a flat number: the proxy sizes each reserve to the chosen operator (a model's own declared output limit, or a quarter of its context window, capped by this value), so a small-context operator gets a right-sized reserve instead of being skipped. Leave it at the default unless you know why you're changing it — the chat app uses the same value, and the eligibility check runs on the derived number, so a proxy-only ceiling makes the two disagree about which operators can serve the same request. Set 0 to refuse such requests instead (max_output_required). A per-model override map, default_max_output_tokens, is also available. This key covers only the omitted case — a client that does send max_tokens is bounded by what operators advertise, not by this value (Response length). |
privacy | true | Transport privacy — routes every request through a relay so the operator you talk to never sees your IP. Set false to talk directly to operators (lower latency, less private). |
allow_privacy_override | true | Whether a single request may opt out of privacy mode via the provider.relay: "off" field — see Routing preferences. |
allow_staging | false | Route to operator nodes flagged on-chain as staging. Leave this off unless you're deliberately testing one — also settable per-launch with --allow-staging. |
affinity_policy | prefer | How strictly multi-turn tool calls are routed back to the same operator that handled the previous turn. prefer falls back if needed, strict refuses to fall back, none disables it. |
mbr_deposit_slots was renamed to concurrent_slots. The old key is no
longer read, so a config file still using it falls back to the default of 10 —
silently. If you had raised it, set the new key (or just run zs-proxy slots <n>). The environment variable moved too: PROXY_ZS_MBR_DEPOSIT_SLOTS is now
PROXY_ZS_CONCURRENT_SLOTS.
allow_private_operators exists for pointing the proxy at a localnet/dev
deployment with private addresses and should stay off (the default)
everywhere else — it's a guard against the proxy being tricked into dialing
an address on your own network.
Discovery tuning
The proxy keeps a live picture of which operator nodes exist and what each one can serve. It refreshes that picture in the background — reading the operator set from the chain, then asking each node directly what models, tools, and prices it currently offers.
Once the network grows past a few dozen nodes, checking every one on every cycle would take longer than the cycle itself, so the proxy asks a bounded number per round and lets the rest coast on what it last learned. These keys control that balance. You shouldn't need to touch any of them — they exist for people running against a large or unusually slow network.
| Key | Default | Notes |
|---|---|---|
refresh_interval | 5m | How often the background refresh runs. |
refresh_timeout | 30s | Time limit for one whole round. A round that overruns is abandoned and the previous picture is kept. |
details_timeout | 2s | Time limit for asking a single node. Deliberately short, so one slow node can't starve the round. |
probe_budget | 96 | How many nodes one refresh round asks directly. A network at or under this size is checked in full every round; past it, rounds take turns. |
max_carry_age | 90m | How long a node that hasn't been asked recently keeps counting as available, on the strength of what it last reported. |
target_topk | 8 | When you request a model, how many of its fastest known servers get re-checked that round, so the ones you're actually likely to use stay fresh. |
details_concurrency | 24 | How many nodes are asked at once within a round. Lower it if the proxy is opening more sockets than your network likes. |
catalog_path | state dir | Where the last-known picture of the network is saved, so a restart resumes from it instead of rediscovering everything. Set "off" to disable saving. |
latency_state_max | 4096 | Upper bound on how many measured routes the proxy remembers timings for. A memory ceiling, nothing more. |
If you change probe_budget, check max_carry_age too. Together with
refresh_interval they decide how long one full pass over the network takes
(roughly nodes ÷ probe_budget × refresh_interval). If max_carry_age is
shorter than that pass, nodes go stale before their turn comes round again and
visibly flicker in and out of /v1/models. Raising probe_budget also makes
each round do more work — keep it in proportion to details_concurrency and
details_timeout so a round still finishes inside refresh_timeout.
Each of these can also be set as an environment variable —
PROXY_ZS_PROBE_BUDGET, PROXY_ZS_MAX_CARRY_AGE, and so on.
spend — your own safety cap
This is the one section worth setting deliberately, especially if you're pointing an autonomous agent or a loop at the proxy: it's an aggregate guard on top of the network's own per-request price ceiling, so a misbehaving agent can't quietly drain your balance.
| Key | Default | Notes |
|---|---|---|
daily_cap_usdc | 0 (unlimited) | Maximum total USDC the proxy will commit per calendar day (UTC) — the counter resets at midnight UTC. Persisted across restarts. |
per_request_cap_usdc | 0 (unlimited) | Maximum USDC a single request may cost. A request that would exceed it is refused before it's sent. |
spend:
daily_cap_usdc: 5.00
per_request_cap_usdc: 0.50
Both are whole USDC (e.g. 5.00), and either can also be set via
PROXY_SPEND_DAILY_CAP_USDC / PROXY_SPEND_PER_REQUEST_CAP_USDC. The daily
counter tracks your actual settled spend — what each request really cost
after the operator's signed receipt reconciles (usually far below the price
ceiling reserved on-chain), so refunded headroom is freed back to the cap. When
it decides whether to admit a new request it also counts any requests still
in flight, so the worst-case commitment can never exceed the cap.
zs-proxy status shows today's spend against it.
logging
| Key | Default | Values |
|---|---|---|
level | info | debug | info | warn | error |
format | text | text | json |
The proxy never logs prompts, responses, or tool-call content — only metadata like token counts, ticket/operator ids, model names, and cost. See Privacy & security.
A minimal config.yaml
Most of the time you don't need a file at all (see Quick
start). If you do — say, to pin mainnet with a spend cap
without retyping flags every time — this is enough:
algod:
network: "mainnet"
spend:
daily_cap_usdc: 10.00
per_request_cap_usdc: 1.00
What's next
- Wallet & funding — funding, the prepaid pool, and
wallet_unfunded. - Routing preferences — per-request operator selection, pricing ceilings, and the relay override.
- Running as a service — keep the proxy up across reboots.