The API
September 7, 2026 · View on GitHub
Composition
api.router preserves public registration order while concern routers contribute ordered route blocks.
bootstrap.create_app() assembles the combined route table into FastAPI roles.
api.app remains the deployed, backward-compatible all role. Everything the CLI + skill do is one
HTTP call over this. The factory lifespan runs read-only verify_db() and creates the shared keepalive
httpx.AsyncClient at app.state.http (and audit.drain()s on shutdown). It also starts the Google Ads conversion uploader (adsconv.worker) as
a background task, but only when adsconv.enabled() - see
ads-conversions.
Content-driven provider companion backfills run in the explicit python -m treg upgrade release
phase, outside every app role's lifespan. The default python -m treg entrypoint also provisions the
guarded local single-user identity before Uvicorn starts.
Instagram authorization strategy
POST /oauth/start keeps the same request shape. TREG_OAUTH_REVIEW_PENDING is the single operational
switch for incomplete reviews; provider-registry metadata maps its keys to fallback capabilities,
conditional warnings, and method defaults. With both Instagram keys pending, provider=instagram
uses the approved Facebook Page core flow. capability=manage selects direct Instagram Login, and
capability=page-messages explicitly widens the Page flow for reviewers and app roles. When keys are
removed, plain Connect first expands to Page messages and then returns to direct Instagram Login.
The response returns capability-specific connect_guidance, so clients need no provider-specific
review logic. Connection rows include authorization method and setup health.
Catalog calls accept X-Treg-Authorization-Method to select one of an endpoint's declared methods;
the header is an internal routing control and is stripped before the faithful upstream relay.
Catalog calls can fail before relay with HTTP 428. Its detail object has a stable error code,
provider, endpoint id, required method, capability and scopes, explanation, CLI command, and
dashboard action. This distinguishes a missing grant, missing resource, missing scope, and expired
grant. See instagram-oauth.
WAF escape hatch - X-Treg-Body-Encoding
Some hosting edges (Cloudflare, including Render's) 403 any request whose body matches an
injection signature - a skill recipe or a proxied call that legitimately carries SQL/HTML. The
pure-ASGI _BodyDecodeMiddleware (registered via app.add_middleware) lets a client smuggle such a
body past the edge: send it base64/gzip-encoded and set X-Treg-Body-Encoding: base64 (or
gzip, or base64+gzip). The middleware calls _decode_request_body() to restore the real bytes,
fixes content-length, and hands the decoded body to routing - so both the Pydantic JSON endpoints
(e.g. POST /skills) and the /call proxy (which relays request.body() upstream) see plaintext. A
malformed encoded body is a clean 400. No header ⇒ untouched. The CLI's _RegistryClient uses this
automatically on a WAF 403 (see cli), as does the local proxy for an intercepted call. Body
replay does not imply connection closure: after delivering the decoded request,
_BodyDecodeMiddleware delegates later receive() calls to the original ASGI channel and forwards
only a real http.disconnect. If the client disconnects before the encoded body is complete, the
middleware skips decoding and replays each consumed partial-body message followed by that real
disconnect.
503 provider_capacity_unavailable - treg's own account is out
A metered (tier-4) call whose provider treg's own account cannot serve right now (a confirmed
balance/quota signal, or the capacity sweep) is refused before any hold with a typed 503:
{"detail": {"error": "provider_capacity_unavailable", "provider", "endpoint_id", "resets_at" | null, "alternatives": [...], "message"}}, X-Treg-Error: 1, no X-Treg-Cost-Micro, refused_by="capacity"
on the audit row. The caller's own key for the provider is never affected (tiers 1/2 win first), and
treg does not call an alternative on the caller's behalf - it names them. A lock set by the call
path lets one call a minute through as a probe and lifts on its 2xx, so the message says a retry
in a minute may succeed; a lock from the sweep lasts until resets_at. Not the pool-saturation 503
(treg_saturated), which is a different exit. See architecture/proxy-model.md § Platform capacity.
X-Treg-Served-Via - this answer came through an overflow relay
GET/PATCH /orgs/{id}/settings carries platform_overflow (default true); false opts the team out -
such calls get the 503 provider_capacity_unavailable below instead of a relay.
overflow:<aggregator> on a metered call that treg served through a treg-owned aggregator account
because its own account for the provider was out. Same request, same vendor body shape (routes are
verified for that), the caller paid the aggregator's real price (X-Treg-Cost-Micro), and
X-Treg-Call-Id is the parent call's. Absent on every direct call. Off by default
(TREG_OVERFLOW_MODE). See architecture/proxy-model.md § Overflow.
X-Treg-Smoothed - the call waited for treg's own rate limit
On platform calls, including owned free polling: wait=<ms> when the call was spaced behind other callers on the
same platform key, retry=1 when a burst-429 with a short retry-after was re-sent once (body-less GET/HEAD only). Informational; the status and body are the provider's. See
architecture/proxy-model.md § Burst smoothing.
X-Treg-Error - whose refusal is this?
bootstrap_handlers._mark_treg_own_errors tags treg's own
refusals on /call/ and /catalog/call/ paths with X-Treg-Error: 1, then answers exactly as
before - the status and body
are untouched, and a client that ignores the header sees what it always saw. Without it a caller cannot
tell treg's 404 ("no tool registered for that host") from the vendor's own 404: both are a status and
some JSON. The local proxy needs that distinction to explain a failure
without ever rewriting a real vendor response. application.call failures carry a mechanism kind
and separately mapped blame; the compatibility header remains the literal 1.
Resolution refusals are actionable: a named miss that resembles one of the caller's usable own tools
returns a structured detail with hint and did_you_mean, including after a real catalog endpoint
falls through and finds no usable marketplace credential. A genuine URL-passthrough tie returns 409
with the names of the colliding usable tools and the explicit /call/<name>/<path> escape hatch.
Auth
require_member() reads the X-Treg-Token header, hashes it
(crypto.hash_token), looks up the
Membership by token_hash, and returns a Caller (membership, user, org + org_id/email/role);
401 on missing/invalid. Every scoped endpoint depends on it except POST /users + POST /invites/accept (open, self-registering) and GET /oauth/callback (browser-hit, protected by state).
Each successful identity dependency commits its read-only transaction before the handler runs, so an
application use case can open its own session without waiting behind the request's pool slot. The
dependency-cached session remains usable because every session maker sets expire_on_commit=False.
require_superadmin is the one gate on a different pool - it takes get_admin_session, and so must
every /admin/* handler under it (FastAPI caches dependencies by identity; see
super-admin).
Authz = org scoping + a role gate: _can_manage lets admin/owner manage any org resource, a member only
what they created; _require_admin_of gates the org-admin endpoints. See
multi-tenancy.
Cookie mechanics stay at the HTTP boundary because they interpret request cookies; the shared
_same_origin CSRF check is available to every cookie-authenticated mutation.
Email OTP and invite sign-in use cases own their sessions and every commit, including OTP rate, attempt, consumption, user provisioning, and invite-token consumption. They return framework-neutral results or semantic errors; the HTTP boundary maps them to responses and sets browser cookies. Every identity door reuses the same first-proof provisioning command.
The CLI pairing state machine prunes before creating a pending entry, pops a completed result exactly once, and preserves session lookup, attempt decrement, pending pop, and result publication order. Its team-selection sessions are read-only. The three short-lived dictionaries are process-local and shared by every pairing door.
Social login builds each authorization request, exchanges the provider code, validates the proven
email, provisions the user, and commits before publishing a CLI result. Provider callback state is
validated before resolving the shared HTTP client. /auth/logout remains an HTTP-only cookie action.
Endpoints
-
Users / orgs:
register_user(POST /users, open, legacy - used by the test fixture) creates the user + an org + owner membership and returns a token once; the dashboard/CLI login doors do NOT go through it (they create the user only, no auto org). Both this door andcreate_orgread the first-partytreg_adcookie (_ad_attribution_from) and, when conversion tracking is enabled, stampOrg.ad_gclid/ad_click_id_type/ad_landing/ad_click_aton the new org when present - see ads-conversions.create_org(POST /orgs,require_identityso a zero-org user can make their first team) +list_orgs(GET /orgs, each org carries atool_count- one grouped query - so the dashboard can land on the org with tools; itsactiveflag followsrequire_member's precedence - per-org membership token, elseX-Treg-Org, else a team-pinned identity token's ownorgclaim - sotreg login --token <pinned key>lands on the baked-in team instead of the caller's first membership); invites viacreate_invite(POST /orgs/{id}/invites, admin+) → one-time code (emailed viaemail.send_invite, best-effort, along with a separate inbox-onlyemail_tokensign-in link - the token is never in the JSON response; see the invite sign-in link below),accept_invite(POST /invites/accept, open) → registers/joins + mints a token. Code-free invites: an invite is addressed to an email, somy_invites(GET /invites/mine,require_identity) lists every pending invite for the caller's proven email andaccept_my_invite(POST /invites/{id}/accept,require_identity) accepts one with no code (403 if the invite's email ≠ yours).list_members/remove_member(GET/DELETE /orgs/{id}/members[/{user}], admin+);set_member_role(PATCH …/members/{user}, owner-only),leave_org(POST /orgs/{id}/leave),delete_org(DELETE /orgs/{id}?confirm=<slug>, owner-only AND the slug is REQUIRED - see hardening - viacascade_delete_org(indomain/governance/teams.py), which now also sweeps each org'sRunRecordrows);list_invites/revoke_invite(GET/DELETE /orgs/{id}/invites[/{id}], admin+). Full behavior: multi-tenancy. -
Agents (machine identities):
create_agent(POST /orgs/{id}/agents, admin+) mints/rotates a member token for a machine caller - its owndaily_call_cap,tool_accessand audit trail, with no new table;list_agents(GET, never returns a token) andrevoke_agent(DELETE …/{user_id}, which also sweeps the deny rules and idempotent replay cache owned by that agent before deleting its membership; the schema cascades that cache as a backstop). An agent token is refused byrequire_identityand can never be an owner. Re-POSTing the same name ROTATES, and a field the caller omits is left as it is - a rotate changes the token, never the limits.AgentInalso takesproject_access(slugs or ids), so an agent can be project-scoped at mint time;created_bystamps the minting admin.GET /orgs/{id}/agents/observed(admin+) lists the agents detected in member traffic - one row per (member, runtime) fromCallRecord.client/RunRecord.clientover 30 days, excluding plain-terminal (''/cli) and machine-identity traffic; attribution, never a gate. See multi-tenancy. -
Projects (a sub-scope inside the org):
create_project/list_projects/delete_project(/orgs/{id}/projects, admin+ to mutate) - deleting frees its tools to org-wide and removes the id from every member'sproject_access, leaving an emptied list as[](NULL would mean every project).POST /toolsandPATCH /tools/{id}takeproject(slug or id; null = org-wide), andset_member_access/create_invitetakeproject_access. See multi-tenancy. -
Deny rules (org policy):
create_deny_rule(POST /orgs/{id}/deny, admin+) blocks a host / path_prefix / method for the whole org, one member (user_id), and/or one project (project_id- fires only on calls through that project's tools); an all-empty rule is refused (it would freeze the org) and a full URL is reduced to its host. Pluslist_deny_rules(GET) anddelete_deny_rule(DELETE …/{rule_id}, 404 across orgs). Enforced on the proxy and both run tiers - see proxy-model.GET /orgs/{id}/policy/cli-deny(admin+, read-only) reports each CLI tool's effective argv deny patterns with their source (skilltreg.jsonvs catalog) so the Policy screen shows every deny layer in one place. -
Usage metering + caps (usage-metering v1,
docs/USAGE-METERING-PLAN.md):org_usage(GET /orgs/{id}/usage?days=, admin+) rolls upCallRecord+RunRecordsince the window start into by-user (with acall/local_run/server_runsplit), by-tool, by-day, and totals - pureGROUP BY, no request/response bodies (they aren't stored).set_member_cap(PATCH /orgs/{id}/members/{user}/cap, admin+) setsMembership.daily_call_cap(-1= unlimited, rejects< -1).my_usage(GET /usage/me, any member) returns the caller's ownused_today+cap.list_membersalso returns each member'sdaily_call_cap+used_today. Enforcement:governance/usage.enforce_daily_capruns in/call/'s authorization gate and, throughrouters/call._enforce_daily_cap, at the top ofrun_tool_serverandgrant_local_run(so no path dodges the cap). It takes the slot with ONE conditional UPDATE ofMembership.calls_today/calls_today_day(take_daily_slot, revision 0024): the WHERE is the check and the SET is the count, so the cap is exact under concurrency, a refused event is not counted, and the first event of a new UTC day starts from 1.-1(default) skips it entirely (zero overhead); the sandbox is exempt; a database error fails open (a courtesy limit, not a money gate).used_todayin the roster and/usage/mestill comes from the journal (count_today= today'sCallRecord+RunRecord), andset_member_capcopies that journal count onto the counter when a member goes from unlimited to capped, so a cap set mid-day does not start from zero. Until 2026-09-06 the gate itself ran that journal count - 2.8 s per call for a member with 110k rows that day. -
Super-admin (cross-tenant,
require_superadmin):/admin/stats|orgs|orgs/{id}|users|tools|calls| errors|health(reads -errorsis failed calls across every credential tier with captured, admin-only request/response evidence, supports atierfilter, and runs the 14-day retention pass; see super-admin)/admin/users/{id}/superadmin|suspend,DELETE /admin/users/{id},/admin/orgs/{id}/suspend,DELETE /admin/orgs/{id}(Phase-2). See super-admin.
-
Secrets:
create_secret/list_secrets/update_secret(re-encrypts on value change) /delete_secret(409 if a tool binding references it). Values never returned (_secret_view). -
Tools:
create_tool(bindings viabody.bindings, or_flat_binding(body)sugar, or[]; validated by_validate_bindings;hostderived by_host_of; optionalexamples; optionalclilocal-run profile validated by_validate_cli_profile),list_tools,update_tool(re-derives host on base_url change;cliset/clear here - this is how the local-run toggle flipscli.enabled),delete_tool. View via_tool_view(now includescli).delete_secretrefuses a secret referenced by a tool binding or acli.injectentry.- Owner-only binding.
_validate_bindings(HTTP bindings) and_validate_cli_secrets(local-runcli.injectentries) require the caller to own every secret they bind/inject, via_require_secret_ownership; only an admin/owner may wire up a shared-key tool with a teammate's secret. This stops a member laundering another member's key into a tool they control and then extracting it (through the proxy'sbase_urlor a/grant).update_toolgrandfathers the secrets already on the tool (it passes their ids as agrandfatherset) - only a newly-added binding/inject is ownership-checked, so re-saving a tool an admin wired with a shared key doesn't lock its owner out on edit. The skill/folder importer runs the same checks. - No SSRF at registration.
_require_public_base_url(reusinghealth.safe_webhook_url) rejects abase_urlpointing at loopback / private / link-local / cloud-metadata hosts - including numeric IP encodings (decimal/hex/octal/short forms) - oncreate_tool,update_tool, and each imported skill tool, so a member can't turntreg callinto a request to an internal address. The proxy also re-resolves the host at call time (health.host_is_public) to defeat DNS rebinding - see proxy-model.
- Owner-only binding.
-
Local runs (
treg run --local, see local-run):grant_local_run(POST /tools/{name}/grant) is the one audited, owner-opt-in exception to "values are never returned" - member+ only (a viewer may call but not extract a value). It matches the catalog profile (providers.match_skill), server-side deny-checks the argv, renders the credential (oauth → leaf only), and writes a synchronousGRANT/DENYaudit row (argv redacted of key-shaped tokens by_redact_argv). Runner-proof gate: returning a secret the caller does not own (a shared-key tool they may run but not read) requires the headerX-Treg-Run-Proofto equalTREG_RUN_PROOF- the value held only by the isolatedtreg-runrunner, which the member's own uid can't read. A caller who owns the injected secret (or is an admin) skips the gate. On refusal aDENYaudit row is written and the grant is 403'd, so a direct member call can never read a teammate's key value. The grant response also carriesredact_output(true exactly when the caller doesn't own the key, i.e. the runner-proof case) - the client then scrubs the injected value from the CLI's output (see local-run).report_local_run(POST /tools/{name}/run-report) takes the client's verdict enum (never raw output);credential_invalidmarks the injected secret(s) invalid via the health fields, skippingparamkind. -
Server runs (
treg run --server):POST /runaccepts{tool, args, timeout_s}and executes the org-scoped tool'sTool.cliprofile throughrunner.run_tool. Secrets enter a scrubbed child environment, argv is an array, and the process gets its own temporary home. A profile is server-runnable without local-run opt-in, but tool/project ACL, deny rules, member usage caps, global/per-user concurrency limits and the executable allowlist still apply.runner.resolve_exec_binis shared by validation and execution; allowed commands come from the catalog orTREG_RUN_ALLOWED_BINS. The sandbox cannot run commands.The response is
{stdout, stderr, exit_code, timed_out}; a nonzero child exit is a normal 200, startup/configuration errors are 4xx, and exhausted run slots return 429.RunRecord.bundle_namestores the tool name for compatibility.GET /runsmerges server records with local grants, taggedwhere=server|localands|l-prefixed ids. Run settings are managed throughTool.cli, not bundle runtime metadata. See local-run.- Resource-limit sandbox (
runner.py, the DoS half of the server-run sandbox): every server-run child is spawned with POSIX rlimits via apreexec_fn(_spawn_preexec/_rlimit_preexec) - a CPU-seconds cap, a max-file-size cap, and core dumps disabled (a core would spill the injected secret to disk). Env-gated (TREG_RUN_RLIMITSon by default;TREG_RUN_CPU_SECONDS,TREG_RUN_FSIZE_MB), a no-op whereresourceis unavailable. Deliberately no address-space or process-count cap - a virtual-memory cap crashes Go CLIs (gh/stripe/doctl) andRLIMIT_NPROCis per-uid, shared with the server. Full filesystem/network isolation needs a container deploy and is a planned follow-up.
- Resource-limit sandbox (
-
Meta:
meta(GET /meta, open) →{public_url, github, google, app_version, treg_version, posthog_key/posthog_host, intercom_app_id}for the dashboard. The last three are the opt-in third-party keys (analytics, support chat): empty on a deployment that didn't set them, so self-hosted pages load neither PostHog nor the Intercom Messenger.intercom_app_idis paired server-side withintercom_secret, which never leaves the server:_intercom_user_hash(HMAC-SHA256 of the email, keyed by the secret) is added toGET /auth/measintercom_user_hash- Intercom identity verification, so a third party who knows an email can't impersonate that user in chat. -
Provider catalog:
providers_catalog(GET /providers.json, open) →{version, providers}- the catalogtreg uploaduses to detect env keys → tools; served so the CLI can refresh centrally. See env-import. -
Catalog discovery and inspection: open routes use
catalog_store; full matching, pricing, ranking and grouping rules live in catalog.Route Contract GET /catalog/platformsNon-empty platforms with capability/endpoint counts and providers, ordered by endpoint count GET /catalog/platforms/{slug}Capabilities, extended endpoints, dashboard domain rows and provider metadata; unknown slug is 404 GET /catalog/search?q=&limit=Ranked endpoint views, count/total and hints; default 25, maximum 100 GET /catalog/endpoints/{id}Endpoint, provider, capability siblings, call template, inline example and next-step hints GET /catalog/examples/{id}Captured JSON, resolved through the catalog before constructing a file path POST /tool-requestsOpen, rate-limited demand report with capped fields and optional caller attribution Domain grouping is server-side (
domain_rows), so CLI and dashboard share ordering and comparison semantics.call_templateuses the verified test request, then documented examples.Search uses token matching, aliases and BM25 weighting, followed by reliability/core/price reranking within the score band. Routed parents accompany matching children; capped groups carry
children_hidden. Empty results includenearmatches and unmet tokens, plus a tool-request hint. Reliability is optional cached evidence: fresh for five minutes, stale while refreshing up to thirty minutes; cold or failed reads still answer 200 with no request DB checkout.Unknown endpoint ids return
{error, hint, did_you_mean[]}, using provider-local segment matching. Retired/broken entries remain inspectable withstatus_noteandsuperseded_by, disappear from discovery, and return 410 on call/access checks.platform_blockedentries remain discoverable for BYOK but cannot use treg's key.Zero-result searches emit identity-free
SearchMissrows through the lossy audit queue. Sources distinguish HTTP, team MCP and the Claude connector. Tool requests may attach a token or same-origin session identity; cross-origin cookie submissions remain anonymous.scripts/usage_report.pyreports this unserved demand. -
Identity doors: GitHub, Google and email OTP share first-proof user provisioning. They create a user without an automatic org; new users name their first team through onboarding or the CLI login picker. Suspended users are refused at every door, and so is any address on a domain listed in
TREG_BLOCKED_EMAIL_DOMAINS(the whole blocklist — nothing is blocked when it is unset; subdomains included): the OTP start and verify, both social callbacks, the emailed invite link, plusPOST /users,POST /orgsandPOST /invites/accept, all with the same 403this address cannot be used to sign inthe machine-identity guard uses. See multi-tenancy.- GitHub/Google:
GET /auth/{provider}[/callback], optional?cli=<id>; callbacks validate state before resolving the shared HTTP client, require a proven email, and set the session before returning CLI users to the team picker. - Email:
POST /auth/email/startand/verify. Six-digit codes, attempt counts and per-email/ per-IP start limits live in DB-backedEphemeralstate.expose_dev_codepermits response/ log disclosure only on guarded local SQLite; other deployments email the code. Verification issues an identity token and session cookie. - Invite sign-in: the admin-visible invite code is join-only. An independent inbox-only
email_tokenauthenticates through/auth/invite-signin, consumed once. Invalid/expired links return/?invite_expired=1. See auth-secrets.
CLI pairing starts at
POST /auth/cli/start, which mints a login id and four-character code. The code appears in the terminal, never the URL.GET /login?cli=<id>validates the id and offers configured identity doors, then a team picker (including first-team creation).GET /auth/cli/orgslists the session user's teams;POST /auth/cli/approverequires the session, same-origin CSRF check, matching code and membership in the chosen team. Wrong attempts are bounded.GET /auth/cli/pollconsumes the completed identity token and selected org exactly once. Pairing dictionaries remain process-local.GET /auth/meaccepts token or session authentication and returns identity/onboarding state.GET /auth/cli-tokenusesapplication.auth.issue_cli_tokenfor optional team pinning. Typed session credentials require expiry andaud=session; copied identity keys useaud=identitywithout expiry. The two audiences are not interchangeable; MCP's internal exchange token is the deliberate 120-second identity exception.Legacy untyped keys with an org claim or without expiry are identity-only. An org-less untyped token with expiry remains compatible only until expiry.
token_versionrevokes permanent keys; missingtvmeans zero.POST /auth/revoke-tokensbumps the version and returns a replacement cookie/token for the caller./auth/logoutis a same-origin cookie action. Onboarding routes arePOST /onboard/demo|skip|reset; see onboarding.The shared dependencies resolve a membership token, identity bearer plus
X-Treg-Org, or session cookie plusX-Treg-Org. Identity and session validation live indomain.identity.session; authorization belongs todomain.identity.access. - GitHub/Google:
-
Static (dashboard + tutorials):
dashboard(GET /,FileResponse+Cache-Control: no-cache),tutorial_js(GET /tutorial.js- sharedwindow.TREG_TUTORIAL+hl()),tutorial_page(GET /tutorial- standalone CLI tutorial). The dashboard tour is aStaticFiles(html=True)mount at/dashboard-tour/(servesweb/tour/-tour.js, the standaloneindex.html, and the WebPimg/). Vendored front-end libraries are an_ImmutableStaticmount at/vendor/(servesweb/vendor/- today just Vue, version-pinned in the filename, henceCache-Control: immutable): the dashboard must not depend on a CDN a visitor's network may not reach, see dashboard.favicon(GET /favicon.svg+/favicon.ico).llms_txt(GET /llms.txt) servesweb/llms.txtastext/plainwith{BASE}templated frompublic_url- the llms.txt agent-onboarding file (call protocol + discovery + auth + CLI + skills + doc links). See dashboard.install_sh(GET /install.sh,{BASE}-templated) serves the CLI installer (web/install.sh).sitetrack_js(GET /sitetrack.js, no-cache) servesweb/sitetrack.jswith{POSTHOG_KEY}/{POSTHOG_HOST}templated from settings: the always-on first-partytreg_utmfirst-touch cookie (utm_* + referring host, read by_utm_attribution_from/_stamp_utmin BOTH signup doors,/usersand/orgs) plus the PostHog bootstrap with pageviews ON. Loaded by every public page - landing, use-case pages, resources, tutorial, and the SPA - so analytics sees the visitor's first hop rather than the post-OAuth/applanding. Without a key the analytics half is inert (empty string).adtrack_js(GET /adtrack.js, no-cache) serves the first-party ad-click capture script loaded byindex.html's<script src="/adtrack.js">; it returns an empty script when conversion tracking is disabled, so unconfigured deployments do not collect advertising cookies. See ads-conversions.well_known_skills_index(GET /.well-known/skills/index.json) +well_known_skill_md(GET /.well-known/skills/treg/SKILL.md) advertise treg's own skill under the agentskills.io convention, making this host a skill source with no registry in between (Hermes reads it directly). The index'sdescriptioncomes from the skill's frontmatter at request time via_skill_frontmatter()- never a second copy - and the SKILL.md route is the same_serve_mdas/skill.md, so{BASE}templates to the serving host and a self-hosted registry advertises itself. See skill.md for the other three distribution doors.terms_page(GET /terms) +privacy_page(GET /privacy) serve the hosted registry's legal pages (_legal_page, no-cache) withlegal_css(GET /legal.css) as the shared skin -/privacyis also the URL given to OAuth providers at app-verification time, so don't rename it.resources_page(GET /resources) is the hub for the outcome pages and the only thing linking to them: the landing footer and each page's own footer carry oneresourceslink rather than five that grow with the cluster, and without the hub every/use-cases/*page is an orphan no crawler reaches.resources.htmlis generated alongside them, so a new page appears on the hub automatically.use_case_page(GET /use-cases/{slug}) serves the per-vertical outcome landing pages - the destinations for search ads and the organic/use-cases/cluster - off the_USE_CASESslug map, withusecase_css(GET /usecase.css) as their shared skin, the same split as the legal pages. Two deliberate differences from/: an unknown slug is a 404 rather than a fall-through to the SPA (a typo in a live ad should be visible, not silently swallowed), and a signed-in visitor is not redirected to/app- bouncing a returning user off the page an ad paid to reach would make the campaign data unreadable. The HTML inweb/usecase-*.htmlis generated frommarketing/landing/*.mdby that directory'sbuild.py+build_html.py; never hand-edit it, and note the generator refuses to emit anything past the ad-kit heading so bid and negative keywords cannot reach a public page. Provider brand marks are mounted at/logos(StaticFilesoverweb/logos/, resolved by conventionlogos/<service>.svg).dashboard_marketplace(GET /app/marketplace/{service}) serves the plain SPA (a connect page is only meaningful to a signed-in member, so no OG meta)._serve_mdbacksquickstart_md(GET /quickstart.md) +tutorial_md(GET /tutorial.md) -{BASE}-templated markdown served as inlinetext/plain(so "open in new tab" shows it, not a download); the docs pages' Copy markdown dropdowns (copy / open-in-tab) fetch these.vendor_listing_md(GET /vendor-listing, alias/vendor-listing.md) serves the same way: the instructions a VENDOR's own coding agent follows to raise a listing PR on the repo (the dashboard's "List as vendor" modal hands vendors a prompt naming this URL; the repo-side counterpart isdocs/VENDORS.md). Browser-facing auth pages (GitHub callback, OAuth-connect result) render via_auth_page(brand card). -
Landing sandbox + hosted skills:
demo_sandbox_mint(POST /demo/sandbox, open, per-IP rate-limited) mints an anonymous throwaway team (its response now carrieslive= whether the seeded stripe tool is a real wire);demo_sandbox_skill(GET /demo/sandbox/skill) exports what the visitor built.skill_samples(GET /skills/samples, open) +skill_install(GET /skills/{name}/install.sh?token=) host sample skills.call_toolshort-circuits sandbox orgs tosandbox.synthesize(real injection, no network). Caps viadomain.governance.sandbox.enforce_sandbox_cap. Full behavior: landing-sandbox.- The one live wire (real Stripe demo). When
demo_stripe_keyis set, a sandbox call to the exact seededstripetool (fingerprint-matched bydemo_sandbox.is_live_tool, GET/POST only) is relayed for real to Stripe's test API via_relay_live_demo- a deliberately narrower relay: the auth header is built from the env key (never from a sandbox secret, which doesn't hold it), the body is form-encoded, andmetadata[visitor]is overridden server-side. Metered per client IP (_enforce_public_demo_ip_cap) since the wire is one shared credential.demo_sandbox_live(GET /demo/sandbox/live) reportslive+ the visitor's feed name for an existing sandbox._require_not_live_demo_tool/_require_not_live_demo_secretfreeze the seededstripetool and itsSTRIPE_KEYagainst edit/delete so a visitor can't break their own live pane. The public payments feed:stripe_webhook(POST /stripe/webhook, 404 whendemo_stripe_webhook_secretunset, verifies the signature viapubfeed.verify_signature, pushes acharge.succeededintopubfeed.push_charge) andlanding_stripe_feed(GET /landing/stripe-feed, unauthenticated SSE viapubfeed.stream, server-chosen fields only).
- The one live wire (real Stripe demo). When
-
Public demo token (publishable, call-only credential):
create_public_token(POST /orgs/{id}/public-token, owner-only) flips the org topublic_demoand mints a viewer-role token bound to a dedicated can't-log-in identity (pub-<slug>@public-demo.treg.local) - safe to print on a web page. Re-POSTing rotates (instant revocation of the old one);delete_public_token(DELETE …) revokes and lifts the lockdown. Lockdown is centralized in the auth deps: whenorg.public_demoand the role is below admin,require_memberallows only/call/*+ GET/HEAD/OPTIONS (every mutation is frozen no matter what routes are added later), andrequire_identityrefuses the token entirely (it must never act as a user - mint identity tokens, create orgs, accept invites). Its/calltraffic is metered per client IP (_enforce_public_demo_ip_cap,PUBLIC_DEMO_HIT_NS, ~10 calls/min/IP) since one token stands in for thousands of strangers. The limiter and its constants share thedomain.governance.publicdemoowner; the API commits its ratestore write before translating a semantic exhaustion result to 429. -
Skills / bundles:
register_skill(POST /skills) composes aBundle+ its secrets + tools atomically, resolving each binding'ssecretlocal-name to the created secret id; the shared core is_register_skill_bundle(also used by the folder importer).list_bundles,get_bundle,delete_bundle(cascades; it 409s if a bundle secret is referenced by a tool outside the bundle - now guarding both an outside HTTP binding and an outsidecli.injectentry, matchingdelete_secret, so a local-run tool can't be left with a dangling secret_id), andupdate_bundle(PATCH /bundles/{id}, creator/admin only) edits the bundle recipe; execution settings belong toTool.cli._bundle_view. Folder importer (dashboard mirror oftreg upload skills):analyze_skill_folder(POST /skills/analyze) writes uploaded files to a temp dir and runs the CLI's ownskills.scan_skills/_classifyto classify each (recipe-only / contract / generated) without registering;import_skill_folder(POST /skills/import) scans +build_payloads + registers the selected ones (_materialize_skill_filessandboxes the upload).list_orgsnow carriestool_count. -
Activity and call inspection:
GET /calls?days=&before_id=&limit=returns org-scoped audit rows, with limit 1-500. SQL excludeskind=async_pollbefore pagination. Failure evidence is neither selected nor returned. Counts and fees in this feed are analytics, not an invoice.GET /calls/{call_ref}returns the audit record, ledger entries and async-task view. Hidden poll records remain inspectable here; admin diagnostics retain failure evidence.- Both routes derive async status, final charge and result from
async_task_app.views_for._async_chargedreturns null while pending and the settled amount afterward, including zero for a refund. The original submission is the Activity row for the task. An authorized terminal poll updates that task immediately when settlement evidence is complete; cron remains the fallback. Polling itself is free and does not add Activity entries. GET /calls/{id}/resultjoins the archive hashes to the request shape and stored response.has_resultidentifies archived rows; unavailable content returnsstored: falseand a reason (own-key/tool, failure, recording off, expiry or hash-only storage). Failure-evidence columns remain excluded. See archive.
-
OAuth connect + the provider marketplace:
oauth_start(POST /oauth/start) creates aPendingOAuthand returnsconsent_url+state+redirect_uri+ registry-ownedconnect_guidance;oauth_callback(GET /oauth/callback, open) exchanges the code and creates/updates the oauth secret;oauth_statuspolls. Two modes (OAuthStartIn): BYO (supplyclient_id/client_secret/auth_uri/token_uri/scopes) or REGISTRY (supplyprovider+ optionalcapability) where treg fills everything from its own approved OAuth app - the marketplace.oauth_providers_list(GET /oauth/providers) lists the providers treg holds an app for, each flaggedconfigured(false when this deployment hasn't set that provider's client credentials) andmetered(true when the provider's upstream bills treg's app per use AND this deployment charges for it - thenbilled_ratescarries the default prices, so the UI can show them before consent; see auth-secrets onplatform_billed). In registry modeoauth_startreads the provider fromoauth_providers.get, resolves scopes viascopes_for(capability), and stashes every per-provider auth quirk on thePendingOAuth(PKCEcode_verifier,auth_params,token_endpoint_auth_method,client_id_param,scope_separator,long_lived_exchange) so the callback exchanges the code exactly the way the consent URL was built.connection_id(BYO or registry) targets ONE existing connection to reconnect/widen it - scoped to the caller's org and matched to the provider, recorded asreplaces_secret_id- instead of adding another account. Callback does the real work: it either replaces the named connection (replaces_secret_id) or adds a new one named by_free_connection_name(the first account for a provider keeps the bare service name -google-search-console- later ones get-2/-3), normalizesgranted_scopesto space-joined, setsexpires_at(oauth.expiry_of), then_autoprovision_provider_toolbinds the fresh credential to the provider's API as a callable tool (idempotent by (org, name); a token-kind provider gets anenvheader binding, an oauth one gets aBearer {access_token}binding; a provider needing treg's own second credential - Google Ads' developer token - also gets a platform binding, see proxy-model) and_record_connected_identitybest-effort asks the provider who connected._upsert_provider_extra_toolsis shared by this connect path and the startup backfill, so companions use the same(org_id, name)upsert and binding shape in both cases. See auth-secrets. The tool'sexamplescome from_provider_tool_examples: the registry's hand-written ones first, then the endpoint catalog's verified core endpoints for that provider (catalog_store.tool_examples→{method, path, note}where the note carries the summary, required params and capability), de-duplicated by (method, path) and capped atCATALOG_STAMP_CAP(12). Unverified endpoints are never stamped - an example is a promise the call works, and theverifieddate is the only evidence of that. Search Console's hand-written example additionally documents that direct own-tool path substitution takes asite_urlencoded exactly once; catalog calls accept either raw values or existing%HHescapes and prevent the latter from being encoded a second time. -
Connections (the marketplace's dashboard surface):
list_connections(GET /connections) returns every OAuth/registry credential in the org - metadata only, no token material - with health, expiry, and (for a known provider)capabilities/missing_capabilities+ extra-credential notes. The filter iskind=="oauth" OR provider!=""so a bring-your-own-token provider (a plainenvstring, e.g. Slack) still lists.connection_resources(GET /connections/{id}/resources) live-fetches what a connection can act on (GSC sites, GA properties, Ads accounts), enriching id-only rows with the upstream's human name concurrently (_enrich_resource_labels) and recording the successful upstream call as proof of health. For providers declaringdiscover_extra_path(the Meta pair) it also walks the Business graph and merges Business-owned rows after the primary ones - deduped by id, id-less rows dropped, and a failing walk swallowed rather than surfaced as a 502;set_connection_resource(POST …/resource) pins the chosenresource_refresource_name.connect_with_token(POST /connections/token) connects any pasted-secret provider - a bring-your-own bot token (Slack) or an API key (Apollo, Hunter, TikHub, Semrush, …) - verifying the credential against the provider's probe before storing (a header- OR query-param probe, an off-hostprobe_url, tolerating a CSV/text body or a 200-with-false-token_verify_fieldreply), then auto-provisioning its tool with a header or query binding. Two probe subtleties the code handles because upstreams don't cooperate: aprobe_pathmay bake in a required?query(PDL, Akta, JustOneAPI, SpyFu), and httpx replaces a URL's own query whenparams=is passed - so the path's query is parsed out and merged into params (the credential wins a key collision) rather than silently dropped; and the body is parsed as JSON regardless of content-type, because ScrapeCreators serves real JSON undertext/plainand gating onapplication/jsonlefttoken_verify_fieldunread (a genuinely non-JSON balance still throws → empty payload → theERROR-text branch, unchanged). A base64-encode provider (token_encode, DataForSEO/Moz) accepts EITHER a rawlogin:passwordOR the dashboard's ready-made base64 blob - a blob is detected (strict-decodes to printable text with a:) and kept as-is instead of being double-encoded. See auth-secrets.set_extra_credential(POST /connections/{id}/extra-credential) stores the second credential a provider needs when treg does NOT hold it centrally (Tomba'sX-Tomba-Secret) and finishes the tool with BOTH bindings - the primary half built by_provider_bindings, so it follows the provider's own auth shape (pasted key or OAuth) rather than assuming a bearer token.revoke_connection(DELETE /connections/{id}) deletes the credential and cleans up: it removes the tool treg auto-provisioned for the provider and drops the dead binding from any user-built tool, leaving that tool's other bindings intact. Allrequire_can_register(member+). Helpers:_owned_connection,_dig(dotted-path walk).
-
Health:
run_health(POST /health/run) →health.run_all;get_health(GET /health) now returnshealth._view(s)plus aneeds_reconnectflag (health.needs_reconnect) so a credential treg can't renew announces itself before it dies. -
The proxy:
routers.call.call_tool(* /call/{rest:path}) captures a framework-neutralCallInput→application.call.service.execute_call→application.call.resolve→ (on a dotted 404, catalog lookup + retirement gate + credential ladder) →application.call.authorize(tool/project ACL, deny, per-user cap, then public-demo rate cap) → load secrets (+ensure_fresh) →db.commit()- the DB phase ends here; a call in flight holds no pooled connection →relay()→audit.record_call. A pool that has no slot within 5 s answers503 {"treg_saturated": true}+Retry-After: 2(_pool_saturated, the handler forsqlalchemy.exc.TimeoutError) rather than a 30 s wait and an anonymous 500. The adapter also callsanalytics.capture_fault(component="db_pool"): saturation is a handled, typed response for the caller but remains an infrastructure fault for PostHog alerting. After identity resolution it also emits onetool_calledevent withoutcome=gateway_failedandfailure_kind=db_pool. During identity resolution it emitscall_intake_failedinstead, without team or target attribution and with acallorcatalog_callsurface label. The same compensation applies to/catalog/call/, including release of an acquired idempotency label so a retry does not get a false 409. An unexpected exception raised whilecall_toolawaitsexecute_callemitstool_calledwith the same outcome andfailure_kind=unexpected_exception. A platform binding carries nosecret_id(its value comes from settings at relay time), so secret-loading now skipssecret_id is None. Detail in proxy-model.call_catalog_endpoint(* /catalog/call/{rest:path}, hidden from public OpenAPI) is the narrower internal entrance used by catalog-only MCP surfaces: it requires an exact catalog id and skips_resolve_call, so an exact same-named team tool cannot shadow the catalog endpoint. From the credential ladder onward it delegates tocall_tool, retaining provider/user credentials, ACLs, deny rules, caps, metering, audit, idempotency and faithful relay.A resolved catalog endpoint with an async descriptor adds one treg-owned response header:
X-Treg-Async, containing compact JSON for the already-known effective descriptor. The router attaches it after catalog resolution and before Starlette begins streaming; no provider response bytes are read, parsed, or rewritten.
Schemas
Pydantic input models: UserIn, OrgIn / InviteIn / AcceptIn, EmailStartIn / EmailVerifyIn,
SecretIn / SecretUpdate,
ToolIn (flat single-binding sugar + optional bindings + health_check + cli) / ToolUpdate (incl.
cli), SkillIn (SkillSecretIn + SkillToolIn, whose cli inject entries reference secrets by
local_name), GrantIn (argv) / RunReportIn (audit_id + exit_code + verdict), OAuthStartIn (now
BYO-or-registry: provider / capability / connection_id plus the BYO client_id/secret/URIs/
scopes), and the connection models ResourceRefIn, TokenConnectIn, ExtraCredentialIn. Output
helpers _secret_view / _tool_view / _bundle_view never leak secret values - _tool_view returns
health_check + examples + cli (it once omitted health_check, so a tool's probe was stored but never
surfaced by GET /tools / /bundles/{id}).
Cross-cutting hardening
- Legacy-host redirect:
_LegacyHostRedirectMiddleware301s GET/HEAD marketing pages (_REDIRECT_PATHS) from the legacy hosts (config.LEGACY_PUBLIC_HOSTS) to the canonicalpublic_urlhost (treg.to)- but only for anonymous visitors: a
treg_sessioncookie is host-scoped, so a signed-in browser (e.g. the invite flow landing on/?invite_org=…) is served in place. The auth entries/auth/github,/auth/googleand GET/oauth/authorize(_REDIRECT_ALWAYS) redirect unconditionally and with a 302 (one-shot OAuth params must not be cached as permanent) - each parks a host-scoped cookie (CSRF state /treg_oauth_return) that the flow's continuation onpublic_urlmust be able to read. Everything else is served in place on BOTH hosts, forever: installed CLIs/skills hold tokens pointed at the legacy host, HTTP clients stripAuthorizationon a cross-host redirect,/vendor-listingis fetched by agents, andcurl {BASE}/install.sh | shruns without-L. The legacy names also stay in MCP's transport allow-lists (mcp._allowed_hosts/_allowed_origins) and in the OAuth token-audience set (mcp_oauth.mcp_resource_audiences()- pre-move grants keep their old audience for life, and refresh reissues it). Never remove the legacy domain from Render.
- but only for anonymous visitors: a
- Security headers: pure-ASGI
_SecurityHeadersMiddlewareaddsX-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer, and HSTS to every response (setdefault, so the/callproxy's stricter CSP/nosniff wins). - No 500 on bad ids/URLs: an
OverflowErrorhandler turns an oversized all-digit id into a404(SQLite's 64-bit INTEGER);_host_of/_resolve_callguardurlsplitValueError(malformedbase_url/passthrough) into a422/400. - CSRF/redirect:
auth_logoutrejects a cross-Originrequest (forced-logout CSRF);oauth_startpinsredirect_urito treg's own/oauth/callback(consent-phishing guard). - Destructive routes name their target:
DELETE /orgs/{id}requires?confirm=<slug>and 422s without it. It is irreversible and sits one path segment above every other org route, so any client that normalizes..turnsDELETE /orgs/{id}/<anything>/..into it -treg org unpin ..really did delete a team in testing, because httpx rewrites the path before the request is sent. Server- side validation cannot see that, so the defence is the confirmation, plus keeping user-supplied values out of path segments (the pin capability moved to a query parameter for the same reason). The CLI and the dashboard already made a human type the slug; now the API insists too. - A machine identity can learn its own org:
GET /auth/mereturnsorg_id/org/rolewhen the caller authenticated with a token.require_identityrefuses machine identities on/orgsby design (create_orghangs off it, so an agent could otherwise mint an org it owns) - but its token IS one membership, and without this every/orgs/{id}/…command died with "no active org" for exactly the callers those commands serve. - Config default: returning the OTP in the response (
dev_code) is now gated by a dedicatedexpose_dev_code- true only on a local sqlite box, never on a real (Postgres) deploy - so a misconfiguredemail_dev_modecan't leak the code and enable an unauth takeover in prod.
Full endpoint list + the running server's OpenAPI: README.md and /docs. CLI-level usage: USAGE.md.
/docs is ours - a server-rendered reference built from app.openapi(), not Swagger. FastAPI's
console moved to /docs/api (ReDoc is off), and /openapi.json is unchanged. The /catalog,
/catalog/<slug> and /robots.txt + /sitemap.xml surfaces live alongside it; see
seo, which also explains why HEAD is widened onto every GET route after registration and
why that widening must be kept out of the schema.
OAuth + MCP routes
treg is an OAuth authorization server for its own MCP endpoint. Detail in
architecture/mcp-oauth.md; this is the surface.
GET /.well-known/oauth-protected-resource what guards /mcp/ (served at BOTH v1 lookup paths)
GET /.well-known/oauth-protected-resource/mcp/v2
distinct metadata for /mcp/v2/
GET /.well-known/oauth-authorization-server endpoints, S256, DCR + CIMD support
POST /oauth/register dynamic client registration (RFC 7591)
GET /oauth/authorize the consent screen (JSON with Accept: application/json)
POST /oauth/authorize the human's decision - approval is never a GET
POST /oauth/token authorization_code and refresh_token grants
POST /oauth/revoke RFC 7009; ends the whole refresh family, always 200
GET /oauth/grants live (non-retired, non-expired) grant families
POST /oauth/grants/{family}/team move family authority to another member team
POST /mcp/ the MCP transport itself
POST /mcp/v2/ catalog-only Claude directory transport
GET /connect-demo a page that pretends to be an MCP client
GET /connect-demo/callback its OAuth callback
GET /connectors/claude setup, scope, pricing, data flow and removal docs
The V2 metadata and transport routes are available only when
TREG_CLAUDE_CONNECTOR_ENABLED=true. The metadata route returns 404 when V2 is disabled. New V2
OAuth grants and catalog-only calls are also refused.
Cost and asynchronous-task headers
X-Treg-Cost-Micro reports the metered call's charge. On an async submission it reports the
reservation, repeated by idempotent replay; the CLI labels it "generation reservation".
Final charge and task status come from /calls or /calls/{ref}.
An owned free platform poll explicitly returns zero. Own-key/tool calls omit the header because
their upstream charge is outside treg's billing. X-Treg-Async carries the effective catalog
descriptor, without parsing or rewriting the provider's response.
Idempotency-Key on /call/
A caller-supplied label that makes a retry free. Sent on /call/, honoured only when present, so a
caller who omits it sees byte-identical behaviour to before the feature existed.
Idempotency-Key: <caller's label> → replay if we already answered this label
X-Treg-Idempotent-Replay: true → on the response, when it came from store
X-Treg-Cost-Micro: <original charge> → what the FIRST call cost, not a new charge
Refusals: 422 when a key is reused for a different request (a caller bug, and answering it would
hand them a response to a question they did not ask), 409 while the first call with that key is
still in flight.
Over MCP the same thing is the optional idempotency_key argument to the call tool, and a replayed
result carries replayed: true.
Reasoning, storage rules and the concurrency guard: architecture/money.md.
Caller tags, budgets and per-tag usage
For a builder embedding treg in their own product and billing their own users. Design and rationale: money.
Tag a call. Set the header from your backend - never from a model, which will eventually omit it:
X-Treg-Meta: customer=cust_8123, workspace=ws_9, feature=email-finder
Up to 5 pairs; keys [a-z0-9_]{1,32}, values ≤128 chars, whole header ≤512 bytes. Any violation is a
422 before anything is relayed (so a malformed bag costs nothing and does not burn an
Idempotency-Key). Values containing @ are refused: tags land in an append-only ledger.
Every relayed response on either call surface carries X-Treg-Call-Id, and so does every refusal
treg raises before the relay, plus the saturation 503 (_stamp_call_exit mints it for the exits that never reach
call_tool's own bookkeeping). The same id is written to the audit row, making it the join key for
your own records. An unexpected fault raised by execute_call is answered by Starlette as a bare 500.
The response has no id because Starlette owns it, but treg records the row and one matching
tool_called event before re-raising. Failures before execute_call, plus body-stream failures after
the handler returns its StreamingResponse, are not covered by that compensation path.
A direct metered /call/ honours X-Treg-Route-Max-Cost: <usd> as a hard per-call ceiling
(service._enforce_caller_max_cost, 2026-09-05): when the reserve with margin would exceed it the
call is refused 402 error: route_max_cost (max_cost_micro, estimated_cost_micro, no charge,
audited refused_by=balance like every 402) — the same header and body shape the routed path uses,
so one agent-side handler covers both. Unlike /do/ there is NO default on a direct call: a caller
who named the endpoint and page size is uncapped unless they send the header. A non-numeric value is
a 400. Asked for by a customer whose runner approved $0.23 and was billed $0.56 (2026-09-04).
Metered responses also carry X-Treg-Cost-Micro; a reserved call that fails before a provider answer
carries an explicit 0. That 0 is what the call ends up costing, but the balance can lag it:
if returning the hold itself fails, the money comes back when the hold is reaped rather than at once
(see money).
| Route | Does |
|---|---|
GET /calls?days=&before_id=&limit= | this team's calls, windowed and pageable. Analytics - not an invoice source |
GET /calls/{call_ref} | one call by its X-Treg-Call-Id, plus the ledger entries for it and its async_task view when it was a metered generation |
GET /calls/{id}/result | what one call asked and what came back - the archive's copy; metered platform 2xx only, stored: false + note otherwise |
GET /orgs/{id}/usage/by-tag?key=&days= | per-value spend for one tag key. Money from the ledger; admin+ |
GET/PUT/DELETE /orgs/{id}/budgets[/{dim}/{val}] | per-tag limits and blocking; admin+ |
GET/PATCH /orgs/{id}/settings | the team's daily spend cap, budget dimensions and primary dimension |
PUT /orgs/{id}/budgets/{dim}/{val} is an upsert that leaves unsent fields alone - a PUT that only
sets status does not wipe the caps. Body: daily_cap_micro, monthly_cap_micro, calls_per_day,
status (active|blocked), note.
Refusals a builder may show their own user. These deliberately carry nothing about your team - no balance, slug, platform cap or top-up link:
403 {error: "tag_blocked", dim, val, message}429 {error: "tag_spend_cap_reached", dim, val, spent_micro, cap_micro, period, estimated_cost_micro, message}429 {error: "tag_call_cap_reached", dim, val, used_today, calls_per_day, message}
Caps are advisory: concurrent calls can overshoot slightly. Your balance is the hard limit.
usage/by-tag reconciles. attributed_micro + unattributed_micro == total_micro, for every key.
Untagged traffic shows up as unattributed_micro rather than being dropped.
Isolation. treg org agent-new <name> --pin customer=cust_A mints a token pinned to one tag value;
the pin beats the header and a mismatch is a 403. Rule of thumb: tag for counting, token for control.
Referrals
GET /referrals · POST /referrals/code · GET /admin/referrals. Policy lives in
money; three API-shaped decisions live here.
require_identity, not require_member. A referral belongs to a PERSON, not to one of their
teams (User.referral_code). The reward does land in an org, but which org is our decision -
the oldest one they own - so nothing on these routes is scoped by X-Treg-Org.
GET /referrals returns the referred person's full email, which makes it the one route here
where a scoping mistake leaks another user's data rather than merely miscounting. It is scoped in
the query itself (referrer_user_id == caller.id), never filtered afterwards, and pinned by a test.
privacy.html discloses the visibility.
/?ref=CODE is the one query string the landing route serves. GET / deliberately treats any
query string as the SPA's and falls through to the dashboard - which for a referral link would send
a stranger who has never heard of treg to an empty app shell instead of the pitch. So a lone ref
counts as parameterless (anything alongside it still belongs to the SPA), and the code is parked in
treg_ref - httponly, lax, 30 days, and revalidated on read exactly like _take_oauth_return,
because a cookie is attacker-supplied and this value reaches a query.
Redemption happens at first team creation, in both org-creating doors (POST /orgs and the
legacy POST /users), immediately after _grant_signup_promo and with the same swallow-and-log
posture: a referral is a marketing nicety and a signup is not. It must never be why someone cannot
make a team. A failed grant recovers by rolling the session back, which expires every object it
tracks - so both doors read their response fields before granting, and the redemption revives an
expired user/org with db.refresh before touching them. The recovery path costs the team its
credit, never the signup response or the referral attribution.