AI Interfaces
Public, read-only endpoints that expose each company's published catalog — as schema.org JSON-LD for search engines, as an MCP server for AI agents, and as a WebMCP embed for in-browser agents.
Every endpoint is keyed by the company's pluginKey — the stable public key
issued by the platform the catalog is sourced from. Unknown keys return 404.
All data is a read-only mirror of the company's published catalog — no customer data or
internal pricing is ever exposed. Examples below use Tudi Mechanical Systems.
1 · JSON-LD feed
GET/companies/{pluginKey}.jsonld
Returns the company's complete schema.org graph as
application/ld+json, ready to embed verbatim in a
<script type="application/ld+json"> tag. The @graph contains:
| Node | Contents |
|---|---|
LocalBusiness | Business name, postal address, geo coordinates, website. |
Product (system type) | One per system type (e.g. HP & Air Handler, Water Heater). The AggregateOffer spans the type's lowest and highest fully-installed price, nests up to three Good/Better/Best tier offers, and carries a configurable note describing what the price covers. Disable per deployment by removing system from CONCOM_OFFERABLE_ITEM_TYPES. |
Product / Service | One per remaining offer group (by category, plus one per uncategorized item), typed by its member items. Each carries an AggregateOffer with lowPrice/highPrice/offerCount. |
Example
curl http://ai-interfaces.toolbox.contractorcommerce.com/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq.jsonld
Abbreviated response:
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "LocalBusiness", "name": "Tudi Mechanical Systems",
"address": { "@type": "PostalAddress", "addressLocality": "…" } },
{ "@type": "Product", "name": "HP & Air Handler",
"offers": { "@type": "AggregateOffer", "priceCurrency": "USD",
"lowPrice": "8500.00", "highPrice": "15200.00", "offerCount": 24,
"description": "Fully-installed price. Includes installation and permits.",
"offers": [
{ "@type": "Offer", "name": "Good", "price": "8500.00" },
{ "@type": "Offer", "name": "Better", "price": "10200.00" },
{ "@type": "Offer", "name": "Best", "price": "12750.00" } ] } },
{ "@type": "Product", "name": "Air Filters",
"offers": { "@type": "AggregateOffer", "priceCurrency": "USD",
"lowPrice": "60.05", "highPrice": "186.04", "offerCount": 3 } }
]
}
Embedding on a website
<script type="application/ld+json">
…paste the response body verbatim…
</script>
2 · MCP server (for AI agents)
POST/companies/{pluginKey}/mcp
A per-company Model Context Protocol server using the
Streamable HTTP transport (JSON-RPC 2.0, protocol 2025-06-18).
It is stateless: every request is answered with a single application/json body,
notifications return 202, and GET returns 405 (no
server-initiated stream). Point any MCP client at the URL and it will discover the tools. After
initialize, send the negotiated MCP-Protocol-Version header on each request —
an unsupported value is answered with 400; CORS preflights allow it.
Tools
| Tool | Arguments | Returns (structuredContent) |
|---|---|---|
get_business_profile | — | The schema.org LocalBusiness/Organization node (name, address, geo, phone, website) plus quote_url, shop_url when known, and service_area.postal_code_count. |
list_offers | — | { quote_url, offers: [ … ] } — offer groups with USD price ranges, offer counts and tier prices. |
search_catalog | query string of 2–100 characters (required; every word must match); limit integer 1–25 (default 10) | { query, total_matches, quote_url, matches: [ { id, name, sku, type, category, price, currency, image_url, description } ] } — ranked exact SKU → exact name → name starts with → name contains → SKU, alphabetical within a tier; the order does not depend on limit. |
check_service_area | postal_code string matching ^\s*\d{5}(-\d{4})?\s*$ (required — a US ZIP or ZIP+4; "02134", not 2134) | { postal_code, served, company, service_area } — the five-digit ZIP actually checked and the size of the territory. |
get_faqs | — | { faqs: [ { category, question, attribute, intent, applies_when: [ { attribute, in, not_in } ], answers: [ { label, description, intent } ] } ] } — the quote flow's branching questionnaire; the same question can recur under different applies_when conditions. |
Every tool declares a title, an inputSchema, an outputSchema and
MCP annotations (readOnlyHint and idempotentHint true,
destructiveHint and openWorldHint false — the answers come from our own
catalog mirror). A successful call returns structuredContent conforming to the tool's
outputSchema, plus the same JSON pretty-printed as its text block. Wherever an agent might
need the next step, the result carries quote_url — our redirect into the contractor's own
quote funnel.
Connect from Claude Code
claude mcp add --transport http tudi-mechanical-systems \
"http://ai-interfaces.toolbox.contractorcommerce.com/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq/mcp"
Then ask things like “do they serve zip 63376, and what do air filters cost?” — the agent discovers and calls the tools itself.
Raw JSON-RPC examples
Handshake:
curl http://ai-interfaces.toolbox.contractorcommerce.com/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"demo","version":"1"}}}'
List tools:
curl http://ai-interfaces.toolbox.contractorcommerce.com/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
Call a tool — search the catalog:
curl http://ai-interfaces.toolbox.contractorcommerce.com/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
"name":"search_catalog","arguments":{"query":"Lennox","limit":5}}}'
Check service-area coverage:
curl http://ai-interfaces.toolbox.contractorcommerce.com/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{
"name":"check_service_area","arguments":{"postal_code":"63376"}}}'
-32601; unknown tools
-32602. Arguments are validated strictly against the tool's inputSchema
before anything runs: an unknown key, a wrong JSON type ("postal_code": 45050 where a
string is declared), a value that misses its pattern ("postal_code": "@@@") or an
out-of-range limit returns a result with
isError: true naming the argument and what was expected, per the MCP tool-error
convention. Leading and trailing whitespace on string arguments is trimmed before validation. A failure
inside a tool returns a flat Tool execution failed. — details are logged server-side, never
sent to the caller.
3 · WebMCP embed (in-browser agents)
GET/webmcp.js
A drop-in script that exposes the same catalog tools to AI agents running
inside the visitor's browser via the emerging
WebMCP
(document.modelContext) API. Add one tag to the company's site —
the script ships with the tool definitions embedded (the same tools/list
the endpoint above serves, annotations included) and registers them the moment it
executes, with no discovery request; each call is forwarded to the MCP endpoint and the
agent receives the tool's structuredContent as a native object. Browser discovery
shows each tool's title and — since WebMCP has no output-schema channel — a Returns:
shape summary appended to its description. Beside the MCP
hints, every tool carries Chrome's readOnlyHint, consequentialHint: false
and untrustedContentHint: true — catalog text is contractor-authored data, not
instructions. No keys or server calls on the contractor's part:
<script type="module"
src="https://ai-interfaces.toolbox.contractorcommerce.com/webmcp.js"
data-plugin-key="000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq"></script>
[webmcp] console line saying the API isn't available and does
nothing more (to exercise it outside the trial, enable
chrome://flags/#enable-webmcp-testing). On success the console shows
[webmcp] registered 5 tools for {pluginKey}. The module is cached for
300 seconds with an ETag, so a deploy
reaches embedded pages within that window; agents should still treat the tool list as
dynamic, as WebMCP expects. The tools, arguments and behavior are identical to the MCP
server above — this is just a browser-native delivery of the same catalog.
4 · Source tracking
Any endpoint accepts an optional source query parameter. When present, the
request is recorded — the source label, the endpoint path, the HTTP method,
and a timestamp — so you can attribute traffic to a campaign, referrer, or integration.
It is optional, never changes the response body or status, and is ignored when omitted or empty.
GET/companies/{pluginKey}.jsonld?source=your-label
Append ?source= (any short label) to any endpoint URL — for example:
curl "http://ai-interfaces.toolbox.contractorcommerce.com/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq.jsonld?source=newsletter"
source are not tracked. Use a stable label per channel
(e.g. newsletter, partner-acme) so hits group cleanly.
referrer (or
referer) variable, its value is rewritten to
concom_ai_interfaces — in the query string or inside a hash route's own
query, wherever it sits. Everything else about the URL is passed through untouched, and
a URL with no such variable never gains one.
5 · Data freshness
The mirror is synchronized from the source platform's API on a recurring incremental schedule; offer groups and price ranges are rebuilt after each sync. Prices reflect the company's published catalog at the time of the last sync.
The pricing view model (section 7) publishes that moment as last_synced_at
(the last successful sync — a failed run never advances it) and summarizes it as
freshness_state: fresh, stale once the last success
is older than the configured threshold, or unavailable when the company has
never synced. Consumers should stop showing prices that are not fresh.
6 · Get Quote (OpenAI local services)
Support for OpenAI's local-services request-quote conversion spec, which lets ChatGPT show an in-chat "Get Quote" launcher for a contractor. Three surfaces work together, and all three are read-only — a quote request is a handoff to the contractor's own funnel, never something this app collects or stores.
Business feed
GET/v1/businesses?page=1&page_size=50
One record per eligible contractor — a current customer
(platform status active, with a live storefront), with a pluginKey, a
business address with coordinates, a valid website, and a phone
number: the fields the spec requires on every record. Churned
contractors' endpoints — this feed, JSON-LD, MCP, quote and pricing
pages — all return 404. A contractor
missing any of them is excluded until the data is completed upstream,
and returns automatically on the next sync. Optional changes_token takes the
changes_token from a previous response and returns only the
contractors whose company or address record has changed since.
Records are validated and ordered — by pluginKey, then id — before
pagination: every page is full except possibly the last, the order is
identical across requests, and no id is duplicated or skipped between
adjacent pages. Tokens are versioned (chg_<version>_<timestamp>):
a token minted before a feed-contract change is treated as stale and
answered with a full re-walk. checksum is false
exactly when your token is current and nothing has changed — skip
processing and keep the token you have.
platform_url is the contractor's own quote funnel when they
publish one — their systems_estimate_url if the source has
vouched for it, else their concom_shop_url storefront — and
the hosted quote page below otherwise. provider_action_url
is always the hosted quote page: that is the click the launcher makes,
and it is the one we attribute. Either way any referrer
variable on a published URL is stamped
concom_ai_interfaces — see
source tracking.
{
"checksum": true,
"page": 1,
"page_size": 50,
"total_pages": 3,
"changes_token": "chg_1755500000",
"businesses": [
{
"id": "000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq",
"name": "Tudi Mechanical Systems",
"address": { "line1": "…", "locality": "…", "region": "…",
"postal_code": "…", "country": "US", "formatted": "…" },
"location": { "latitude": 37.793, "longitude": -122.396 },
"phone_number": "+14155551234",
"website_url": "https://example.com",
"platform_url": "https://example.com/shop/",
"service_providers": [
{
"provider": "contractor_commerce",
"provider_business_id": "000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq",
"action_type": "request_a_quote",
"provider_action_url": "https://ai-interfaces.toolbox.contractorcommerce.com/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq/quote",
"display_name": "Get Quote"
}
]
}
]
}
changes_token checkpoint tracks each contractor's
source-system source_updated_at, so a contractor whose
source timestamp predates onboarding, or one that has since been
deactivated, may not produce a change signal — a periodic full walk
of the feed (no changes_token) is still required.
/mcp and the quote
page. Over the limit returns 429. The JSON-LD feed and the per-company MCP
server in sections 1 and 2 are unaffected.
Provider MCP server
POST/mcp
Install-base-wide, unlike the per-company server in section 2. It serves
the request_service launcher — bound to the
ui://widget/request-service.html card — plus a
widget-accessible check_service_area helper. Both take the
business_id from the feed (the contractor's pluginKey).
curl -s https://ai-interfaces.toolbox.contractorcommerce.com/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"request_service","arguments":{"business_id":"000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq"}}}'
Both tools declare an outputSchema describing their
structuredContent, and both validate their arguments: a
missing or empty business_id returns a tool error naming
the argument, while a well-formed id matching no active contractor
returns a distinct No business found error. Both arrive as
MCP tool errors (isError: true), never protocol errors. The
launcher's structured result carries provider_business_id —
pass that value (not the listing's id) to follow-up tool
calls.
Quote page
GET/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq/quote
The contractor's public quote page — provider_action_url
in the feed, and platform_url too for contractors with no
funnel of their own.
When the contractor has a quote funnel of their own it
302s straight to it: their
systems_estimate_url when the source has vouched for it,
else their concom_shop_url storefront. Someone who clicked
“Get Quote” wants the funnel, not another page with a button on it. The
redirect is temporary by design — the destination comes from mirrored
settings and can change on any sync.
Contractors with no funnel get the page itself instead: address, phone,
published price ranges, and a link to their website. So the URL is
always valid, which is what keeps the launcher appearing at all. Append
?source= to attribute the click (section 4) — tracking
survives the redirect.
contractor_commerce). Publishing the feed is
necessary but not sufficient.
7 · Pricing page + view model
Pricing view model
GET/companies/{pluginKey}/pricing.json
The data contract behind the WordPress Public Pricing Page feature: the company's published system pricing as one small JSON object, so a consumer never parses the full JSON-LD graph. Everything in it is already public — the same catalog the feed above serves.
| Field | Contents |
|---|---|
business | Name, website, E.164 phone, address, geo coordinates, and the service area (postal_code_count + the codes). |
categories[] | One row per published system type: name, slug, price_low/price_high, offer_count, and tiers (good/better/best starting prices; a tier the catalog doesn't publish is absent, never zero — render it as "Not published"). |
currency | ISO code for every price in the payload. |
price_description | What an installed price includes — render it next to any price you show. |
data_provenance | Why this data beats third-party estimates: first-party, current, non-anonymized pricing from the named local contractor. Render it near the prices — search and LLM retrieval weigh exactly this distinction against aggregator averages built from past anonymous jobs. |
quote_url | The CTA target. Redirects into the contractor's own quote funnel when they have one (section 6). Append ?source= to attribute clicks. |
last_synced_at / freshness_state | See section 5. Don't show prices unless fresh. |
content_updated_at | When the pricing content last actually changed — your sitemap <lastmod>. Unlike last_synced_at, a sync that changes nothing does not move it. |
content_version | Fingerprint of the pricing content (business, categories, copy, quote URL). Changes only when the content changes — cache your rendered page against it. |
Example
curl https://ai-interfaces.toolbox.contractorcommerce.com/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq/pricing.json
Abbreviated response:
{
"business": { "name": "Tudi Mechanical Systems", "address": { "locality": "…" },
"service_area": { "postal_code_count": 91, "postal_codes": ["…"] } },
"categories": [
{ "name": "A/C & Gas Furnace", "slug": "ac-gas-furnace",
"price_low": 11006, "price_high": 29644, "offer_count": 24,
"tiers": { "good": 11006, "better": 14074, "best": 26540 } }
],
"currency": "USD",
"price_description": "Fully-installed price. Includes installation and permits.",
"data_provenance": "These are Tudi Mechanical Systems's own current installed prices — first-party pricing published by the local contractor that performs the work …",
"quote_url": "https://ai-interfaces.toolbox.contractorcommerce.com/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq/quote",
"last_synced_at": "2026-09-01T17:30:17+00:00",
"content_updated_at": "2026-08-30T19:33:42+00:00",
"freshness_state": "fresh",
"content_version": "6f90f9a0802bfdec"
}
Polling cheaply
Responses carry an ETag and
Cache-Control: public, max-age=300.
Send If-None-Match with the last ETag and an unchanged
payload answers 304 with no body. The ETag tracks the whole
response (a sync advancing last_synced_at busts it);
content_version tracks only the pricing content — use the
ETag to poll, and content_version to decide whether a
cached rendering needs regenerating.
?source=wp and append
?source=wp to quote_url clicks so both ends of
the funnel attribute (section 4). Unknown or inactive companies 404 —
render your fallback, don't retry.
Hosted pricing page
GET/companies/000qeNvgDSrmHEbIejt1beAWureP3Z5HrsB3NZFq/pricing
A server-rendered reference implementation of the pricing page: the direct answer, the tier table, inclusions and the quote CTA — all present in the initial HTML response, no JavaScript required, with page-scoped JSON-LD (LocalBusiness + one Product per visible category) built from the same object as the visible content. It demonstrates the contract above; the SEO destination is the page on the contractor's own domain, not this one.
Sitemap inclusion (WordPress)
The pricing page belongs in the contractor's WordPress sitemap
— that is where ranking equity should accrue. Register its URL through
WordPress core's sitemap provider API
(wp_sitemaps_register_provider, WP 5.5+), or the
equivalent filter when an SEO plugin owns the sitemap
(Yoast: wpseo_sitemap_index; Rank Math:
rank_math/sitemap/index).
Use content_updated_at as the entry's
<lastmod> — it moves when the pricing content
changes, not on every sync. For exactness, publish
max(content_updated_at, when you first saw the current
content_version): the one change the timestamp cannot date is a
system type disappearing outright, which flips
content_version only.
categories and
freshness_state is not unavailable — the same
condition under which the page should be indexable at all. When that
stops holding, drop the URL from the sitemap and serve the page
noindex (the hosted reference page above behaves exactly
this way) rather than advertising a page with no prices.