Consumer reference
This is the detail behind Getting started with the Data Studio. Start there: it covers which host to use, connecting over MCP, the lean surface and the consumer lane, finding skills, your first property calls, prices, retries, reading the answer, and the error codes. This page picks up where it stops.
It is written for anyone holding a consumer key. An author key can consume too, and everything here applies to it;
if you will create or update skills, Authoring skills is the companion.
§2 Who sees which skills: the three gates
Every key’s catalog is filtered through three gates, in order:
- Publish state. Only skills with
status='published'reach MCP. Drafts, skills in review and archived skills stay invisible. If a skill you expect is missing, the most common cause is that it is not published yet. - Skill category. Internal platform skills (
category='system', such as tag validators and skill builders) are never served to external keys, whatever their status or your tier. You cannot unlock this boundary. - Per-key access. If your key has roles, the roles alone decide: each role’s
tag_allow, minus itstag_deny, is matched against each skill’stags. If your key has no roles, yourtierdecides, and aconsumer-tier key sees only the skills markedmcp_visibility='consumer'.
Roles outrank the visibility column. A consumer-tier key with a role allowing Ontario-tagged skills sees those
skills even where they are marked mcp_visibility='admin'.
Role decides data scope; tier decides capability shape. Your tier decides which auxiliary tools register at all (the read-side surface: discovery, dictionary and feedback tools). Your roles decide which skills you see among them.
Two colleagues can therefore see different catalogs from the same ecosystem. A change to your key’s roles can take up
to 30 minutes to show in tools/list, because each key’s tool list is cached. tip-key-introspect describes your key
as it is right now.
§5 The dictionary tools
The lean surface serves the dictionary tools listed in Getting started. Their detail:
portal-browse-dictionary: what terms are canonical in this ecosystem?
// tools/call portal-browse-dictionary
{ }
Returns the ecosystem’s harmonized vocabulary: terms, definitions, types, and which sources provide each term. Use it
when you join data across sources (a provincial assessor, a city’s open data, a land registry) and need to know which
fields canonically mean “owner name” rather than “registered owner” or “title holder”. Filter by geography or tags.
portal-dictionary-release: pinned versions, the latest pointer, and the naming standard
The dictionary is also published as versions you can pin (portal-dictionary-release, or
GET /api/portal/<ecosystem>/dictionary/releases/<version>).
- Is there a newer release than the one I pinned? You can tell from the pinned read itself. REST answers with the
headers
X-Dictionary-Latest-VersionandLink: <…/releases/<latest>>; rel="latest-version". The release listing and/dictionary/diffcarrylatestVersionin the body, and every MCP response that names a version carrieslatestVersion. The pointer is never inside the document, so a pinned release and itscontentHashnever change. A cached pinned response replays the pointer it was served with; the listing is the fresh read. - Read the naming standard before proposing a term.
GET /api/portal/<ecosystem>/dictionary/naming-standard, orportal-dictionary-releasewithnamingStandard: true, returns the rules a proposed term is checked against. It comes as markdown, with itsversionand acontentHash(also theETag). It is versioned on its own, apart from any release; the listing and the MCP manifest point at it. Any key that can read a release can read it.
find-skills-for-concept: who touches this concept, and which way round?
Concept-anchored discovery. Pass a term from portal-browse-dictionary (either as a dictionary/property path or as
the dotted termName that tool returns) and, optionally, relationType to pick the direction:
// "I have a PID: what can you get me with it?"
{ "dictionarySlug": "<the dictionary slug portal-browse-dictionary returns>", "propertySlug": "pid", "relationType": "requires" }
// "who can produce an AVM band for me?"
{ "concept": "Custom/avm-estimate-min", "relationType": "outputs" }
outputs: skills wired to emit that term. Derived from each skill’s source field mappings, so it is a claim about wiring, not about coverage in a given jurisdiction.requires: skills that declare an input meaning that term, which answers “I hold this; what can I call?”. Derived from each skill’s own published input schema, so the match is exactly what you can pass. Each row carriesmatchedInputParams(the argument names to put your value in) alongsideontologyRequiresCount, so you can call straight through.requiresmeans “accepts”, not “mandatory”: the skill’s owninput_schema.requiredsays which arguments it cannot run without.
Both directions are read from the live catalog at call time: a skill that gains an input or a field mapping shows up on its next publish. A parameter the ecosystem dictionary has no term for produces no edge at all, so an empty result means “nothing declares this concept”, never “someone forgot to annotate it”.
query, category and domain narrow and rank results within the concept match. They don’t replace it: the concept
filter is required.
On the full surface only
portal-browse-skills (catalog browsing with category, q, tier and jurisdiction filters) and
portal-browse-catalog (the data sources behind the skills) register only on the full surface, without
surface=lean. portal-browse-catalog’s geography narrows by any of three levels: a country (US), a province or
state (CA-BC), or a municipality or county slug from availableMunicipalities (vancouver, pierce-county). A
municipality filter returns the sources about that place; a province-wide source does not match a city inside it.
The REST twin, GET /api/portal/<ecosystem>/catalog?geography=, takes the same values and lists them as
municipalities.
§5b Coverage: which of this source’s fields carry a dictionary term?
portal-browse-dictionary tells you what a term means. Coverage tells you which published sources actually carry it,
and, the question you will have the moment you hold a real envelope, what each of a source’s other fields is
instead.
// tools/call portal-dictionary-coverage
{ "slug": "bc-address-geocoder" }
REST, if you would rather not go through MCP:
# British Columbia: one source, field by field
curl -s "https://api.dev.trustinfrastructure.com/api/portal/real-estate/dictionary/coverage/bc-address-geocoder" \
-H "Authorization: Bearer $TIP_API_KEY"
# Vancouver: same shape
curl -s "https://api.dev.trustinfrastructure.com/api/portal/real-estate/dictionary/coverage/vancouver-property-tax-report" \
-H "Authorization: Bearer $TIP_API_KEY"
Drop the slug for a page of every published source with its counts only (?cursor= and ?limit=, up to 200; the
MCP tool pages at 50).
If you call skills rather than sources, ask about the skill. Sources and skills have separate slug spaces, so a skill slug passed as a source is simply not found:
curl -s "https://api.dev.trustinfrastructure.com/api/portal/real-estate/dictionary/coverage/skills/british-columbia-property" \
-H "Authorization: Bearer $TIP_API_KEY"
// tools/call portal-dictionary-coverage
{ "skill": "vancouver-property" }
You get every data source the skill’s orchestration dispatches, each in exactly the shape above, plus an aggregate
roll-up per direction. Every response states which kind of thing it describes in kind ("source" or "skill"), so
the two can never be confused.
Two things the skill response is careful about. A source the skill names that the ecosystem does not publish is
listed under unpublishedSources and left out of the aggregate: it has no declared fields to count, and counting
it would read as a coverage gap rather than as the publishing gap it is. And a published skill whose orchestration
names no source at all answers namesNoSources: true rather than 404. It exists, and “nothing to measure here” is the
true answer, usually because it is a computation skill or reaches its data through skills it dispatches, each of which
has its own coverage under its own slug.
Each field arrives in exactly one of four states, and the states are the contract:
| state | what it means | align to it? |
|---|---|---|
term | linked to a dictionary term; carries the term name and its envelope path, e.g. Valuation.AssessedTotalValue | yes |
unmapped_by_design | somebody ruled that no term applies; carries the class saying why: system_column, geometry, transport_param, open_data_column, duplicate_of_linked, deprecated_upstream, container | no |
open | nobody has ruled on it yet: the absence of a decision, not a decision | no |
term_unresolved | linked to a dictionary entry that serves no envelope path today (superseded, retired, or awaiting a section) | no |
If you join on a term’s sources[], page it
The release overlay (?include=coverage) gives each term a sources[]: the sources that can feed it. That array is
a page, not the whole list. sourceCount is the whole:
sourceCount = every source naming this term
sources[] = one page of them, ordered by slug, default 50, max 200
Both lanes carry this overlay, and each names its own parameters. They are not the same words, so check which lane you are on:
| REST | MCP (portal-dictionary-release) | |
|---|---|---|
| ask for the overlay | ?include=coverage | includeCoverage: true |
| next page | &cursor=<nextCursor> | sourceCursor: "<nextCursor>" |
| page size | &limit= (max 200) | sourceLimit: (max 200) |
The REST route also accepts the MCP spellings as aliases (includeCoverage, sourceCursor, sourceLimit), so a
call written from an MCP response works here unchanged. The REST names above stay canonical.
curl -s "https://api.dev.trustinfrastructure.com/api/portal/real-estate/dictionary/releases/live?include=coverage&terms=Address.UnparsedAddress&cursor=carberry-property-assessments" \
-H "Authorization: Bearer $TIP_API_KEY"
Page until sourcePage.nextCursor is null. A term with more sources than one page is not a term missing sources:
before paging existed, Address.UnparsedAddress named 339 sources and showed the alphabetically-first 50, and a
consumer joining on the array concluded that vancouver-issued-building-permits did not populate a term it genuinely
populates.
Two ways to get the answer, and they answer different questions. Page sources[] when you want which sources can
feed this term. Read the coverage export per source (GET …/dictionary/coverage/<slug>) when you want what will
arrive under which term from this source: it is keyed per declared field with exactly one winner, so it is the one a
cache should follow.
Every provider entry says how it reaches the term and whether its link still resolves. direction is output
(the source can return the value), input (it accepts the term as a parameter) or both. resolved: true means at
least one of that source’s links to the term points at a declaration that direction actually has: an output link at
a declared field, an input link at an input_schema parameter. resolved: false means none do. declaresNoFields: true is the third state: the source describes nothing of that kind at all, which is an absence of evidence rather
than a broken link. The same keys appear on portal-browse-dictionary’s providers[].
Read direction before you act on a provider: a source that accepts a term as an input is not a source that can give
you its value.
linked is the whole linked population; termUnresolved is the part of it you cannot use. Both counts ride every
per-direction block, on the page and on the per-field response:
linked = (fields carrying a usable term) + termUnresolved
Subtract to get the number you can actually align to.
A non-harmonized field is never served with a candidate term. The class is the answer, because it says why no term applies. A best guess would read as a promise the harmonizer never made.
Two things these numbers do not say. They describe what is harmonized, not what is populated: a field
linked to a term arrives under that term when the source returns it, and whether the source returns it for your
property is a different question. And a direction that declares nothing has no completeness: measurable is
false and completeness is null, rather than 0% or 100%, because a source nobody has described is neither
perfectly harmonized nor perfectly unharmonized.
The page envelope also carries corpus: seven ecosystem-wide totals, measured by the same query the platform’s own
scheduled corpus check asserts them from, so a number you read here and a number your operator reads there cannot
disagree. Pass includeCorpus: false to skip it.
Nothing here is invoked, no data source is contacted, no invocation is recorded and nothing is billed.
§5c Proposing: portal-dictionary-propose (links, new terms, supersessions)
A proposal is an ask against the ecosystem dictionary, with a status you poll. Sending one invokes nothing, contacts
no upstream and bills nothing, and it does not change the dictionary by itself. The rules below are what the tool
enforces. They are also served with every proposal, as statements.
| Lane | Call |
|---|---|
| MCP | portal-dictionary-propose: submit; read your own with id; withdraw your own with withdraw: true |
| REST | POST /api/portal/<ecosystem>/dictionary/proposals (submit) · GET …/proposals and GET …/proposals/:id (read your own) · POST …/proposals/:id/withdraw (withdraw your own) |
Your ecosystem and your app (the proposer) come from your key, never from the body. A proposal lives on the host you send it to (dev or staging), and is read, withdrawn and applied there.
Which host this is true on (2 October 2026). Sending a link by
envelopePath, the fourenvelope_path_*refusals, themarker, withdrawal,applied, and the apply step itself are on dev from the deploy that carries them. They are not on staging until the Data Studio team deploys them there, which waits on a note to you at least one day before. Until then staging serves the earlier surface: a link byfieldonly, no marker, no withdrawal, and an accepted link is a ruling that writes no link. Everything else in this section is true on both hosts.Added 6 October 2026: on dev that day, on staging since 7 October 2026, 13:15 UTC. A
termsent as its release id is read as the same term as itsSection.Name, the marker’salsoLinkedTo, and the refusal of a count over MCP (it was dropped before).
Sending a link
| Argument | Required | Meaning |
|---|---|---|
kind | yes | link |
term | yes | The dictionary term, by name (BedroomsTotal), as Section.Name (Property.BedroomsTotal), or by its id in a dictionary release (property.prop-bedroomstotal) |
source | yes | The data source slug |
field or envelopePath | one of them | field: the upstream column name, as the source declares it. envelopePath: the key as you read it on the envelope (Section.Term; an array section may carry []). The Data Studio resolves the column. Never both. |
skill | no | With envelopePath only: the skill that served the key. It narrows the resolution, but no answer depends on it today. |
confirmedBy (MCP) / confirmation: { by, at? } (REST) | to be accepted | Who on your side confirmed the link. A link is accepted on this confirmation. |
note | no | Anything a reviewer should read |
A term is identified by its name. On the envelope a key is Section.Term, but a skill can serve a term under
another section than the term’s own: Buildings.BedroomsTotal is the term BedroomsTotal, whose entry lives in
Property. So term: "Buildings.BedroomsTotal" and term: "Property.BedroomsTotal" name the same term. The section
only chooses between entries when one name exists in several sections, and today no term name does. A term’s id in a
dictionary release (the term of each entry portal-dictionary-release serves, such as property.prop-stories)
names the same term as that entry’s path (Property.Stories); either spelling is read as the same term.
By envelope path. The Data Studio resolves the raw column on the host you send to, from the same mapping the envelope is built from. The answer records what it resolved:
// tools/call portal-dictionary-propose
{ "kind": "link", "source": "<source slug>", "envelopePath": "Property.BedroomsTotal",
"term": "Property.BedroomsTotal", "confirmedBy": "network-admin" }
// → 201 (REST) / the proposal (MCP)
{ "status": "received",
"proposal": { "field": "<the resolved column>", "envelopePath": "Property.BedroomsTotal",
"resolution": { "via": "link", "envelopeKey": "Property.BedroomsTotal", "declared": true, "skillCount": 3 } },
"marker": { "alreadyLinked": { "linkId": "…", "term": "Property.BedroomsTotal", "sourceFieldName": "<column>" } } }
resolution.declared: false means the column is created by a transform, not declared on the source. The link still
works. resolution.skillCount is how many published skills serve the key from this source.
resolution.alsoWrittenByNameCount is present when some of them write the same key by name, so the link does not
change it there. You read counts, not skill names; the Data Studio team’s reviewers see which skills they are.
When no single column stands behind the key: four refusals
Only a link sent by envelopePath can be refused this way. Nothing is stored. REST answers 409 with the code in
detail.refusal. MCP answers an error with the code in error. Both carry skillCount (how many published skills
the answer is about) and name no skill.
| Code | What it means | What to do |
|---|---|---|
envelope_path_ambiguous | Several raw columns feed the key; fields lists them. About 1% of keys, mostly case variants of one value. | Resend with field naming the one your ruling is about, or send one proposal per column |
envelope_path_written_by_name | A skill writes the key by name (fields names the raw field when there is one; skillCount how many skills), so a link would not change it | Send it as a note; it is reviewed as a mapping change |
envelope_path_derived | A transform computes the key (coordinates today), so no single raw column stands behind it | Nothing to link; send a note if the value is wrong |
envelope_path_not_served | This source serves no such key on this host through a link or a by-name write, or the source is not on this host | Check the key and the source. If the envelope attributes the key to this source, a transform computes it; send a note |
A column withheld from responses is never named in any of these answers.
When the link is already held: a marker, never a refusal
A link is always recorded. When something is already known about it on this host, the answer and the stored proposal
carry marker:
| Marker | Meaning |
|---|---|
alreadyLinked: { linkId, term, sourceFieldName } | This column is already linked to this term |
alsoLinkedTo: [{ linkId, term }] | With alreadyLinked only: the column is also linked today to other terms. Shown for review; it does not make your proposal a correction |
duplicateOf: "<proposal id>" | You sent the same link before. Points at the earliest proposal that is not rejected or withdrawn |
correctionOf: [{ linkId, term }] | The column is linked today, only to other terms: your proposal is a correction |
marker is null when none applies. It is computed on the resolved column when you send envelopePath.
States
received → validated → queued (in review) → accepted | rejected. Before a ruling a proposal can also be stalled,
or withdrawn by you. An accepted new term or supersession moves to released when a dictionary release carries it.
rejected, stalled and withdrawn always carry a reason. rejected, released and withdrawn are final.
transitions on each proposal lists where it can go next. Every ruling (accept, reject, stall, release) is the Data Studio team’s.
Withdrawing your own proposal
curl -s -X POST "https://api.<env>.trustinfrastructure.com/api/portal/<ecosystem>/dictionary/proposals/<id>/withdraw" \
-H "Authorization: Bearer $TIP_API_KEY" -H 'Content-Type: application/json' \
-d '{"reason":"our network admin reversed the ruling"}'
On MCP, pass withdraw (true), id and reason: { "withdraw": true, "id": "<id>", "reason": "…" }. You can
withdraw from received, validated, queued or stalled, never after a ruling. Only the app that sent a proposal
can withdraw it. Any other app gets 404 (MCP: proposal_not_found), which does not confirm the proposal exists.
Without a reason the answer is 409 reason_required. After a ruling it is 409 transition_not_allowed.
What acceptance changes, and when
Acceptance is a ruling. An apply step then writes the link on the host where the proposal lives, and records what it
did on the proposal as applied:
"applied": { "environment": "dev", "appliedAt": "…", "outcome": "inserted", "linkId": "…",
"section": "Property", "term": "BedroomsTotal", "sourceFieldName": "<column>" }
outcome is inserted, or already_held when the link was already held. The proposal stays accepted. applied is
null until the apply step has run on that host.
- dev: applied after acceptance, with no notice.
- staging: a new link changes what you receive, so it lands only after you have been told, at least a day before.
The apply step checks the link against the host’s catalog again: the source, the declared column, a served term, and a
column that is not withheld. A link that fails these checks is not applied: it stays accepted with applied null
until a reviewer rules on it again.
Codes on this surface (closed)
| Where | Code | Answer |
|---|---|---|
| submit | proposal_invalid (MCP) / 400 (REST) | The body does not validate; every error is named. A body carrying a count is refused. |
submit by envelopePath | envelope_path_ambiguous, envelope_path_written_by_name, envelope_path_derived, envelope_path_not_served | 409 / MCP error; nothing stored |
| read / withdraw | proposal_not_found (MCP) / 404 | Not yours, or no such id |
| withdraw | proposal_id_required (MCP), reason_required, transition_not_allowed | 409 on REST |
These codes are this tool’s own. They are not invocation error_codes. Adding one is announced before it ships, and
an existing one is never renamed.
§5d Pre-flight: property-scout (placement and price; the unit-versus-building verdict)
property-scout is a $0 skill with two modes. It never fetches findings, never bills, and calls no paid source.
Which host this is true on (5 October 2026). This section describes
property-scout1.0.1. It was published on dev first, and reaches staging only after a note to you at least one day before. Answers from 1.0.0 differ in the ways listed under “Changes in 1.0.1” below.
The two modes
| Mode | You send | You get back | What it calls |
|---|---|---|---|
| Address mode | address (one line, with its city and province or state), optionally unit, or jurisdiction when you already know it | the jurisdiction it resolves to; coverage (what is known about the place); pricing (the package that would answer, its price, and the per-call price of the sources that package names, from the catalog) | a geocoder, to place the address. That call is never billed to you. No paid source. |
| Envelope mode | envelope (the data.envelope of a package answer you already hold, unchanged), optionally unit | the unit-versus-building verdict over that answer, with typed caveats, and the price of the package that produced it | no upstream call at all |
Send exactly one of address or envelope.
Where envelope mode places the envelope
The verdict is judged in a jurisdiction. In envelope mode it is the first of these that is present:
- the
jurisdictionyou send; - the envelope’s
Address.StateOrProvince(a state or province code, such asBCorNY); - the envelope’s
Provenance.Route.Jurisdiction(whereproperty-open-dataanswers carry it); - the envelope’s root
resolved_code.
A route or code is taken only when it is a subdivision code such as ca-bc or us-ny; a bare country is not. When
none is present, the answer says jurisdiction_unresolved.
What coverage carries
| Key | Meaning |
|---|---|
wired | a package covers this place. In envelope mode, the envelope you sent proves one served it. |
unitDrill | full, passthrough, flag_only or none: how far this place’s packages reach toward a unit |
ownerData | public, redacted_statewide, commercial_gated, mixed or unknown: what owner data this place’s records carry |
registry | ok, no_jurisdiction or unavailable: whether the catalog read for the place succeeded |
What pricing carries
Everything in pricing is read from the catalog; nothing is called.
| Key | Meaning |
|---|---|
package | {slug, cost, pricing}: the package that would answer the address, or that produced the envelope. pricing is priced, free or unpriced; unpriced means the catalog holds no price, never that the call is free. |
sourcesFrom | where that package names its own sources: dag (the steps it runs), price_provenance (the sources its price is built on, for a package that picks its sources per address), or null when it names none |
sources | [{slug, costPerCall, pricing}]: the per-call price of the sources that package names. Omitted when sourcesFrom is null: the scout does not guess which sources a package that dispatches per address would call for this place. |
For the BC Legislature (501 Belleville Street, Victoria), address mode names victoria-property, free, with
sourcesFrom dag and six sources at $0. An envelope from property-open-data for the same place names that package
with sourcesFrom null and no sources.
The verdict
resolution.scope is one of unit, building, parcel or unresolved: the scope the answer actually reached.
resolution.askedScope is unit when you sent unit (or a unit inside the address) and property otherwise, and
resolution.scopeValid says whether the asked scope was reached. A scope that collapsed (a unit asked, a building
answered) is stated by scopeValid and the caveats, not by the grade. The caveats this skill can emit, all
professional-facing:
| Caveat | Meaning |
|---|---|
building_scope_only | the answer is for the building, not a unit |
unit_not_resolved | a unit was asked and the answer did not reach it |
aggregate_lot_zero_values | a condominium or co-op billing lot carrying zero values, not a unit’s |
unit_drill_unavailable | a unit was asked and this place has no unit-level descent |
scope_signals_limited | this jurisdiction’s packages carry no unit-level signals, so the verdict can tell a parcel from a building but cannot confirm a unit. Today only New York City and New Jersey carry them. |
ambiguous_address | the address resolved ambiguously across jurisdictions |
jurisdiction_unwired | no package covers this jurisdiction yet (coverage.wired is false) |
jurisdiction_unresolved | the address (or the envelope) could not be placed in a jurisdiction; a fuller address may resolve. A served $0 answer, not a refusal. |
next_actions name the next call: fetch_full_envelope names the package that would answer (priced), and
drill_to_unit names it with the unit written into the address, where unit descent exists.
The grade
The run’s completeness and the answer’s provenance.completeness are the same grade, always:
| Grade | When |
|---|---|
full | the place was matched to a package that covers it (whatever the scope verdict) |
empty | it was not: unresolved, ambiguous, out of coverage, or the geocoder unavailable. Still status: success, a served $0 answer. |
partial | a step of the scout itself failed, for example the catalog read behind pricing; status is partial too |
Changes in 1.0.1
pricing.sourceslists only the sources the answering package names, and is omitted when it names none. 1.0.0 listed a sample of every source registered for the region, which could include paid sources the package never calls.coveragecarries the four keys above. 1.0.0 also carried that regional sample and its counts.- Envelope mode places an envelope by the jurisdiction it states about itself (above). 1.0.0 read only the envelope’s
Address.StateOrProvince, so aproperty-open-dataenvelope (which carries none) came backjurisdiction_unresolved. - The two grades agree. 1.0.0 could answer
completeness: fullbesideprovenance.completeness: empty.
Refusals
Every refusal is input_invalid, costs $0, and its message names the defect:
| Defect | When |
|---|---|
neither | neither address nor envelope was sent |
both | both were sent |
not_an_object | envelope is not a JSON object |
error_body | envelope is an error response, not a package answer |
no_provenance | envelope carries no Provenance block, so it is not a package answer as the Data Studio returns it |
An argument the skill does not declare is refused at the boundary, as for every skill.
§6 Feedback: the field shapes
Getting started §10 introduces the two feedback tools. Their shapes:
submit-invocation-feedback: feedback on one call
Every call’s _meta carries an invocationId. Rate the call against it:
// tools/call submit-invocation-feedback
{
"invocation_id": "9ff4cb60-c4d0-4a79-b732-b919b51b5352",
"status": "accepted",
"reason": "Returned the right parcel for the Empire State Building."
}
invocation_id and status are required. status is accepted or rejected, a closed set. reason is optional,
and most useful on a rejection. It is a two-step flow: call the skill first, then rate it by its id.
submit-repo-feedback: anything not tied to one call
For “this skill exists but is broken”, “I wish there were a skill for X”, “the docs say X but the API does Y”:
// tools/call submit-repo-feedback
{
"title": "Address-only lookups pick the wrong borough for 350 5th Ave",
"body": "Tested 350 5th Ave with no borough hint; came back as a Brooklyn parcel, not the Empire State Building.",
"category": "bug_report"
}
title and body are required. The Data Studio team triages every entry.
§7 Re-key and revoke
Keys are issued by your ecosystem’s operator, through your Data Studio contact.
- If your key leaks (committed to a public repo, posted in chat, visible in a screenshot): tell your Data Studio contact at once. A revoked key fails its next call. Skills you authored stay in the catalog; revocation only blocks future calls.
- If you need rotation (security policy, a scheduled rotation): a new key is issued with the same tier and roles, sent to you over a secure channel, and the old one is revoked. There is no self-service rotation today.
- If your access needs to change (a new province, a new capability): your roles are changed on the same key, and the new visibility shows on later calls, within 30 minutes (§2).
The key’s prefix names its environment (tip_dev_ for dev, tip_stg_ for staging, tip_ for production), so a
leaked key is identifiable by environment. The prefix is checked before anything else, so a key works only on the
environment that issued it.
§8 Is my key wired right?
When something looks off (an empty catalog, fewer skills than you were told to expect, a tool that should register and doesn’t), check in this order:
- The health check.
GET https://api.<env>.trustinfrastructure.com/api/mcp/<ecosystem>/health?lane=consumer&surface=leanwith your key.{"ok":true,…}means the key authenticates on this host and the consumer lane accepts it. tip-key-introspect. It describes the key making the call: its family (consumer,authororadmin), its role slugs, the write capabilities it holds, and whether the consumer lane accepts it. Compare it with what you were told your key would be.- The catalog.
get-skills-manifestwithsummary=truelists what your key can call. If a skill you expect is missing, §2’s three gates say why: it is not published, it is internal, or your roles don’t reach its tags.
If all three look right and the answer is still wrong, send submit-repo-feedback with what you expected and what you
got, or ask your Data Studio contact.
§9 Common 401, 403 and empty-result causes
The full table of error codes is in Getting started §9. These are the three failures partners meet most, with what to check.
1. 401 Unauthorized: the key and the host don’t match
Symptom. Any MCP call returns:
HTTP 401
{"jsonrpc":"2.0","error":{"code":-32001,"message":"Unauthorized — missing, invalid, or revoked API key"},"id":null}
The message is the same for a missing header, a mistyped key, a revoked key and a key sent to the wrong environment.
Common causes, most likely first.
- The wrong environment. The key’s prefix must match the host:
tip_dev_onapi.dev.trustinfrastructure.com,tip_stg_onapi.staging.trustinfrastructure.com,tip_onapi.trustinfrastructure.com. - A missing
Authorizationheader. The value must beBearer <full key>. Some HTTP libraries drop theBearerprefix; check the raw header. - A revoked key. Ask your Data Studio contact.
- A mistyped key. One transposed character is enough. Copy it again from the secure channel it came through.
2. 403 on the consumer lane
Symptom. HTTP 403 with key_family_not_accepted (JSON-RPC -32004).
Cause. You sent lane=consumer with a key that can write (an author or admin key). The lane accepts read-only keys
only, and refuses before anything runs, at $0. Connect without lane=consumer, or use a consumer key.
tip-key-introspect, called without lane=consumer, shows which family your key is.
3. An empty or unexpectedly small catalog
Symptom. get-skills-manifest or tools/list shows far fewer skills than you were told to expect.
Common causes, most likely first.
- No role, where you expected one. A key with no roles falls back to its tier, and a
consumerkey then sees only the skills marked consumer-visible, not the role-scoped catalog. - A role narrower than your data. The role allows
geography:CA-BC, and the skills you want are tagged Ontario. - A role change still in the cache. A change takes up to 30 minutes to show (§2).
- Write tools missing on an author key. The four
tip-skill-*tools register only when a role grantscapability:skill-write; see Authoring skills.
tip-key-introspect shows your key’s roles; your Data Studio contact can change them.
See also
- Getting started with the Data Studio: connecting, discovery, the products, prices, retries, reading the answer, and the error codes.
- Authoring skills: creating, updating and publishing skills over MCP.
- connect-claude-code.md: the connection steps as plain Markdown, written for an agent.
- The Developer Portal (your key needed): the catalog, a request builder and the API Reference.