Tether
DocsGitHub

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
  }
}
SettingDefaultEffect
TimeoutSeconds20Per attempt. A tool call sits inside a turn someone is waiting on
MaxAttempts3Reads only. Writes are never retried. 1 disables retries
MaxResponseBytes262144256 KB. Exceeding it fails the call rather than truncating
AllowInsecureEndpointfalsePermits http://. Local development only
AllowPrivateNetworkEndpointfalseDisables 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" }
      }
    }
  }
}
SettingDefaultNotes
BaseUrlhttps://api.openai.com/v1Must 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
AuthHeaderAuthorizationAzure OpenAI uses api-key
AuthSchemeBearerBlank sends the bare key
SupportsServerConversationStatetruefalse 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"
SettingDefaultEffect
EnabledfalseOpens the endpoint
IpHashSaltemptyRequired 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
MaxPerIpPerDay3New tenants per source address per day. Over it: 429 rate_limited
MaxPerEmailTotal1Tenants per email address, ever. A repeat: 409 email_already_used
MaxGlobalPerHour50New 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
VerifyEmailfalseConfirm 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
VerifyUrlemptyRequired 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
LinkLifetimeMinutes60How long a confirmation link works
MaxLinksPerEmailPerHour3Confirmation links sent to one workspace an hour, the signup's own included. Over it: 429 rate_limited
MaxLinksPerIpPerDay10Confirmation links asked for again from one source address a day. Over it: 429 rate_limited
NotifyAddressemptyAn operator address told about every new self-serve workspace (name, address, tenant id), on either path. Needs a Mail transport

Account

SettingDefaultEffect
MaxLiveKeys10Live 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.

SettingDefaultEffect
Transportnonenone 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
MaxSignInMailsPerWindow5Sign-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:TimeoutSeconds30Per message
Smtp:HeloNamemachine nameSent 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

SettingDefaultEffect
FreePlanRequestsPerDay200Chat 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.

SettingDefaultCounts
EnabledtrueTurns the limiter on
RuntimePerMinute120Per tenant credential, for /v1/chat, /v1/insights, /mcp, /v1/agent, /v1/tools and /v1/actions
ControlPerMinute600Per tenant credential, for every other authenticated route
AnonymousPerMinute10Per client address, for requests that carry no credential, such as signup
QueueLimit0Requests 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
SettingDefaultEffect
EnabledfalseSwaps the model for a scripted one; your catalog, endpoint and write gate stay real
ToolunsetThe read tool the scripted model calls. Unset means the first tool it is offered
ArgumentsunsetThe 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
Replya fixed lineThe 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.

SettingDefaultEffect
EnabledfalseMaster switch. Off means the local check is the whole gate
DefaultForManagedPlanstrueWhen 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
Modelomni-moderation-latestThe provider's moderation model
TimeoutSeconds8Kept 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.

SettingDefault
Enabledtrue
MaxPages40
MaxTotalBytes8388608
MaxPageBytes1048576
TimeoutSeconds15
ChunkChars900
ChunkOverlapChars120
MaxFeedItems500
MaxDataEndpoints6
MaxItems300

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"
SettingDefaultEffect
EnabledfalseOpens the endpoint
IpHashSaltemptyRequired when enabled. Source addresses are stored as a salted hash; without a salt the endpoint answers 503 sandbox_unavailable
LifetimeMinutes60How long an unclaimed sandbox lives before the sweep deletes it and its content
MaxPages8Pages read, the named one included
MaxTotalBytes1048576Bytes read across the crawl
MaxPageBytes262144Bytes read from one response
CrawlSeconds20Wall time for the whole crawl; what was read by then is kept
MaxFeedItems100Feed entries read
MaxDataEndpoints6Public JSON endpoints the pages reference that are read for listed items
MaxItems300Listed items kept, from JSON-LD, __NEXT_DATA__ and those endpoints together
MaxPerAddressPerDay5Sandboxes one source address may start in 24 hours. Over it: 429 rate_limited
MaxGlobalPerHour30Sandboxes everybody together may start in an hour. Over it: 429 rate_limited
MaxConcurrentCrawls4Crawls one instance runs at once. Beyond it: 503 sandbox_busy
MaxTurnsPerSandbox20Answers per sandbox. Over it: 429 sandbox_budget_exhausted, sandbox_limit
SandboxBudgetUsd0.05Model spend per sandbox, from the usage ledger. Over it: sandbox_limit. 0 refuses every turn
DailyBudgetUsd2.0Model spend of every sandbox together in a UTC day. Over it: daily_limit. 0 refuses every turn
SweepIntervalSeconds60How 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).

SettingDefaultEffect
CheckTimeoutSeconds8Wall time for the whole check; a page slower than that is reported as not answering
CheckMaxBytes524288Bytes read from the page; the rest is not looked at
UserAgentTetherSiteCheck/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.

SettingDefaultEffect
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.

SettingDefault
Enabledtrue
MaxDocuments20
MaxDocumentChars50000
ChunkChars900
ChunkOverlapChars120

Startup validation

Three things fail the boot rather than failing later:

CheckFailure
Every model in AI:Tiers has a price entrySpend caps would silently stop enforcing
AI:Provider:BaseUrl is an absolute http(s) URLEvery model call fails with a confusing transport error
AI:Memory thresholds are in rangeRetrieval 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.