Clear ExchangeDocs Live demo

Supply adapters: adding a publisher or marketplace

A source is anything that can answer a query with licensed content: a publisher's own API, a marketplace that fronts many publishers, a Prebid seller module in a Trusted Server, or the seeded demo catalogue. Every source plugs into the exchange through one interface, SupplyAdapter (src/supply/adapter.ts). Routing, re-scoring, pricing, entitlements, clearing and telemetry never change when you add one.

There are two ways to add a source:

You haveYou addCode changes
A publisher that implements the v1 offer contract belowOne entry of type http-offer in a sources fileNone
Any other API (a marketplace, a CMS, a partner feed)One adapter file plus one line in SOURCE_TYPES (src/supply/registry.ts), then an entry in a sources fileThe adapter file and the line

The SupplyAdapter interface

interface SupplyAdapter {
  readonly id: string;                       // unique; namespaces content ids
  readonly kind: "local_catalogue" | "publisher_api" | "marketplace" | "prebid_trusted_server";
  readonly status: "live" | "stub";          // stubs are listed but never routed to
  search(req: SupplySearchRequest): Promise<SupplyCandidate[]>;
  retrieve(content_id: string, ctx?: RetrieveContext): Promise<ContentBody | null>;
  profile?(): SourceProfileSeed;             // topics, samples, min_score, probe_queries
  readonly network_allowance_ms?: number;    // expected round trip, taken off tmax
  rateCards?(): PublisherRateCard[];         // for the operator and publisher views
  readonly source?: SupplySourceInfo;        // name, URL, publisher, licence, note
  health?(): SupplyHealth;                   // for adapters that call a remote source
}
  • search() gets the query, the requested usage, the publisher's share of the buyer's cap (max_price_micros, after the fee), tmax_ms and an AbortSignal. It returns candidates with title, snippet, URL, relevance_text (what the scorer sees; never shown to agents), the pricing rate_card, and optionally offered_prices_micros, self_score, content_hash, content_licence, section, tags, published_at and updated_at. Return the dates whenever you have them: queries that ask for a period ("this month", "latest") demote undated items, and offers show freshness to agents. Never return full text from search.
  • retrieve() is called only after the exchange has verified an entitlement for that content. ctx carries the entitlement, the usage tier and the agent's Content-Telemetry-ID. Return null if the content is gone (the agent gets 404 content_not_found); throw if the source failed (502 supply_unavailable, nothing charged).

The full types, with comments, are in src/supply/adapter.ts.

How the exchange uses a source

  1. Routing. The exchange keeps a thin profile of each source: declared topics plus a bounded sample of titles and abstracts (never full text). Samples come from the source's declared profile, from probe_queries the exchange sends at startup (query-based sampling), and from what the source returns to live queries. Each query goes to the best-matching sources, at most FANOUT_CAP (default 5), with EXPLORE_SLOTS (default 1) kept for new or under-served sources so they get traffic and build a history. A source's SPUR telemetry history (grounded, cited, presented, engaged per paid retrieval) is a prior that nudges routing and ranking.
  2. Fan-out. The routed sources are called in parallel under one budget (FANOUT_BUDGET_MS, default 900 ms). Each gets a shortened tmax: what is left of the budget, minus a reserve for scoring, minus its expected network round trip (network_allowance_ms). A source whose tmax would fall below MIN_SOURCE_TMAX_MS is skipped. Replies after the deadline are ignored and the request is aborted. Complete fan-outs are cached per normalised query (ROUTE_CACHE_TTL_SECONDS).
  3. Re-scoring. The exchange scores every candidate's title and snippet with its own scorer, so all sources are judged on one scale. A source's self_score moves relevance by at most SELF_SCORE_CAP (0.05), and a source whose claims run above the exchange's scores (now or historically) is penalised (DIVERGENCE_PENALTY x overclaim).
  4. Pricing and selection. Each tier is priced at min(rate card price, offered price): a source may discount, never charge above its card. The 10% clearing fee (CLEARING_FEE_BPS) goes on top, and the buyer's cap applies to that all-in price. Only offers with relevance >= BAND_RATIO (0.9) x the top and >= BAND_FLOOR (0.5) are offered; the cheapest of them is selected. If none clears, the agent gets "no good match".
  5. Retrieval. After an agent accepts an offer and gets a signed entitlement, the exchange calls the source's retrieve with that entitlement. If the offer declared a content_hash, the text must match it or the agent is not charged.

