Files
treg/docs/context/architecture/catalog.md
T
SToneX fd4453ea17 chore: keep hosted operational material in the private workspace (#722)
Preserve public page sources and portable maintenance tools while moving hosted records and pricing evidence out of the public tree. Require explicit external pricing evidence and remove hosted database access helpers.

Update the catalog, ads conversion, archive, super-admin, data-model, MCP OAuth, API, SEO and skill context fragments. Merge the companion private import before this change.
2026-09-30 15:54:01 +08:00

159 KiB
Raw Blame History

title, status, sources, related
title status sources related
Endpoint catalog — what you can DO with a connected key, and which provider should do it shipped
src/treg/catalog/fetchinio.yaml
src/treg/web/logos/fetchinio.svg
src/treg/catalog/examples/fetchinio.linkedin.user.profile.json
src/treg/catalog/examples/fetchinio.linkedin.company.profile.json
src/treg/catalog/examples/fetchinio.linkedin.user.posts.json
src/treg/catalog/examples/fetchinio.linkedin.user.reactions.json
src/treg/catalog/examples/fetchinio.linkedin.post.comments.json
src/treg/catalog/examples/fetchinio.linkedin.post.reactions.json
src/treg/catalog/examples/fetchinio.linkedin.post.engagement.json
src/treg/catalog/fishaudio.yaml
src/treg/catalog/examples/fishaudio.tts.s2-1-pro.json
src/treg/catalog/examples/fishaudio.voices.create.json
src/treg/catalog/examples/fishaudio.voices.discover.json
src/treg/application/provider_resources.py
src/treg/domain/provider_resources.py
src/treg/routers/provider_resources.py
src/treg/catalog/tavily.yaml
src/treg/application/call/octen.py
src/treg/catalog/linkup.yaml
src/treg/catalog/you.yaml
src/treg/catalog/examples/you.web.search.json
src/treg/catalog/examples/you.web.contents.json
src/treg/web/logos/you.svg
src/treg/web/logos/linkup.svg
src/treg/catalog/examples/linkup.web.search.json
src/treg/catalog/examples/linkup.web.fetch.json
src/treg/catalog/examples/linkup.web.fetch.structured.json
src/treg/catalog/examples/linkup.web.answer.json
src/treg/catalog/examples/linkup.web.answer.status.json
src/treg/catalog/keenable.yaml
src/treg/catalog/olostep.yaml
src/treg/catalog/spidercloud.yaml
src/treg/catalog/examples/spidercloud.web.scrape.json
src/treg/catalog/examples/spidercloud.web.crawl.json
src/treg/catalog/examples/spidercloud.web.search.json
src/treg/catalog/examples/spidercloud.web.links.json
src/treg/catalog/examples/spidercloud.web.unblock.json
src/treg/catalog/examples/spidercloud.web.screenshot.json
src/treg/catalog/tinyfish.yaml
src/treg/catalog/examples/tinyfish.web.search.json
src/treg/catalog/examples/tinyfish.web.search.news.json
src/treg/catalog/examples/tinyfish.web.search.publications.json
src/treg/catalog/examples/tinyfish.web.fetch.json
src/treg/catalog/examples/tinyfish.web.agent.run.json
src/treg/catalog/examples/tinyfish.web.agent.run.get.json
src/treg/catalog/examples/tinyfish.web.agent.run.cancel.json
tests/test_tinyfish.py
src/treg/catalog/exa.yaml
src/treg/catalog/anyapi.extended.yaml
src/treg/catalog/adyntel.yaml
src/treg/catalog/examples/adyntel.meta-ads.library.advertiser.json
src/treg/catalog/examples/adyntel.meta-ads.library.search.json
src/treg/catalog/examples/adyntel.linkedin.search.ads.company.json
src/treg/catalog/examples/adyntel.linkedin.search.ads.keyword.json
src/treg/catalog/examples/adyntel.google.ads.transparency.json
src/treg/catalog/examples/adyntel.tiktok-ads.library.search.company.json
src/treg/catalog/examples/adyntel.google.domain.keywords.overview.json
src/treg/web/logos/adyntel.svg
src/treg/catalog/trestleiq.yaml
src/treg/catalog/financialdatasets.yaml
tests/test_financialdatasets.py
src/treg/catalog/quickenrich.yaml
src/treg/catalog/influencersclub.yaml
src/treg/catalog/quickenrich.extended.yaml
src/treg/catalog/trykitt.yaml
src/treg/catalog/contracts.yaml
src/treg/catalog/millionverifier.yaml
src/treg/catalog/adapters.yaml
src/treg/catalog/capabilities.yaml
src/treg/catalog/prospeo.yaml
tests/test_route_cost_ceiling.py
src/treg/catalog/tomba.yaml
src/treg/domain/catalog/routing/__init__.py
src/treg/domain/catalog/routing/contracts.py
src/treg/domain/catalog/routing/paths.py
src/treg/domain/catalog/routing/plan.py
src/treg/domain/catalog/routing/synthetic.py
src/treg/application/call/async_bridge.py
src/treg/application/call/route.py
src/treg/catalog/wiza.yaml
src/treg/catalog/examples/wiza.people.email.find.json
src/treg/catalog/examples/wiza.people.email.find.terminal.json
src/treg/catalog/examples/wiza.people.phone.find.json
src/treg/catalog/examples/wiza.people.phone.find.terminal.json
tests/test_routing.py
tests/test_wiza.py
.github/workflows/catalog-drift.yml
scripts/catalog_drift.py
scripts/catalog_ingest.py
scripts/catalog_validate.py
src/treg/catalog/aliases.yaml
src/treg/catalog/fx.yaml
src/treg/catalog/cloro.yaml
src/treg/catalog/aviato.yaml
src/treg/catalog/crustdata.yaml
src/treg/catalog/google-search-console.yaml
src/treg/catalog/google-search-console.extended.yaml
src/treg/catalog/google-tag-manager.yaml
src/treg/catalog/google-tag-manager.extended.yaml
src/treg/catalog/instagram.yaml
src/treg/catalog/instagram.extended.yaml
src/treg/catalog/justoneapi.extended.yaml
src/treg/catalog/minimax.yaml
src/treg/catalog/apify.yaml
src/treg/catalog/brightdata.yaml
src/treg/catalog/companyenrich.yaml
src/treg/catalog/oceanio.yaml
src/treg/catalog/akta.extended.yaml
src/treg/catalog/dataforseo.yaml
src/treg/catalog/dataforseo.extended.yaml
src/treg/catalog/scrapecreators.yaml
src/treg/catalog/scrapecreators.extended.yaml
src/treg/catalog/serpapi.yaml
src/treg/catalog/serpapi.extended.yaml
src/treg/catalog/diffbot.yaml
src/treg/catalog/diffbot.extended.yaml
src/treg/catalog/tikhub.extended.yaml
src/treg/catalog/lusha.extended.yaml
src/treg/catalog/openrouter.yaml
src/treg/catalog/openrouter.extended.yaml
src/treg/catalog/replicate.yaml
src/treg/catalog/replicate.extended.yaml
src/treg/catalog/reapi.yaml
src/treg/catalog/piapi.yaml
src/treg/domain/catalog/__init__.py
src/treg/domain/catalog/store.py
src/treg/catalog/hunter.yaml
src/treg/mcp.py
src/treg/domain/money/settlement.py
src/treg/domain/catalog/stats.py
src/treg/infra/catalog_observations.py
src/treg/application/catalog_stats.py
src/treg/alembic/versions/0038_endpoint_day_stats.py
src/treg/routers/catalog.py
tests/test_aigc_pr_b.py
tests/test_catalog_api.py
tests/test_catalog_validate.py
architecture/money.md
architecture/proxy-model.md
interface/cli.md

Endpoint catalog — platform-grouped operations per provider

Fetchin

fetchinio.yaml curates Fetchin's seven public LinkedIn data routes: profile, company, member posts and reactions, post comments and reactions, and the combined engagement read. All seven are strict-query GET tools and are available through BYOK or the platform key; the normal own-key-first ladder keeps a team's credential unmetered. GET /api/v1/subscription is deliberately internal: it is the free connection probe and capacity collector rather than an account-kind catalog tool. Verified adapters add profile, company, member posts, post comments and post reactions to their provider-neutral LinkedIn routes. The member-posts contract compares Fetchin with Aviato and HarvestAPI; member reactions and combined engagement remain direct Fetchin tools because no shared contracts describe those provider-native operations.

Live balance deltas on 2026-09-24 confirmed one credit for every ordinary successful route and two for combined engagement. The shared account's acquired PAYG replacement rate is $1.50 per 1,000 credits, so the rows are $0.0015 and $0.003 per call. Fetchin also bills one credit for a 404 while every other failure is free. Its response contains no per-call charge evidence, and treg's generic settlement rule never charges a rejected response on an estimate; the shared tier therefore absorbs that upstream 404 cost. fullProfile=true can cost either one or two credits without reporting which happened, so the strict curated profile row excludes it. A team's raw BYOK tool remains a faithful relay and can still request it.

The examples were captured from public-figure and company fixtures. Comment and reaction actors, their text, cursors and profile identifiers are replaced with reserved synthetic values before commit; only the public test post identifier remains.

Fish Audio v1

Fish Audio contributes synchronous S2.1 Pro TTS, public-voice discovery, and private voice create/update/delete tools. Fish's documented single-model GET is not exposed as a catalog tool because live private workspace voices return 403 while list, update, delete, and TTS reuse succeed. It is used internally only to verify that a non-team TTS reference is public. The raw account-wide model list remains own_account/BYOK-only, while the separate discovery tool fixes self=false and requires callers to choose whether licensed=true narrows the public catalog. application.provider_resources.list_for_caller gives HTTP, dashboard, CLI, and MCP one unified read: it selects Fish's account list when BYOK exists and otherwise returns only the current organization's ProviderResource voices in the same normalized shape. Its access-check database session closes before Fish I/O. Shared-key TTS fixes the model header to s2.1-pro and displays $15 / 1M UTF-8 bytes rather than the internal per-byte settlement rate; private voice creation fixes type=tts, train_mode=fast, and visibility=private in multipart form data. TTS is priced at the documented $15 per million UTF-8 bytes.

managed_resource is the generic catalog contract for durable provider objects. It names the CRUD operation, resource kind, id location (path/query/body scalar or array, or create response), optional display-name source, create-compensation endpoint, and an optional public-resource verification read for use operations. This is the narrow exception allowing an otherwise account-kind tool onto a platform key: the runtime proves organization ownership locally, or verifies an unassigned id against the declared public predicate after closing the database session, before relay. An id assigned to any organization is never sent through the public lookup. BYOK never applies this policy. Fish remains deployment-disabled until live management-price, shared-account permission, and commercial checks pass.

Authorization metadata

An endpoint can declare authorization_method, ordered authorization_methods, method-specific authorization_paths, required_scopes, required_resource, and token_type. _normalize keeps these fields on the internal row and exposes them on endpoint detail only when present. Marketplace resolution uses them for preflight and grant selection. Instagram is the first user; its 32-row audit is in instagram-oauth.

Meta's published reference is not available as a machine-readable OpenAPI document. Reviewed Instagram input and authorization contracts are therefore curated catalog data, and ingestion carries them forward instead of erasing them on a later scrape. Instagram is also parameter-multiplexed: profile lookup and business discovery intentionally share GET /{ig_user_id}; the required fields=business_discovery... value selects the latter operation.

Why

Adyntel ad intelligence (2026-09-22)

adyntel.yaml catalogs seven synchronous POST tools across Meta, LinkedIn, Google, TikTok and domain-keyword analysis. Live balance deltas established one credit for ordinary result pages, two credits for domain keywords, and one additional credit for Google creative text extraction and TikTok influencer ads. The acquired pay-as-you-go rate is $55 / 5,000 credits, or $0.011 per credit. HTTP 204 misses and rejected requests used no credits; generic per_success settlement therefore treats 204 as unbilled.

Every catalog row uses body_allowlist. Normal continuation tokens and documented filters remain, while all_ads, webhooks, provider-selection controls, and LinkedIn batch arrays are excluded from the shared-key contract. This prevents an unbounded auto-pagination request while preserving caller-controlled page-by-page access. An own raw tool is unaffected. Google Shopping submission and status polling are excluded. TikTok keyword search and ad-detail lookup are also excluded because repeated live requests, including the documented search shape and sample ad id, returned HTTP 204 without a usable hit fixture.

The seven retained rows were live-hit again and each has a sanitized response fixture. Their platform/capability pairs describe the returned dataset: Meta advertiser/search, LinkedIn ad search, Google Ads Transparency, TikTok ad search and Google domain overview. They are direct-only: none declares a routing adapter or enters Enrich Arena because none matches an existing shared response contract. Every row is both BYOK-callable and platform-callable; the own-key-first ladder keeps a team's credential unmetered and ahead of treg's key.

The shared account is pay-as-you-go and is replenished manually in the provider dashboard. Adyntel does not expose an account balance endpoint, response charge field or remaining-capacity header, so capacity is manual/informational rather than API-observed. The catalog holds at most $0.011 for an ordinary page and $0.022 for the two-credit calls/modifiers, then settles successful results or releases HTTP 204 and failures. The provider asks clients to stay at or below 5 requests/second. An eight-request concurrent live burst returned eight HTTP 200 responses and no Retry-After header, so no enforced 429 signature was observed; treg still follows the published 5 rps guidance. HTTP 402 is documented for balance problems, but an insufficient-balance response was not forced against the funded account and is not treated as a unique exhaustion signal.

The marketplace registry (oauth_providers.py) catalogs credentials: how to connect a provider. It says nothing about what you can DO once connected — which endpoints exist, what they cost, what they return. Agents guess paths from external docs and burn paid calls. This layer answers that, and it runs through the team's OWN keys: every call is proxied, governed and audited.

The catalog adds that operations layer:

  • platform (tiktok, instagram, google, web, …) → the marketplace grouping axis: click TikTok, see every provider + endpoint that serves TikTok data.
  • capability (tiktok.user.profile) → the same operation across providers, so a user can compare TikHub vs JustOneAPI for one job, and a future router can fail over between them.
  • verified example responses → captured during live testing, because docs show request params but choosing an API comes down to what actually comes back.

Crustdata and Aviato support both BYOK and treg's platform-key tier. Their catalog costs stay in the vendors' native credits; fx.yaml converts the actual replacement rates treg pays ($0.30 per Crustdata credit from the configured 500-for-$150 auto-top-up, $0.01 per Aviato credit from the configured 1,000-for-$10 recharge and paid receipt). Every paid row therefore has a computable USD price and is platform-eligible when the deployment keys and allow-list are set.

Their core catalogs use only existing marketplace platforms. Crustdata has eight live-verified, single-call operations: five on Company data and three on People & contact data. Batch routes are omitted because they can create unexpectedly large jobs and costs; sales-enabled routes that the connected account cannot verify are also omitted. Its generic web search and page fetch are not placed on the web platform because that marketplace card currently means backlinks, authority and domain metrics. Aviato has 21 curated operations: nine on Company data, seven on People & contact data, and five on LinkedIn social. Both Aviato people-search forms remain: the POST route exposes the full DSL, while the GET route is a separate simple-query workflow.

Bulk behavior stays inside the faithful relay. Crustdata batch operations are not catalogued. Aviato company and person bulk enrichment are synchronous JSON calls. No provider-specific buffering, callback receiver, or proxy branch is added. Crustdata's required x-api-version: 2025-11-01 header remains provider metadata and is bound on every BYOK and platform-key call.

Variable prices use the existing reserve→settle path. Crustdata reserves the documented maximum for the requested record count and settles the exact X-Credits-Used response header. cloro (2026-09-07) is the second header-reporting provider: every billed response carries X-Credits-Charged, the catalog value is the price of the full-surface test_request (an upper bound — the ChatGPT ads/shopping include family and the Google AI Overview flags are +2 each), the top-level state body field is a generic cost.modifiers rider, and the header settles the exact charge. The header is absent on cloro's free routes and on a failed extraction, which it does not bill, so an absent header settles as unreported rather than as zero. The cost.modifiers reserve path is open to any credit-priced provider with a fx.yaml rate, not only Aviato. AI Ark is the third header-reporting provider: its exact X-Credit debit is negative, and _CREDIT_HEADERS declares an explicit -1 multiplier instead of treating every negative number as a charge. Aviato's preview calls reserve zero; observed email/rescrape add-ons are declared in each endpoint's generic cost.modifiers map and derived from request flags; synchronous bulk calls reserve per lookup and settle per returned successful record. Simple people search reserves the documented one-credit-per-result enrichment add-on but settles its observed 0.25-credit base. A lower price needs repeat balance evidence because Aviato does not return the exact call charge. That evidence showed that company single and bulk rescrape, person single rescrape, and person bulk email riders are not billed, although the authenticated price page lists them. Person single email and person bulk rescrape riders are billed. A reserve_only: true modifier keeps each documented but unbilled rider in the temporary hold. settle: modifiers then uses only the measured modifiers for the final charge. This protects treg from a documented maximum without overcharging the caller.

A cost.modifiers rule names a parameter location (query, body, or lookups), a match rule (truthy or present), and exactly one credit effect: make the call free, add fixed credits, or add credits per requested result. The validator rejects any other shape. This keeps vendor numbers in catalog YAML while the billing code reads the rules without provider-specific credit constants. An optional cost.settle: base keeps documented riders in the reserve but settles the successful call at the catalog base when repeat live evidence proves that the provider neither bills nor delivers those riders.

Tavily follows the provider-specific request/response pattern used for Hunter, Tomba and Openmart. resolve._tavily_pricing reads cost.tavily_rates and the caller's original body to size a bounded hold. Search reserves one or two credits and settle._tavily_cost_micro reads its per-request usage.credits. Extract and Map instead settle fractional per-success rates from their documented results arrays. Crawl settles a conservative per-returned-extraction allocation combining its mapping and extraction modes; Tavily does not expose every successfully mapped page, so treg absorbs any hidden mapping difference under the 20-page platform cap. Extract, Map and Crawl never use the provider account's grouped usage.credits to decide which team pays. BYOK bypasses all metering. Each endpoint's tavily_rates mapping has an exact mode-key contract. Catalog validation rejects an incomplete, extra, non-finite or non-positive rate, and runtime repeats that check before reserve or relay so catalog drift cannot silently turn a platform call into a free call.

Octen's octen_rates mapping likewise keeps the original published USD unit prices in catalog data. octen.rates_micro rejects incomplete or non-micro-USD rates before a shared-key call. The platform request check bounds search result counts, Broad subqueries, News subject results, and Extract URL count without rewriting the caller's body. A team's own key bypasses that check. The call runtime uses the bounded maximum for the hold and this response's meta.usage for settlement; the relay does not parse or reshape Octen's answer. Catalog validation requires each route's exact rate keys and checks that the displayed base price matches its rate table.

Linkup's curated web.search and Markdown web.extract rows have verified routing adapters; Research remains a direct asynchronous web.answer tool. Search's depth and outputType select one published per-success price. The Fetch route has separate Markdown and structured catalog rows because supplying schema changes the price; both rows price mode and renderJs with declarative tables and enforce distinct body allowlists. Research requires an explicit reasoningDepth so callers choose its $0.25–$2.50 price; Linkup otherwise defaults to L at $1.50. Its published pricing varies by depth, not mode. Research reserves the requested depth price, releases failed tasks under Linkup's documented no-charge-on-error policy, and settles successful tasks after the owned GET /v1/research/{id} poll reports completion. The polling read is free and restricted to the team that submitted the task on the shared key. Account-wide task listing, mixed batch Tasks, closed-beta Extract, and the undocumented Responses route are outside the shared-key catalog.

Spider's spidercloud.yaml curates Scrape, Crawl, Search, Links, Unblocker and Screenshot. The standard routes are live-verified with public targets. Only the bounded Search listing is offered on the shared key: with fetch_page_content=false, a two-result limit and the default listing each debited ten Spider API credits ($0.001). Search's response has no per-call cost object. A fetched-page Search probe produced a very large response and a variable charge, so the catalog exposes only the listing form. The other five routes remain BYOK-only because bandwidth, compute, browser and Unblocker charges depend on the target. max_credits_allowed=1 did not stop Search or Unblocker from debiting more than one credit, so it is not used as a shared-key reserve ceiling. A team's own credential remains unmetered by treg.

A verification stamp proves the request shape, response shape, and paid behavior that the evidence actually observed. A placeholder path value or a free miss does not prove a paid hit. Such rows keep the documented price and say which paid behavior remains unobserved. Captured examples use public records and omit private identities or content when counts are enough to prove the response shape.

Path placeholders are substituted by the marketplace caller. Raw values are percent-encoded; a value that already contains a valid %HH escape is kept verbatim so callers can safely reuse encoded resource names returned by an upstream API. An invalid/literal % is still encoded as %25. Search Console's siteUrl examples deliberately use the raw sc-domain:example.com form to demonstrate the default path. Google Tag Manager is the opposite case: its parent/path values describe a hierarchy rather than one opaque identifier, so the curated catalog exposes atomic account/container/workspace/version ids. catalog_ingest.google_flat_path_params makes the generated GTM input schema use the same atomic placeholders already present in Discovery's flatPath; no slash-delimited resource name is passed through one placeholder and accidentally encoded as %2F.

Where things live

src/treg/catalog/
  capabilities.yaml        # the shared capability taxonomy (the cross-provider join key)
  aliases.yaml             # query word -> catalog words (search-time vocabulary bridge)
  fx.yaml                  # currency -> USD rates + per-PROVIDER credit rates (see "Cost" below)
  <service>.yaml           # CORE tier — hand-curated; <service> = OAuthProvider.service
  <service>.extended.yaml  # EXTENDED tier — machine-generated full endpoint surface
  examples/<endpoint-id>.json  # truncated, scrubbed real responses captured at verify time
scripts/
  catalog_drift.py            # path+method drift against providers' public OpenAPI documents
  catalog_validate.py         # schema + referential checks (run in CI / after any edit)
  catalog_verify.py           # live-tests CORE endpoints with a real credential; writes examples/
  catalog_verify_extended.py  # the same for the extended tier, in bulk, under a spend cap
  catalog_ingest.py           # bulk-generates the extended tier from provider specs
  catalog_cost_provenance.py  # backfills cost units + provenance; re-run after any re-ingest
src/treg/routers/catalog.py    # open Catalog JSON routes, attached in legacy registration order

Data files are YAML (curation-friendly) and loaded through catalog_store. The open JSON handlers live in routers.catalog; api.py attaches their router at the original position so the specific Catalog API paths continue to precede the later /catalog/{slug} page route.

Two tiers

Curation and coverage pull in opposite directions: an agent needs to know that TikHub can read Zhihu answers (breadth), and separately needs one endpoint per job that is known to work and known to cost $0.001 (depth). The catalog carries both, in separate files, distinguished by tier.

tier: core — <service>.yaml tier: extended — <service>.extended.yaml
written by a human, one endpoint at a time scripts/catalog_ingest.py, from the provider's spec
size ~10–15 per provider every route the provider exposes (hundreds to ~1400)
capability required — the cross-provider join key absent; nothing is mapped to the taxonomy yet
input / test_request required, hand-written generated, when the provider documents parameters
verified + example_response expected where the generated test_request passed a live call
example size ~10 KB, arrays → 2 items ~2 KB, arrays → 1 item (shape, not fidelity)
cost required present when the provider publishes per-route prices

The extended tier originally carried no input, test_request or verification at all — the tiers split on curated vs. generated, and it read as if it also split on tested vs. untested. It does not, and it should not: a provider that documents its parameters (with example values, as TikHub does) gives us everything needed to generate a test request and make the call. What stays exclusive to core is the part a machine cannot do — mapping the endpoint to a capability, choosing the test target deliberately, and a full-fidelity example. See "bulk-verifying the extended tier" below.

An ingested price is a claim we will charge on, so a generated free is a bug, not a default. x.extended.yaml shipped 168 routes priced free off a plan-tier model X had already abolished, while the proxy — which skips a free block, its usd being falsy — billed the provider fallback: the catalog published $0 and the balance moved $0.10. Where the upstream bills treg (an oauth_billed provider, auth-secrets), the generator must therefore price every route it emits, and a test walks the provider asserting the published price equals the reserved one.

The second half of that lesson cost a review round: the fix for a blanket price is not a smaller blanket. The first repair priced all 74 X writes at $0.015 — the post-creation rate — when X publishes a row per ACTION, and creating a list is $0.010, managing one $0.005, deleting an interaction $0.010. Read the rate card and transcribe it (catalog_ingest.X_RATES is the card, X_ROUTE_RATES the route→row mapping, and each entry's note names the row it was priced from); where the mapping is a judgement call, confidence: inferred says so and takes the dearer reading, because treg pays the difference. Watch for conditional rates in particular: X's $0.001 "owned read" applies only when the caller owns the developer app, which on a registry connect is treg — quoting it for our members under-billed the very calls we are charged the most for.

Core wins on collision: catalog_ingest.py drops any (method, path) the provider's core file already curates, so an endpoint appears exactly once across both tiers. Promoting an extended entry means moving it into the core file and completing it (steps 3–8 below) — not editing it in place; extended files are regenerated wholesale and hand edits are lost.

OpenRouter video models are the deliberate exception to path-only collision detection: every model uses POST /videos, so core_body_models skips only the fixed body.model values already curated in core. Its OpenRouter and Replicate generation ingesters emit no capability guesses and explicitly set domain: models; this keeps every coverage row as a standalone model. Their carry_verification(..., carry_capability=False) migration keeps verification evidence and reviewed names/kinds without resurrecting old inferred capability tags. The core AIGC generation rows pin domain: models too and carry PER-MODEL capabilities (video-gen.hailuo.from_text, proposed in their provider files) rather than the job-level video-gen.from_text family. Generation models are not interchangeable - a merged row comparing Hailuo with Wan or Seedance is a false comparison - so the job-level capabilities are deliberately memberless, reserved for hand-picked models (see capabilities.yaml). The AI generation modality pages therefore render as flat model walls; the same model reachable over several routes (MiniMax direct, OpenRouter, Replicate all serve Hailuo) sits adjacent under model-led names, which is the comparison that actually means something. The per-model capability is the join key that lets those routes merge onto one row if that comparison is later curated. reAPI and PiAPI are the first pair to share join keys on purpose: both files use video-gen.seedance-2-5.generate, video-gen.seedance-2-5-unrestricted.generate, image-gen.gpt-image-2-5.generate, image-gen.gpt-image-2.generate and image-gen.gemini-3-pro-image.generate (shared keys, so they live in capabilities.yaml), so the two routes to one model sit on one row with their prices side by side. The -unrestricted key names the Less Restriction route (reAPI content_filter: false, PiAPI's seedance-2.5-less-restriction task): the only route on which a real person's photo is accepted as the subject reference, which is the whole reason those resellers are listed beside the official-rate OpenRouter route. OpenRouter's Seedance 2.5 is curated into openrouter.yaml on the same join key (its generated extended twin is therefore skipped by the ingester's curated-model rule), so the default-filter row compares three routes and the Less Restriction row two. Replicate's official google/nano-banana-pro, openai/gpt-image-2 and both openai/gpt-image-2.5-* models are curated into replicate.yaml on the image keys the same way (per output image by quality or resolution, from the model pages' price criteria), so each image model row compares reAPI, PiAPI and Replicate. Merged rows are titled by the capability description, which for these per-model keys is the plain model name ("Seedance 2.5"), not a sentence.

Schema

capabilities.yaml

capabilities:
  tiktok.user.profile: "Public profile of a TikTok user (followers, bio, stats)"
  web.backlinks.summary: "Aggregate backlink profile of a domain or URL"
platforms:
  tiktok: "TikTok"
  web: "The web at large (backlinks, authority, traffic)"
  video-gen: {label: "Video generation", category: "AI generation"}
  image-gen: {label: "Image generation", category: "AI generation"}
  voice-gen: {label: "Voice generation", category: "AI generation"}

Rules:

  • A capability id is dot-delimited, lowercase; the FIRST segment is its platform slug.
  • Ids name the job, not the provider's endpoint ("get user profile", not "fetch_user_profile_v2").
  • Adding a capability = adding it here. Provider files may carry proposed_capabilities: (same mapping shape) when curation discovers a job the taxonomy lacks. A proposal is live the moment it loads (the loader merges it into the taxonomy, first file by name wins the description), so the validator keeps it a staging area: an endpoint may use a capability that is global or proposed in its own file; a proposal that repeats a capabilities.yaml id, or one id proposed with different descriptions in different files, is an error; and a proposal that endpoints of two providers use is a warning to promote it here, deleting it from every provider file.
  • One job, one id. Two ids of one platform with the same description are a validator warning: they split one comparison row in two. Rename the losing id on its rows (endpoint ids do not change) and check contracts.yaml and adapters.yaml, which are keyed by capability. Count-only search variants are <platform>.search.count, lookalike search is companies.similar.
  • Under AI generation, platform means the generated-media modality rather than a system that owns the data. The job-level video-gen.from_text, video-gen.from_image, image-gen.from_text, image-gen.edit and voice-gen.from_text are deliberately memberless (see "Two tiers" above and the capabilities.yaml header): rows carry per-model capabilities such as video-gen.hailuo.from_text, and a model joins a job-level row only as a hand-picked editorial choice. Text-to-video and image-to-video stay separate because their required inputs and prices differ.

<service>.yaml

provider: tikhub                # must equal OAuthProvider.service in oauth_providers.py
source:
  docs: https://docs.tikhub.io/
  openapi: https://api.tikhub.io/openapi.json   # null when the provider has no spec
  curated: 2026-07-28
limits: "10 requests/second per key"   # optional, provider-level: the rate/quota model in one line
pricing_url: https://…                 # optional: where CURRENT prices live (values in cost blocks age)
endpoints:
  - id: tikhub.tiktok.user.profile   # unique; convention: <provider>.<capability>
    capability: tiktok.user.profile  # must exist in capabilities.yaml (or proposed_capabilities)
    platform: tiktok                 # must equal the capability's first segment
    domain: user                     # optional: the platform page's section. One lowercase word.
                                     #   Omit it and the loader derives one — capability's middle
                                     #   segment, else a path keyword, else the path's grouping
                                     #   segment, else "other". Set it only to override a bad guess.
    scope: any_account               # any_account (scrapers) | own_account (first-party OAuth)
    kind: data                       # optional; data (DEFAULT) | action | account | utility.
                                     #   what the endpoint IS — see "Kind" below. Absent ⇒ data.
    method: GET
    path: /api/v1/tiktok/web/fetch_user_profile   # relative to the provider's base_url
    name: "Get user profile"        # optional short DISPLAY title (≤60 chars). Set it when the
                                    #   summary is doc-prose too long for a row heading; clients
                                    #   fall back to `summary` when absent.
    summary: "Public TikTok profile by username"  # the provider's own description, kept VERBATIM —
                                    #   `name` is ours to word, `summary` is theirs
    input:                           # split by location — mirrors treg's binding model
      queryArrayEncoding: json      # optional default for array query params: json | comma |
                                    # repeated (the compatibility default).
      queryParams:
        uniqueId: {type: string, required: false, note: "username from the profile URL", example: "tiktok"}
        secUid:   {type: string, required: false}
      note: "one of uniqueId | secUid; uniqueId preferred"
      # also allowed: pathParams, body, bodyType (json|form)
    test_request:                    # EXACT params catalog_verify.py sends — must be cheap
      queryParams: {uniqueId: "tiktok"}
    expect:                          # optional; default is "HTTP 2xx"
      json_path: code                # dotted path into the response JSON
      equals: 200                    #   (for providers that answer HTTP 200 even on failure)
    cost:
      type: per_success              # per_call | per_result | per_success | free | quota_rows
      value: 0.0015
      currency: USD
      note: "charged on 2xx only; errors free"
    verified: 2026-07-28             # date of the last PASSING catalog_verify.py run; absent = unverified
    example_response: examples/tikhub.tiktok.user.profile.json   # written by catalog_verify.py
    docs_url: https://docs.tikhub.io/…

Domain sections — grouping endpoints for browse

catalog_store._domain derives the domain: heading above (capability middle segment, else a path keyword, else the path's grouping segment, else other) by splitting the candidate into whole words — on non-letters and camelCase humps, via _WORDS = re.compile(r"[A-Z]?[a-z]+") — then matching against DOMAIN_KEYWORDS. A short key (ads, llm, ad_→ad) must match a whole word; a stem key (keyword→keywords, backlink→backlinks, shop→shopping) matches by prefix. This keeps ads from matching inside leads or commentthreads, and user from matching inside abuserreports. DOMAIN_NOISE drops vendor-internal path segments that would otherwise earn their own heading: brand-family markers (dataforseo_labs, appendix) and delivery-version markers (web_v2, web_v3, web_v4).

Some brand-family or version headings come from a capability id's own middle segment rather than a path segment, so DOMAIN_KEYWORDS/DOMAIN_NOISE cannot filter them — google-analytics's measurement_protocol_secret / google_ads_link / firebase_link, x's account_activity, or douyin's xingtu / xingtu_v2. Renaming those reads as a capabilities.yaml taxonomy edit, not a heuristic change; open renames are tracked in catalog-review-proposal.md.

Async descriptors

catalog_store._normalize sets cache: forbidden for image-gen, video-gen, and voice-gen endpoints, including synchronous generation, task/result utilities and generated extended rows. Their kind is unchanged. These requests must reach the provider, not replay shared-account task ids or media from an identical prompt. Other platforms retain their declared/default cache policy.

An asynchronous submission endpoint may carry an async: descriptor. A provider file may put the same block at top level as a default for every endpoint in that file; an endpoint block replaces it whole (effective_async_descriptor): a descriptor is one protocol, and a protocol that differs in one axis differs in poll target, status vocabulary and result location together (MiniMax v2 against v1), so a field-wise merge only produced descriptors nobody had written down. catalog_store serves the effective descriptor on the normalized endpoint. An explicit endpoint async: false opts a utility or synchronous endpoint out of the provider default; absence means inherit.

An async endpoint may also declare terminal_example_response. Its ordinary example_response remains the submission response shown by the catalog, while adapter verification uses the terminal fixture returned by the poll endpoint. This lets an async tool join a routed capability without pretending that its kickoff body is the final enrichment result.

Poll mode in practice. Every listed provider polls a static catalog id (poll.endpoint), which the CLI reaches through /call/<id> on any credential tier. Replicate offers both urls.get and the stable GET /v1/predictions/{id}; the static form is listed (replicate.predictions.get) because a --await that polled the absolute URL through /call/https://… was refused for a team on treg's key - that path resolves only a team's own tool (sample run, 2026-09-02). The dynamic-URL mode (poll.url_from + url_hosts) stays in the schema, validator and worker for a provider that offers nothing else (BFL), but today it works only for BYOK teams; serving it on the platform key needs a host-allow-listed relay that is not built. Do not document it as available.

Envelope errors. A submission endpoint may carry expect (the provider-wide or per-endpoint success rule already used by settle); application.call.service._submission_accepted gates deferral on it, so MiniMax's HTTP-200-with-base_resp.status_code: 2013 releases at once instead of becoming a task nobody can poll. The synchronous MiniMax image endpoint carries the same rule; otherwise an invalid prompt or output count would be charged at the image table or fallback price. OpenRouter's terminal failure set includes failed, cancelled, and expired; all three release the hold as soon as the status endpoint reports them.

async:
  id_from: task_id
  poll:
    endpoint: minimax.video-gen.task.status
    param: {in: queryParams, name: task_id}
    # Alternative mode:
    # url_from: polling_url
    # url_hosts: [api.example.com]
  status:
    path: task.status
    success: [succeeded]
    failure: [failed, cancelled]
  result:
    path: task.content.url
    # Alternative mode:
    # fetch: provider.video-gen.result.retrieve
    # fetch_param: {in: pathParams, name: video_id, value_from: id}
    ttl_note: 9h
  interval: 10

A cost.table also prices out as a range: at load time _table_floor computes the cheapest row (a times row at its field's declared min) into cost.table_min, and cost_view exposes it as usd_min beside usd, which stays the validated ceiling (what reserve and eligibility read). Every price surface - the wall, treg catalog search, the dashboard, /access - shows $low-$high for a table rather than the worst case alone. A table whose every row multiplies by a recognized meter field is quoted at that unit rate: duration is displayed in seconds and max_steps in steps. For example, a video's $0.47-$13.9/success (shortest clip at the cheapest resolution up to the longest at the dearest) reads as nonsense beside a vendor page saying $0.12/s; so _table_rate records the row span as cost.table_rate, cost_view serves it as rate_usd_min, rate_usd, and the derived rate_unit, and the dashboard and CLI quote $0.119-$0.462/s for those rows while usd/usd_min keep pricing the whole call for reserve. type: per_success on these rows is the billing rule (a failed generation is not charged), not the display unit.

The validator checks the effective descriptor. Dotted JSON paths are syntactically valid; success is non-empty; failure may be empty only when optional, non-empty billed_failure supplies the terminal failure values; optional progress names expected non-terminal values so the CLI can distinguish them from a new undocumented provider state; all status lists are pairwise disjoint. interval is positive; poll has exactly one of endpoint or url_from; result has exactly one of path or fetch; every descriptor block rejects unknown keys. Status values are compared after string coercion on both sides; a missing or unrecognized value means still in progress, in both the CLI awaiter and the settlement worker, but the CLI warns once when a value is neither terminal nor declared in progress. A billed_failure remains a CLI failure but settles terminal usage instead of releasing the hold (for providers that charge work completed before cancellation). Static poll/fetch ids must be same-provider GET utility endpoints. Their mapping is explicit: poll param is exactly {in, name}, while result fetch_param is exactly {in, name, value_from} so a terminal field such as MiniMax's file_id is not confused with the utility request parameter. The named path/query input must exist on the target endpoint. Body-mode polling is deliberately outside the frozen contract because no surveyed provider uses it and the generic client could not faithfully execute it. Dynamic URLs require a non-empty url_hosts allow-list. Any endpoint with async: must use cost.type: per_success. The descriptor is metadata beside the faithful relay: it never changes provider-native parameters or response bodies. The call router serializes the effective descriptor into X-Treg-Async before the response stream starts; it does not inspect or buffer the upstream body.

Older async pairs that settle on their existing request paths use resource_ownership alongside the deferred-settlement design. produces maps response JSON paths to provider-local resource kinds; requires binds a path/query parameter to one of those kinds. On treg's shared key, a 2xx producer records the opaque id for the caller org, and a consumer is refused before relay unless the same org owns that provider/kind/id tuple. This covers Apify run/dataset ids, Bright Data snapshot ids, CompanyEnrich bulk job ids and LeadsForge enrichment/followers job ids without changing their billing behavior. Ownership is only as trustworthy as the producer's answer: a provider that dedupes on Idempotency-Key would hand one org another's job under a shared label, which is why the relay re-scopes that header per org on treg's key (proxy-model). The validator requires declared parameters and exact non-empty {kind, path} / {kind, param} shapes. BYOK does not use this metadata because the provider account itself belongs to the caller. Formal descriptors also materialize their poll/fetch ids under endpoint-namespaced resource kinds; their utility rows declare matching requires rules. The frozen AsyncTaskRecord remains the compatibility authority for tasks created before the resource table existed, while the explicit utility rule prevents a later catalog edit from silently turning a protected endpoint fail-open. The catalog test additionally rejects platform-eligible task/status/result object reads that have a required id but omit this metadata, so new legacy-style pairs cannot rely on a reviewer noticing the boundary by hand. Generated legacy task consumers for which no trustworthy producer→id chain is represented are explicitly platform_blocked instead: Akta request status, TikHub's captions-result route, and the DataForSEO on-page/SERP task consumers remain callable with BYOK but never receive treg's shared key. carry_verification preserves that reviewed block across re-ingestion just like a verification stamp; silently regenerating it away would reopen the tenant boundary.

MiniMax's curated Hailuo routes intentionally use the v1 three-step protocol: submit with POST /v1/video_generation, poll GET /v1/query/video_generation with a query-string task id and the terminal values Success/Fail, then pass the returned file_id to GET /v1/files/retrieve. The v2 generation path serves the H3 family and is not a protocol upgrade for the Hailuo models in this listing.

MiniMax also supplies the first voice-gen rows through the same provider connection. Speech 2.8 HD and Turbo are separate model rows over POST /v1/t2a_v2, each fixing its model plus stream: false and output_format: url; this keeps the response bounded and returns a 24-hour audio URL. The route is synchronous and billed per input character, so _body_text_characters scales the reserve from the provider-facing text value. The two rows remain marked skipped until a deliberate paid verification call is authorized; documentation provenance is enough for platform eligibility, but is not presented as live route evidence. The Voice generation Actions shelf exposes minimax.voice-gen.voices.list so callers can discover valid system voice IDs. Its request is fixed to voice_type: system: account-specific cloned and generated voices must not cross team boundaries when treg's shared MiniMax connection is used. The synchronous minimax.image-gen.from_text row likewise pins body.model: image-01 through platform_request.

reAPI answers every submission with a bare {id, status} and reports the charge on the poll body (usage.credits, 1 credit = $0.001); video rows keep the file-level descriptor (output.video_urls) and image rows replace it whole for output.image_urls. PiAPI wraps its task routes in {code, data} (HTTP 200 with code 400 on a bad request, hence the provider-wide expect), but its OpenAI-shaped /api/v1/images/generations/async route answers the bare task object, so those two rows override both id_from and expect (error.code 0). PiAPI's meta.usage counts "points" at ten million per dollar; it is read for the evidence ledger, not settled on, because points carry no fx rate: the settlement engine accepts usd and a provider credit priced in fx.yaml. reAPI's Seedance rows settle that way, on the terminal body's usage.credits, and price the provider's duration: -1 (auto, mandatory when the prompt edits a video reference) with flat rows ahead of the per-second rows: thirty seconds at the requested resolution, as a reserve only.

OpenRouter ingest reads /api/v1/videos/models, emits one extended row per model on the shared POST /videos route, and converts duration-based pricing_skus into price tables with rate_card_api provenance. It converts cents_per_* units to USD, maps resolution/audio dimensions, orders narrower conditions first, and collapses indistinguishable mode SKUs to the highest rate. Token, image-input, reference-image, and megapixel-second SKUs preserve the live rate card but stay explicitly unknown/BYOK-only because one bounded times field cannot safely describe them. Two verified Wan 3.0 480p/2s calls each quoted $0.10 from pricing_skus but reported usage.cost: 0.2125; rate-card rows are therefore documented, not observed-cost verified. Replicate ingest joins the official text-to-image, text-to-video, and image-to-video collections; each generated row takes its request fields from latest_version.openapi_schema. Its generated prices are explicitly unknown, while the curated core rows carry per-model page provenance. Both ingesters sort their inputs and produce byte-identical output when upstream data is unchanged.

Utility capability names still describe the utility's actual job. OpenRouter model discovery uses the file-local proposed video-gen.models.list; OpenRouter and MiniMax content retrieval share video-gen.result.retrieve; polling uses video-gen.task.status. These rows remain hidden management plumbing because kind: utility.

<service>.extended.yaml

Generated — never hand-edited. Re-run uv run python scripts/catalog_ingest.py <service> instead.

provider: tikhub                  # same rule as core: equals OAuthProvider.service
source:
  method: openapi + provider rate card   # how the entries below were derived
  ingested: 2026-07-28                   # date of the generating run
  spec_urls:                             # every upstream the run read, so it is reproducible
    - https://api.tikhub.io/openapi.json
endpoints:
  - id: tikhub.x.zhihu-web-fetch-answer-comments   # <service>.x.<path-slugified>; the `.x.`
    tier: extended                                 #   infix keeps extended ids out of the
    platform: zhihu                                #   `<provider>.<capability>` namespace
    method: GET
    path: /api/v1/zhihu/web/fetch_answer_comments
    name: "Zhihu answer comments"   # optional, same meaning as core; the ingesters harvest it
                                    #   where the spec offers a human title distinct from the
                                    #   description (TikHub's Apifox op names, Just One API's
                                    #   per-op summary / info.title, DataForSEO's operationId).
                                    #   Carried across re-ingests by id; providers may also carry
                                    #   reviewed `capability` mappings when coverage policy permits.
    summary: "Get comments of a Zhihu answer"
    kind: data                      # optional; data (DEFAULT) | action | account | utility (see "Kind")
    cost: {type: per_success, value: 0.001, currency: USD}   # optional
    docs_url: https://docs.…                                 # optional
    input:                          # generated from the provider's parameter docs
      queryParams:
        answer_id: {type: string, required: true, note: "Answer id", example: "1913...”}
    test_request:                   # generated: documented example values, page sizes clamped
      queryParams: {answer_id: "1913…", limit: 5}
    verified: 2026-07-28                                     # a real call passed
    example_response: examples/tikhub.x.zhihu-web-fetch-answer-comments.json

Rules:

  • Required: id, platform, method, path, summary. platform must exist in capabilities.yaml — that is what puts the endpoint on a marketplace shelf.

  • capability is normally ABSENT (extended entries are unmapped). AIGC generation coverage forbids inferred mappings entirely: comparison membership is curated in core, and extended rows use the explicit models domain. If another extended file has a reviewed mapping, the validator holds it to the full core rules so promotion by hand cannot silently drift.

  • cost is optional, because several providers price per API family rather than per route. When present it must still be a real cost model (cost.type from the same enum as core).

  • input / test_request appear when the provider publishes enough parameter documentation to generate them; both are machine-written and are rewritten on the next ingest.

  • Query arrays carry an explicit wire encoding when the provider does not accept repeated keys: input.queryArrayEncoding sets the endpoint-wide format. catalog_store.query_values() is shared by MCP request assembly and call_template(), so the structured schema and paste-ready command cannot disagree. Complete name=value arguments are shell-quoted with shlex.quote after canonical boolean/JSON encoding. Endpoint declarations are only valid when every array parameter shares a wire format. Meta Ad Library's array parameters all use JSON; undeclared endpoints retain repeated keys. Pinterest's mixed convention remains a documented catalog gap until a live connection can verify a separate per-parameter extension.

  • Nested JSON bodies keep the dotted-key schema convention (params.domain beside a parent params object). call_template() runs unflatten_dotted() on the assembled --data object so the paste-ready command emits {"params":{"domain":…}} rather than a flat "params.domain" key plus a "params":"<object>" placeholder. MCP request assembly does not share this helper: callers already send a nested JSON body. Query parameter names that literally contain a dot (user.fields, searchVolume.min) are not bodies and stay unexpanded.

  • verified + example_response mean a live call was made and passed, and carry exactly the same weight as in core — the validator applies one rule to both tiers: verified ⇒ a test_request to re-verify with and an example_response file that exists.

  • Exactly one of verified, unverified, untestable or skipped should be present on an entry that has been through the pipeline:

    • untestable: <reason> — set at INGEST: no test request could be generated (route absent from the provider's docs, or a required parameter only the caller can supply, e.g. their own platform cookie). No call was made and none is possible with a bare key.
    • unverified: http 404 … — set at VERIFY: the call was made and failed, with the status code and the provider's message. This is a finding, not a gap: 402 means the route needs a paid plan tier, a family-wide run of 404s means the provider's upstream scraper is broken.
    • skipped: <reason> — set at VERIFY: a usable test request exists and the call was deliberately not made, to conserve a paid balance. The reason names the sibling endpoint that WAS verified, or why one call costs too much. See "the fourth state" below — these clear with money, not investigation, which is what separates them from the two above.

    catalog_validate.py ENFORCES this: an extended entry that has been through the pipeline (it has a test_request, or any of the four keys) must claim exactly one of them, non-empty. Two at once is a contradiction; a key present but empty is the failure that motivated the rule — a re-run overwrote an endpoint's result record, dropped the reason string, and stamped an empty state, which every other check happily passed. A never-verified entry straight out of ingest has neither a request nor a state key and is left alone.

  • Ids are unique across the WHOLE catalog, both tiers, all providers.

  • Two optional fields exist only in this tier, both added for providers with split surfaces:

    • host: <fqdn> describes an additional API root for an endpoint whose path is not relative to the provider's primary base_url. It becomes executable only when the provider explicitly opts in with OAuthProvider.catalog_targets; otherwise historical host metadata remains inert and calling still uses the provider's primary profile. The catalog cannot authorize a host by itself. OAuthProvider.catalog_targets must map the exact hostname to a safe HTTPS base URL and any credential-profile override. profile_for_catalog_host rejects missing, duplicate, malformed, credential-bearing, port-bearing, query-bearing, and fragment-bearing targets before reserve or relay. The endpoint path is joined after the approved base URL's existing prefix, so primary KG paths and alternate Extract paths do not duplicate or erase version prefixes. Diffbot uses this for its KG, Extract, Web Search, and Natural Language host families; Web Search's target also changes query-token injection to its documented Bearer header. Absence of host retains the primary provider profile and base_url.
    • scope_gap: <one line> — the credential treg's OAuth app obtains CANNOT call this, and this is the scope that is missing. These are listed rather than dropped on purpose: the set of gaps is the answer to "which scopes should we add to the registered app", and it is only visible if the endpoints stay in the file. scope_gap present ⇒ expect 403 until the app is widened.

Kind — the browse surface vs. the plumbing

kind says what an endpoint IS, so the marketplace can lead with the useful surface and tuck the provider's own machinery out of the way. It is optional in BOTH tiers; absent reads as data.

kind what it is examples browse
data (default) fetch / scrape / enrich a resource get user profile, backlink summary, SERP shown
action a meaningful WRITE on the connected user's OWN account post a video, reply, update an ad budget, upload shown
account the provider's own list/webhook/saved-search/credit CRUD create/delete a lead-list, manage webhooks hidden
utility helpers with no data of their own token/x-bogus generators, enum & location listings, decrypt/encrypt, device register hidden

data + action are the browse surface; account + utility are management endpoints. Three things follow, and they are the whole point of the field:

  • The platform census counts data + action only. GET /catalog/platforms reports each shelf's endpoints / capabilities / verified and its "from …" price over the browse surface — a management endpoint is real inventory but it is not what a tile advertises, so it never inflates those numbers (nor the marketplace tile counts the dashboard renders from them).
  • The default platform view drops them. GET /catalog/platforms/<slug> returns only the browse surface in capabilities / extended / domains, plus a hidden_count. Pass ?include_hidden=1 to get the WHOLE surface back — every endpoint carries kind, so a client can fold the plumbing behind its own control. The dashboard does exactly this: it requests include_hidden, renders data/action in the ledger, and files account/utility behind a small per-section "N management endpoints" expander (the same show-more gesture as the platform tiles).
  • kind is a reviewed judgement, carried across re-ingests. Like capability and name, an extended entry's kind is set by review, not derived from the spec, so catalog_ingest.py's carry_verification re-attaches it by id — regenerating the file must not reset it to data.

catalog_validate.py only checks the value when present: a stated kind must be one of the four.

Naming — name is the search surface we own

summary is the provider's text, verbatim; name is OURS, and since 2026-08-20 it is searched (same weight as summary). That makes it the one per-endpoint field where curation may put the words agents type. The formula: job + the input the caller must hold + top output facets, ≤60 characters, and it must read as a natural title — it is the row heading on every surface.

Linkedin: get company profile (web_v2)   ->  LinkedIn company profile by URL or slug — headcount, industry
Get user profile                         ->  TikTok user profile by username — followers, bio, stats

The rules (applied catalog-wide in the 2026-08-20 rewrite; every new provider follows them):

  1. Name the JOB in task words — never the vendor's operation title or version codes.
  2. Say the INPUT ("by name", "by domain", "by ASIN", "by LinkedIn URL"). Agents search by what they hold; only the caller knows its inputs — that doctrine applies to naming too.
  3. Say the top OUTPUTS when people search by them ("headcount", "reviews", "hiring signal").
  4. One concept, one word, catalog-wide: always "postings", never sometimes "vacancies"; aliases.yaml covers the agent's side, our side must be consistent.
  5. Prefer the longer word form — "postings" contains "posting"; substring matching never works backward.
  6. No dead words: "API", "data", "get", "fetch", "endpoint" are soft tokens worth nothing.
  7. No stuffing. If it does not read as a title, it is wrong. Overflow vocabulary belongs in the capability description (weight 3, shared by the group) or aliases.yaml, never in the name. Worked case (2026-08-27): "find instagram influencers by niche…" returned ZERO results because influencers.club's name/summary said only "creators" — fixed by naming the job in the endpoint (…influencers by niche & size), carrying the facet words (country, followers, engagement, Instagram/TikTok/YouTube) in the creators.search capability description, and aliasing influencer(s)/kol(s)/microinfluencer(s) → creators and ig → instagram. Long natural-language queries still require every rare word to appear somewhere; the near: hint tells the agent which words to drop.
  8. TRUTH over vocabulary: derive the name only from the row's own summary, path and input fields. A name claiming an output the endpoint does not return is a lie an agent will spend money on.

Cost — the file keeps the billing unit, the server computes USD

A cost block stays in whatever unit the PROVIDER bills in; that is the number that stays correct when a rate moves. cost.usd is added at SERVE time by Catalog.cost_view from fx.yaml, so a rate refresh re-prices the whole catalog without touching a provider file. Clients (dashboard cards, treg catalog search, treg catalog get) lead with usd because a column is only comparable in one unit, and fall back to the native amount when usd is null.

The full block:

cost:
  type: per_result        # per_call | per_result | per_success | free | quota_rows
  value: 2.00             # non-null unless confidence: unknown
  currency: USD           # USD | CNY | credit | unit
  per: 1000               # the quantity `value` covers (default 1)
  unit: row               # what `per` counts — or, under `currency: unit`, the provider's meter
  source: docs            # rate_card_api | docs | observed | vendor_email | inferred
  source_url: https://…   # the exact rate card / pricing page (or rate-card endpoint)
  checked: 2026-07-28     # when the PRICE was confirmed — not when the route was called
  confidence: documented  # verified | documented | inferred | unknown
  note: "…"               # free text: the half of the charge the schema cannot hold, caveats, traps

For finite AIGC matrices, linear rates, and usage-settled generation, value is replaced by an ordered first-match table plus an explicit fallback upper bound:

cost:
  type: per_success
  table:
    - {when: {body.model: Model-A, body.resolution: 512P, body.duration: 6}, value: 0.3}
    - {when: {body.model: Model-B, body.resolution: 768P}, value: 0.13, times: body.duration}
  fallback: {value: 2.0, note: "most expensive supported combination"}
  currency: USD
  settle: table                 # or usage
  # usage: {path: usage.cost, unit: usd}
  source: docs
  source_url: https://example.com/pricing
  checked: 2026-09-01
  confidence: documented

Rows match in file order. when is a subset comparison: every named field must equal the request value after input defaults are applied, using exact forms. References are location-qualified dotted paths (body.model, body.input.num_outputs, queryParams.mode) so query/body collisions cannot silently price the wrong field. Every when field must be required or declare default in input. times multiplies by one numeric request field with a positive max. Narrow rows must precede broad ones; the validator rejects a later condition shadowed by an earlier subset, duplicate conditions, unknown row/fallback keys, non-finite values, values outside input enum/min/max, and simultaneous cost.value plus cost.table. fallback is a hand-written, explained global upper bound, checked against every row's maximum computable price. A times value outside the field's declared range (or non-finite, or non-positive) matches no row and prices at the fallback, so a request cannot reserve zero or bill past the ceiling. With settle: table, the matched row is reserved and settled (fallback when unmatched). With settle: usage, the matched row is reserved as the rate-card estimate and the reply's usage.path figure settles (the terminal document on an async row, the buffered body on a synchronous one), which may exceed the reserve (OpenRouter's unpublished minimums). A flat value may also declare settle: usage with no table: its explicit fallback is the reserve. Either form requires exactly a dotted usage.path and a supported usage.unit (usd; credit when fx.yaml prices that provider's credit; or a provider-native meter with a numeric unit_rates_usd[provider][unit] entry), or usage.terms with unit: usd for a provider that reports several meters and no charge: a list of {path, rate} whose figure is the sum of each meter times its positive USD rate. A term path may select a list item by key (candidatesTokensDetails[modality=IMAGE].tokenCount), because per- modality entries have no guaranteed order. An absent meter counts as zero (proto3 JSON omits zero fields); a response with none of them is unobserved and settles at the reserve. Any other settle rejects a stray usage block. A times value is never non-positive, whatever minimum the field declares, so a field that admits a sentinel such as -1 cannot multiply a rate by it; the sentinel is priced by a flat row that pins it, and that row is left out of the advertised per-second rate span. The money fragment describes the settlement itself.

value + currency + per answer how much; type + unit answer per what; source + source_url + checked + confidence answer says who, and how sure. All four questions have to have an answer before treg will spend its OWN money on an endpoint (see "platform-eligible" below), which is the whole reason the provenance keys exist.

per and unit. Read a block as "value currency per per unit". SpyFu bills a CPM, so value: 2.00, per: 1000, unit: row — and cost_view divides, serving usd: 0.002 per row. Hunter Domain Search charges 1 SEARCH credit per 1–10 emails returned (per: 10, unit: record), so usd is the linear slice ($0.00245/email) that reserve can scale with limit. A live hit does not sell that slice: it bills one whole credit (~$0.0245) for one email or ten (observed 2026-07-31). cost.display with grouped + round_up advertises the credit (display_usd: 0.0245, "started 10 emails"); Catalog.advertised_usd is what catalog_search / catalog_get put on usd_per_call. Settlement still reads usd and the derived email-count rule — display only. Catalog.cost_view reads the provider-neutral cost.display flags: unit names the shown unit, grouped shows the price for cost.per units, round_up labels a started block, variable adds a plus sign for selected additions, and maximum labels a validated reserve ceiling as "up to" (it may wrap a price table, whose fallback is already the global upper bound). The result is display USD / unit / prefix / suffix fields only; usd and settlement are untouched. The validator checks the flags, requires a grouped price to declare a positive integer per, and refuses a table display that does not describe its maximum. Akta bills 1.5 credits per 50 reviews the same per way. Without per, every one of those had to be either wrong or rounded into prose. A scalar unit: character is request-priced rather than page-priced: _body_text_characters counts the top-level JSON text string and multiplies the normalized per-character USD rate. Invalid JSON or a missing/empty string reserves one character, never zero; the normal request/envelope checks decide whether the provider served anything and per_success releases a rejected call.

Three kinds of denomination convert, and they convert differently:

  • A real currency (currency: USD, CNY) uses fx.yaml's rates_to_usd, keyed by currency.
  • currency: credit is NOT a currency. A credit is a PROVIDER-SCOPED unit — one scrapecreators credit and one lusha credit have nothing to do with each other — so it converts with the rate for the endpoint's provider from fx.yaml's credit_rates_usd block, keyed by service. That is why cost_view(cost, provider) takes the provider: the same value: 1, currency: credit is worth $0.00188 on scrapecreators and $0.1248 on lusha.
  • currency: unit is the provider's own METER: Semrush's "API units", Majestic's three independent allowances, Moz's row quota. unit names which meter, and the rate comes from fx.yaml's unit_rates_usd[provider][unit]. A provider can spend several meters at once — Majestic's analysis / retrieval / index-item units no more convert into each other than two providers' credits do, so each gets its own row. Before this existed, Moz's quota_rows blocks carried no currency at all, defaulted to USD, and served every Moz route as costing $1.00.

A credit_rates_usd entry may carry kind: treg_shared_plan: a rate TREG SET for a flat-fee provider (a subscription with a rate limit or unlimited calls), where no per-call vendor price can exist. The credit is then "one call on treg's shared plan" and the machinery is unchanged — the honesty lives in the entry: the basis must start with "treg shared-plan rate", name the vendor fee, and state the break-even volume, and fee_usd_month must be present as data (the validator's check_fx enforces all of it). The rate is reviewed monthly against reconcile.shared_plan_recovery and edited by hand. The full ladder: docs/SHARED-PLAN-PRICING-PLAN.md; the billing side (429 never billable, the recovery report): architecture/money.md.

For synchronous providers that disclose the exact charge in the response, a paid cost may declare reported_charge: {path: ..., unit: usd} or use unit: credit when the provider has an fx.yaml credit rate. The catalog estimate still reserves a safe ceiling. A finite nonnegative response value settles the call at that amount; missing, invalid, or non-finite evidence falls back to the normal estimate/miss rules. reported_charge is generic catalog metadata, not a provider-specific billing branch, and cannot be combined with cost.settle.

spooled_response: true marks a synchronous endpoint whose answer inlines media too large for the 8 MiB settlement buffer (Gemini returns images as base64 in its JSON: ~9 MB at 2K, ~23 MB at 4K; Lyria songs arrive as base64 MP3). Its metered 2xx is read to disk and settled from exactly the paths its row reads: the top-level objects its usage terms start at, and its expect success leaf (resolve._spool_evidence_paths; proxy-model.md), so the evidence cannot drift from the price. A token-metered row settles on usage; a fixed price (Lyria's per song) needs an expect rule so a refused generation is not billed. The validator requires one of the two and refuses the field beside async, resource_ownership or managed_resource, which need the whole body.

A pathParams field that declares an enum is enforced on treg's key: the value names what the shared credential is spent on (Google AI's model), so any other value is a 400 before reserve. Own-key calls still relay whatever path the caller asks for.

platform_request fixes exact body, header or query values needed only on the shared credential. A queryParams.* pin must appear exactly once and is read as the pinned value's type, so a run option such as a spend cap or memory size can bound what one call costs. An Apify per_result price may add call_fee, the flat per-run charge settled with its counted rows (money.md, Apify dataset-row settlement). Provider-specific request guards bound shapes whose billing formulas need more context than an exact selector: Openmart requires its explicit 1-25 record count, while Tavily Map and Crawl require an explicit integer limit from 1 to 20. Resolution applies these only after selecting the platform offer and before reserve; a team's own credential retains the upstream contract.

A second treg-set kind, kind: treg_trial, prices a provider at exactly $0 with a trial_calls_per_team_day allowance as data beside the zero: a capped taste served on treg's own FREE-tier key. The allowance is what makes $0 honest — at zero the price gives no brake, so the cap is the congestion control (_enforce_trial_allowance, per team per UTC day, successful platform calls with a non-free catalog cost only, fail-closed). Free endpoints, failed calls and BYOK calls do not consume it. cost_view attaches the allowance to every $0 it serves, because a bare $0.00 reads as unlimited. The validator refuses a non-zero "trial" and a zero with no allowance.

Each credit_rates_usd / unit_rates_usd entry carries usd plus the basis/source/checked that justify it — the cheapest PUBLICLY listed tier (plan price ÷ credits included), so the served figure is an upper bound on real spend, never an under-estimate. usd: null is a deliberate state, not a gap: the provider publishes no per-credit price (sales-negotiated like Crunchbase, or not credit-priced at all like BrightData). Those endpoints keep cost.usd = null and display natively ("3 credits/success"), because a guessed dollar figure is worse than an honest credit count. Both blocks are hand-maintained and must stay ABOVE rates_to_usd: — catalog_fx_update.py rewrites the file from the text before that key and discards anything below it.

Provenance — confidence is a claim about the PRICE, not about the route

verified: 2026-07-28 on an endpoint says the route answered. cost.confidence: verified says the money figure was confirmed. They are independent, and conflating them is how a guess gets spent:

confidence what earns it
verified observed being billed on a real call (source: observed), or read from the provider's own live rate card (source: rate_card_api — TikHub's get_all_endpoints_info, DataForSEO's /appendix/user_data, ScrapeCreators' credits_charged in its OpenAPI)
documented transcribed from the provider's docs or pricing page
inferred the figure is a floor or the top of a published range — a base fee with a per-row half on top ("1 credit base + 1 per ad"), a spread ("1–9 credits", "$0.50–$5.00 per 1,000"). The recorded number is not the whole charge, and the note says what else applies
unknown no figure is published anywhere citable. value MUST be null and note MUST say why

Rules the validator enforces: value: null and confidence: unknown appear together or not at all; a verified/documented price names its source_url (source: observed is exempt — its evidence is the captured example response, not a page that may have moved); every priced entry carries checked, and CI WARNS past 90 days. A file whose header says UNVERIFIED caps its prices at documented: nothing in it has been called, so no price in it can have been seen being charged.

Free is spelled exactly one way — type: free, value: 0, currency: USD, unit: call — and needs no provenance, because 0 does not move and there is nothing to re-check. It was previously written three incompatible ways across 661 endpoints, which left cost.usd null on most of them: indistinguishable, downstream, from "price unknown".

scripts/catalog_cost_provenance.py owns the mapping from what the repo knows about a provider's pricing to these keys, and is re-runnable — the extended tier is regenerated wholesale, so provenance typed by hand into a generated file would not survive the next catalog_ingest.py.

Platform-eligible — when treg may serve a catalog fallback

Catalog.platform_eligible(endpoint) is the single predicate behind catalog fallback access. Most eligible rows use prepaid platform-key tier 4. A row with platform_auth: anonymous instead uses the provider's verified public route without a credential. One implementation keeps the API, validator and proxy in agreement. Eligibility requires ALL of:

  • cost_view(...)["usd"] is not None — the charge is machine-computable;
  • cost.confidence is verified OR documented (policy widened 2026-07-31: a rate the provider itself publishes is billable; verified stays the gold standard the drift reports police, and inferred/unknown stay refused — a guess is not a rate);
  • scope != own_account and kind != account — the provider's own bookkeeping is never worth spending on, and an own-account route needs the caller's own credential by definition.

The live-called verified: stamp is no longer required (same 2026-07-31 change): a broken route fails unbilled under per_success/per_result billing, providers that report in-band settle at 0, and the fail-closed daily platform cap bounds whatever remains — coverage beats caution now that the reserve/settle machinery is proven. Eligibility alone still enables nothing. A normal platform call requires a configured key and the provider allow-list (platform_key_for). An anonymous fallback requires only the same provider allow-list (platform_provider_enabled) because it loads no provider key.

platform_auth: anonymous is deliberately narrower than ordinary eligibility. Catalog validation accepts it only for live-verified, free GET operations with scope: any_account, no provider authorization metadata, and no shared async-resource lifecycle. Resolution preserves the normal team-tool then team-credential precedence. Only when both miss does _anonymous_offer create a virtual tool with an empty binding list and credential tier anonymous. The faithful relay then forwards the caller's request without injecting a provider credential. This is generic catalog metadata; the call runtime contains no provider or path list.

Routed ranking assigns separate priority to the four tiers: team tool or credential first, anonymous fallback second, and paid platform-key access third. This keeps the own-key guarantee intact if an anonymous endpoint later receives a verified routing adapter.

The doctrine is asymmetric on purpose: a missing or unknown price reads as "refuse", never as free. An endpoint with no cost block at all is therefore not platform-eligible without anything having to be written out for it, which is why the extended tier's unpriced routes need no annotation. Where an endpoint carries only observed_cost (DataForSEO prices per API family, not per route), _effective_cost synthesizes the block with source: observed, confidence: verified and checked = the verify date: a figure the provider itself reported charging is the strongest provenance the catalog has.

Core-wins dedup compares NORMALISED paths — except on Graph

A hand-curated core file and a machine-readable spec never agree on placeholder spelling: core says /v1beta/properties/{property_id}:runReport, Google's discovery document says {property}. A naive (method, path) comparison therefore misses, and the endpoint ships in both tiers — that is the DataForSEO /v3 bug below, in its other form. The Google and X ingesters compare with every {...} collapsed to {}, so the two spellings match.

Meta is the exception and uses exact comparison, because on the Graph API the node id IS the first path segment: /{post_id}/insights and /{page_id}/insights differ only by the placeholder name and are genuinely different endpoints. Normalising there would silently drop post insights because the core file curates page insights.

Process — adding / curating a provider

Do these steps in order; each has a hard success criterion.

  1. Ingest. If the provider publishes OpenAPI (/openapi.json), fetch it and list candidate operations from there — do not hand-transcribe paths (that is how typos ship). Otherwise work from the official docs and record source.openapi: null.
  2. Select. Curate, don't mirror: pick the ~8–15 endpoints an agent would actually reach for, and ALWAYS include the endpoints matching capabilities other providers already implement — overlap is the point (comparison + failover). Skip exotic ops.
  3. Map. Assign each endpoint a capability from capabilities.yaml. Missing job → add it under proposed_capabilities: in your provider file, don't edit the shared taxonomy in parallel work.
  4. Describe. Fill input from the spec/docs: param names, types, which are required, where they ride (path/query/body). Copy real constraints ("one of A|B") into note.
  5. Cost. Record the provider's price model per endpoint from their pricing page — with its provenance (source, source_url, checked, confidence) and its unit (per, unit), per "Cost" above. quota_rows is for row-quota APIs (Moz). Unknown exact value → value: null + confidence: unknown + a note saying why. If the provider exposes its rate card as an endpoint, prefer it over the pricing page and record it as source: rate_card_api: it is re-checkable, which is what lets treg serve the route on its own key.
  6. Test-request. Give every endpoint a test_request that is CHEAP (smallest limit, one item, public well-known target — e.g. user "tiktok", domain "moz.com"). This is what verification and future health checks replay, so it must not burn meaningful credits. ⚠️ Quota trap (learned live, Moz 2026-07-28): never probe an endpoint with an empty body/params "expecting a free validation error" — an endpoint with NO required params answers with its FULL default result set and bills for it (Moz's global_top_* ate an entire 50-row period quota in two calls). Always pass an explicit smallest limit, and on row-quota APIs check the usage endpoint before and after the first call.
  7. Verify + capture. Run scripts/catalog_verify.py <service>.yaml with the credential in the TREG_CATALOG_CRED env var. It calls every endpoint's test_request, checks expect, writes the truncated example response to examples/, and prints PASS/FAIL per endpoint. Stamp verified: <today> ONLY on endpoints that passed — documented ≠ verified; docs lie.
  8. Scrub. Read every captured example: replace anything personal that is not the public test target's own public data. The account-info endpoints of YOUR OWN key (quota, balance) must have emails/ids masked before commit.
  9. Validate. scripts/catalog_validate.py must exit 0: schema shape, unique ids, capability and platform referential integrity, example files exist for verified endpoints, provider exists in oauth_providers.py.

Success criteria for a provider PR: validator exits 0; every endpoint either carries a verified date + example file or an explicit comment why it could not be live-tested; no credential value appears anywhere in the diff.

Process — bulk-ingesting the extended tier

uv run python scripts/catalog_ingest.py tikhub          # one provider
uv run python scripts/catalog_ingest.py all --refresh   # every provider, re-downloading the specs
uv run python scripts/catalog_validate.py               # must exit 0

The script owns <service>.extended.yaml end to end: it fetches the provider's spec, maps every route to a platform, drops what the core file already covers, and rewrites the file. Downloads are cached under ~/.cache/treg-catalog-ingest (override TREG_INGEST_CACHE); --refresh re-fetches. Output is deterministic — a re-run with unchanged upstreams produces a byte-identical file, so a diff always means the provider changed.

Adding a provider means adding an ingest_<service>() function and registering it in INGESTERS. Three rules it must honour:

  • Never probe with a real call. Discovering an HTTP method by sending a GET is how you get billed 1400 times (see the quota trap above). TikHub's methods come from an OPTIONS request, which Starlette answers 405 + allow: before the handler — and therefore the meter — runs.
  • The published spec outranks the probe (resolve_method). A wrong method is not a cosmetic error: treg enforces the recorded verb, so the endpoint becomes uncallable from both sides at once — POST refused here ("… is GET"), GET refused upstream (405). The probe is weaker than it looks: a preflight answering with a method list walks its preference order and comes out GET whatever the handler takes. So when the OpenAPI declares exactly one method, that wins; probe and docs are the fallback for routes the spec doesn't describe.
  • The verb and the parameter POSITION are one decision, from one document. TikHub's Apifox docs list every TikTok-Ads parameter under parameters.query while its OpenAPI declares the same route POST-with-a-JSON-body. Taking the verb from one and the position from the other yields a POST carrying its arguments in the query string — still uncallable, just differently. When the spec declares a JSON body and the docs gave us none, the documented "query" parameters ARE that body.

Catalog rot is a category of bug, and it is not the ingester's fault

The 2026-08-17 TikTok-Ads breakage was first written up here as an ingester defect. It was not, and the correction matters more than the original claim. Those twelve routes really were GET when ingested: TikHub's July spec says get, and the captured example_response is a real billed 200 from a GET on 2026-07-27. TikHub moved them to POST some time after. The catalog did not mis-read the provider — it went stale, and at the time nothing re-checked a provider's spec for drift.

That reframed the fix. Preferring the spec over the probe is a genuine hardening, but it only helps at re-ingest time, and only if the cached spec was refreshed — the cache under ~/.cache/treg-catalog-ingest is what an unqualified catalog_ingest.py <provider> reads, so a re-run against a months-old cache faithfully reproduces months-old truth. A verified: stamp is evidence about the day it was written and nothing after it.

scripts/catalog_drift.py now closes that gap without making a paid API call: it discovers public OpenAPI documents from each provider file's source provenance, downloads the document with no credential, and compares every checked-in (path, method). Plain JSON is preferred; the same salvage_json_map used by the ingester recovers a complete paths map from a truncated document, and YAML OpenAPI is accepted too. An unmarked missing path, method change, or marked route that has reappeared exits non-zero. Known absent marked rows are reported as acknowledged, not drift. The daily catalog-drift.yml workflow currently runs TikHub—the provider with demonstrated production rot—and the script remains provider-general for every catalog file that cites a public OpenAPI URL.

Retired and broken endpoints are tombstones, not offers

Provider rot must not turn an id an agent cached yesterday into either a bare provider 404 or an unexplained registry 404. Keep the row and add:

status: retired                 # or broken
status_note: why it is gone and what changed
superseded_by: provider.live-id # optional; only when the operation is genuinely equivalent

catalog_store._parse always retains the normalised row in by_id, so direct endpoint inspection can return its story, but excludes it from endpoints, the source for search, browse, capability counts and platform eligibility. On a direct endpoint-id call, _resolve_marketplace_call raises an actionable 410 before choosing or loading any credential; the pre-relay audit class is retired. /catalog/endpoints/{id}/access applies the same gate. This is catalog fallback only: an org tool whose exact name matches the retired id resolves first and remains callable, and URL passthrough never enters catalog lookup.

The validator treats the marker as a contract: only retired and broken are valid; every marker needs a non-empty note; status_note and superseded_by cannot float without status; and a successor must be a different, existing, live catalog id. A marked id is therefore an explanation, not an alias chain or a route treg will still spend against.

The marker is not TikHub-specific, and the provider does not have to answer 404 for a row to be dead. lusha.x.decision-makers (2026-09-09) is the second shape: Lusha removed POST /v3/contacts/decision-makers on 2026-08-12 in favour of /v3/contacts/buying-group, the only operation that accepts contactsLimit and personas - but a legacy handler kept answering companies-only bodies on the old path and rejected the cap parameter with a 400. A route that still returns 200 while silently ignoring the caller's spend control is broken in the way that costs the most (every call ran at the 60-contacts-per-company default, 1 credit each), so it is retired with superseded_by: lusha.x.buying-group even though the old URL "works". The successor was written from the provider's OpenAPI bundle without a live probe and says so with skipped and no example_response; an invented fixture would be worse than none. lusha.extended.yaml is hand-maintained (no ingester reads Lusha's client-rendered reference), so the "regenerated wholesale" caveat above does not apply to it and the tombstone survives.

platform_blocked: — works upstream, but not on treg's plan

A third state sits between "offer" and "tombstone": the route works and the price is real, but treg's own subscription cannot serve it — Akta answers every alternative-data call (jobs, posts, website-traffic, employee-reviews, headcount-trends, product-reviews) on the shared key with a free 403 "Your current subscription does not include access to this endpoint". Marking those status: broken would be a lie (a caller's OWN key on a bigger plan serves them fine) and leaving them unmarked sold them as platform offers — a customer ran a whole evaluation lane into that wall of 403s before learning the gate existed. platform_blocked: <reason> keeps the row in discovery but makes platform_eligible() refuse it, and the reason rides on the served row so every surface can say "bring your own key" before the call instead of relaying the 403 after it.

  • Platform is the system the data is ABOUT, not the API family it lives under: DataForSEO's /v3/merchant/amazon/products/live/advanced is amazon, not merchant. Anything not tied to one system is web. Every new slug goes into capabilities.yaml's platforms in the same change — the script exits non-zero if a generated platform is unknown, which is the guard.
  • Normalise slugs across providers. Just One API calls it douyin-tiktok-china and TikHub calls it douyin; if both don't land on douyin, the marketplace shelf splits in two and the cross-provider comparison the catalog exists for silently stops working.

The first-party OAuth wave (2026-07-28; Google Tag Manager added 2026-08-27)

The scraper providers sell breadth and their extended tier reads as a menu. The nine providers where treg owns the OAuth app are the opposite question — what can this one connected account actually do? — and their sources differ per provider:

service source entries scope gaps
google-search-console searchconsole v1 discovery 7 0
google-analytics analyticsdata + analyticsadmin v1beta discovery 63 (55 on the admin host) 32
google-tag-manager tagmanager v2 discovery 98 8
google-business-profile six My Business discovery docs + 7 hand-listed legacy v4 routes 60 (45 off-host) n/a
youtube youtube v3 discovery + the published quota-cost table 76 2
google-ads the GAQL resource reference — one entry per queryable resource 42 0
x X's own v2 OpenAPI 168 91
facebook / instagram / meta-ads hand-curated from the Graph HTML reference 26 / 22 / 34 6 / 2 / 8

Three things generalise from it:

  • Google publishes a Discovery document for every API at https://<service>.googleapis.com/$discovery/rest?version=<v> — httpMethod, flatPath, a description, the full typed parameter list with required flags, and the OAuth scopes each method accepts. It is the same class of source as an OpenAPI spec and should always be preferred to the HTML reference. Scopes are ALTERNATIVES (holding any one suffices), so coverage is an intersection, not a subset. The My Business documents are the exception that declares no scopes at all, which is why that provider has no computable gaps.
  • Google Tag Manager keeps risky administration outside the grant. Its core catalog presents an audit → workspace edit → version/publish workflow across cumulative read/write/manage tiers. The generated catalog still lists methods requiring container deletion or account/user management, but marks all eight with scope_gap; those three scopes are intentionally never requested.
  • Google Ads is a resource list, not a route list. One endpoint (googleAds:searchStream) answers every read and what varies is the GAQL FROM clause, so the unit of coverage is the queryable resource. Forty entries share a path and differ in input.note and docs_url.
  • No test_request anywhere in this wave. Every route needs a property id, a customer id or a Page id that belongs to the connected business and that no spec can supply. They are verified by replay against a live connection (--via-treg), not by a generated blind call.
  • Instagram Messaging is deliberately core-curated. Conversation listing and message sending carry the Page-token/IGSID/window constraints and complete Try-form inputs in instagram.yaml; conversation listing targets the linked Facebook Page id (/{page_id}/conversations with platform=instagram), and replies use that Page's /{page_id}/messages edge—not the Instagram account id used by profile/media routes. The send route remains explicitly unverified so no catalog sweep can deliver a real DM. The Instagram generator omits these two messaging routes; they exist only in core. Meta's exact (method, path) core-wins dedup still protects all other generated routes whose placeholder names carry different Graph object semantics.

Process — bulk-verifying the extended tier

TREG_CATALOG_CRED='<secret>' uv run python scripts/catalog_verify_extended.py tikhub --dry-run
TREG_CATALOG_CRED='<secret>' uv run python scripts/catalog_verify_extended.py tikhub --budget 1.80
uv run python scripts/catalog_validate.py            # must exit 0

--dry-run prints the queue and what it would cost at list price; nothing is called. The real run goes CHEAPEST FIRST and stops before any call that would push the run past --budget, so a half-finished run has verified the cheap majority rather than an arbitrary slice. Results are written back into the yaml after every run and a re-run skips what already carries verified, which makes an interrupted run resumable instead of a repeat bill.

Three things to know before pointing it at a new provider:

  • A missing cost reads as free, and silently disables --budget. DataForSEO publishes prices per API family, so not one of its 216 extended entries carries a cost block — which made the spend cap inert: a run queued the whole platform at an estimated $0.000 and still spent real money, with only the after-the-fact balance readback noticing. The fix is observed_cost: the charge the provider states in its own response (tasks.0.cost), written onto the endpoint at verify time and used to budget the next run. It is the better number regardless — measured, not transcribed from a price list — and summing it gives a defensible run total, which balance arithmetic cannot because it cannot separate our calls from anything else using the same key. DataForSEO's full sweep, summed this way: $4.85521 across 177 endpoints.
  • observed_time is measured, not read. The wall-clock seconds WE waited for the response, recorded on the endpoint next to observed_cost. Two reasons it is not lifted out of the body: only DataForSEO reports its own duration, and TikHub's time field is a TIMESTAMP ("2026-07-27 23:27:48"), so an extractor trusting the field name would write a date into a numeric column. It is also the number that matters — CALL_TIMEOUT applies to OUR client. Worth having because a timeout is recorded as the endpoint's verdict, and the same DataForSEO route can swing wildly: merchant/amazon/sellers/live/advanced answered in 9s and 105s on two identical calls, products/live/advanced in 26s and 55s. Under the old 60s ceiling both were coin flips that would have written "unverified" onto a healthy route on some runs and not others. Elapsed time predicts nothing about price, either: a 0.04s call cost 4x a 26s one.
  • Cost accounting assumes the provider bills per success. The run's spend is the sum of the prices of the calls that returned 2xx. If a provider bills per call, that is wrong in the optimistic direction — check the balance delta the script prints against its own estimate before trusting a large run. It reads the balance before and after for exactly this reason.
  • The parameter source has to give example VALUES, not just names. A generated test request that invents an id verifies nothing: it produces a 404 that looks like a broken endpoint. If the provider documents parameters without examples, the honest output is untestable, not a guess. (For TikHub, the values come from sampleValue in their Apifox docs API — their own demo values.)
  • Examples are trimmed to ~2 KB, arrays to one item. At 1385 endpoints, core's 10 KB cap would add ~14 MB of JSON. An extended example is there to show the response SHAPE.
  • Check for a PER-ROUTE rate limit, not just the account-wide one. TikHub allows 10 req/s on the account but only 1 req/s on any single route. A global pacer does nothing about that — it spaces consecutive requests across different routes — while a retry by definition hits the same route again. Retrying after 0.5s therefore guarantees a 429, and the 429 lands in the file as though the endpoint had failed: 66 endpoints on the first full run carried a rate-limit verdict that said nothing about the endpoint. Any same-route retry has to wait out that window (PER_ROUTE_GAP), and 429 must count as retryable rather than as an answer.

⚠️ Read the recorded failures before believing them. A unverified: line is evidence about one call at one moment, and the failure modes that look identical in a summary count are not: a 400 that repeats is a verdict, a 400 that passes on the third try is a flaky upstream (TikHub's LinkedIn family), and a 429 is usually our own fault. Grouping the failures by status code and by platform family, then re-running one family, is what separates them — pass rates per platform in the same run ranged from 8% to 100%, and the low ones were mostly not the provider's fault.

A fix landing mid-sweep leaves the un-noticed batches wrong. DataForSEO's 8-batch sweep ran across the moment the /v3/v3 URL bug (see below) was fixed. The web batch failed loudly at 100% and was re-run after the fix; the amazon batch had failed the same way, nobody re-ran it, and its pre-fix results merged into the file as 7 endpoints marked unverified: http 404 — which then read as a retired Amazon route family. All 7 passed on a re-run, first try, for $0.075. Nothing was ever wrong with them.

Two signals identified it, and both are worth checking before believing any block of failures:

  • The failures aligned exactly with a batch boundary. amazon was the only platform in the file with a single unverified, and it held 100% of what that batch touched. Endpoint problems do not respect our batching; tooling problems do.
  • Siblings verified by a DIFFERENT code path passed. dataforseo_labs/amazon/ranked_keywords and merchant/amazon/asin were green in the same two families, verified earlier by catalog_verify.py rather than the bulk runner. A family cannot be both retired and working, so the disagreement was between our two callers, not about the endpoints.

The general rule: after fixing a bug that could have produced failures, re-run every batch that ran before the fix, not just the one whose failure you noticed. The loud batch is the one you already know about; the quiet ones are what ship a false verdict into the catalog.

How many passes, and when to stop. On TikHub, LinkedIn went 8% → 27% → 67% → 90% verified over four passes with no change other than being asked again — 43 of 48 endpoints that a single pass called broken. Conversion per pass is the stopping signal, not a pass count: 672, +30, +16, +14, +2. A pass that converts ~2 is convergence, and what remains after it is genuinely broken (for tikhub, 107 of the final 115 failures are the provider's own "Request failed. Please retry." after six attempts each). Raising --retry-attempts is the cheapest lever available on a flaky provider and costs nothing but wall-clock under per-success billing.

Just One API shows the same curve from its far end, and what a confirmed verdict costs to establish. Its 13 failures were one uniform error, code 301 COLLECT FAILED, clustered in whole families (Kuaishou, Taobao, JD) — the exact shape that ought to mean "our fault". They survived 3 retries inside a call, then 4 runs, then a serial pass hours later, then a sixth with retry depth raised 3 → 6: the last two passes converted one endpoint each, for ¥0.35. Same decay, further along, so its 11 survivors are evidenced verdicts rather than impatience. The rule is therefore not "retry until it works" but retry until the result stops changing.

Two things generalise from that. The one endpoint that flipped was LinkedIn — the family that is also flaky through TikHub, a different vendor entirely. That is the scraped platform defending itself, not the API vendor, so expect it from anyone scraping LinkedIn, and treat two LinkedIn scrapers as one point of failure rather than a redundant pair. And retrying is only free under per_success billing (both social providers); on a per_call provider like DataForSEO each retry and each extra pass is a purchase, so that budget belongs in the plan rather than in a loop.

One caveat on reading per_success as "bad input is free": the provider decides what counts as success. TikHub answers some invalid inputs (a bogus channel id) with HTTP 200, the error nested in the body, and "this request will incur a charge" — so the platform meter bills it, faithfully to what TikHub charges us. When TikHub uses a real 4xx it says "You won't be charged" and the meter releases the hold. Verified live 2026-07-30.

Then read a sample of the captured examples for PII before committing, as with core — bulk capture does not remove the scrub step, it just means sampling per platform family rather than reading all of them.

skipped: — the fourth state, for a call that was affordable but not made

verified / unverified / untestable above cover passed, called and failed, and no call is possible. Verifying two paid providers against nearly-empty accounts surfaced a fourth case they cannot express: the test request exists, the call would very likely pass, and it was deliberately NOT made because the balance was needed elsewhere. Calling that untestable is a lie about the endpoint, and unverified is a lie about the provider — it invents a failure that never happened.

  skipped: family verified via dataforseo.x.backlinks-summary-live; the DataForSEO account held
    $0.739 on 2026-07-28 and $0.58 of it was spent verifying one endpoint per API family

The reason must say what to do about it, which in practice is one of: the sibling that WAS verified (whole-family skips — 155 of DataForSEO's 216, 6 of Just One API's WeChat endpoints at ¥1.0–1.5/call), or why one call is too expensive to justify (DataForSEO's llm_responses routes exceed the $0.15/call ceiling). A skipped entry needs no re-investigation — only money — so a top-up plus a re-run clears them in bulk, while unverified and untestable need a human.

The state distribution is itself the report. Just One API: 227 verified, 21 unverified (11 of them the provider's own code 301 COLLECT FAILED after six passes, 5 NO PERMISSION — an account fact, not an endpoint fact), 6 untestable, 6 skipped. DataForSEO after its top-up and full sweep: 177 verified, 0 unverified, 39 untestable, 0 skipped — nothing in its extended surface is broken, and every remaining gap is structural (23 routes whose spec ships no example body, 16 on_page routes needing an async crawl id).

Form-body credentials

A provider whose POST routes take application/x-www-form-urlencoded may read the credential from the form body and reject a query token with a misleading auth error (Just One API). For bodyType: form entries catalog_verify.py and catalog_verify_extended.py move it into the body. A provider's other quirks live on its rows as note, enum or example, never here.

Choosing between providers (domain/catalog/stats.py)

307 capabilities are served by more than one provider, and prices inside one capability differ by up to 261×. So "which provider" is a real decision, made on every call.

The agent makes it, not treg — see docs/CAPABILITY-CHOICE-PLAN.md for the measurement behind that. Two reasons, and the second is the load-bearing one. Providers of the same capability take different requests (only 5 of 171 match exactly), so a router would need a canonical schema treg does not have; and they sometimes ask a different QUESTION entirely — hunter.people.email.find wants a domain and a name, leadmagic.x.b2b-profile-email wants a LinkedIn URL. Only the caller knows which inputs it holds. A router picking on price would choose the second for someone holding a name, and fail. Routing would also have been the first feature to break the founding rule that treg relays rather than models.

What treg owes instead is the half only treg can supply, because only treg sees every call from every tenant: endpoint_stats.observed() aggregates success rate, p50/p95 latency, last-answered and sample size per endpoint from CallRecord — which has recorded endpoint_id, status_code and duration_ms since the marketplace shipped and was never read. It rides on /catalog/endpoints/{id}, attached to the endpoint and every sibling, because the choice is made on that page and an agent will not make a second round-trip to compare reliability.

The same page states the one price the cost block cannot: what the call bills when treg's own account is out and the overflow relay serves it. routers.catalog._overflow_disclosure reads the enabled OverflowRoute through domain.capacity.routes_view (a read, the worker stays the only writer) and puts overflow_price_usd, overflow_price_unit and overflow_via on the endpoint view plus a hint, only when the deployment can actually relay it (TREG_OVERFLOW_MODE=on, a key for the aggregator, platform_eligible, an enabled route). A catalog-free endpoint with an overflow route is the case that made this necessary (apollo.people.search, 2026-09-08); the MCP catalog_get lifts the three fields onto its result so the schema advertises them.

The aggregate is authoritative but no longer request-time. stats.EndpointObservationReader is the narrow domain port, and bootstrap supplies CachedEndpointObservationReader around a PostgresEndpointObservationReader. Entries are keyed by endpoint id. They are fresh for five minutes; from five through thirty minutes HTTP and MCP search serve the old value immediately and start a refresh; after thirty minutes they publish no observation until a refresh succeeds. A cold process therefore answers the first requests without reliability weighting instead of making either Catalog entry point wait for Postgres. The API shape does not change: observed is null when no acceptable entry exists.

Refresh is process-level singleflight. Concurrent misses join one shared Task, duplicate endpoint ids already in flight are not queued again, and the Task batches the requested ids. Its PostgresEndpointObservationReader opens an independent session only around one small read and closes it as soon as that finishes. HTTP /catalog/search, both MCP catalog-search tools, routed planning in application.call.route.build_plan, and the prose pages that print observed stats (/use-cases/*, /workflows and /workflows/*) receive the same reader instance from bootstrap, so their request paths have no observation DB dependency, check out zero connections, and join the same refresh Task. A refresh failure keeps stale entries, backs off before retry, and never changes the Catalog response status; a failure with no cached entry is honest emptiness. The adapter exposes entry-level fresh, stale, and miss counters plus refresh and refresh_failure counts. Its invalidation story is the two TTLs: deploys and process restarts begin cold, and no cross-instance correctness depends on the cache.

The evidence is folded once, off the request path (application/catalog_stats.py, run by the treg-worker catalog stats cron). Refreshing straight from callrecord meant every web process re-aggregating thirty days of audit rows for an endpoint and its siblings whenever its cache expired, and again from cold after each deploy: on a large audit table that is tens of seconds per pass, each pass evicting the pages the money path needs. The worker instead walks the audit table by primary key from a persisted cursor (EndpointStatCursor) and folds each row into one EndpointDayStat bucket per endpoint per UTC day: counts, the newest success, the hit/per-success tallies, and a uniform reservoir of at most stats.LATENCY_SAMPLE successful durations. Rows younger than sixty seconds wait for the next run so an audit insert that commits late is never skipped; a plain tool call (no endpoint_id) and a treg refusal (refused_by) are not evidence and are not folded, exactly as the live query excludes them. The first run bisects the primary key to the first row inside the window rather than reading older pages, consumes at most --max-rows per run, and the reader keeps computing the live aggregate until a run reports it has caught up (caught_up_at), so a deployment that never schedules the worker behaves as before. The same fallback applies when the worker stops: a cursor not updated for STALE_AFTER_S (two hours) sends the reader back to the live aggregate with a warning, so a dead cron degrades to the old cost rather than to buckets that silently age out of the window. Each batch is one transaction under the cursor row's lock and re-reads every bucket it touches inside that lock; nothing about a bucket is carried between batches, so two overlapping runs (a slow backfill still going when the next schedule fires) serialize cleanly instead of one erasing the other's fold with the cursor already past the rows. Once caught up, an observation is the sum of that endpoint's day buckets from the day of the window's start onward (stats.window_days, at most one day more evidence than the live cut, never less), published through the same stats.publish floors the live path uses; the fold and the SQL are held equal by tests/test_catalog_stats_refresh.py. Merging days weights each day's latency sample by the calls it stands for (Tally.merge, Tally.percentile): a reservoir is uniform within its day, so a busy day's four hundred samples must count for its thousands of calls, or the window's p95 would be the quiet days'. Buckets older than the window are pruned at the end of each caught-up run. stats.Tally is the one shape all three paths share: a day, a merged window, or the live aggregate.

Five rules worth keeping:

  • A 4xx never counts against the provider. It usually means the caller sent bad parameters; counting it would let one agent's mistake make a healthy endpoint look broken to everyone. Only 2xx versus 5xx decides the rate.
  • 405 is the exception, and the rule's own justification is why. "The caller sent bad parameters" cannot apply to a method the caller was never allowed to choose: /call/ refuses a catalog call whose method differs from the recorded one with a 400, before relaying. So a 405 coming back from the provider says the RECORDED METHOD is wrong — a stale contract, which is the one thing this module exists to surface — and it counts as decided against the endpoint. Without it, the seven straight 405s on tikhub.x.tiktok-ads-search-ads sat in the excluded bucket and the WORKS column read — (7): indistinguishable from an endpoint nobody had tried. That is the half of the 2026-08-17 report that survived two rounds of review — fixing LAST OK stopped the row claiming success, but only this makes it say failure.
  • A treg refusal is not evidence about the endpoint. Rows with refused_by set (a paywall 402, a daily-cap 429 — see the data-model fragment) never reached the provider; they are excluded even from samples, or a burst of refused calls dresses itself up as traffic. The 2026-08-12 Hunter incident — 309 refusals next to 488 real calls — is why.
  • miss semantics ride on the endpoint. Some providers answer "asked and answered: no result" with an error status (PDL 404s a person it has no record of; Hunter's combined-find does the same). Endpoints with evidenced miss behaviour carry a miss: {status, means} block in their YAML, surfaced through endpoint_view — so an agent reads "404 = no match, don't retry" instead of treating an expected empty answer as a failure. Only annotate what the wire has demonstrated. The router and the arena read the same block through one function (routing.contracts.declared_miss, wrapped by route._declared_miss and called by arena.classify): a child answering the declared 4xx is a MISS — the waterfall goes on and a fully-missed call ends as a 200 miss, never route_failed. Where one status carries both a miss and a fault, when: adds a body predicate in the adapter expression language, evaluated only on a JSON-object body (catalog_validate.py rejects a when that is not a comparison or call, since a misspelt path would evaluate False forever and silently revert the endpoint to "every 4xx is an error"; endpoint_view shows agents status and means but not when): prospeo answers 400 for NO_MATCH (a miss) and for INVALID_DATAPOINTS (a fault), so its three person endpoints declare miss: {status: 400, when: "error_code == 'NO_MATCH'"}. Provider knowledge lives in the YAML; route.py never names a provider. Live 2026-09-18: 64% of three days of treg.people.email.find 502s were a limadata 404 or a prospeo NO_MATCH among otherwise clean misses — the block had never been declared on either (limadata's cost note said "a 404 miss is free"; prose is not read by the router). Before 2026-09-04 only PDL carried the block; the annotated set (aviato, hunter, leadmagic, findymail, companyenrich, thecompaniesapi, fiber-ai, scrapecreators linkedin) came from 30 days of prod children answering 404 with a "not found" body, and the router treated each as a rejected request: 1,824 phone.find parents were 502 in that window, 768 of them with no failure but an aviato 404 (voice-ai-outbound's GT report). Only a 4xx is honoured — a status: 200 block (tikhub) is agent documentation; the adapter's own miss predicate decides a 2xx. Note a per_call provider (companyenrich) still bills the request on its declared miss. Aviato company enrichment also declares 404 as a miss after the 2026-09-08 Arena sweep returned Not Found for microsoft.com; its company-enrich documentation identifies the response as Company Not Found Error. Arena and routed calls use the same metadata.
  • Below MIN_SAMPLES we publish the count and nothing else. "100% from two calls" is noise dressed as evidence, and on a quiet endpoint a rate could expose one org's activity. The floor applies to decided calls (2xx + provider-fault failures), not total traffic: four caller 422s cannot lift one 200 or 405 into a published rate. Latency has its own floor of successful calls; one success is not both a p50 and p95 merely because enough failures made the rate publishable.
  • Sample size is always visible, so 100% (8) cannot beat 99% (121) by looking rounder.
  • "Free" is a price, not a missing one. platform_eligible used to demand confidence in (verified, documented) for every route, but confidence says how much we trust a NUMBER we are about to charge — and a free route has no number. Requiring it anyway refused 61 endpoints across 8 providers (28 of Hunter's 35) as though "costs nothing" meant "we don't know", which is the one distinction this file otherwise keeps apart. A type: free route is now eligible without provenance; a PAID route without provenance is still refused.
  • A claim never wears a measurement's badge. The LAST OK column prints a bare age when a real call produced it and a ✓ age when it came from the catalog's verified: stamp — the same discipline confidence: already applies to price. The stamp is the cold-start answer: it covers 1,380 of 1,810 eligible endpoints for free, which is why the column is useful on day one.
  • last_ok means the last SUCCESS. It was max(created_at) over every row, success or not, so an endpoint that had been called seven times today and failed all seven read WORKS — (7) next to LAST OK: today — which is how tikhub.x.tiktok-ads-search-ads passed for a merely new row while being uncallable (2026-08-17).
  • Below the floor, the outcome stays unpublished — not even a yes/no. The 2026-08-17 fix first added any_ok ("has it EVER answered?") on the argument that a boolean survives any sample size. It did not survive the two rules above. On a quiet endpoint it exposed the outcome of one tenant's one call, which is half of why the floor exists; and because samples counts 4xx while successes do not, a single caller's malformed 422 published any_ok: false and made a healthy endpoint look broken to every other tenant — precisely the failure the 4xx rule prevents. It was removed. "Never worked" is read off ok_rate == 0, which is computed from DECIDED samples only, so no volume of caller errors can produce it.

Search scoring — most words must match, and the rare ones decide

catalog_store.search demanded EVERY query token match (AND). Right for the 2–3 word refinement ("tiktok comments" must not return every tiktok endpoint), and fatal for how agents actually query: the day the SearchMiss log shipped it recorded "company job postings hiring open jobs linkedin" → 0 results while three endpoints matched 6 of the 7 words. The only misses were "linkedin" on rows shelved under companies (the agent names where the data lives, the catalog names what it is), and "open" on the row shelved under linkedin. Since 2026-08-20 a query may miss one token in every three (1–2 words: all still required), and each matched token scores its field weight times its BM25 idf — "by" matches 558 endpoints and is worth ~nothing, "postings" matches 4 and decides the order. That asymmetry is also what keeps the miss allowance safe: dropping a rare word costs more score than dropping filler, so full-match fluff cannot outrank a near-match on substance. Rows matching the same tokens in the same fields still sum identical floats, so the tie band below keeps working. Query-side layers close what scoring alone cannot. Function words ("on", "this", "what") and single-letter tokens ("K&L" tokenizes to k + l, df 2,000+) are dropped before the miss allowance is computed — they select nothing, but each one raised the number of real words a row had to match. Tokens matching over SOFT_DF_SHARE (25%) of the catalog ("data" 33%, "api" 50%, "get" 40%) are SOFT: they still add score where they match, but a row is never punished for missing them — a statistical stopword list no hand list would keep up with. And aliases.yaml bridges vocabulary: substring containment only works in one direction, so "cryptocurrency" never finds the catalog's "crypto" without the map. A token matches under its own spelling or any curated alias, same field weight. NOUNS ONLY: aliasing a verb to a commoner verb poisons the key (lookup: [search, find] inflated lookup's match set 27 → 689 endpoints and destroyed its ranking power). The file is query-side only — it rewrites no provider text, survives every re-ingest, and the validator (check_aliases) rejects entries that could not survive the tokenizer and warns on aliases whose target occurs nowhere in the catalog. The tokenizer also retains contiguous CJK text, so Chinese task-phrase aliases are real searchable keys rather than discarded punctuation spans. Alias keys remain one token, while targets may be lowercase hyphenated phrases because matching tests the target string directly against catalog text. This makes t2v → text-to-video selective. The original AIGC aliases mapped model names and Chinese task phrases to video/image: live treg catalog search expanded Hailuo/Seedance/t2v to 521 endpoints and Flux to 172, including YouTube and unrelated image utilities. Model-family aliases were removed once real endpoint text contained those names; compact and Chinese task terms now target only text-to-video and/or image-to-video. A post-change CLI run returned 10 Hailuo, 11 Seedance, and 14 Flux matches; the task aliases returned 24 for t2v, 39 for i2v, 21 for the Chinese text-to-video query, and 35 for the Chinese image-to-video query, with generation models at the top instead of unrelated utilities. The SearchMiss log is its feed: a zero-result query whose words name an existing endpoint in different vocabulary is one row here.

A query token that IS a platform slug ("tiktok", "linkedin") is the caller's hard filter, but idf prices it low — half the catalog serves the big platforms — so rows matching a rarer facet word ("followers") outranked rows matching the asked-for platform. Platform-slug tokens therefore score DOUBLE where they match; rows matching the same tokens in the same fields still sum identical floats, so the tie band survives.

A zero-result answer surfaces its near_misses — the rows that just missed the admission gate, with the exact words each one matched and missed ("apollo.companies.jobs matches job, hiring, signal; misses law, firm"). The matcher had already computed this; discarding it and answering with prose was the least useful thing the data allowed. Served structured over MCP (near), in the HTTP route's near + a hint line, and as "almost:" lines in the CLI — the caller is usually an LLM, and told exactly what to drop it re-queries correctly on the next call.

The gate has a second failure the miss allowance cannot reach: task-phrased queries whose words are parameter VALUES. "apple stock closing prices for last year" carries six rare words, four must hit, and no row will ever contain "apple", "last" or "year" — the two rows that answer it match three each and stop one short. That is a semantic judgment (a value is not a capability), and the discovery experiment tests a relevance judge behind a widened recall (candidates: admit on ONE required hit, cut at 30) against this ranker on caller behaviour, without changing anything here while it runs in shadow.

scripts/search_bench.py is the labeled replay (30 agent-shaped queries): sentence-style hit@8 went 14% → 100% (hit@1 64%, MRR .766) with the 8 short-query regression rows byte-identical. The residue past this is semantic matching — an embedding model — which the bench so far says is not needed.

The evidence decides the ORDER, not just the detail page

Token scoring ties by the dozen — all 24 "ad library" matches score alike — so "which 8 do I show?" was answered by file order. That returned seven near-duplicate tikhub rows (one of them the uncallable one above) and cut off scrapecreators.x.v1-tiktok-ad-library-search, cheaper and 17 for 17 measured. catalog_store.rerank() now settles equal scores over the band rank_band() returns, on buckets rather than a weighted formula (an ok_rate and a price are different units, and a blended score is one nobody can predict or argue with):

relevance → measured (good · unknown · poor · never-worked) → core before extended → price

where the measured bucket comes from ok_rate alone — >= 0.9 good, None unknown, 0 never worked, else poor — so a demotion always rests on calls the provider actually decided.

The band takes the tie group whole. A cut made inside a group of equally relevant rows is the arbitrary cut, and reranking a slice that already dropped the best-measured row cannot put it back — so rank_band() keeps taking while the score stays equal to the last row kept. That group is 17 rows for "ad library" and 24 for "email", but 523 for the bare word "tiktok", and this is an OPEN route: taking every group whole would put a 523-id IN clause behind every search. So it is bounded at RERANK_BAND (250) and says when it truncated — ranking_note over MCP, a hint on the HTTP route — because a bounded cut that announces itself is the thing this fix set out to build, and a silent one is what it set out to remove.

Two orderings there are deliberate. Evidence outranks curation, because a core row that has never answered is not the better suggestion. Curation outranks price, because core is the hand-picked route and extended the bulk-ingested long tail — letting a tenth of a cent promote the tail made "tiktok comments" lead with douyin danmaku. Unmeasured sits above measured-poor: a new endpoint is an unknown, not a suspect.

Team policy sits on top: CapabilityPin (see data-model) lets an org fix a capability to one provider, enforced in _resolve_marketplace_call before anything is reserved.

Its boundary, verified rather than assumed: a pin gates the catalog id, which is the only route to treg's own key — so it cannot be side-stepped to spend our money (a URL-passthrough call resolves against the org's OWN tools and 404s without one). A team holding its own key for another provider can still call that provider by URL; that is their credential and their bill, and DenyRule — host-scoped, applied to every shape of call — is the tool for blocking it.

Routed groups in discovery — a search page is a list of JOBS (2026-08-28)

Three rules, all in group_routed / search, shared by /catalog/search, MCP catalog_search and the CLI so the three surfaces cannot disagree:

  • A matched child brings its routed parent. find leads matched leadsforge.* on the provider's NAME; the row an agent should see first for that job — treg.people.search, where treg chooses among every provider — contained no word of the query. search now adds treg.<capability> at the best child's score whenever a child matched. The token-filter tests exempt these pulled-in rows: their own text need not contain the query.
  • Vocabulary before ranking. The same query first ranked people.email.find above people.search because find is a token of the former's capability NAME (weight 3) and only of the latter's summary (weight 2). The fix was to say in capabilities.yaml what the job is — people.search is "lead lists and prospects (sales leads)" — not to bend the scorer; aliases.yaml then only needs lead → leads, prospect → leads, prospects.
  • A group shows its best MAX_ROUTED_CHILDREN (5) children. One capability's 24 providers had eaten the whole 25-row page. An approved hub tool of the same job (kind: "hub") sits in the group but is never cut: the router does not list it. The parent is stamped children_hidden; the CLI prints + N more providers — treg catalog get <parent>, MCP says so in routed. To keep the page full after collapsing, search ranks a band of 4× the page (≤ 100) and cuts to limit AFTER grouping.

Routing — first-party routed endpoints (treg.<capability>)

The one place treg models an upstream API, and the explicit opt-in where the caller asks treg to choose (docs/CAPABILITY-ROUTING-PLAN.md). Everything else in the catalog stays verbatim relay.

  • Contracts — contracts.yaml: per capability, one-of identity variants (structural keys, never provider names — {full_name, domain}, {first_name, last_name, domain}, {linkedin_url}), derive rules so the two name shapes match the same adapters, a small output core (email required; confidence, names, verified optional) and miss in canonical terms. raw — the winning provider's body — is always returned and never documented as stable. advice_unverified (email and phone finds, and people.search) is one sentence the router attaches as _treg.advice to a hit whose verified is not true — a found contact is not a confirmed one (Hunter's accept_all, LeadMagic's personal finder, every phone provider), and a team that sent to such hits unverified bounced on most of them (2026-09-06). A search contract has no verified output, so its advice attaches to every hit: rows are directory listings, and the same team's 79-address bounce list (2026-09-08) was 73 unverified Hunter domain-search rows and agent-guessed info@ addresses that one verify call each would have caught. The hunter.companies.emails catalog summary carries the same warning for direct /call/ users, whose body is relayed verbatim. A suggestion only: treg never chains the verify call, which would double every hit's price and change what the find bills. routed: false declares an admission-only contract: its adapters verify like any other (which is what the archive's has_result_rules reads), but no treg.<capability> row is ever generated from it. For a capability whose "children" are one provider's price tiers, not a choice treg should make. scoping names identity keys that scope the answer rather than describe it (people.search: company_domain). A candidate whose adapter never sends one the caller supplied is dropped from the plan with the reason, not ranked down like an ignored filter: a title-only search asked for one company's CEO returns title-matched strangers for any company and bills them as a hit. The rule is per candidate, so {q, company_domain} also drops the q-only providers.

  • Adapters — adapters.yaml, one per endpoint: accepts (identity variants), in (contract field → queryParams.x / body.x), const (fixed provider params), out (core field → expression over the body), miss. The expression language (domain/catalog/routing/paths.py) is deliberately tiny: dotted paths with [i] (root [0], . = the whole body), coalesce (first non-empty argument, else the last one), / N, ==/!= against literals, and named transforms (split_first, split_last, join, has_type, len, list, obj, fmt, csv, lower/upper, at_least, null_if, choose, linkedin_handle/ linkedin_url, email_domain, host, dfs_location, seranking_source, tca_filter). values reads rows from object-keyed or list responses; get applies dotted/indexed lookup to another expression result (for example, the first company in a domain-keyed response). These are generic helpers, not provider-specific rewrites. in_expr builds provider params from expressions (URL-array bodies, DSL objects); test_identity states the fixture's identity when in builds a value rather than copying one; filters carry defaults and are always sent. null_if removes explicitly declared empty markers while retaining other values; choose selects between two expression values. Optional adapter cost_units expresses an upper bound in catalog-priced units (for example, fixed-page billing). Both the public quote and call-time plan use it through routing/plan.py::cost_at; invalid unit values remain unpriced. It does not replace the child's normal reserve/settle rules.

  • Verified at load, or absent — routing/contracts.py::verify: in must reproduce the endpoint's own test_request and out must fill every required core field from its example_response (an example that is itself a miss passes with the hit half unverified). A failing adapter is not a candidate; the endpoint is still callable via /call/ exactly as before. tests/test_routing.py pins that every shipped adapter passes. An adapter may list additional_capabilities: load_routing verifies each extra contract against the same fixture and admits it through verified_capabilities only when the primary passes, the extra contract exists and its filters match; Catalog.for_capability counts those memberships. An invalid extra never disables the primary.

  • The generated row — routing/synthetic.py: every capability with ≥ 2 verified children gets treg.<capability> (store.load skips a routed: false contract) (provider: treg, kind: routed, POST /<capability>, input = the contract, cost = the children's range, routed_children, miss_billed_by = the children priced per call or per result, whose provider bills an answer treg judges a miss: the caller pays those too, and the cost note says so). Never hand-written; not in any provider file. catalog_get on it returns the contract and the ranked plan (the quote) — nothing is reserved.

  • Ranking — routing/plan.py: own keys (tier 2) first at cost 0; then expected_cost_per_hit = cost_at(request) × P(billed) / P(hit) where cost_at prices this request at its requested size (per-result × limit, credit-with-minimum rounded up) and P(hit) is the measured hit rate when ≥ 20 decided samples exist, else ok_rate, else 1.0 (flagged unmeasured). build_plan reads that evidence through bootstrap's shared process cache; cold or unavailable observations degrade to unmeasured ranking while the cache refreshes off the request path. X-Treg-Route-Prefer / -Exclude override. An exhausted platform provider with an enabled overflow route remains a candidate at the overflow route's price, so the ordinary child ladder can skip the known-dry direct account and use the aggregator; without an enabled route it is dropped. Providers with no key on the deployment are also dropped and named in dropped (needs {…} says which identity variant a dropped child wanted).

  • Execution — application/call/route.py, entered from service._execute_call when the resolved catalog row is kind: routed. Each attempt is a full child execute_call on a CallContext whose call_ref is {parent}:r{n} — its hold id, ladder (tiers 1/2/4/overflow), reserve, relay, settle, audit row and cancellation compensation are the ordinary ones. Vendor 4xx (not 402/408/429) = usually the caller's fault, but scrapers answer 400 for their own outages (tikhub, live 2026-08-28), so the waterfall goes on ONLY to candidates that bill nothing for a rejected request — per_success, free, the org's own key, or per_call ≤ 1¢ (CHEAP_RETRY_MICRO; since 2026-09-07 a per_call rejection settles only at a charge the vendor itself reports, so this is a bound on the reported-charge risk, not on the estimate — see money.md) — never the same provider again, within the error bound; if every one rejects it, the caller gets route_caller_fault naming each attempt. When another provider already ANSWERED the same question (a hit, weak hit or miss), the question is valid and the 4xx is that provider's own: it is recorded as rejected and the waterfall goes on like any provider error, so the rows already answered are still returned (live 2026-09-23: prospeo's 400 after two answers ended a people.search as the caller's 400). A miss plus rejected attempts ends as a 200 miss. A 4xx the endpoint's YAML declares as its "no result" status (miss: {status: 404} or miss: {status: 400, when: …}, see "miss semantics ride on the endpoint") is a MISS instead, not a fault. An adapter method (to_upstream, from_upstream, is_miss) that throws is recorded as an error attempt and the waterfall continues; the identity's linkedin_url is normalised once at planning time (canonical_identity: scheme-less URL or bare handle → public URL) so no adapter forwards an invalid URL. Our 5xx/503/429 or a vendor 5xx/429/402 = error → next candidate, at most two extra, only for idempotent contracts. A treg-side tool_access_denied, policy_denied, or capability_pinned refusal is local to that child and follows the same error fallback. A platform child's vendor 401/403 also falls back because it indicates treg's provider credential, not the routed caller's request. Balance and spend-cap refusals remain terminal because another provider cannot change the org-wide decision. A 2xx response whose body lacks a REQUIRED core field is a MISS, not a hit (dataforseo's result: null under a 20000 envelope). A MISS tries the next candidate — the waterfall is ON by default (decided 2026-08-28: the endpoint's job is to find the thing, and misses on the per-success children are free); X-Treg-Route-Waterfall: 0 stops at the first miss. A child never settles its own hold: it leaves it open in the parent's deferred_settles list (settle.DeferredSettle) with the amount its settle would charge, and run_routed closes every one exactly once at the end (settle.close_deferred, one transaction): settled at that real price when the routed call answers (a hit or a 200 miss), RELEASED when it fails (route_failed, route_caller_fault, route_max_cost, a balance refusal, a cancellation). A routed call that fails therefore charges nothing (owner decision 2026-09-21): its error detail says charged_micro: 0 and released_micro names what the providers billed treg. A crash between the two leaves the holds to the reaper, which releases in the caller's favour. X-Treg-Route-Max-Cost (default $1) bounds the sum before each reserve (a candidate that would breach it is skipped). Quota-row quotes scale with the requested row count, just like per-result quotes. Each child also receives the remaining ceiling after actual earlier charges; the shared reservation gate checks the resolved estimate including margin, even when the advisory quote was too low or the child uses overflow. A budget refusal skips that candidate without using the provider-error retry allowance; if every candidate is skipped, return 402 route_max_cost. A retained weak answer keeps its own outcome when later candidates are skipped. When the waterfall ends with some candidates skipped due to max-cost, the response includes _treg.capped: true and X-Treg-Route-Capped: true — a partial miss is distinguishable from an exhaustive one, so callers can raise their budget if needed (feedback #131, 2026-09). Response: {output, raw, _treg: {served_by, provider, tier, outcome, tried[], charged_micro, capped?}}, X-Treg-Served-By, X-Treg-Providers-Tried, X-Treg-Route-Outcome, X-Treg-Route-Capped?, X-Treg-Cost-Micro = the sum, one X-Treg-Call-Id. The parent owns the idempotency label (a success replays without touching a provider; a failure now costs nothing, so it is not stored and a retry with the same key tries again) and writes one audit row (credential_tier: routed) beside the children's. An async child uses the shared async bridge to submit once and poll through ordinary authenticated child calls. The final poll response, not the kickoff response, is passed to the adapter. Routed execution waits for up to 60 seconds. If the task is still processing, or a foreground poll cannot prove a declared terminal state, it returns HTTP 202 with _treg.outcome: pending, the provider and endpoint, child call reference, poll descriptor, reserved_micro, and charged_micro: null. A pending attempt stops that waterfall because the child may still complete and charge; the existing async worker owns eventual settlement. Only declared terminal misses and failures may continue under the normal bounded fallback rules.

  • Hit rate — CallRecord.hit (nullable, alembic 0009, last column) is the adapter's verdict for a completed result. An accepted async submission leaves it NULL; the terminal finalizer classifies the finished body and queues an audit update for the originating row, including a routed child. The poll response does not wait for that best-effort write. A confirmed terminal failure counts as a miss; pending and timed-out jobs remain undecided. Its AsyncTaskRecord.hit keeps the verdict if polling beats the background audit insert. Async endpoints read their CallRecord observations live: the daily fold may consume a submission before its terminal poll changes the hit, and its one-way cursor cannot revise it. stats.observed publishes hit_rate/hit_samples (floor 20) and, for synchronous per-success endpoints, reads historical rows too (a 2xx with cost_observed_micro == 0 is a miss). Async per-success endpoints use only the terminal verdict: a found result can cost zero credits. The plan, catalog_get and the CLI's HIT column read it; a registered tool (tier 1) or stored key (tier 2) for a provider ranks first at cost 0.

  • R0 done (2026-08-28): the top-traffic untagged .x. endpoints carry capabilities now (google.serp.maps/news/local/ai_mode, google.keywords.trends — each dataforseo + serpapi — plus companies.jobs.search, companies.domain.find, amazon.product.sellers/variants, tiktok.video.captions); untagged platform traffic fell from 12% to 1.4%, and 202 capabilities with 2+ eligible providers cover 88% of calls.

  • Ranking, specificity (2026-08-29): among candidates of the same tier, one that USES more of the keys the caller actually sent outranks a cheaper one that uses fewer — {company_domain, title} goes to a title-aware search, not a free domain-only one that would answer the whole company. Only caller-supplied keys count (rank(given=…)), never keys reached through derive. Price decides among equals.

  • Ranking, dropped filters (2026-08-29): a candidate whose adapter cannot express a filter the caller SENT ranks below every candidate that can — len(candidate.ignored) sits in rank()'s key between specificity and price. It answers a LOOSER question, and a non-empty answer to the looser question still passes adapter.miss, so cheapness alone must never buy it. Found live: a people.search for {q, title, location: "London, United Kingdom", country: GB} went to the cheapest child, which mapped neither geo filter, and returned people in Bengaluru and San Francisco — reported as a hit, $0.0025, no signal to the caller. ignored_filters() is pure and computed at PLANNING time (routing/plan.py), so the ranking and the per-attempt report read the same set. The provider stays reachable: it still wins when nothing better is callable, and price still decides among candidates that ignore equally much. Coverage caveat: of 16 people.search children, only icypeas maps geo today, so the rule currently floats one provider. lusha, crustdata, companyenrich and leadmagic all filter on location upstream — their adapters just do not map it. Until they do, the rule is doing more work than it should have to.

  • A contract that cannot say what the brief says (2026-08-29): people.search exposed only {q, company_domain, title, full_name} + {country, location, limit}, while icypeas natively filters on keyword, skills, pastJobTitle, school, languages and totalYearsOfExperience. And q is IDENTITY, so when icypeas matched the {title} variant the free text was never sent — a routed search for "backend developers in London with microservices" reached the provider as title + location, with the requirement dropped. Every failing bench query had this shape ("football scouting analysts" → title="Football Analyst", 15 rows, 0 qualified). keywords is now a FILTER (filters always travel; identity does not) mapped to icypeas query.keyword.include, leadsforge's keyword field, and folded into exa's semantic query. Measured on the bench's 30 recruiting briefs: 55.4 → 69.2 overall (nDCG@10 51.2 → 64.3, coverage 49.7 → 68.1), failing queries 8 → 2. A titles list filter was tried at the same time and REVERTED: paired over the same 30 queries it cost −0.068 ± 0.022 (95% CI [−0.112, −0.024]). Broader title variants ("Software Engineer" for a backend brief) buy recall the metric does not want and lose precision.

  • min_results, and why it is bounded (2026-08-29): X-Treg-Route-Min-Results: N records a hit with fewer than N rows as weak and keeps going, returning the fullest answer seen. It is what the hand-written bench policy did (if len(rows) < 3 -> semantic fallback) and the routed path could not express. Unbounded it is ruinous on LOOKUP briefs, whose honest answer IS one person: nothing ever clears the bar, so every call pays the whole ladder — the bench's deterministic set went $1.76 → $22.35 over 28 queries, 12.7x, for answers that were already right. Bounded at MAX_WEAK_FALLBACKS = 2, mirroring the error fallback, the same set costs ~$0.39 — cheaper than the baseline — and recruiting keeps its gain (it never needed more than one fall-through). Pair it with X-Treg-Route-Max-Cost on any capability where thin answers are normal.

  • Routed parity with a hand-written policy (2026-08-29): after the two changes above, paired over the same 30 recruiting briefs against the 08-27 hand-written icypeas policy, the routed path is indistinguishable — nDCG@10 −2.40 (95% CI [−7.12, +2.33]), utility −0.80 ([−3.10, +1.51]), qualified/query −0.53 ([−1.84, +0.77]) — and ahead of the published Lessie 68.2, Exa 64.7 and Claude Code 50.5. The remaining differences are agent-side, not routing: the hand-written policy post-filtered rows on location and over-fetched (size: 20, trimmed to 15).

  • The answer says what it ignored (2026-08-29): ignored_filters was on _treg.tried[] only, which no caller reads. It is now also on _treg itself for the child that served and on an X-Treg-Ignored-Filters response header, so an agent can post-filter, or say why the rows are wrong, without walking the attempt list. Opt-in refusal (2026-09-04): X-Treg-Route-Strict-Filters: 1 drops every candidate that cannot express a sent filter at planning time (listed in dropped with strict: true and what the adapter takes instead) and answers route_no_candidate 422, unbilled, when none is left — a 503 stays reserved for capacity/key drops. Off by default: the ignored-but-billed call ({full_name, country: GT} → New York, voice-ai-outbound 2026-09-03) is the documented behaviour, and the fix for that case was to give the candidate the filter.

  • Lusha is the sixth phone rung (2026-09-04): lusha.people.phone.find — the phone-only view of search-and-enrich — accepts every phone.find identity and ranks last on price (6 credits a hit; a miss free, a matched-but-no-number profile the 1-credit search, all settled from billing.creditsCharged). Added for LatAm coverage after a Guatemala test found 7 in 44 across the other five. Apollo cannot join: its phone reveal is webhook-only, never inline.

  • people.* sweep (2026-08-29): people.search 6 → 16 children (aviato dsl/simple, companyenrich scroll, crustdata, fiber-ai, leadsforge, leadmagic search + role-finder, findymail employees + domain — the last retagged from email.find, it returns a list), people.enrich 9 → 14 (aviato bulk, fiber-ai, tomba profile/combined, hunter combined-find), people.email.find 9 → 11 (fiber-ai turbo, leadmagic personal), identity.resolve 3 → 4 (findymail reverse-email); five examples captured live. Still out: apollo/coresignal people.search (no fixture; apollo's person_titles[] needs a bracket-safe target), crustdata/diffbot people.enrich (truncated examples), the *.bulk jobs (async), hunter multi-domain (masked rows).

  • Filters reach providers, or say they did not (2026-08-29): country (ISO code) becomes a name through country_name (catalog/countries.json, 249 rows generated from pycountry) for providers that filter on a location NAME (icypeas); location is a free-text pass-through ("London, United Kingdom", "Europe") for the same providers; a filter the caller sent that an adapter never mentions is listed on the attempt as ignored_filters — silently unapplied was the worst outcome (the bench had post-filtered in the agent because of it). Bench re-run, recruiting 30: same icypeas rows as the hand-written policy, one automatic fall-through, region briefs rescued by the pass-through.

  • Routed DISCOVERY is a runtime switch (2026-08-29): TREG_ROUTED_DISCOVERY=off (default on) stops search leading with treg.<capability> and stops a routed parent riding in when a child matches — the endpoints stay callable, priced and reachable by id, and catalog get/POST /call/ are untouched. Off also HIDES routed rows from search results, not merely ungroups them: a routed row matches a keyword query on its own summary, so leaving it in would steer by the back door. One choke point (group_routed) serves both callers (mcp.py, routers/catalog.py); MCP also narrows its rank band back to limit when off, since the widening exists only so groups can collapse. Same dashboard-flip shape as platform_providers and TREG_OVERFLOW_MODE — no redeploy. It covers every surface that steers, not just search: the platform BROWSE view (/catalog/platforms/{slug}, which sorts the routed parent to the top of its capability group) drops routed rows too, and /skill.md and /llms.txt strip their routed section — a deployment that hides the row from search must not keep TEACHING agents to call it, or the docs and the catalog disagree and the agent believes the docs. The section is delimited in those two files by <!--routed-->…<!--/routed-->; the markers are stripped either way, and the unrelated overflow / provider_capacity_unavailable guidance in the same paragraphs is kept (it came from the capacity work, not from routing — which is also why a git revert of #242 would be the wrong instrument). It exists because "does the router answer well" and "should every agent be led to it by default" are separate questions: the bench answered the first (55.4 → 69.2 on recruiting, parity with a hand-written policy), and only traffic can answer the second.

  • creators.search: routed, then UNROUTED (2026-08-31): the contract was added because influencersclub filters on location / keywords_in_bio / number_of_followers and returns that data inline while exa.creators.search returns a URL and a title, so an agent picking blind chose exa and then verified follower counts by hand. Measured, the contract made the bench category WORSE: influencer 54.4 → 49.3, queries answered 29 → 27, and the profile-verification calls it was meant to remove went UP (131 → 155). The cost fell 42% ($10.96 → $6.34), which is the only part that held. Best explanation, same shape as the reverted titles filter: sending the follower band and location as HARD filters over-constrains, and a metric that pads to K=15 pays for volume — five exact matches score below fifteen loose ones. Reverted so production matches the submitted bench data. If it returns, the filters should be opt-in rather than always-sent, and measured first.

  • What is not routed on purpose: *.bulk endpoints (a routed call is one subject, one answer), and providers whose rows are teasers — hunter multi-domain (masked, no names, ignores limit), apollo people.search (free, but last names obfuscated: a search→reveal CHAIN, which mode C of the bench showed rescues hard B2B briefs and which the router does not do yet). catalog get lists them under ALSO with the rest of the same-job endpoints that have no adapter; the search page's "+N more" points there. The routed row's example body and /access dry-run use the identity variant MOST children accept, and the dry-run tries every variant before saying "unservable". That dry-run is ONE identity shape, so its drops are mostly "this adapter takes another identity", not "your team cannot reach this provider" — /access used to label them "not available here", which read as a missing key and sent a reader hunting for one (2026-08-29: aviato, callable on treg's platform key and serving live calls, was listed as unavailable). It now names the shape and gives each drop its own why.

  • Coverage (2026-08-28): 74 routed capabilities = 80.9% of 30-day platform calls (88% was the routable ceiling). The per-capability ledger — what shipped with which children, what is 🚫 and why (one usable vendor, async task-post engines, identity-less feeds), and the 49 zero-traffic rows still open — is docs/CAPABILITY-EXPANSION.md (git-excluded, Jason's working doc).

  • Not built (plan R4): "prefer routed" in the agent files after a shadow week; a proper kind: filters / Location layer for the DSL/SQL providers (aviato dsl and pdl sql ride obj/ fmt today; crustdata/diffbot/coresignal/apollo do not); own-key-dry → treg-key fallback.

  • Name-only Leadsforge requests (2026-09-07): email and phone adapters accept the derived {first_name, last_name, domain} variant or a LinkedIn URL. Removed the redundant {full_name, domain} fallback: a one-word name cannot derive last_name, so that fallback selected an identity variant whose name was not mapped and sent only companyDomain. Complete full names still derive both parts and work normally. Regression tests exercise the actual matched-variant request, including rejection of mononyms and preservation of LinkedIn. Arena also validates full names before quoting, preventing Hunter's invalid_full_name error. Leadsforge and Fiber contact lookup success flags no longer populate verified: neither flag is an explicit mailbox deliverability verdict. The field remains absent when unknown.

Archive comparison declarations

The effective cache: block accepts ignore_paths: [...] alongside max_age_s. The default is an empty list. Provider-header inheritance and whole-block endpoint override follow the existing cache policy rules. store._validate_cache rejects an invalid list or path during catalog loading, including provider-header declarations even when endpoint blocks override them.

Paths are case-sensitive dot-separated property names matching [A-Za-z0-9_][A-Za-z0-9_-]*, with [*] suffixes for arbitrary array elements: request_id, data.items[*].updated_at, or matrix[*][*].request-id. Leading digits are allowed, e.g. 2fa_enabled. A root array can use [*].request_id. Empty lists are valid; null, non-lists, non-string members, empty paths, numeric indices, plain *, $ prefixes, spaces, empty segments, escaping and recursive wildcards are rejected. Keys containing literal dots or brackets are deliberately not addressable in this first grammar. Missing paths are harmless.

archive._normalized_hash removes only these paths from a parsed copy for TTL equality. It never changes archived or served data, raw hashes, deduplication or hit/miss classification. Without a nonempty list, exact byte comparison remains authoritative. A declaration lives on the row with a one-line reason; the evidence behind it (archive_change_observed reports, HogQL in archive) belongs in the PR that adds it, not here.

Security

PII IS THE HARD RULE. This repo is public, and every captured example ships in it. Three checks before any example is committed, all learned the hard way:

  1. No named private individuals. Contact-lookup routes (LinkedIn contact info, people-enrichment by email) return a real person's name, personal email and phone. Such an endpoint stays in the catalog — the route is real and useful — but it is marked untestable: with the reason and carries NO test_request (so a re-verify cannot silently re-capture it). No captured person response is stored. A routing adapter may use a hand-sanitized structural fixture only when its contact values use reserved fake domains/numbers, it cannot be refreshed by the verifier, and separate live evidence establishes the mapped response fields.
  2. No third-party PII riding along. Emails and phones turn up inside unrelated payloads — a YouTube description, a review body. Sweep every captured example for address-shaped strings and mask anything that isn't a business contact.
  3. No first-party identity. Own-account verification (mine=true, your own site in Search Console) captures YOUR channel, sitemap and metrics. Point test requests at neutral public targets instead, and scrub what you already captured.

Credentials are NEVER written into catalog files, examples, scripts, or docs — the verifier reads TREG_CATALOG_CRED from the environment only. Captured examples are truncated (arrays → 2 items, long strings clipped, ~10 KB cap) by the verifier, then human-reviewed for PII before commit.

Operator-supplied pricing evidence

catalog_ingest.py requires TREG_CATALOG_EVIDENCE_DIR for AnyAPI and JustOneAPI imports. The directory supplies anyapi_measured_charges.json (skus, as_of, window_days) and justoneapi_prices.json (prices). Missing files fail before catalog output is written, rather than silently replacing measured prices with estimates or dropping dashboard prices. Other providers do not require these files. Published catalog prices remain part of the public product; private ledger exports and account evidence do not. Hosted operators maintain the inputs in treg-internal/tools/catalog-evidence/.