Operations
Configuration
Standard .NET configuration: appsettings.json, environment overrides, and a small number of
settings that will refuse to let the process start if they are wrong.
Environment variables
Any setting is overridable with a double underscore for each level of nesting. This is how
secrets should reach a hosted deployment — never in a committed appsettings.json.
ConnectionStrings__Postgres="Host=…;Database=tether;Username=…;Password=…"
AI__Provider__ApiKey="sk-…"
AI__Budget__OrgDailyCapUsd="5"
ToolTransport__TimeoutSeconds="20"
Local secrets
src/Tether.Api/appsettings.Local.json and appsettings.{Environment}.Local.json are loaded
after the defaults and before environment variables, and both are gitignored. They are where
the provider key, the operator key and the signup salt belong on a developer machine — never
in appsettings.json, which is tracked.
{
"AI": { "Provider": { "ApiKey": "sk-…" } },
"Operator": { "ApiKey": "…" },
"ConnectionStrings": { "Postgres": "Host=localhost;Port=55435;Database=tether;Username=tether;Password=tether" }
}
An environment variable still wins over the Local file, so a deployment that sets
AI__Provider__ApiKey is unaffected by anything a Local file says.
Database
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=tether;Username=tether;Password=tether"
}
}
PostgreSQL with the pgvector extension. Schema is managed by EF migrations:
dotnet ef database update --project src/Tether.Engine --startup-project src/Tether.Api
Migrations are not applied on startup. Run them as a deploy step.
ToolTransport
Policy for calling your customers' endpoints. Global — the endpoint and signing secret are per-tenant and live on the tenant record.
{
"ToolTransport": {
"TimeoutSeconds": 20,
"MaxAttempts": 3,
"MaxResponseBytes": 262144,
"AllowInsecureEndpoint": false,
"AllowPrivateNetworkEndpoint": false
}
}
| Setting | Default | Effect |
|---|---|---|
TimeoutSeconds | 20 | Per attempt. A tool call sits inside a turn someone is waiting on |
MaxAttempts | 3 | Reads only. Writes are never retried. 1 disables retries |
MaxResponseBytes | 262144 | 256 KB. Exceeding it fails the call rather than truncating |
AllowInsecureEndpoint | false | Permits http://. Local development only |
AllowPrivateNetworkEndpoint | false | Disables the private-address guard. Local development only |
AI:Provider
The one place a provider hostname is configured.
{
"AI": {
"Provider": {
"BaseUrl": "https://api.openai.com/v1",
"ApiKey": "",
"AuthHeader": "Authorization",
"AuthScheme": "Bearer",
"SupportsServerConversationState": true,
"Surfaces": {
"embeddings": { "BaseUrl": "https://cheaper-host/v1" }
}
}
}
}
| Setting | Default | Notes |
|---|---|---|
BaseUrl | https://api.openai.com/v1 | Must include the API version prefix — the runtime appends responses, so https://api.openai.com alone posts to /responses and gets a 404 that names the URL it hit |
ApiKey | — | Falls back to OpenAI:ApiKey when unset |
AuthHeader | Authorization | Azure OpenAI uses api-key |
AuthScheme | Bearer | Blank sends the bare key |
SupportsServerConversationState | true | false makes the runtime carry the transcript in each request instead of using previous_response_id |
Surfaces | — | Per-surface overrides: unified, agent, chat, embeddings, images, document-parsing, menu-enrichment |
AI:Tiers
Per-plan model, output ceiling, tool-turn ceiling and deep-analysis allowance. Full table in Plans and ceilings.
AI:Budget
{
"AI": {
"Budget": {
"GlobalDailyCapUsd": 0,
"GlobalMonthlyCapUsd": 0,
"OrgDailyCapUsd": 0,
"OrgMonthlyCapUsd": 0,
"OrgMonthlyTargetUsd": 0,
"OrgMonthlyWarnUsd": 0,
"OrgMonthlyReviewUsd": 0
}
}
}
0 means unlimited, and every cap defaults to 0 — the breaker ships dark. The three
Target / Warn / Review values log and never refuse. Boundaries are UTC. See
Cost and quotas.
AI:Usage
{ "AI": { "Usage": { "QueueCapacity": 5000, "SpillDirectory": "usage-spill" } } }
Every turn writes a usage row to the ledger that the budget breaker and the invoice read. When the
database will not take those writes, up to QueueCapacity rows wait in memory and the rest are
appended to files under SpillDirectory (one usage-yyyyMMddHH.jsonl per hour), then replayed
and deleted once the database answers. A file that cannot be parsed is left in place and reported,
never deleted. If the disk cannot be written either, the row is lost and a critical log line
carries its figures.
Point SpillDirectory at a mounted volume. On an ephemeral filesystem the spill survives a
database outage but not the loss of the instance. The Docker image sets it to
/var/lib/tether/usage-spill and creates it for the tether user.
AI:Unified and AI:Agent
Runtime defaults for the two model-calling paths. Tier resolution overrides the model, output ceiling and tool-turn ceiling per request, so these are floors and fallbacks rather than the operative numbers.
{
"AI": {
"Unified": {
"Model": "gpt-5.6-luna",
"VisionModel": "gpt-4o",
"MaxTokens": 8000,
"Temperature": 0.3,
"TimeoutSeconds": 60,
"StoreResponses": true,
"MaxToolTurns": 10,
"ReasoningEffort": "low"
},
"Agent": {
"Enabled": true,
"Model": "gpt-4o",
"MaxToolCallsPerTurn": 5,
"TimeoutSeconds": 60,
"MaxTokens": 4000,
"MaxReturnedRows": 50,
"MaxInputChars": 8000
},
"DeepOps": { "Enabled": true, "ReasoningEffort": "medium", "MaxToolTurns": 12, "PosReadToolsEnabled": true },
"Orchestration": { "MaxConcurrentTools": 4 }
}
}
MaxConcurrentTools bounds how many tool calls one turn dispatches at once — that is the burst
your customers' endpoints see.
PosReadToolsEnabled is true by default. Set to false, tools registered as deep-analysis-only
are withheld from every turn, deep or not; leave it on unless you want them gone everywhere.
AI:ActionGuardrails
{
"AI": {
"ActionGuardrails": {
"Enabled": true,
"HighValueThreshold": 1000
}
}
}
Enabled: false disables the risk and value gates. The tenant boundary check is not part of
this switch and always runs. HighValueThreshold: 0 disables the value gate alone; declared
risk levels keep working.
AI:Memory
{
"AI": {
"Memory": {
"PreferenceExtractionEnabled": true,
"MinPreferencePersistConfidence": 0.7,
"MinPreferenceInjectConfidence": 0.7,
"KnowledgeRetrievalEnabled": false,
"RetrievalTopK": 5,
"MinCosineSimilarity": 0.25,
"EmbeddingModel": "text-embedding-3-small",
"IngestionJobEnabled": false,
"LlmPreferenceExtractionEnabled": false,
"LlmExtractionModel": "gpt-4o-mini"
}
}
}
Validated at startup: confidences in 0–1, RetrievalTopK in 1–50, MinCosineSimilarity in 0–1,
both model names non-empty. A typo used to silently disable retrieval or preference injection
rather than failing.
KnowledgeRetrievalEnabled requires pgvector to actually be installed. LlmPreferenceExtractionEnabled
adds a model call per turn. See Context and memory.
AI:Multimodal
{
"AI": {
"Multimodal": {
"ImageInputEnabled": true,
"AllowedMimeTypes": ["image/png", "image/jpeg", "image/webp"],
"MaxImageBytes": 4194304,
"AllowedUrlHosts": [],
"RequireHttpsForUrls": true
}
}
}
AllowedUrlHosts empty means any HTTPS host. Populate it if you want image URLs restricted to
your own storage.
AI:Routing, AI:Rag, AI:ContextCache
{
"AI": {
"Routing": { "Enabled": true, "IntentGatedToolsEnabled": true, "GeneralConversationEnabled": true },
"Rag": { "Enabled": true, "RetrievalEnabled": true, "MaxChunks": 4, "MaxChunkChars": 420, "IncludeSourcesInMeta": true, "CatalogWarmupEnabled": false },
"ContextCache": { "Enabled": true, "OrgMetaTtlMinutes": 10, "MaxConcurrentSectionBuilds": 4 }
}
}
IncludeSourcesInMeta is what populates meta.sources on a response. ContextCache governs
pre-turn context assembly, which currently has no builders registered — see
Context and memory.
AI:Policy
{
"AI": {
"Policy": {
"RequireContext": false,
"HistoryWindowDays": 7,
"ToolCallingEnabled": true,
"AppGuideEnabled": false,
"BlockedKeywords": [],
"OutOfScopeMessage": "I can answer questions about your data and stage actions for you to confirm. What would you like to know?"
}
}
}
OutOfScopeMessage is user-facing on every refused turn — write it in your product's voice.
BlockedKeywords short-circuits matching turns before any spend.
AI:Telemetry
{ "AI": { "Telemetry": { "Enabled": true } } }
Off means no ai_request_telemetry rows, and no way to answer why a turn behaved as it did.
Leave it on. See Telemetry.
Security
Security__SecretKey="$(openssl rand -base64 32)"
Security:SecretKey is a base64-encoded 32-byte key. It is not in appsettings.json, and outside
Development the process refuses to start without it:
Security:SecretKey is required outside Development. It encrypts tenant upstream credentials and
tool signing secrets at rest. Generate one with `openssl rand -base64 32`, and keep it.
It encrypts each tenant's tool signing secret and any upstream bearer credential with AES-256-GCM;
a stored value starts enc:v1:. API keys are not encrypted, they are hashed and cannot be
recovered.
What that means for you:
-
Keep a copy of the key somewhere other than the database and its backups. Without it those secrets cannot be read.
-
A missing or different key does not stop the instance from starting. Everything that needs no stored secret carries on, including authentication with API keys and signup. A tool call that needs a stored secret fails before your endpoint is contacted, with this message:
A stored secret could not be decrypted with the configured Security:SecretKey. It is not the key the database was written with, or the stored value was altered.Put the original key back and it recovers.
-
There is no rotation procedure yet. Changing the key does not re-encrypt what is stored, so it makes existing secrets unreadable.
-
In Development, with no key, secrets are stored in the clear. Rows written before a key was set stay readable and are not guaranteed to be re-encrypted until they are next saved.
Signup
Self-serve signup, POST /v1/signup. It ships off: with Enabled false the endpoint answers
404 signup_disabled, which is the state of every instance until you open it.
Signup__Enabled=true
Signup__IpHashSalt="a-long-random-secret"
| Setting | Default | Effect |
|---|---|---|
Enabled | false | Opens the endpoint |
IpHashSalt | empty | Required when enabled. Addresses are stored as a salted hash; without a salt the endpoint answers 503 signup_unavailable with reason: misconfigured, which does not name the setting to an anonymous caller, so check this first |
MaxPerIpPerDay | 3 | New tenants per source address per day. Over it: 429 rate_limited |
MaxPerEmailTotal | 1 | Tenants per email address, ever. A repeat: 409 email_already_used |
MaxGlobalPerHour | 50 | New tenants across all callers in the last hour. Over it: 429 rate_limited |
BlockedEmailDomains | [] | Domains refused with 400 disposable_email, added to a built-in list of common disposable providers. Subdomains match |
VerifyEmail | false | Confirm the signup address. The workspace and its keys are created at once either way; with this on, a confirmation link is queued on the outbox and the workspace is not live until it is opened (POST /v1/signup/confirm; see Signup). Off, every signup is live at once. Needs a Mail transport; without one the endpoint answers 503 signup_unavailable |
VerifyUrl | empty | Required with VerifyEmail. The onboarding console page the link opens, https (or http on loopback), with no query; the token is appended as ?confirm=. Every instance sharing the database needs the same value and the same IpHashSalt, since any of them may send or open a link |
LinkLifetimeMinutes | 60 | How long a confirmation link works |
MaxLinksPerEmailPerHour | 3 | Confirmation links sent to one workspace an hour, the signup's own included. Over it: 429 rate_limited |
MaxLinksPerIpPerDay | 10 | Confirmation links asked for again from one source address a day. Over it: 429 rate_limited |
NotifyAddress | empty | An operator address told about every new self-serve workspace (name, address, tenant id), on either path. Needs a Mail transport |
Account
| Setting | Default | Effect |
|---|---|---|
MaxLiveKeys | 10 | Live keys a tenant may hold. Over it: 409 key_limit_reached |
Mail
The mail Tether sends a business's people: sign-in links and approval notices (Mail). Every problem below stops the host when it starts, naming the setting.
| Setting | Default | Effect |
|---|---|---|
Transport | none | none sends nothing; console (Development only) writes each message, link included, to standard output; smtp sends through Smtp. Anything else is refused |
From | — | Required for smtp. The sender address |
FromName | — | Optional display name |
MaxSignInMailsPerWindow | 5 | Sign-in messages per person per business within a link's lifetime |
Inbox:BaseUrl | — | Required unless none. The approval app every link points at. https, or http to the same machine |
Inbox:BaseUrls:<tenant id> | — | The approval app for that tenant and every business it created |
Smtp:Host, Smtp:Port | — | Required for smtp |
Smtp:Security | — | Required for smtp: starttls, tls or none. Never defaulted |
Smtp:Username, Smtp:Password | — | Together or not at all; refused with Security none |
Smtp:TimeoutSeconds | 30 | Per message |
Smtp:HeloName | machine name | Sent in EHLO |
Mail__Transport=smtp Mail__From=approvals@yourdomain Mail__Inbox__BaseUrl=https://approvals.yourdomain \
Mail__Smtp__Host=… Mail__Smtp__Port=587 Mail__Smtp__Security=starttls \
Mail__Smtp__Username=… Mail__Smtp__Password=…
Messages are sent by the same background worker as events, so Events:Delivery sets their retries.
Quota
| Setting | Default | Effect |
|---|---|---|
FreePlanRequestsPerDay | 200 | Chat turns a free tenant may make in 24 hours on the managed-AI path. Over it: 429 plan_quota_exceeded. 0 turns the cap off |
Every signup lands on the capped free plan; nothing here can grant a paid one. Keep the salt in
an environment variable or a Local file, never in appsettings.json.
Operator
Operator__ApiKey="a-long-random-secret"
Operator:ApiKey is the credential for issuing keys and creating tenants (/v1/keys,
/v1/tenants). It is empty by default, and while it is empty those routes cannot be reached with
any key: they answer 401 invalid_api_key. A tenant's own key never works there. Treat it like a
root credential.
RateLimit
Requests are counted per minute and refused with 429, code rate_limited, and a Retry-After
header.
| Setting | Default | Counts |
|---|---|---|
Enabled | true | Turns the limiter on |
RuntimePerMinute | 120 | Per tenant credential, for /v1/chat, /v1/insights, /mcp, /v1/agent, /v1/tools and /v1/actions |
ControlPerMinute | 600 | Per tenant credential, for every other authenticated route |
AnonymousPerMinute | 10 | Per client address, for requests that carry no credential, such as signup |
QueueLimit | 0 | Requests held waiting for a permit. 0 refuses immediately |
The two credentialed lanes are separate: exhausting ControlPerMinute does not stop chat. /health,
/swagger and /openapi are never limited. The limit is per tenant and not per address, because a
browser widget is thousands of addresses and one backend is one.
AI:MockMode
AI__MockMode__Enabled=true
AI__MockMode__Tool=list_orders
| Setting | Default | Effect |
|---|---|---|
Enabled | false | Swaps the model for a scripted one; your catalog, endpoint and write gate stay real |
Tool | unset | The read tool the scripted model calls. Unset means the first tool it is offered |
Arguments | unset | The JSON arguments the scripted model sends. Unset means arguments built from the tool's schema: every required property, filled with a value the schema accepts |
Reply | a fixed line | The leading text of the answer |
Run it on a separate instance, not the one your users reach. See Testing with mock mode.
AI:Approvals
{ "AI": { "Approvals": { "Expiry": { "Low": "24:00:00", "Elevated": "04:00:00", "High": "01:00:00" } } } }
How long a staged write stays confirmable, by the risk it was registered with, as hh:mm:ss. A
confirm after the deadline answers 410 Expired. See Actions.
AI:Safety:Moderation
The provider's moderation classifier, run on staged writes only, behind the local child-safety check that always runs. See Security model.
| Setting | Default | Effect |
|---|---|---|
Enabled | false | Master switch. Off means the local check is the whole gate |
DefaultForManagedPlans | true | When the switch is on, whether a tenant with no preference is moderated. A tenant on bring_your_ai is excluded by default whatever this says, and only an operator can change a tenant |
Model | omni-moderation-latest | The provider's moderation model |
TimeoutSeconds | 8 | Kept short because it sits in front of a person clicking Approve. On a timeout or an error the check fails open and logs |
ProhibitedCategories | ["sexual/minors"] | Categories that make a write non-executable. Deliberately narrow |
Preview
Bounds on Preview Mode, which reads a public site to answer before anything is connected. With
Enabled false, POST /v1/preview answers 404 preview_disabled.
| Setting | Default |
|---|---|
Enabled | true |
MaxPages | 40 |
MaxTotalBytes | 8388608 |
MaxPageBytes | 1048576 |
TimeoutSeconds | 15 |
ChunkChars | 900 |
ChunkOverlapChars | 120 |
MaxFeedItems | 500 |
MaxDataEndpoints | 6 |
MaxItems | 300 |
PreviewSandbox
The anonymous preview sandbox, POST /v1/preview/sandbox (Preview Mode):
a stranger's website read and answered from before signup. It ships off: with Enabled false
the endpoint answers 404 sandbox_disabled.
PreviewSandbox__Enabled=true
PreviewSandbox__IpHashSalt="another-long-random-secret"
| Setting | Default | Effect |
|---|---|---|
Enabled | false | Opens the endpoint |
IpHashSalt | empty | Required when enabled. Source addresses are stored as a salted hash; without a salt the endpoint answers 503 sandbox_unavailable |
LifetimeMinutes | 60 | How long an unclaimed sandbox lives before the sweep deletes it and its content |
MaxPages | 8 | Pages read, the named one included |
MaxTotalBytes | 1048576 | Bytes read across the crawl |
MaxPageBytes | 262144 | Bytes read from one response |
CrawlSeconds | 20 | Wall time for the whole crawl; what was read by then is kept |
MaxFeedItems | 100 | Feed entries read |
MaxDataEndpoints | 6 | Public JSON endpoints the pages reference that are read for listed items |
MaxItems | 300 | Listed items kept, from JSON-LD, __NEXT_DATA__ and those endpoints together |
MaxPerAddressPerDay | 5 | Sandboxes one source address may start in 24 hours. Over it: 429 rate_limited |
MaxGlobalPerHour | 30 | Sandboxes everybody together may start in an hour. Over it: 429 rate_limited |
MaxConcurrentCrawls | 4 | Crawls one instance runs at once. Beyond it: 503 sandbox_busy |
MaxTurnsPerSandbox | 20 | Answers per sandbox. Over it: 429 sandbox_budget_exhausted, sandbox_limit |
SandboxBudgetUsd | 0.05 | Model spend per sandbox, from the usage ledger. Over it: sandbox_limit. 0 refuses every turn |
DailyBudgetUsd | 2.0 | Model spend of every sandbox together in a UTC day. Over it: daily_limit. 0 refuses every turn |
SweepIntervalSeconds | 60 | How often expired sandboxes are deleted |
Embed
"Check my site", POST /v1/embed/check (Embed): one page of the
business's site, read through the outbound guard (ToolTransport:AllowPrivateNetworkEndpoint decides
whether a private or loopback address may be read, as for tools).
| Setting | Default | Effect |
|---|---|---|
CheckTimeoutSeconds | 8 | Wall time for the whole check; a page slower than that is reported as not answering |
CheckMaxBytes | 524288 | Bytes read from the page; the rest is not looked at |
UserAgent | TetherSiteCheck/1.0 (+https://tether.ge) | Sent with the check |
ForwardedHeaders
Signup and the preview sandbox count by the caller's address. A console that calls them from its
own server is one address for every visitor unless it says whose request it relays, which it does
in X-Forwarded-For. That header is honoured from loopback and from the proxies listed here, one
hop only, and from nobody else, so a caller cannot choose its own bucket.
| Setting | Default | Effect |
|---|---|---|
KnownProxies | [] | Addresses, besides loopback, whose X-Forwarded-For sets the caller's address, such as the onboarding console's server |
RelaySecret | "" | For a console with no fixed address, such as one on Vercel: a request carrying this value in X-Tether-Relay has its caller's address set from X-Tether-Visitor. At least 32 characters, or the host does not boot; empty turns it off. A wrong value changes nothing |
Knowledge
Bounds on the documents a business uploads with /v1/knowledge. With
Enabled false, every knowledge route answers 404 knowledge_disabled. Values out of range stop
the boot.
| Setting | Default |
|---|---|
Enabled | true |
MaxDocuments | 20 |
MaxDocumentChars | 50000 |
ChunkChars | 900 |
ChunkOverlapChars | 120 |
Startup validation
Three things fail the boot rather than failing later:
| Check | Failure |
|---|---|
Every model in AI:Tiers has a price entry | Spend caps would silently stop enforcing |
AI:Provider:BaseUrl is an absolute http(s) URL | Every model call fails with a confusing transport error |
AI:Memory thresholds are in range | Retrieval or preference injection silently disabled |
Health
curl https://api.trytether.ge/health
{ "status": "ok" }
Unauthenticated, alongside /swagger and /openapi. It reports that the process is up — it
does not check the database or the provider.