The v1 offer contract

Contract name clear-exchange.offer, version 1.x. Readers accept any 1.x and ignore unknown fields. Prices are integer micros (1 USD = 1,000,000 micros) in the publisher's rate card currency. Types and validation live in src/supply/offer-contract.ts.

POST {offer_url}

Request:

{
  "contract": "clear-exchange.offer",
  "version": "1.0",
  "request_id": "5d0e8f3a-6a43-4d8c-9b0e-1f2a3b4c5d6e",
  "query": "Do heat pumps work in cold climates?",
  "tier": "display_snippet",
  "tmax_ms": 1295,
  "max_price": { "amount_micros": 9090, "currency": "USD" },
  "max_candidates": 5
}
  • tier is grounding_only, display_snippet or full_text. Broader tiers include the rights of narrower ones.
  • tmax_ms is how long you have. Answer within it or the reply is ignored.
  • max_price is the most the buyer will pay you per item (the exchange's fee is already taken off). You may use it to skip items you would not sell at that price.

Response (HTTP 200, at most 5 candidates; extras are ignored):

{
  "contract": "clear-exchange.offer",
  "version": "1.0",
  "request_id": "5d0e8f3a-6a43-4d8c-9b0e-1f2a3b4c5d6e",
  "currency": "USD",
  "candidates": [
    {
      "content_id": "qk-0101",
      "url": "https://quarrykiln.example/2026/cold-climate-heat-pumps",
      "title": "Cold-climate heat pumps: what a winter of field trials showed",
      "snippet": "Air-source heat pumps in forty homes kept rooms warm through a winter...",
      "price_micros": { "grounding_only": 1800, "display_snippet": 3600, "full_text": 9000 },
      "content_hash": "sha256:9f2c...64 hex chars",
      "self_score": 0.48,
      "published_at": "2026-09-02T08:00:00Z",
      "section": "retrofit",
      "tags": ["heat pumps", "field trial"]
    }
  ]
}
  • Required per candidate: content_id ([A-Za-z0-9._~-]{1,128}), url (http or https), title (max 300 chars), snippet (max 1000 chars, shown before purchase and used for scoring), price_micros (at least one tier) and content_hash (sha256: plus the hex SHA-256 of the UTF-8 text retrieval will return).
  • Optional: self_score in [0, 1], published_at, updated_at (ISO date-times), section, tags.
  • A malformed envelope fails the whole reply. A malformed candidate is dropped on its own.
  • A price above your rate card for that tier is ignored: the item is priced at the card.

POST {retrieve_url}

Called only after the exchange has issued an entitlement for the item.

{
  "contract": "clear-exchange.offer",
  "version": "1.0",
  "content_id": "qk-0101",
  "tier": "display_snippet",
  "entitlement": "eyJhbGciOiJFZERTQSIs..."
}

Verify entitlement against the exchange's public keys at /.well-known/jwks.json (an EdDSA compact JWS whose cx.content_id is <source id>~<your content_id>). The exchange also sends the agent's Content-Telemetry-ID header when it has one. Respond:

{
  "content_id": "qk-0101",
  "title": "Cold-climate heat pumps: what a winter of field trials showed",
  "url": "https://quarrykiln.example/2026/cold-climate-heat-pumps",
  "text": "Full text...",
  "content_hash": "sha256:9f2c...",
  "published_at": "2026-09-02T08:00:00Z"
}

Example: add a publisher by configuration

  1. Write the publisher's rate card (clear-exchange.rate-card 1.x; see Rate cards and data/rate-cards/quarry-kiln.json). Its publisher_id must match the source's.
  2. Add an entry to a sources file, for example config/sources.json:
{
  "sources": [
    {
      "id": "acme-news",
      "type": "http-offer",
      "publisher": { "publisher_id": "pub_acme", "name": "Acme News", "domain": "acme.example" },
      "offer_url": "https://api.acme.example/clear/offer",
      "retrieve_url": "https://api.acme.example/clear/retrieve",
      "rate_card": "rate-cards/acme.json",
      "auth_token_env": "ACME_OFFER_TOKEN",
      "timeout_ms": 1500,
      "network_allowance_ms": 100,
      "profile": {
        "topics": ["energy", "climate policy", "utilities"],
        "samples": [{ "title": "Grid storage in 2026", "abstract": "Batteries now..." }],
        "probe_queries": ["grid battery storage", "energy prices"]
      }
    }
  ]
}
  1. Start the exchange with SOURCES_FILE=config/sources.json (comma-separate several files). The source appears in GET /v1/supply with its profile and routing stats.

Field notes:

  • id: lowercase letters, digits and dashes. It namespaces content ids (acme-news~<content_id>), so pick it once.
  • Relative paths resolve against the sources file's directory. ${SELF_URL} in a URL is replaced with the exchange's own loopback address (used by the demo publishers).
  • Never put credentials in the file. auth_token_env names an environment variable; its value is sent as Authorization: Bearer <token> to the publisher.
  • enabled: false keeps an entry in the file without loading it.
  • profile.min_score keeps a broad source (an encyclopedia) in contention for any query.
  • Unknown types, duplicate ids, bad URLs or a missing token variable stop startup with a clear configuration error.

The demo publishers in data/sources/demo-publishers.json are five complete examples.

A group of local publishers with their own items (a demo vertical) needs no sources file at all: add a directory under data/verticals/. See Demo supply.

Example: add a marketplace or other new kind of source

For an API that does not speak the offer contract, write an adapter that maps it onto SupplyCandidate, then register it:

// src/supply/acme-market.ts
import type { SupplyAdapter, SupplyCandidate, SupplySearchRequest, ContentBody } from "./adapter.ts";

export class AcmeMarketAdapter implements SupplyAdapter {
  readonly kind = "marketplace" as const;
  readonly status = "live" as const;
  readonly network_allowance_ms = 150;
  constructor(readonly id: string, private readonly apiKey: string) {}

  profile() {
    return { topics: ["business", "technology"], probe_queries: ["AI chips"] };
  }

  async search(req: SupplySearchRequest): Promise<SupplyCandidate[]> {
    // Honour req.tmax_ms and req.signal; return title + snippet, never full text.
    const res = await fetch(`https://market.example/search?q=${encodeURIComponent(req.query)}`, {
      headers: { authorization: `Bearer ${this.apiKey}` },
      signal: req.signal,
    });
    const items = (await res.json()) as Array<{ id: string; title: string; summary: string; url: string; price: number }>;
    return items.slice(0, 5).map((it) => ({
      content_id: `${this.id}~${it.id}`,
      supply_id: this.id,
      publisher: { publisher_id: "pub_acme_market", name: "Acme Market" },
      title: it.title,
      snippet: it.summary,
      url: it.url,
      relevance_text: `${it.title}\n${it.summary}`,
      rate_card: ACME_CARD, // the ceiling; see src/ratecard/schema.ts
      offered_prices_micros: { display_snippet: it.price },
    }));
  }

  async retrieve(content_id: string, ctx?: { entitlement?: string }): Promise<ContentBody | null> {
    // Called only after an entitlement is issued; ctx.entitlement can be passed upstream.
    return null;
  }
}
// src/supply/registry.ts, in SOURCE_TYPES
"acme-market": (spec, ctx) => new AcmeMarketAdapter(spec.id, ctx.env[String(spec.api_key_env)] ?? ""),
{ "sources": [{ "id": "acme-market", "type": "acme-market", "api_key_env": "ACME_MARKET_KEY" }] }

That is all. src/supply/stubs.ts shows where TollBit-style marketplaces (TollbitAdapter) and the Prebid seller module in Trusted Server (PrebidTrustedServerAdapter, the v2 home of the same offer contract) plug in.

Testing a source

  • npm run mock:publishers runs the demo publishers on port 8090 (honest, discounting, score-inflating, marked-up and slow), all speaking the contract.
  • DEMO_PUBLISHERS=on npm start serves them from the exchange itself and routes to them; the agent console's routing panel shows which sources were asked and how each answered.
  • test/sources.test.ts shows how to drive an exchange against a mock publisher server, and how to register a custom source type in a test.