ThinkWatch Core reads one file, config.yaml. This page describes every
field in it: what it does, its default, the values it takes, and how a
change reaches the running process. To run core on a server and control it
from the desktop app, see Running core on a server.
The field tables on this page are generated from the code, and a test fails when they disagree, so a field listed here is a field the binary reads.
| Platform | Default location |
|---|---|
| macOS, Linux | ~/.thinkwatch/config.yaml |
| Windows | %APPDATA%\ThinkWatch\config.yaml |
THINKWATCH_HOME replaces the directory, and --config <path> names the
file for a single command. The directory holds everything else core keeps
as well: the request database (data.db), the backups of the configuration
(history/), the downloaded price table (model_prices.json) and the local
control socket (twcore.sock; on Windows a loopback port recorded in
control.port). The directory is private to its owner (0700), the file is
0600: it holds keys in plain text.
These permissions are the file's only protection on this machine. They keep
out other users, not programs running as the same user: such a program can
read every key in the file, and with the control key it holds, change the
configuration through the control plane. Outbound redaction does not change
that; it protects what a request carries off the machine, not what is on disk.
The control key is masked where the configuration is shown so that a write
through the control plane cannot change it
(listen.control), not to keep it from local
programs; upstream and gateway keys are shown as written.
Tool-call inspection has a built-in rule for this directory,
thinkwatch-data. A tool call whose path or command points into one of the
default locations above, or into /var/lib/thinkwatch or /etc/thinkwatch
on a server, is recorded, and under enforce the response is cut off, so a
model cannot be steered into reading these keys or rewriting its own
protections. Mentioning the path, as in a document being edited, does not
count. With THINKWATCH_HOME elsewhere, a custom rule in
security.inspect_tools can cover that path.
twcore serve writes a starting configuration when there is none, and
twcore init writes one on request. Both produce this:
version: 1
listen:
control:
key: 6629…753d # generated
clients:
- name: default
key: tw-… # generatedThat is a complete, valid configuration. It has no upstream yet, so the control plane runs and requests are answered with an error saying so. Adding one upstream makes it forward:
providers:
- name: anthropic
base_url: https://api.anthropic.com
key: ${ANTHROPIC_API_KEY}- A field name that is not in this reference is an error. A misspelled
prot:is not ignored while the gateway quietly starts on the default port; the whole file is refused and the message names the field. - Defaults are not written into the file. Anything left out has the default in the tables below. The app and the command line add a field only when its value differs from the default, so what is in the file is what someone chose.
${VAR}reads an environment variable in fields marked "${VAR}allowed": upstream keys, header values, proxy passwords. It is read from the environment of the core process when a request is sent; an unset variable fails that upstream's requests and names the variable. Under systemd, that environment is the unit'sEnvironmentFile.- Names are references. Rules, groups and keys refer to upstreams,
groups, routes and price sheets by name. A name that points nowhere is an
error at load time rather than a rule that never matches. Renaming in the
app changes every reference in the same write. Names starting with
__are reserved for built-ins. versionis the format version,1. A file with a higher version was written by a newer twcore and is refused.
There are three ways to change the configuration, and they all go through
the same path: the desktop app, twcore config …, and editing the file
in an editor. Core watches the file and reloads it within a second of a
save; nothing needs to be restarted.
A new version is applied only if it passes every stage:
- It parses as YAML.
- It matches the schema: known fields, the right types.
- It is consistent: names are unique, references resolve, patterns compile, CIDR ranges are well formed.
- The runtime objects can be built from it.
If any stage fails, the previous configuration stays in service, and the error says which stage failed and where. A typo never takes the gateway down. The desktop app shows the rejection until a valid version is saved.
Changes to listen.gateway apply live as well: core opens the new
listener, and if it cannot (the port is taken) it keeps the old one and
reports why. Retention changes are applied on the next hourly clean-up.
When two writers edit at once (the app and a hand edit), the second write is refused with a version mismatch instead of overwriting the first.
Every change to the configuration leaves a version in history/ beside the
file: a configuration pack (.twconfig) with the plugins and the app's
preferences, recording where the change came from (the app, the command
line, an outside edit, an import, a restore, sync). The last 10 are kept; a
credential rotation does not make one. The control key is masked in them.
twcore check # validate the file without starting anything
twcore config show # print it, with its version
twcore backup list # list the versions, newest first
twcore backup restore 1792315800000 --dry-run # what going back would change
twcore backup restore 1792315800000 # go back to a version
twcore export -o all.twconfig # the whole configuration as a pack
twcore export --providers relay --no-secrets --encrypt -o relay.twconfig
twcore import all.twconfig --dry-run # what importing would do
twcore config set /listen/gateway/port 8790 --intA restore keeps the settings that belong to this machine (listening, the
control key, retention, sync), the ChatGPT sign-ins and the gateway keys that
clients set up on this machine use. An import adds what is not here and asks,
item by item, what to do with what differs (--on-conflict answers for all).
A pack carries no settings of this machine and no ChatGPT sign-in; without
--no-secrets it holds keys in plain text unless --encrypt is given (the
passphrase is asked for, or read from TWCORE_PASSPHRASE). Overwriting an
item here with one from a pack without keys keeps this machine's keys only
while the item still points at the same place (scheme, host and port; AWS
region; OAuth token endpoint; the outbound proxy it goes through; a proxy's
address); otherwise, and for an item imported under another name, the
upstream comes in disabled until its key is entered.
These commands work on the files directly, so they work when core is not running, which is when a restore is most needed; when the file does not load at all, a restore brings the whole version back with the current control key. A running core picks their changes up like any other save.
twcore config set <path> <value> changes one value that is already written
in the file. The path names list items by their name
(/providers/anthropic/base_url); a number is an index (/routes/0/rules/1/to).
The value is a string unless --int, --bool or --null says otherwise.
The result is validated before it is written. To add a field that is not in
the file yet, edit the file.
One write is not made by a person. When an upstream's OAuth token endpoint
issues a new refresh token, the old one stops working, so the gateway
writes the new token (and the access token with its expiry) back into
providers[].oauth. Only those values change; comments and layout are left
as they are.
Each table lists every field of one section. "—" in the Default column means the field is simply absent unless written; the description says what that means.
| Field | Type | Default | Description |
|---|---|---|---|
version |
integer | required | Format version of this file. The only version is 1. A file with a higher number was written by a newer twcore and is refused rather than half-understood. |
listen |
object, listen |
— | Where the gateway and the control channel listen. |
clients |
list of clients[] |
[] |
Gateway keys. At least one is required; twcore init and the first twcore serve write one named default. |
providers |
list of providers[] |
[] |
Upstreams. None is a valid configuration: the control plane runs and requests are answered with an error saying no upstream is configured. |
proxies |
list of proxies[] |
[] |
Outbound proxies, declared once and referred to by name from providers[].proxy. |
pricing |
object, pricing |
— | Refreshing the default price table, and price sheets of your own. |
client_probes |
object, client_probes |
— | What happens to the helper requests clients send on their own (health checks, warm-ups, titles). |
security |
object, security |
— | The three guards. All of them start in observe, so out of the box nothing is changed or refused. |
retention |
object, retention |
— | How long request logs are kept. |
failover |
object, failover |
— | How long an upstream is set aside after it fails, and how long the start of a stream is awaited. |
aliases |
map of alias → string or list of strings | {} |
Model aliases: one name for the same model across upstreams, mapped to the name each upstream uses, in order. A request for an alias goes to any upstream offering one of the listed names, under the first of them it offers. An alias cannot list another alias. |
groups |
list of groups[] |
[] |
Strategy groups: several upstreams behind one name, with a way to pick among them. |
routes |
list of routes[] |
[] |
Routes. Without any, requests fail over across all upstreams in the order they are declared. |
default_route |
string | — | The route for keys that do not name one. Unset: the route named default, or the built-in failover when there is none. |
default_key |
string | — | The gateway key for clients that were not given a key of their own. Unset: the key named default, or the first key. It cannot be disabled. |
plugins |
list of plugins[] |
[] |
Script plugins, in the order they run. The app installs them; each one's code and settings are a file next to this one. |
sync |
object, sync |
— | Multi-device sync. A local setting: configuration packs and other devices never carry it, and restoring a backup keeps the current one. Unset: sync is off. |
Where core accepts connections. There are two kinds: the AI gateway that
clients send requests to, and the control channel the desktop app and
twcore commands use.
| Field | Type | Default | Description |
|---|---|---|---|
gateway |
object, listen.gateway |
— | The AI gateway: the address clients send requests to. |
control |
object, listen.control |
— | The control channel: how the desktop app and twcore commands reach core. It holds the control key, so every configuration has it. |
| Field | Type | Default | Description |
|---|---|---|---|
bind |
loopback | all | interface name | IP address |
loopback |
loopback is this machine only; all is every interface; an interface name (en0, eth0) is looked up again every few seconds and follows address changes, and while the interface is not there the gateway listens on 127.0.0.1 only and adds it once it appears; a fixed IP address stops working when the address changes. Binding one interface also listens on 127.0.0.1. |
port |
integer | 8788 |
TCP port of the gateway. |
allow_from |
list of strings | [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7] |
Sources other than this machine that may connect, as CIDR ranges or single addresses. This machine is always allowed. [] means this machine only; 0.0.0.0/0 allows everyone and has to be written out. |
When bind reaches beyond this machine, allow_from decides who gets in.
The gateway has no TLS: expose it on networks you trust, or put it behind a
tunnel or VPN.
listen:
gateway:
bind: all
port: 8788
allow_from: [192.168.1.0/24]The control channel is how the desktop app, and twcore config and
twcore control-key on the same machine, talk to core. Locally it is a
socket file in the data directory (a loopback port on Windows); no network
port is opened for it unless remote is enabled.
Every control connection, local or remote, starts with a handshake that
proves both ends hold key
(Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s: the key is the pre-shared key,
each connection negotiates fresh session keys, and the traffic is
encrypted). There are no certificates. A peer without the key cannot
complete the first message.
| Field | Type | Default | Description |
|---|---|---|---|
key |
string | generated | The control key: 64 hexadecimal characters (32 bytes). Every control connection, local or remote, proves it knows this key. twcore serve writes one before listening if it is missing; a configuration where it is malformed is refused. Show it with twcore control-key, replace it with twcore control-key --rotate. |
remote |
object, listen.control.remote |
— | A network port for the desktop app on another machine. Additional to the local channel, never instead of it. |
key is written by twcore serve before the control channel starts
listening, if it is missing, and by twcore init. It is masked wherever the
configuration is shown or kept in a backup; saving a masked value back
keeps the real one. A missing or malformed key (not 64 hexadecimal
characters) makes the whole file invalid, so it cannot be replaced by a
short, guessable one.
twcore control-key # print the key, to paste into the desktop app
twcore control-key --rotate # replace it; connected apps have to reconnectBoth run on the machine where core runs.
A network port for a desktop app on another machine. It is opened in
addition to the local channel, so a mistake here (a port that is taken, an
allow_from that shuts you out) never locks out the machine itself:
twcore config and the local app keep working.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Listen on the remote port. Unset or false: no network port is opened for control. twcore remote enable and twcore remote disable switch it; a running core follows within a second. |
bind |
loopback | all | interface name | IP address |
all |
Interface to listen on, written as for listen.gateway.bind. |
port |
integer | required | TCP port. There is no fixed default: twcore init and twcore remote enable write a random port between 20000 and 32000 (never the gateway's) when they write this section. It cannot be 0 or the gateway's port. |
allow_from |
list of strings | [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7] |
Sources that may connect, as for listen.gateway.allow_from, except that this machine is not let in automatically (it has the local channel). A connection from anywhere else is closed before the handshake, without a byte in reply; narrowing the list also closes open connections it no longer allows. A source that fails the handshake 5 times within a minute is ignored for a minute. |
listen:
control:
key: 9f2c…e41a # 64 hexadecimal characters
remote:
enabled: true
bind: all
port: 23483 # random, written when the section is generated
allow_from: [192.168.1.0/24]A connection over the remote port cannot do three things, whatever the app
asks: shut core down (it is managed by systemd), change listen.control
(the door it came in through), or produce a diagnostic bundle (it would be
written on the server). Handshakes time out after 5 seconds; five failed
handshakes from one source within a minute block that source for a minute.
Gateway keys: the keys clients such as Claude Code and Codex send to the gateway. A key is an identity. Limits, model scope and route are per key.
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Name of the key; unique. Routing rules match it with when.client. |
key |
string | required | The key clients send (as x-api-key or Authorization: Bearer). Generated keys start with tw- so they are not mistaken for an upstream's key. Unique. |
max_concurrent |
integer | — | Requests with this key that may run at once; the rest wait. Unset: no limit. 0 is refused. |
allow |
list of strings | — | Models this key may use, as model ids or globs (claude-*). An upstream model name also allows the aliases that list it; an alias allows only the alias. Unset: every model. []: none at all. |
route |
string | — | Name of the route requests with this key take. Unset: default_route. |
client |
string | — | The client this key was made for (claude-code, codex, …), recorded when the desktop app points a client at the gateway. A client has at most one. |
disabled |
bool | false |
Refuse every request made with this key, and keep the key. |
limits |
list of clients[].limits[] |
— | Usage limits: requests, tokens or cost per minute, hour, day, week or month. A request has to pass every one. Unset: no limit. |
clients:
- name: default
key: tw-a3f9c8d1e5b2h7k4m6n8p2q4
- name: build-server
key: tw-q8r2s4t6u8v2w4x6y8z2a4b6
max_concurrent: 4
allow: [claude-sonnet-*]
route: cheapUsage limits for a key: at most so many requests, tokens or dollars per minute, hour, day, week or month. Each entry counts one of the three; a key can have several, and a request has to pass every one.
minute and hour are rolling: the last 60 seconds, the last 60 minutes.
With every, the window spans several of them: { per: hour, every: 5 } is
the last 5 hours.
When one is used up, a request waits for the next free slot if it frees
within failover.wait_secs (120 seconds by default), and is refused
otherwise. Any later wait for an upstream comes out of the same time.
day, week and month follow the calendar in the time zone of the machine
twcore runs on and start again at midnight, on Monday and on the 1st. When one is used up, requests are refused until it starts again. If the
machine's time zone changes, the current day, week and month are added up
again from the request records.
A refused request gets HTTP 429 in the client's own error format, naming the
key, the limit, the amount used and when it resets, and it shows in the
traffic list. A request that never reaches an upstream counts toward no
limit: one refused by a rule, the content filter or a limit, or turned away
because every upstream was at its max_concurrent. Cost is what is recorded
for each request, so a model without a price and an upstream with
billing: free count as $0. A request still
running counts with an estimate of its input until it is recorded. After a
restart, the day, week and month are added up again from the request records,
so the records have to cover the period: retention.row_days of at least 1
for a daily limit, 7 for a weekly one and 31 for a monthly one. Minute and
hour limits start empty.
On a Responses WebSocket connection, each response.create is a request of
its own: it is recorded with its usage and cost and checked against these
limits, and a refused one is answered with response.failed while the
connection stays open. A Realtime connection (/v1/realtime) is one request:
it is checked against the limits when it opens, and the tokens and cost of
all its answers count when it closes.
| Field | Type | Default | Description |
|---|---|---|---|
per |
minute | hour | day | week | month |
required | The period. minute and hour are rolling (the last 60 seconds, the last 60 minutes, or every of them); day, week and month start again at local midnight, on Monday and on the 1st. |
every |
integer | — | How many minutes or hours the rolling window spans: {per: hour, every: 5} is the last 5 hours. From 1 to 60 for minute, from 1 to 24 for hour; day, week and month do not take it. Unset: 1. |
requests |
integer | — | At most this many requests. Token counts, answers the gateway gives itself and requests that never reach an upstream do not count. |
tokens |
integer | — | At most this many tokens: uncached input, cache writes and output. |
cost |
number | — | At most this much, in US dollars, as recorded for each request; at least 0.01. Models without a price and upstreams with billing: free count as 0. |
cache_reads |
bool | false |
Count cache reads too. Only for a tokens limit. |
clients:
- name: build-server
key: tw-q8r2s4t6u8v2w4x6y8z2a4b6
limits:
- { per: minute, requests: 30 }
- { per: day, cost: 5 }
- { per: month, tokens: 20000000, cache_reads: true }Upstreams: the APIs requests are forwarded to.
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Name of the upstream; unique, and not the name of a group. Names starting with __ are reserved. |
base_url |
string | required | Endpoint, http:// or https://, up to the version segment where the provider documents one (https://api.anthropic.com, https://api.openai.com/v1). For Bedrock, the region's runtime endpoint: https://bedrock-runtime.<region>.amazonaws.com. No backslashes, and no user name or password before the host (credentials go in key or headers). |
key |
string, ${VAR} allowed |
— | API key. It goes in the header the protocol expects: x-api-key (Anthropic), Authorization: Bearer (OpenAI, and a Bedrock API key), x-goog-api-key (Gemini). Leave it out for upstreams without a key, or when the credential is written in headers. Cannot be combined with oauth or aws. |
headers |
map of header name → value | {} |
Additional request headers, in the order written; values may use ${VAR}, and {{access_token}} where oauth is set. At most 32. Headers HTTP or the gateway manages (host, content-length, connection, …) cannot be set. |
oauth |
object, providers[].oauth |
— | OAuth credential: an access token obtained from a refresh token. Instead of key. |
aws |
object, providers[].aws |
— | AWS access keys of a Bedrock upstream, written there or read from an AWS profile: every request is signed with them (SigV4). Instead of key, which holds a Bedrock API key. |
protocol |
anthropic | openai-chat | openai-responses | gemini | chatgpt | bedrock |
— | API format of the upstream. Unset: recognized from base_url for the official endpoints (a Bedrock runtime endpoint is bedrock), otherwise treated as anthropic. |
forward_client_identity |
bool | false |
Also send the client's own identity: its User-Agent, identity headers such as x-app and originator, and identity fields in the request body such as metadata.user_id. Values are the client's, never made up. Off: requests carry ThinkWatch's User-Agent and no client identity. For upstreams that admit only certain clients (Kimi For Coding, Bailian Coding Plan, relays restricted to official clients). Not available for chatgpt. |
proxy |
string | direct |
direct; system, the proxy in the core process's HTTPS_PROXY, HTTP_PROXY or ALL_PROXY environment variables; or the name of an entry in proxies. |
on_proxy_fail |
fail | direct |
fail |
When the proxy cannot be reached: fail the request, or go direct. |
models |
list of strings | [] |
Models added by hand, by exact id, for those the upstream serves but leaves out of its list. They count as offered together with the models the upstream lists, or are the whole list when it lists none: they appear in /v1/models and requests for them are routed here. models_only still applies. No wildcards, no duplicates, at most 256 characters each. |
models_only |
list of strings | — | Use only these of the upstream's models, as ids or globs. Others are not listed and are not routed here. Unset: all of them. Empty is refused; use disabled. |
billing |
per-token | free |
per-token |
per-token: cost is usage times the price in the upstream's price sheet, subscription accounts included. free: cost is recorded as 0. |
pricing |
string | — | Name of a price sheet under pricing.sheets. Unset: the default price table. |
model_specs |
map of model id → providers[].model_specs.* |
{} |
Context window, output limit, reasoning and image input of single models of this upstream, written by hand, by exact model id. They take precedence over the price table: for models it does not know, or gets wrong. |
max_concurrent |
integer | — | Most requests sent to this upstream at the same time, from 1 to 1000. When it is full, a conversation that stays on it waits for a free slot and other requests go to the next upstream; see failover.wait_secs. Unset: no limit. |
limits |
list of providers[].limits[] |
— | Usage limits of this upstream, written like its own quota: requests, tokens or cost every few minutes or hours, per day, week or month. Every one has to have room before a request is sent to it. When a rolling window is full, requests go to the next upstream or wait for room; when a day, week or month is used up, requests go to the next upstream until the next one starts. Unset: no limit. |
disabled |
bool | false |
Take the upstream out of routing and out of the model list, and keep its configuration. |
balance |
auto | off | openrouter | deepseek | moonshot | sub2api | newapi | thinkwatch |
auto |
Where the upstream's balance is read from, with its own key and proxy: openrouter, deepseek, moonshot, sub2api, newapi (New API and One API) or thinkwatch (a ThinkWatch Enterprise gateway). auto: recognized from base_url for OpenRouter, DeepSeek and Moonshot; any other address, except Anthropic's, OpenAI's and Gemini's own, is asked once whether it is a Sub2API, ThinkWatch Enterprise or New API relay, and the answer is kept in memory only. off: no balance. ChatGPT and Z.ai accounts and Bedrock have no balance whatever is written here. |
signed_in |
zai | bigmodel |
— | Which account sign-in the key came from: zai (Z.ai) or bigmodel (BigModel). Signing in to the account in the desktop app writes it; replacing the key there removes it. It does not change how requests are sent. Unset: the key was written by hand. |
A credential is one of four things: key, which goes in the header the
protocol expects; oauth, a token obtained from a refresh token; aws, the
access keys a Bedrock upstream signs its requests with; or headers, when the
upstream wants something of its own. headers can be combined with the
others, except for the header that already carries the credential.
providers:
- name: anthropic
base_url: https://api.anthropic.com
key: ${ANTHROPIC_API_KEY}
- name: relay
base_url: https://relay.example.com/v1
protocol: openai-chat
headers:
X-Relay-Token: ${RELAY_TOKEN}
proxy: office
models_only: [gpt-4.1*, o3]
pricing: relay-discount
max_concurrent: 4
- name: local
base_url: http://127.0.0.1:11434/v1
protocol: openai-chat
billing: freeAn upstream's models are the ones it lists on /v1/models (for Bedrock, the
region's model list) and the ones written in models. Upstreams often leave
models out of their list: a relay lists only some of what it serves, an
account backend hides new models from older clients. A model added in models
is listed in /v1/models, can be named by an alias, pinned in a rule and
allowed for a key, and is routed to this upstream like a listed one;
models_only applies to both. An upstream that lists nothing has exactly the
models in models; one with neither lists no model and is still sent requests
for any model.
A request carries the request itself and the headers its upstream needs,
and nothing else from the client: the credential and the headers written in
headers; ThinkWatch's own User-Agent; and, from the client's request, only
the headers the upstream's protocol uses (anthropic-* for Anthropic,
Idempotency-Key and X-Client-Request-Id for OpenAI, none for Gemini).
Identity fields that clients fill in themselves, such as Claude Code's
metadata.user_id, are removed from the body. For an upstream that admits only
certain clients, turn on forward_client_identity.
A request converted to Anthropic, or to Claude on Bedrock, marks where the upstream may cache the prompt when the client marked nothing itself. Clients in OpenAI or Gemini formats such as Codex cannot mark anything: those providers cache a repeated prompt on their own, while Anthropic caches only what is marked. The marks go at the end of the tools, at the end of the system prompt and at the end of the last two user turns, at most four, each kept for the default five minutes. The earlier of the two user marks is where the previous request ended, so each turn reads back what the turn before wrote and pays the cache price for it instead of the full input price. Cache writes and reads are charged at the price table's cache prices. A request that carries its own marks, as Claude Code's do, keeps exactly those, and a request sent on in the upstream's own format is not changed.
On Bedrock, marks are added only for the Claude models AWS lists as supporting prompt caching: Claude 3.7 Sonnet, Claude 3.5 Sonnet v2, and every Claude from version 4.5 on, including newer ones not yet listed. Older models, such as Claude 3 Haiku, Sonnet 4 and Opus 4.1, get none. An upstream that refuses the marks is sent the request once more without them, and that upstream is not sent marks for that model again until the core restarts.
A ChatGPT account upstream (protocol: chatgpt) takes only the credential
the desktop app obtains by signing in; it cannot be written by hand. Claude
and Google subscription sign-ins are not supported; use an API key.
Some relays and accounts accept only a few requests at a time and refuse the
rest. max_concurrent keeps the gateway within that number: a request takes a
slot on the upstream when it is sent, and gives it back when the answer has
been passed on in full or the client has gone. When the upstream is full, a
conversation that stays on it to reuse its prompt cache waits for a slot;
any other request goes straight to the next upstream. How long a request
waits is failover.wait_secs. Waiting is not a failure: the upstream is
not set aside. Requests that only count tokens do not take a slot. On a
Responses WebSocket connection, each response.create takes a slot from when
it is sent until its answer ends, and so does a key's max_concurrent; an
idle connection takes none.
Usage limits for an upstream, written like the quota the upstream itself
enforces: at most so many requests, tokens or dollars every few minutes or
hours, per day, week or month. The entries are written like a key's limits;
an upstream can have several, and every one has to have room before a request
is sent to it.
Each attempt sent to the upstream counts as a request, whether it succeeds or fails, unless the upstream answers it with 429. Tokens and cost count for the attempt that is answered. Requests that only count tokens do not count. An attempt still running counts with an estimate of its input until it is recorded.
When a minute or hour window is full, the upstream is passed over like a
busy one: a conversation that stays on it waits for room, other requests go to
the next upstream, and when no candidate has room a request waits for the
first one that does, within the time a request may wait (see failover). When
a day, week or month is used up, requests go to the next upstream until
the period starts again; none waits for it. Being full is not a failure: the
upstream is not set aside. After a restart the day, week and month are added up
again from the request records, so retention.row_days has to cover the
period, as for a key; minute and hour windows start empty.
| Field | Type | Default | Description |
|---|---|---|---|
per |
minute | hour | day | week | month |
required | The period. minute and hour are rolling (the last 60 seconds, the last 60 minutes, or every of them); day, week and month start again at local midnight, on Monday and on the 1st. |
every |
integer | — | How many minutes or hours the rolling window spans: {per: hour, every: 5} is the last 5 hours. From 1 to 60 for minute, from 1 to 24 for hour; day, week and month do not take it. Unset: 1. |
requests |
integer | — | At most this many requests sent to this upstream. Each attempt that reaches it counts, unless it answers 429; token counts do not count. |
tokens |
integer | — | At most this many tokens in the answers of this upstream: uncached input, cache writes and output. |
cost |
number | — | At most this much, in US dollars, as recorded for the requests this upstream answers; at least 0.01. Models without a price count as 0, and so does everything with billing: free. |
cache_reads |
bool | false |
Count cache reads too. Only for a tokens limit. |
providers:
- name: relay
base_url: https://relay.example.com
key: sk-relay
limits:
- { per: hour, every: 5, requests: 200 }
- { per: week, cost: 50 }| Field | Type | Default | Description |
|---|---|---|---|
access |
string | — | Current access token. Written back by the gateway after every refresh; unset means one is obtained on first use. |
expires_at |
string | — | When access expires, RFC 3339 in UTC. Written back with it. Unset: used until the upstream answers 401. |
refresh |
string | required | Refresh token. When the token endpoint issues a new one, the old one stops working, so the gateway writes the new one back into this file. |
endpoint |
string | required | Token endpoint URL. |
client_id |
string | — | OAuth client id, if the endpoint wants one. |
client_secret |
string | — | OAuth client secret, if the endpoint wants one. |
refresh_before |
duration (30s, 5m, 1h) |
— | How long before expiry to refresh. Unset or unreadable: 5m. |
The access token goes in the protocol's authorization header. To put it
somewhere else, write the header in headers with {{access_token}} where
the token goes:
oauth:
refresh: ${VENDOR_REFRESH_TOKEN}
endpoint: https://auth.example.com/oauth/token
client_id: my-client
headers:
X-Access: Token {{access_token}}| Field | Type | Default | Description |
|---|---|---|---|
access_key_id |
string, ${VAR} allowed |
— | Access key ID. Written together with secret_access_key; profile instead. |
secret_access_key |
string, ${VAR} allowed |
— | Secret access key. |
session_token |
string, ${VAR} allowed |
— | Session token of temporary credentials, such as those STS issues. When it expires, requests are refused until it is replaced. |
profile |
string | — | Profile in the AWS credential files to read the access keys from, instead of writing them here: ~/.aws/credentials and ~/.aws/config, or the files AWS_SHARED_CREDENTIALS_FILE and AWS_CONFIG_FILE name, on the machine core runs on. The files are read again when they change. |
region |
string | — | Region to sign for. Unset: the one in base_url, which must then be a standard runtime endpoint. Required when base_url is a VPC endpoint or a proxy. |
A Bedrock upstream authenticates in one of two ways. A Bedrock API key goes in
key and is sent as Authorization: Bearer. AWS access keys go in aws,
written there or read with aws.profile from a profile in the AWS credential
files: every request is signed with them (SigV4) once its body is final, and the
keys themselves are never sent. Keys written in the configuration can be read
from the environment with ${VAR}. A profile is read from the files on the
machine core runs on, and read again when they change, so a tool that refreshes
temporary keys in ~/.aws/credentials needs no restart. Nothing runs a command
to obtain a credential, so a profile that signs in through IAM Identity Center
(aws sso login), runs a credential_process or assumes a role cannot be used;
export the keys it produces instead.
providers:
- name: bedrock
base_url: https://bedrock-runtime.us-east-1.amazonaws.com
key: ${AWS_BEARER_TOKEN_BEDROCK}
- name: bedrock-keys
base_url: https://bedrock-runtime.eu-west-1.amazonaws.com
aws:
access_key_id: ${AWS_ACCESS_KEY_ID}
secret_access_key: ${AWS_SECRET_ACCESS_KEY}
session_token: ${AWS_SESSION_TOKEN}
- name: bedrock-profile
base_url: https://bedrock-runtime.us-west-2.amazonaws.com
aws:
profile: devRequests are converted to Converse. The model list comes from the region's
control plane: the foundation models that can be invoked on demand, the
inference profiles AWS defines (us.anthropic.claude-…), and the account's
application inference profiles, listed by the ARN they are invoked by. Listing
needs bedrock:ListFoundationModels and bedrock:ListInferenceProfiles;
without them requests are still forwarded, and models can list the models by
hand. For a VPC endpoint or a proxy, write its address in base_url and the
region in aws.region; the model list is asked of that address too.
A model's context window and output limit come from the price table. A relay's own models are often missing from it, and now and then it is wrong. Write the numbers here, for this upstream and by the exact id in its model list. A value written here takes precedence over the price table; one left out still comes from it. At least one of the two is written, and neither can be 0.
The same numbers are used everywhere: in /v1/models for every client format,
for an alias this upstream serves, when the gateway judges whether a
conversation still fits the model it is on, and as the output limit of a
request converted to Anthropic that does not set one. When several upstreams
offer the same model, /v1/models describes it by the first of them in
providers.
| Field | Type | Default | Description |
|---|---|---|---|
context_window |
integer | — | Context window: the most tokens a request can take in. Unset: the price table's. |
max_output_tokens |
integer | — | The most tokens an answer can have. Unset: the price table's. |
reasoning |
bool | — | Whether the model reasons. The model list (GET /v1/models) carries it, so clients offer reasoning levels for it. Unset: the price table's; the list leaves it out when the price table does not say. |
image_input |
bool | — | Whether the model takes images as input. The model list carries it, as input_modalities. Unset: the price table's; the list leaves it out when the price table does not say. |
providers:
- name: relay
base_url: https://relay.example.com/v1
protocol: openai-chat
model_specs:
glm-5-air: { context_window: 128000, max_output_tokens: 16384 }
claude-sonnet-4-5: { context_window: 1000000 }Outbound proxies. Different upstreams often need different ones, so there
is no global switch: an upstream picks one with proxy.
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Name used in providers[].proxy. direct and system are built in. |
type |
socks5 | socks5h | http | https |
socks5h |
socks5h sends the host name to the proxy to resolve; socks5 resolves it locally first. http and https are HTTP proxies. |
addr |
string | required | host:port of the proxy. |
auth |
object, proxies[].auth |
— | User name and password, if the proxy wants them. |
| Field | Type | Default | Description |
|---|---|---|---|
user |
string | required | User name. |
pass |
string, ${VAR} allowed |
required | Password. |
proxies:
- name: office
type: http
addr: proxy.example.com:3128
auth:
user: alice
pass: ${PROXY_PASSWORD}on_proxy_fail: fail is the default because falling back silently sends a
request by a path you did not intend; you would believe you were on the
proxy while you were not.
The cost of a request is its usage times the price of the model. Prices come from the default price table (LiteLLM's public dataset, a copy of which is built into the binary and refreshed daily) or from a price sheet an upstream picks. A change of price applies to requests from then on, never to ones already recorded.
| Field | Type | Default | Description |
|---|---|---|---|
auto_update |
bool | true |
Refresh the default price table from the network once a day. It is saved as model_prices.json beside config.yaml; the table built into the binary is used until then and when offline. |
sheets |
list of pricing.sheets[] |
[] |
Price sheets of your own. An upstream uses one with providers[].pricing. |
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Name of the sheet; unique. |
multiplier |
number | 1 |
Applied to every price of the default table, cache and long-context prices included. |
models |
map of model id → pricing.sheets[].models.* |
{} |
Prices for single models. They replace the default table's price for that model and are not multiplied. |
Prices are in US dollars per million tokens, as printed on vendors' price pages. Every field is written out; nothing is inferred when pricing.
| Field | Type | Default | Description |
|---|---|---|---|
input |
number | required | US dollars per million input tokens. |
output |
number | required | US dollars per million output tokens. |
cache_read |
number | required | US dollars per million tokens read from the prompt cache. |
cache_write_5m |
number | required | US dollars per million tokens written to a 5-minute cache. |
cache_write_1h |
number | required | US dollars per million tokens written to a 1-hour cache. |
input_above_200k |
number | — | Input price once a request's input, cache reads and writes included, exceeds 200K tokens. Written together with output_above_200k, or neither. Cache prices stay the ones above. |
output_above_200k |
number | — | Output price once a request's input, cache reads and writes included, exceeds 200K tokens. |
pricing:
sheets:
- name: relay-discount
multiplier: 0.8
models:
claude-sonnet-4-5-thinking:
input: 3
output: 15
cache_read: 0.3
cache_write_5m: 3.75
cache_write_1h: 6Some requests clients send are not the user's: connectivity checks,
warm-ups, session titles, topic detection, suggestions. Each class is either
answered locally (intercept, nothing is sent upstream) or forwarded
(forward). Forwarded ones go through the routing rules like any other
request and carry their class, so a rule can catch them with when.intent.
The defaults intercept only what nobody would miss.
| Field | Type | Default | Description |
|---|---|---|---|
health_check |
intercept | forward |
intercept |
Connectivity checks (max_tokens: 1). Answered locally by default: nothing is lost. |
warmup |
intercept | forward |
intercept |
Warm-up requests. Answered locally by default. |
titling |
intercept | forward |
forward |
Requests that name a session. Forwarded by default: intercepting them gives every session the same title. |
topic_detect |
intercept | forward |
forward |
Topic detection. Forwarded by default. |
suggestion |
intercept | forward |
forward |
Suggestions. Forwarded by default. |
Three guards, applied to every upstream alike. Each has a mode: off,
observe (detect and record, change nothing) or enforce (handle each match
by its rule's action). All three start in observe. Every rule has an
action: redaction replaces with a placeholder or only records, tool-call
inspection cuts the response off or only records, and the content filter
refuses, deletes, replaces or only records.
| Field | Type | Default | Description |
|---|---|---|---|
redact |
object, security.redact |
— | Outbound redaction (Secrets & privacy in the app): credentials and personal information in the text a request carries (messages, system prompts, reasoning, tool arguments and results, tool descriptions) are replaced with placeholders before it leaves, or only recorded, rule by rule. Identifiers, model names and other protocol fields are sent as they are. |
inspect_tools |
object, security.inspect_tools |
— | Tool-call inspection (Dangerous commands in the app): dangerous commands in the tool calls a model returns cut the response off. |
content |
object, security.content |
— | Content filter: words, patterns or characters (hidden ones among them) in what the caller sends, and words or patterns in the text of the answer; each rule refuses the request, deletes or replaces what it matched, or only records it. |
Before a request leaves, the text written in it is searched for credentials
and personal information: system prompts, the messages of every role
(earlier answers included), reasoning, tool-call arguments, tool results and
tool descriptions. Under enforce what a rule finds is replaced with
placeholders, and put back where the answer repeats them; what a rule with
action: record finds is sent as it is and only recorded. Values the
protocol needs as they are, such as ids, types, roles, tool names, model
names and signatures, are never searched, nor are field names, images,
files, settings, or parts of a request the gateway does not recognize as
written text.
| Field | Type | Default | Description |
|---|---|---|---|
mode |
off | observe | enforce |
observe |
off does nothing (Off in the app); observe detects and records only, and changes nothing (Monitor); enforce detects and handles each match by its rule's action (On). |
enable |
list of strings | [] |
Built-in rules to switch on that are off out of the box, by id. |
disable |
list of strings | [] |
Built-in rules to switch off, by id. |
actions |
map of built-in rule id → placeholder | record |
{} |
What a built-in rule does under enforce, written only where it differs from the factory setting (email: record). placeholder sends a placeholder in place of the value and puts the value back where the answer repeats the placeholder; record sends the value as it is and records the match. |
custom |
list of security.redact.custom[] |
[] |
Rules of your own: whatever a rule matches is replaced like a credential, or only recorded. |
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. |
pattern |
string | required | The text to look for; a regular expression with match: regex. |
match |
contains | regex |
contains |
contains: pattern is looked for as written; characters such as . and * have no special meaning. regex: pattern is a regular expression. |
case_sensitive |
bool | false |
Tell capital letters from small ones. Off by default: ABC and abc both match. |
label |
string | SECRET |
Placeholder name: what the rule matches is replaced with <<TW_label_1>>, numbered per name. Capital letters, digits and underscores, starting with a letter, at most 24 characters. |
action |
placeholder | record |
placeholder |
Under enforce: replace what the rule matches with a placeholder, or only record the match and send it as it is. |
disabled |
bool | false |
Switches the rule off and keeps it in the file. |
Built-in rules:
| id | Name | Out of the box |
|---|---|---|
anthropic-api-key |
Anthropic API key | on |
openai-project-key |
OpenAI project key | on |
openai-api-key |
OpenAI API key | on |
github-personal-token |
GitHub personal access token | on |
github-oauth-token |
GitHub OAuth token | on |
github-server-token |
GitHub server token | on |
github-user-token |
GitHub user token | on |
github-fine-grained-token |
GitHub fine-grained token | on |
slack-bot-token |
Slack bot token | on |
slack-user-token |
Slack user token | on |
slack-app-token |
Slack app token | on |
aws-access-key-id |
AWS access key ID | on |
aws-temporary-key-id |
AWS temporary access key ID | on |
google-api-key |
Google API key | on |
google-oauth-token |
Google OAuth token | on |
gitlab-token |
GitLab token | on |
stripe-live-key |
Stripe live key | on |
stripe-restricted-key |
Stripe restricted key | on |
npm-token |
npm token | on |
digitalocean-token |
DigitalOcean token | on |
sendgrid-key |
SendGrid key | on |
private-key |
Private key | on |
jwt |
JWT | on |
conn-string-password |
Connection string password | on |
cn-resident-id |
Chinese resident ID number | on |
bank-card |
Bank card number | on |
email |
Email address | off |
cn-mobile-phone |
Chinese mainland mobile number | off |
internal-ip |
Internal IP address | off |
internal-domain |
Internal domain | off |
cn-resident-id, bank-card, email and cn-mobile-phone look for
personal information rather than credentials. The first two are on out of the
box and match only what checks out by structure:
cn-resident-id: an 18-character resident ID number of the People's Republic of China whose first two digits are a province-level code, whose date of birth is a real date between 1900-01-01 and today, and whose last character is the right check character (ISO 7064 MOD 11-2). The old 15-digit numbers are not matched.bank-card: a card number whose prefix and length belong to UnionPay, Visa, Mastercard, American Express, JCB, Discover or Diners Club and which passes the Luhn check, written as one run of digits or in groups of four separated by single spaces or single hyphens (American Express also 4-6-5, Diners Club also 4-6-4). The test card numbers published by Stripe, Braintree and Adyen are not matched.
email and cn-mobile-phone are off out of the box: they have no structure
to check, and code and documents are full of things that look like them.
email: an address whose domain has at least two parts, the last of them two or more letters. User names in URLs (https://user@host) and file names such asicon@2x.pngare not matched.cn-mobile-phone: a Chinese mainland mobile number, 11 digits starting with1and a second digit from3to9, not part of a longer run of digits.
A number that is part of a longer run of letters or digits is not matched,
and neither is one written as a JSON number in the request body (in a tool
call's arguments, for instance), since replacing it would leave the body
invalid JSON. The placeholders of these rules say what was there
(<<TW_ID_NUMBER_1>>, <<TW_CARD_NUMBER_1>>, <<TW_EMAIL_1>>,
<<TW_PHONE_1>>), and the security log shows only the last four characters
of a number, and the first character and the domain of an email address. A
custom rule replaces with <<TW_SECRET_1>> unless it names its own
placeholder (label).
Tool calls a model returns are checked against the rules. Under enforce,
a match with rules set to cut stops the response, so the client never
receives a complete call to run.
| Field | Type | Default | Description |
|---|---|---|---|
mode |
off | observe | enforce |
observe |
off does nothing (Off in the app); observe detects and records only, and changes nothing (Monitor); enforce detects and handles each match by its rule's action (On). |
enable |
list of strings | [] |
Built-in rules to switch on that are off out of the box, by id. |
disable |
list of strings | [] |
Built-in rules to switch off, by id. |
actions |
map of built-in rule id → cut | record |
{} |
What a built-in rule does under enforce, written only where it differs from the factory setting (rm-rf-root: record). |
custom |
list of security.inspect_tools.custom[] |
[] |
Rules of your own, matched against the arguments of a tool call. |
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. |
pattern |
string | required | Regular expression. |
action |
cut | record |
record |
Under enforce: cut the response off, or only record the match. |
disabled |
bool | false |
Switches the rule off and keeps it in the file. |
Built-in rules:
| id | Name | Under enforce, out of the box |
|---|---|---|
curl-pipe-sh |
Download and run | cut |
base64-decode-exec |
Decode and run | cut |
exfil-env |
Send out environment variables | cut |
exfil-credentials |
Send out a credential file | cut |
exfil-credentials-reversed |
Send out a credential file (verb first) | cut |
ssh-key-read |
Read a private key or cloud credential | cut |
secret-to-unknown-host |
Send a credential to an unknown host | cut |
thinkwatch-data |
Read or change ThinkWatch's own data | cut |
write-startup-item |
Write a startup item | cut |
crontab-install |
Install a scheduled job | cut |
rm-rf-root |
Delete home or root | record |
chmod-777 |
World-writable permissions | record |
upload-file-to-host |
Upload a local file to an external host | record |
Words, patterns or characters in what the caller sends (user messages and
the tool results in them, not the system prompt or the model's own turns),
and, for custom rules with response in their scope, words or patterns in
the text of the answer. Each rule matches a keyword (contains), a regular
expression (regex) or code points (codepoints, requests only), and says
what happens under enforce: block refuses the request (requests only),
strip deletes every match and sends the rest, replace puts the rule's
replacement in place of every match and sends the rest, record only
records it. In a request, the text is checked again after deleting or
replacing, so a keyword split by hidden characters is caught once they are
gone; a replacement is not checked again. In an answer, what a rule deletes
or replaces never reaches the client, and the request record keeps the answer
the client received.
| Field | Type | Default | Description |
|---|---|---|---|
mode |
off | observe | enforce |
observe |
off does nothing (Off in the app); observe detects and records only, and changes nothing (Monitor); enforce detects and handles each match by its rule's action (On). |
enable |
list of strings | [] |
Built-in rules to switch on that are off out of the box, by id. |
disable |
list of strings | [] |
Built-in rules to switch off, by id. |
actions |
map of built-in rule id → block | strip | replace | record |
{} |
What a built-in rule does under enforce, written only where it differs from the factory setting. Built-in rules check requests only; replace puts *** in place of a match. |
custom |
list of security.content.custom[] |
[] |
Rules of your own, each checking requests, answers or both. |
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. |
pattern |
string | required | A keyword; a regular expression with match: regex; code points with match: codepoints (U+200B, U+E0000–U+E007F). |
scope |
list of request | response |
[request] |
What the rule checks: request is what the caller sends (its messages and the tool results in them); response is the text of the answer the client sees, the input of free-form tools such as Codex's apply_patch included, and neither tool calls nor reasoning. Both may be listed. A streamed answer is never held back whole: only the last few characters that could still grow into a match wait for the next piece, at most one fewer than the longest keyword, and at most 256 for a regular expression; a match longer than that may have its beginning sent already. Under enforce, rules that delete hold back as many characters again: text that comes together where something was deleted is checked again, until no match is left. A match nested deeper than that may have its beginning sent already, and the rest of it is deleted. Under enforce, when any rule that checks answers deletes or replaces, logprobs are removed from streamed answers, and from a whole answer whose text changed, since they repeat the text token by token. A rule that checks answers cannot block, and cannot match by codepoints. |
match |
contains | regex | codepoints |
contains |
contains: the text contains pattern as written; characters such as . and * have no special meaning. regex: pattern is a regular expression. codepoints: the text has a character among the code points or ranges listed in pattern, separated by commas; requests only. |
case_sensitive |
bool | false |
Tell capital letters from small ones. Off by default: ABC and abc both match. Code points are what they are either way. |
action |
block | strip | replace | record |
record |
Under enforce: block the request (requests only), strip what matched and send the rest, replace what matched with replacement and send the rest, or only record the match. Text that comes together where something was stripped is checked again, until no match is left; a replacement is never checked again, and in an answer a regular expression anchored to the start or end of the text or of a line (^, $, \A, \z) is checked once. Replacing cannot be undone: the answer does not get the original back. |
replacement |
string | "***" |
What replace puts in place of a match. |
disabled |
bool | false |
Switches the rule off and keeps it in the file. |
Built-in rules:
| id | Name | Group | Out of the box | Under enforce, out of the box |
|---|---|---|---|---|
unicode-tags |
Unicode tag characters | invisible | on | strip |
bidi-controls |
Bidirectional controls | invisible | on | strip |
zero-width |
Zero-width characters | invisible | off | strip |
private-use |
Private-use characters | invisible | off | strip |
ignore-previous-instructions |
Ignore previous instructions | injection | on | block |
ignore-all-previous |
Ignore all previous | injection | on | block |
disregard-your-instructions |
Disregard your instructions | injection | on | block |
jailbreak |
Jailbreak | injection | off | block |
dan |
DAN | injection | off | block |
developer-mode |
Developer mode | injection | off | block |
you-are-now |
Persona manipulation | persona | off | block |
new-persona |
New persona | persona | off | record |
act-as |
Act as | persona | off | record |
pretend-to-be |
Pretend to be | persona | off | record |
system-prompt |
System prompt extraction | persona | off | record |
reveal-your-instructions |
Reveal instructions | persona | off | record |
what-are-your-rules |
What are your rules | persona | off | record |
base64-wall |
Base64 smuggling | persona | off | record |
zh-ignore-previous |
Ignore previous instructions (Chinese) | chinese | off | block |
zh-forget-your |
Forget your instructions (Chinese) | chinese | off | block |
zh-do-not-follow |
Do not follow (Chinese) | chinese | off | block |
zh-you-are-now |
You are now (Chinese) | chinese | off | block |
zh-role-play |
Role-play (Chinese) | chinese | off | record |
zh-reveal-your |
Reveal your instructions (Chinese) | chinese | off | record |
zh-system-prompt |
System prompt (Chinese) | chinese | off | record |
zh-jailbreak |
Jailbreak (Chinese) | chinese | off | block |
The invisible group matches characters people cannot see and models can
read. Unicode tag characters (U+E0000–U+E007F) and bidirectional controls
(U+202A–U+202E, U+2066–U+2069) are on out of the box; zero-width characters
(U+200B–U+200D, U+2060, U+FEFF) and private-use characters (U+E000–U+F8FF,
U+F0000–U+FFFFD, U+100000–U+10FFFD) are off, since emoji, Persian and icon
fonts use them too. All four delete what they match under enforce.
security:
redact:
mode: enforce
enable: [internal-ip]
custom:
- name: employee-id
pattern: 'EMP-\d{6}'
label: EMPLOYEE
inspect_tools:
mode: enforce
content:
mode: enforce
enable: [zero-width]
custom:
- name: project-x
pattern: project-x
action: strip
- name: codename
pattern: polaris
scope: [request, response]
action: replace
replacement: "[codename]"Two limits, because the two kinds of data differ in size by three orders of magnitude: request bodies are tens of kilobytes each, a request's record a few hundred bytes. The byte limit covers bursts.
Each request and response body is kept up to 4 MiB; of a longer one, the
beginning is kept. Bodies are written with credentials and personal numbers
already taken out. A request stored under enforce carries the placeholders
the upstream received; anything else the redaction rules
(security.redact) recognize is masked, in every
mode, off included.
| Field | Type | Default | Description |
|---|---|---|---|
body_days |
integer | 7 |
Days to keep request and response bodies. |
row_days |
integer | 90 |
Days to keep the record of each request (time, model, usage, cost). |
body_max_bytes |
integer | 5368709120 |
Upper bound on the bytes bodies may take; beyond it the oldest days go first. The default is 5 GiB. |
An upstream that fails is set aside for a while, so that the next requests
go straight to the next candidate. How long depends on the reason the
upstream gives: an insufficient balance waits for a top-up, and a used-up
quota waits until the moment the upstream says it resets. A request with a
single candidate is never affected.
On a Responses WebSocket connection, each response.create counts here like
a request: one that fails because of the upstream before any content arrives
counts as a failure, and so does a connection the upstream refuses or that
cannot be made.
Before the first content of a streamed answer reaches the client, an error the upstream sends in the stream moves the request to the next candidate, the same as an error status would.
When every candidate has been tried and the last one failed temporarily
before any content reached the client — 500, 502, 503, 504 or 529, an
overload or server error reported in a streamed answer, unreachable, or its
stream broke or ended before any content — the request is retried after a
pause instead of passing the error on. The pause is 2, 4 and 8 seconds, then
16 seconds each time, varied by up to a fifth; when the upstream asks for a
longer one (retry-after-ms, Retry-After), it is used instead. Each retry
goes again to the candidates that failed temporarily, in order, skipping those
set aside unless all of them are; a conversation that stays on an upstream
retries only that one. Retrying stops when the next pause would end after
wait_secs, the time a request may wait in all; the client then gets the last
error. Rate limits, used-up quotas, an insufficient balance, rejected
credentials, missing models, errors in the request itself, other errors
reported in the stream, other 5xx statuses and idle timeouts are not retried;
they still move the request to the next candidate as before. Each retry appears among the request's attempts with its
number and the pause before it. wait_secs: 0 turns retrying off.
An upstream can also go quiet: it accepts the request and then sends nothing,
or stops partway through. After idle_timeout_secs (300 by default) without
content, the gateway stops waiting for it. The time counts from the moment the
request is sent to that upstream, so waiting for a free slot does not count,
and starts again with every piece of content: text, reasoning and tool calls
count; keep-alives (SSE comments, Anthropic's ping, empty chunks, Responses'
response.in_progress) do not, so an upstream that only keeps the connection
alive still runs out of time. A whole, non-streamed answer counts from sending
to the complete answer.
- When no content has reached the client yet, the upstream counts as failed
(towards
failures_to_pause, like a 5xx), the attempt appears with the outcomeidle_timeout, and the request moves to the next candidate. Until then an upstream's answer is held back from the client, so the next upstream starts it afresh. With no candidate left, the client gets a timeout error (504) in its own format. When the client receives a stream, the last candidate's stream is held the same way while the request can still be retried withinwait_secs; otherwise it is passed on as it arrives, and once its response has started, a timeout there ends it with an error event instead. A non-streamed answer from the last candidate is always passed on as it arrives. - A streamed answer is held for at most 15 seconds. If no content has
arrived by then, the client receives
200and the streaming headers, so that its own wait for headers does not run out, followed by an SSE comment (: keep-alive) every 15 seconds; Gemini clients get no comments. The upstream's events stay held until its first content, and failover continues as before under the same200: the next upstream's stream starts cleanly, and when no candidate is left, the stream ends with an error event in the client's format instead of a 504. The comments do not count as content. - When content has already reached the client, the request cannot move on without repeating it: the answer ends with an error event in the client's format, and the request is recorded as failed.
The conversation then no longer stays on that upstream for the rest of its turn. Models that think for a long time before they write anything need a longer timeout.
failover:
idle_timeout_secs: 600An upstream that answers 429 (rate limited) is not set aside but paused:
for as long as it says (retry-after-ms, Retry-After, or a delay in the
error body; under one second, or under one millisecond for retry-after-ms,
counts as not said), and otherwise for 5, 10, 20 and 40 seconds, then 60
seconds each time, by its consecutive 429s; any other answer starts the count
again. The
pause is at most rate_limit_max_pause_secs, and the circuit breaker and
load-balance do not count it as a failure. The request that got the 429
moves to the next candidate, or waits for the pause to end; the client sees
nothing of it. A streamed answer that reports a rate limit before any content
counts as a 429 too; only on a request's last candidate, once the request can
no longer wait (wait_secs), does the client get that error (the upstream is
paused all the same). When the pause ends, one request goes first, and the
others follow once the upstream has accepted it (for a streamed answer, once
its content starts), or at once if that request gets no answer, times out or
finds the upstream's quota used up. A pause lasts until twcore restarts at
most.
A request waits for upstreams that cannot take it yet for at most wait_secs
in all: an upstream at its max_concurrent, one paused after a 429, and one
at its minute or hour usage limit. The same time also covers waiting for
a key's minute or hour limit and the pauses before retries, so a request
never waits longer than wait_secs for all of them together. An ongoing
conversation waits for the upstream it stays on and, if it does not become
available in time, moves on to the next one, where its cache starts over. A new conversation skips such an
upstream at once. An upstream that has used up a day, week or month usage
limit is skipped. When no candidate is left, the request waits for whichever
becomes available first; streamed answers keep the client's connection open
with the 200 and keep-alives described above. If none becomes available, the
client gets a 429 with Retry-After and retry-after-ms set to when the
first one will; when every candidate has used up its period, Retry-After
is the earliest reset and x-should-retry is false. If an upstream did
receive the request and failed, the client gets that failure instead.
| Field | Type | Default | Description |
|---|---|---|---|
failures_to_pause |
integer | 3 |
Consecutive failures without a stated reason (5xx, connection errors) before the upstream is set aside. From 1 to 100. |
pause_secs |
integer | 60 |
Seconds the first such pause lasts. Each further pause doubles it, up to max_pause_secs; one success resets it. |
max_pause_secs |
integer | 600 |
Upper bound on the doubled pause, in seconds; not less than pause_secs. |
no_balance_pause_secs |
integer | 1800 |
Seconds to set aside an upstream that reports an insufficient balance. |
quota_pause_secs |
integer | 3600 |
Seconds to set aside an upstream whose quota is used up when it does not say when the quota resets. When it does, the upstream is set aside until then. |
rate_limit_max_pause_secs |
integer | 3600 |
Longest pause, in seconds, after an upstream answers 429 (rate limited). The pause lasts as long as the upstream says (retry-after-ms, Retry-After, or a delay in the error body); when it says nothing, it backs off by the upstream's consecutive 429s: 5, 10, 20 and 40 seconds, then 60 seconds each time. A pause is not a failure: requests wait for the upstream within wait_secs, and the circuit breaker does not count it. |
idle_timeout_secs |
integer | 300 |
Seconds an upstream may go without sending content before the gateway stops waiting for it. Counted from the moment the request is sent and started again by every piece of content: text, reasoning and tool calls count, keep-alives do not. A whole (non-streamed) answer counts from sending to the complete answer. Before any content has reached the client, the upstream counts as failed and the request moves to the next one; with none left, the client gets a timeout error. After content has reached the client, the answer ends with an error. From 30 to 3600. |
wait_secs |
integer | 120 |
Seconds a request waits in all, counted once the key's own max_concurrent lets it in: for a key's minute or hour limit to free up, for an upstream that cannot take it yet — at its max_concurrent, paused after answering 429, or at one of its minute or hour usage limits — and before retrying upstreams that failed temporarily before any content (overloaded, a server error, unreachable). A key limit that does not free up in time refuses the request. An upstream that cannot take it is skipped for the next one, and when none is left the request waits for the first to become available; a conversation that stays on an upstream waits for it. When nothing became available in time, the client gets 429 with Retry-After; when retries did not succeed in time, it gets the last upstream error. 0: never wait or retry. From 0 to 600. |
A model alias is another name for the same model. Clients request it like any model; each upstream receives the name it uses for that model.
aliases:
deepseek-v4.1: DeepSeek-v4.1-flash
claude-sonnet-5:
- claude-sonnet-5
- us.anthropic.claude-sonnet-5-v1:0
- anthropic/claude-sonnet-5- An upstream serves an alias when it offers one of the listed names within
its
models_only, and receives the first such name in the list. Every upstream that serves an alias takes part in failover for it. GET /v1/modelslists aliases next to the upstream models, and the original names stay available.- An alias takes precedence over an upstream model of the same name: a
request for
claude-sonnet-5above goes only to upstreams offering one of the three names. List an upstream's own name when it should keep serving it. - A key's
allowand a rule'swhen.modelwritten for an upstream model name also cover the aliases that list it; written for an alias, they cover only the alias. - When the upstream answers with the model it was sent, the answer carries the name the client asked for. An answer naming a different model is passed on as it is.
- Prices and the upstream check-up use the name sent upstream. The request log keeps the name the client asked for, and each attempt the name it sent.
- To send one key's requests to a particular upstream model, use a rule with
pinned models (
routes[].rules[].to) rather than an alias: an alias changes what the name means for every client.
A group puts several upstreams behind one name. Rules send requests to a
group with to.
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Name of the group; unique, and not the name of an upstream. |
type |
fallback | select | load-balance | url-test | cheapest |
fallback |
fallback: the first healthy member, in order. select: the member named in selected. load-balance: requests are shared out in proportion to the members' weights; a conversation in progress stays where it is. url-test: the fastest by measured time from sending a request to the first content of the answer. cheapest: the lowest input price. |
providers |
list of strings or groups[].providers[] |
required | Member upstreams, by name; not groups. Each upstream appears once in a group. In a load-balance group, a member can be written as {name, weight}. |
selected |
string | — | For select: the chosen member. |
balance_by |
weights | latency | health | latency-health |
weights |
For load-balance: what the members' weights are multiplied by. weights: nothing; the weights alone. latency: faster upstreams get more. health: upstreams that fail less get more. latency-health: both. Other group types take only weights. |
fallback is the default because a single user's machine has no load to
spread.
In a load-balance group, a member can carry a weight, from 1 to 100; a
member written as just its name has weight 1. Weights set how the group's
requests are shared out: with { name: anthropic, weight: 7 } and relay,
the official API serves seven requests in ten. Conversations in progress
stay on the upstream that answers them (see below) and count toward its
share, so the balance is kept by where new conversations start. An upstream
that is cooling down after failures, is at its max_concurrent, or cannot
serve a request, sits that request out, and the others share it by their
weights. A new WebSocket connection is placed the same way and counts as one
request; everything sent on it then goes to the upstream it connected to.
Other group types take no weights.
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | The upstream, by name. A member written as just its name has weight 1. |
weight |
integer | 1 |
The member's share of a load-balance group's requests, in proportion to the other members' weights. From 1 to 100. Other group types take no weight other than 1. |
groups:
- name: pool
type: load-balance
providers:
- { name: anthropic, weight: 7 }
- relayWhatever the type, a conversation stays on the upstream that last answered
it, so that what the upstream holds of it in its prompt cache is read again
rather than paid for in full elsewhere. Within a turn (while the client sends
tool results back) it always stays; across turns it stays while the previous
answer read or wrote at least 1024 tokens of prompt cache and came less than
five minutes ago. An upstream that is cooling down after failures releases
the conversation, and whichever upstream answered after a failover is the one
it stays on. The rule a turn matched at its start also holds for the rest of
that turn: rules keyed on input size or images do not move a turn halfway,
unless its input no longer fits the context window of a model the rule sends
it to. A load-balance weight is therefore the long-run share of requests:
conversations in progress stay where they are and count toward that
upstream's share.
A conversation carrying content an upstream encrypted is a different case.
OpenAI Responses hands clients encrypted state to send back each turn
(encrypted_content: reasoning, compacted history, what Codex sub-agents
report to each other), and only the account that encrypted it can read it.
Such a conversation goes back to the upstream that last answered it whatever
the rule now picks, even after the configuration changed, the cache went cold
or a day has passed (it is remembered for 30 days). When it carries more than
encrypted reasoning, that upstream is the only candidate: there is no
failover, and if the upstream has been removed the request is refused with an
explanation. Encrypted reasoning alone can be left out, so other upstreams
remain as failover. After twcore restarts the gateway no longer knows which
upstream encrypted a conversation; if another upstream receives it and cannot
read it, the failure says so, and the conversation has to go back to the
original upstream or start over.
balance_by lets a load-balance group also look at how each upstream has
been doing lately. Each member's weight is multiplied by a factor, and the
group shares out requests by the result in the same way as above.
weights(the default): the weights alone.latency: faster upstreams get a larger share. Speed is the typical time from sending a request to the first content of the answer, the same measurementurl-testuses. An upstream twice as fast as the middle of the group has its weight multiplied by four, by at most ten and at least a tenth.health: upstreams that fail less get a larger share. It looks at the last 50 requests within the past 30 minutes. Server errors, used-up quota or balance, rejected credentials, timeouts (includingfailover.idle_timeout_secsbefore any content) and connection errors count as failures; errors caused by the request itself do not, and neither does a rate limit (429), a client that cancels, a request aborted by hand, or an upstream skipped because it is at itsmax_concurrentor at a usage limit. An upstream that keeps failing keeps a twentieth of its weight, so it still gets the occasional request and its recovery is noticed; one that fails outright is set aside byfailoveras before.latency-health: both factors, multiplied.
Speed is measured on streamed answers only, from the moment the request is
sent to that upstream, so waiting and upstreams that failed before it do not
count. An upstream given up on because it sent no content within
failover.idle_timeout_secs counts as taking the whole wait. On a
Responses WebSocket connection, each response.create counts as one request
for both speed and failures, its speed measured from the moment the upstream
starts answering it.
An upstream without enough measurements yet counts as average. As with weights alone, conversations in progress stay where they are, and new conversations make up the difference.
groups:
- name: pool
type: load-balance
balance_by: latency-health
providers:
- { name: official, weight: 3 }
- relayA route is a list of rules evaluated top to bottom. Each key takes the route
named in its route, otherwise default_route, otherwise the route named
default; without any, requests fail over across all upstreams in the
order they are declared.
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Name of the route; unique. default is the one keys use unless told otherwise. |
rules |
list of routes[].rules[] |
[] |
Evaluated top to bottom; the first rule with to or deny that matches decides where the request goes. |
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Name shown in logs and in the traffic view. |
when |
object, routes[].rules[].when |
— | Conditions, all of which have to hold. Unset: matches every request. |
to |
string, or list of routes[].rules[].to[] |
— | An upstream or a group, by name; __all__ is every upstream in declared order. Or pinned models: a list of upstreams with the model to send to each, tried in order. Not allowed together with when.provider_would_be. |
set |
object, routes[].rules[].set |
— | Parameters to rewrite. Collected from every matching rule, not only the first. |
deny |
string | — | Refuse the request with this reason. |
to names an upstream or a group, or pins models: a list of upstreams, each
with the model sent to it, tried in order. A pinned model is sent as written,
without aliases or set.model, so a rule can send a key's requests to one
upstream's model even when an alias of the same name points elsewhere.
routes:
- name: default
rules:
- name: Opus on Bedrock
when: { model: claude-opus-5 }
to:
- { provider: bedrock, model: us.anthropic.claude-opus-5-v1:0 }
- { provider: anthropic, model: claude-opus-5 }| Field | Type | Default | Description |
|---|---|---|---|
provider |
string | required | An upstream, by name; not a group. Each upstream appears once in the list. |
model |
string | required | The model name sent to that upstream, as written: aliases do not apply, and no set.model changes it. Not allowed together with set.model in the same rule. |
| Field | Type | Default | Description |
|---|---|---|---|
model |
string | — | Requested model, glob (claude-opus-*). An upstream model name also matches requests for the aliases that list it; an alias matches only requests for the alias. |
client |
string | — | Name of the gateway key the request used, exactly. |
dialect |
string | — | API format the client spoke: anthropic, openai-chat, openai-responses, gemini. |
input_tokens |
comparison (>200k, <=4k, ==3) |
— | Estimated input tokens. |
max_tokens |
comparison (>200k, <=4k, ==3) |
— | The request's max_tokens. A request without one never matches. |
tool_count |
comparison (>200k, <=4k, ==3) |
— | Number of tools offered. |
cache |
bool | — | Whether the request uses the prompt cache. |
tools |
bool | — | Whether the request offers tools. |
image |
bool | — | Whether the request contains an image. |
thinking |
bool | — | Whether extended thinking is on. |
stream |
bool | — | Whether the response is streamed. |
intent |
string or list of strings | — | A client helper request: assistant_internal for any of them, or one class (titling). Classes set to intercept in client_probes are answered locally and never reach routing. |
time |
string or list of strings | — | Local time of the machine core runs on, as a window [days ]HH:MM-HH:MM: mon-fri 09:00-18:00, sat,sun 00:00-24:00, 22:00-06:00. Several windows: any of them. See below. |
provider_would_be |
string or list of strings | — | The upstream routing chose. Such a rule is evaluated after routing, may only set or deny, and cannot have to. |
A comparison starts with >, >=, <, <= or ==, and the number may
end in k or m: ">200k", "<=4k". Without an operator it is an error,
not an equality: "200k" alone is refused.
A time window is [<days> ]<HH:MM>-<HH:MM>, evaluated against the local
wall-clock time of the machine core runs on, at the moment the request is
routed. <days> is a comma-separated list of mon, tue, wed, thu,
fri, sat, sun and ranges such as mon-fri or fri-mon (which wraps
past Sunday); leave it out for every day. Case does not matter. The hours are
24-hour: the start is included, the end is not, and the end may be 24:00.
An end earlier than the start is an overnight window, and its days are the
days it starts on: fri 22:00-06:00 runs from Friday 22:00 to Saturday 05:59.
A value that does not fit this shape is refused when the configuration is
read, naming the rule and the value.
set.model may name an alias; each upstream then receives its own name for
it. Answers carry the name the client asked for, as with aliases.
set.reasoning writes the effort where the client's format keeps it
(output_config.effort, reasoning_effort, reasoning.effort, Gemini's
thinkingLevel, or the thinking budget when the client gave a budget). A
request converted for an upstream of another format carries it the way that
format writes it: off sent to an OpenAI Chat or Responses upstream is
none there, and sent to Gemini 2.5 Flash it is a thinking budget of 0.
When an upstream answers 400 because of the reasoning effort, whether the
client or a rule chose it, the request is sent once more to the same upstream
with a level the upstream named (the nearest one, or the ceiling it gave), or
without an effort when it named none. A refused none is replaced by the
lowest level the upstream named, so reasoning stays as low as it allows. The resend is a new attempt in the
request details and does not count as a retry. If it succeeds, the level is
remembered for that upstream and model for 7 days in effort-memory.json in
the data directory, so later requests send the working level directly. The
entries of an upstream are forgotten when its address, key or protocol
changes, or when it is removed. Requests that only give a thinking budget are
not resent. On WebSocket connections (Codex), a turn refused for its effort is
not resent and nothing is remembered: the refusal reaches the client as it
is.
| Field | Type | Default | Description |
|---|---|---|---|
model |
string | — | Send a different model. The prompt cache of the session is lost. |
max_tokens |
integer | — | Replace max_tokens. |
reasoning |
off | low | medium | high | xhigh | max |
— | Reasoning. off turns it off: the reasoning settings and the effort are removed, and in the OpenAI formats the effort is set to none. A level changes the effort. Only requests that already have reasoning on are changed; requests without reasoning (such as title generation) are left as they are. |
only_at_session_start |
bool | false |
Apply only when a session starts. Recorded and shown; not in effect yet. |
groups:
- name: fast
type: url-test
providers: [anthropic, relay]
routes:
- name: default
rules:
- name: long context goes to the official API
when: { input_tokens: ">200k" }
to: anthropic
- name: titles go to the cheap model
when: { intent: titling }
set: { model: claude-haiku-4-5 }
- name: everything else
to: fast
default_route: defaultScript plugins change requests before they reach an upstream and answers
before they reach the client. They run in a sandbox inside core, without
access to files, the network or the real values of secrets. The app installs
them: each plugin's code goes to plugins/<id>.js next to this file, a copy
of the approved code to plugins/.approved/<id>.js, and the code's SHA-256
to sha256.
A plugin's file holds its settings too. The manifest at the top of the file
says what to do when the plugin fails (on_error), which requests it handles
(match) and the value of each setting (settings.<name>.value). When the
app changes one of these, it rewrites only the manifest in the file and
updates sha256 along with it. This list keeps just the plugin, its approved
hash and whether it is on. Entries written by core 0.58 also have on_error,
scope and settings: they are ignored, and removed the next time the app
changes a plugin.
A plugin runs only while its file has exactly the approved hash. When the
file changes on disk or disappears, the plugin stops within seconds and the
app shows the change for review. Until the change is approved, the requests
the plugin covers are refused (on_error: "reject", the default) or pass
without it (on_error: "skip"), as the approved file says. A plugin that does
not load is handled the same way. Neither keeps the rest of the configuration
from taking effect.
Plugins run in the order of this list.
In match, clients, models and upstreams are lists of names or
patterns with * anywhere in them, matched regardless of case; a list that
is empty or left out matches everything. clients names the client app
(claude-code, codex, …), and a request whose app is not recognised
matches only an empty list. models matches the model sent to the upstream:
when a routing rule renames the model, the new name is the one that matches.
upstreams applies to requests and answers alike.
A plugin changes a request after routing, each time the request is sent to an
upstream. A request that fails over to another upstream starts again from what
the client sent, and the plugin sees which upstream and which model name the
request goes to. Routing, model checks and session grouping use what the
client sent. A plugin that changes the model name only renames what is sent to
that upstream: the request is not routed again, and the new name must still be
one of the models the key may use (clients[].allow), or the
request is not sent.
A plugin handles the kinds of request its code declares: conversations
(Anthropic Messages, OpenAI Chat Completions and Responses, and Gemini,
including their token counts and compaction), embeddings (/v1/embeddings,
Gemini :embedContent and :batchEmbedContents) and legacy completions
(/v1/completions). A plugin that declares none handles conversations only.
Requests of a kind a plugin does not handle pass without it, whatever its
on_error. Other endpoints, such as images and audio, pass without any plugin.
| Field | Type | Default | Description |
|---|---|---|---|
id |
string | required | Lowercase letters, digits and hyphens, 1 to 40 characters; unique. |
file |
string | required | The plugin's code, relative to this file's directory. It is always plugins/<id>.js; the app writes it. |
sha256 |
string | required | SHA-256 of the approved code, 64 lowercase hexadecimal characters. When the file no longer has this hash, the plugin stops running until the change is approved in the app. The approved code is kept in plugins/.approved/<id>.js. |
enabled |
bool | true |
Run the plugin. false keeps it installed and out of every request. |
plugins:
- id: add-date
file: plugins/add-date.js
sha256: 9f2b6c0e4a1d8f3b7c5e2a9d6f1b4c8e3a7d0f5b2c9e6a1d4f8b3c7e0a5d2f9b
enabled: trueThe start of plugins/add-date.js:
export const manifest = {
name: "Add date",
api: 1,
permissions: ["system"],
match: { clients: ["claude-code"], models: ["claude-*"], upstreams: [] },
on_error: "reject",
settings: {
note: { type: "string", label: "Note", value: "Answer in English." },
},
};Several devices (this Mac, a laptop, a server running a remote core) can keep
one configuration in step through a sync location you choose: a folder that a
cloud drive carries between devices, a WebDAV server, or S3-compatible
storage. Each change is merged object by object with what the other devices
did since the last sync; only when two devices change the same part of the
same object to different values is there a conflict, which the app shows on
every device until one of them resolves it. Turn sync on, join a location and
resolve conflicts in the app (Settings, Sync and backup) or with
twcore sync enable on a server; this section records the result.
What syncs is the configuration a configuration pack carries: upstreams,
proxies, gateway keys, aliases, groups, routes, price sheets, security rules,
plugins and the general settings, keys included. listen, retention and
sync itself stay on this device, and so do a ChatGPT account's sign-in and
OAuth tokens. An upstream signed in on another device stays turned off on this
one until it is signed in here; that does not turn it off anywhere else.
A device that joins with a configuration of its own merges it item by item; items that differ are chosen one by one, except the default gateway key: every installation makes one, so the joining device takes the sync data's value and the clients set up on it are updated.
With encryption on, versions that are not encrypted are never read. A keyring that went missing, or an older one put back, does not turn encryption off: the app asks whether to put back the copy this device keeps. A location that reads as empty is checked a few more times before the app says so, and turning sync off is still up to you.
| Field | Type | Default | Description |
|---|---|---|---|
device_id |
string | required | Generated when sync is turned on. File names at the sync location show its first 8 characters. |
device_name |
string | required | Shown in the device list of every device that syncs here. |
encrypt |
bool | true |
Encrypt what goes to the sync location with the sync passphrase. Every device at one location uses the same setting; change it in the app, which re-uploads the latest version. |
passphrase |
string | — | The sync passphrase; required when encrypt is on, and kept after encryption is turned off: the keyring that says it is off is checked with it. Kept here in the clear, like the other keys in this file. |
exclude |
list of strings | [] |
Upstreams, by name, that are not synced: they are not uploaded and another device's upstream of the same name does not replace them. Routes, aliases and groups that refer to them are not synced either. |
target |
object, sync.target |
required | The sync location. The keyring, versions/ and devices/ live directly in it. |
| Field | Type | Default | Description |
|---|---|---|---|
type |
folder | webdav | s3 |
required | Which kind of location; the other fields depend on it, see the three tables below. |
| Field | Type | Default | Description |
|---|---|---|---|
type |
string | required | folder |
path |
string | required | An absolute path on the machine core runs on: a folder that iCloud Drive, Dropbox, OneDrive, Syncthing or a NAS share carries between devices. |
proxy |
string | direct |
Not used for a folder. |
| Field | Type | Default | Description |
|---|---|---|---|
type |
string | required | webdav |
url |
string | required | The WebDAV address of the folder. https://, with the certificate checked; http:// only for this machine and the local network. |
user |
string | '' |
User name. |
pass |
string | '' |
Password, or the app password the service issues for WebDAV. |
proxy |
string | direct |
Where requests to the sync location go out: direct, system (the system proxy), or the name of a proxy under proxies. An http:// address on this machine or the local network has to be direct. |
| Field | Type | Default | Description |
|---|---|---|---|
type |
string | required | s3 |
endpoint |
string | required | The S3 endpoint. Hosts ending in amazonaws.com, aliyuncs.com or myqcloud.com put the bucket in the host name; every other endpoint (Cloudflare R2, MinIO, an address) puts it in the path. http:// only for this machine and the local network. |
region |
string | required | The signing region; auto for Cloudflare R2. |
bucket |
string | required | The bucket. Keep it private. |
prefix |
string | '' |
A folder inside the bucket. No .. parts, and not starting with /. |
access_key_id |
string | required | Access key ID. |
secret_access_key |
string | required | Secret access key. |
proxy |
string | direct |
Where requests to the sync location go out: direct, system (the system proxy), or the name of a proxy under proxies. An http:// address on this machine or the local network has to be direct. |
sync:
device_id: 3f9a2c1e-0b1d-4e2f-9a3b-5c6d7e8f9a0b
device_name: Mac mini
encrypt: true
passphrase: "correct horse battery staple"
exclude: [local ollama]
target:
type: webdav
url: https://dav.example.com/dav/ThinkWatch
user: name@example.com
pass: "app password"| Variable | Effect |
|---|---|
THINKWATCH_HOME |
Data directory, instead of ~/.thinkwatch (%APPDATA%\ThinkWatch on Windows). |
TWCORE_LOG |
Log filter, in tracing syntax (info, debug, tw_gateway=debug). |
HTTPS_PROXY, HTTP_PROXY, ALL_PROXY, NO_PROXY |
Used by upstreams with proxy: system. |
| Any other | Read where the configuration writes ${NAME}. |