Adding an OAuth provider
Two connect modes exist. BYO (POST /oauth/start with a caller-supplied client_id/secret) already works for any OAuth2 provider and needs no code. This skill is the registry path: treg owns the registered app, so the user picks a provider and supplies nothing. Only add a provider here if treg holds — or is willing to go get — the platform approval behind it.
Read src/treg/oauth_providers.py's module docstring first. The rule it states is the one most easily broken: scopes are per capability, never per provider.
The code changes
All of these, in order. Skipping any one produces a provider that looks fine in /oauth/providers and fails somewhere the tests don't reach.
1. Credentials → src/treg/config.py
Add <name>clientid / <name>clientsecret to Settings, loaded from TREG<NAME>CLIENTID / SECRET. Reuse an existing pair when the platform uses one app for several products — all four Google providers plus YouTube share googleclientid, because Google verification and API audits are scoped to the Cloud project, not to the OAuth client. A second client in the same project isolates nothing.
2. The provider → src/treg/oauth_providers.py
Add the OAuthProvider and register it in REGISTRY (easy to forget; the provider silently doesn't exist until you do).
Capabilities are cumulative supersets, never swaps:
scopes={
"read": [READ_SCOPES],
"post": [*READ_SCOPES, UPLOAD], # post CONTAINS read
"manage": [*READ_SCOPES, UPLOAD, WRITE], # manage CONTAINS post
}
satisfiedcapabilities() is set-containment, so a non-cumulative write yields a connection that can write but reports "no read". defaultcapability is the broadest (most scopes) — deliberately, see its docstring.
Split a capability whenever the platform splits the scope. YouTube needs read/post/manage because uploading a video and being able to edit or delete one are different Google scopes; collapsing them means a connection that can post and then never fix a typo.
Per-provider quirks worth knowing: authparams={} for providers that reject Google's accesstype/prompt (LinkedIn, Slack, X), pkce=True + tokenendpointauthmethod="clientsecretbasic" for X, extracredential_* when a second credential rides along (Google Ads' developer token).
3. Discovery — "which account does this connection act on?"
Set discoverpath / discoverkey / discoveridfield / discoverlabelfield.
- Label and id fields are dotted paths (
_dig), so nested values work:
discoverlabelfield="snippet.title".
discoverbaseurl when listing lives on a different host than the data API
(GA4 reports come from analyticsdata, properties from analyticsadmin).
discovernestedkey when the rows are nested one level down.
enrich_* when the listing returns bare ids and a second call is needed for a
human name (Google Ads).
- Query strings in
discover_path are fine — discovery does not pass params.
Leave it unset if the credential acts on the whole account; the UI then says "whole account" rather than showing an empty picker.
4. probe_path — health + the Tools "Use" prefill
A cheap authenticated GET on baseurl (not discoverbase_url — the probe runs against the provisioned tool's own host). Without it the tool reads "unchecked" forever and the Try panel opens blank.
Prefer the path that returns a human-recognisable field, and keep it identical to the sample path in step 5 — openUse prefers health_check.path over the sample map, so if they differ the prefill silently changes after a reconnect.
5. Dashboard → src/treg/web/index.html
Provider rows, the Connect button and the pickers are all data-driven from listing(). No new provider needs a UI change — except:
- A new capability name must be added to
capLabel / capHelp (~line 2762).
An unknown name renders as the bare key plus "Requests the x scopes."
samplePath (~line 3024) is a hardcoded host→path map used when a tool has
no health_check yet. Add the host so the Try panel prefills for tools that were provisioned before the probe existed.
6. Tests
tests/testoauthprovidersm3.py::testeveryprovideris_registered asserts an exact set of service names. Add yours or the suite fails.
Then: uv run --with pytest-xdist pytest -n auto -q (full suite — the registry touches health, tools and connections).
The platform side (the actual long pole)
The code is an afternoon. Approval is weeks. Do these in parallel, not in series.
- Enable the APIs on the project before touching the consent screen — scope
pickers usually only list scopes belonging to enabled APIs.
- Add the scopes. Ask only for what you can demonstrate; see pitfalls.
- Redirect URI must match
{TREGPUBLICURL}/oauth/callback exactly.
- Verification / app review, where the platform gates sensitive scopes.
- Separate product audits — these are extra and often slower than the OAuth
review: YouTube's compliance audit + quota extension, Business Profile API access, the Google Ads developer token, LinkedIn's Community Management API.
For the verification submission
- App name, homepage, privacy policy and demo video must all agree. The
consent-screen brand is what reviewers compare everything against — a mismatch is a routine rejection.
- The privacy policy must name the platform's data explicitly and link the
platform's ToS plus the provider's privacy policy.
- The demo video must show each requested scope actually in use, with the
client id visible in the consent URL. This is the real constraint on scope count — see the first pitfall.
Pitfalls
Each of these cost real time.
Never request a scope you can't film. "Requested scopes exceed what the demo shows" is the standard rejection. YouTube's youtubepartner needs a YouTube CMS account to make any call at all, so an app without one literally cannot produce the evidence. Copying a scope list off another product imports approvals it holds and you don't — ask for what your own demo can show, and add the rest later.
A short-lived token with no refreshtoken is a connection that is already dead. Meta's authorization-code exchange returns a 1-2 hour user token and never issues a refreshtoken, so a provider added without longlivedexchange=True produces connections that break before anyone uses them — the connect succeeds, the probe passes, the tool 401s that afternoon. The extra granttype=fbexchangetoken call buys ~60 days, which is the ceiling for a Meta user token; it still can't renew unattended, so it surfaces through needsreconnect like LinkedIn rather than pretending to auto-heal. Before trusting any new provider, read what its token endpoint actually returned: expiresin absent does not mean "never expires", and the 3600s default in exchangecode will happily invent an expiry that isn't real.
Publishing status "Testing" expires refresh tokens after 7 days. Every connection then breaks weekly — fatal for treg, and invisible until day 8. Confirm the app is In production before trusting any connection made during setup.
The "hasn't verified this app" interstitial is expected pre-approval. Click through via Advanced. Demo videos are necessarily filmed in that state.
Scope pickers filter on the scope URL, not the product name. yt-analytics.readonly does not contain the string "youtube". Use the "manually add scopes" box when the picker won't cooperate.
TREGPUBLICURL decides the redirect_uri, not the port you're browsing. Running two dev servers and connecting from the wrong one sends the callback to the other server, where your session cookie doesn't apply.
healthcheck attaches at connect time. Adding a probepath does nothing for already-connected tools until they reconnect (the existing-tool branch of autoprovisionprovider_tool backfills it).
httpx replaces a URL's query string whenever params is passed — even []. probe merges with copyaddparam for exactly this reason; don't "simplify" it back, or any probe path carrying its own query gets truncated and reports a healthy credential as invalid. Covered by testprobepreservesaquerystringintheprobepath.
.ttable .tn is white-space:nowrap, and .ttable-wrap is overflow:hidden. Long text in a .tn cell widens the table past the modal and clips the action button off-screen rather than scrolling to it — the row becomes unclickable. The capability modal deliberately does not use .tn.
Test upstreams must be defined at module scope. Test files use from future import annotations, so FastAPI resolves a route's Request annotation from module globals; a route declared inside a test function 422s instead of matching.
Verify before calling it done
uv run --with pytest-xdist pytest -n auto -q # full suite
scripts/dev-local.sh up # see .agents/skills/dev-local
Then, in the dashboard: Connect → capability picker reads in plain English → consent → account/channel picker shows real names (not bare ids) → #tools → Use → Send returns live data. Only the last step proves the credential, the binding, the proxy and the base URL are all correct together.