Architecture and request flow
Clear Exchange is a private exchange where publishers compete to answer AI agents' questions with licensed content. One Node.js process serves the API, the web UI and (optionally) the demo publishers. It has no runtime dependencies.
Request flow
agent ──POST /v1/query──▶ Exchange.query
├─ cache (normalised query) ── hit: reuse the candidate pool
├─ routeQuery(): profiles + telemetry prior → top FANOUT_CAP sources,
│ one exploration slot for new/under-served sources
├─ fanOut(): SupplyAdapter.search() under FANOUT_BUDGET_MS, shortened
│ tmax per source, skip if too little left, ignore late replies
├─ rankCandidates(): constraints → re-score (one scale) ± capped self-score,
│ overclaim penalty, prior → price (card is a ceiling) + fee on top
│ → drop over max_price → sort by relevance → select cheapest in band
└─ offers (stored with a TTL in ExchangeState: memory + VAR_DIR/state.jsonl)
agent ──POST /v1/acquire──▶ Exchange.acquire: the same clearing, then accept + retrieve the selected offer
agent ──accept──▶ issueEntitlement() ── Signer (Ed25519 today; attested key later)
agent ──GET content + Bearer──▶ verifyEntitlement() → SupplyAdapter.retrieve()
├─ content_hash check, Ledger.record() publisher price + fee = all-in
└─ telemetry: content_retrieved (source_role "index")
agent ──POST /v1/telemetry + Bearer──▶ validateAgentBatch() → grounded / cited / presented / engaged
Step by step:
- Query.
parseQueryRequest()validates the body. The exchange creates or continues a session. - Route. If the normalised query is in the cache, the cached candidate pool is reused. Otherwise
routeQuery()picks at mostFANOUT_CAPsources (see Routing). - Fan out.
fanOut()calls each routed source'ssearch()in parallel under one time budget. - Rank.
rankCandidates()applies constraints, re-scores every candidate, prices it from its rate card, adds the fee, drops what is over budget, sorts by relevance and selects the cheapest offer in the relevance band (see Ranking and selection). - Offer. Offers are stored for
OFFER_TTL_SECONDS, bound to the querying agent, inExchangeState(journalled toVAR_DIR/state.jsonl, so they survive a restart). Every response reports its stagetimingsagainst the routing budget.POST /v1/acquirestops here: it takes the selected offer, stores only that one, and runs steps 6 and 7 itself, returning the entitlement and the content in one response. - Accept.
issueEntitlement()signs a JWS with the resolved terms, price and fee split (Entitlements). - Retrieve. The exchange verifies the entitlement, calls the source's
retrieve(), checks the content hash, records the ledger entry once per entitlement and emitscontent_retrieved. - Report. The agent posts SPUR events; the exchange validates them against the entitlement and stores them (Telemetry). Stage counts feed each source's telemetry prior.
Code map
src/
domain/money.ts integer micros; no floats in any price or ledger value
ratecard/ rate card schema v1.x (validation) and pure pricing
supply/ SupplyAdapter interface, source registry, v1 offer contract, http-offer adapter,
local catalogue and demo verticals, coverage report, Wikimedia (live),
TollBit and Prebid/Trusted Server stubs
routing/ source profiles, router, fan-out under a time budget, cache, per-source stats
demo/mock-publishers.ts mock publishers speaking the offer contract (honest, discounting, inflating, slow)
relevance/ RelevanceScorer interface, BM25 default
exchange/ query parsing, ranking (pure), Exchange service, errors
entitlement/ Signer and key resolver interfaces, JWS issue and verify
clearing/ledger.ts fee arithmetic and the append-only ledger
telemetry/spur.ts SPUR v1.0 event building and agent batch validation
telemetry/timings.ts recent query timings, percentiles per source (GET /v1/admin/timings)
storage/jsonl.ts append-only log (memory, or a JSONL file reloaded at startup)
storage/state.ts sessions, offers and issued entitlements: memory + compacted JSONL journal
http/server.ts node:http routing, JSON in and out
http/static.ts web UI assets: fixed allowlist, served from memory with a strict CSP
docs/ docs site: Markdown renderer and pages built from docs/ at startup; see docs-site.md
app.ts composition root: chooses signer, storage, scorer and adapters
config.ts, server.ts env config and production checks; entry point
data/ seeded fictional publishers and articles, rate cards (incl. demo Wikimedia cards),
sources/ (sources files) and demo-publishers/ (mock publisher content)
web/ marketing page (index.html) and app (app.html, app.js, lib.js); fonts/ the headline serif; see web-ui.md
docs/ this documentation, also served as the docs site
harness/ synthetic agent-user test harness; see agent-harness.md
survey/ the LLM customer survey; see customer-survey.md
scripts/ e2e-agent.ts (end-to-end walkthrough), keygen.ts, mock-publishers.ts,
check-live-sources.ts (CI probe), record-wikimedia-fixtures.ts
test/ node:test suites; fixtures/wikimedia/ holds recorded API responses
Exchange (src/exchange/exchange.ts) is transport-agnostic; src/http/server.ts wraps it. Tests build exchanges directly with buildExchange(config, overrides) to swap adapters, scorer, signer, clock or fetch.
Routing
Route, don't index. The exchange holds a thin profile per source: declared topics plus up to 100 sampled titles and abstracts, never full text. Samples come from the source's declared profile, from probe_queries the exchange sends at startup, and from what the source returns to live queries.
- Each query is scored against the profiles with BM25, plus
PRIOR_WEIGHT× (telemetry prior − 0.5).profile.min_scorekeeps a broad source (an encyclopedia) in contention for any query. - At most
FANOUT_CAP(5) sources are routed.EXPLORE_SLOTS(1) of them go to sources routed fewer thanEXPLORE_MIN_QUERIES(20) times, so new sources get traffic and build a history. When there are no more sources than the cap, all are routed (reason: "all"). - Stub adapters are never routed to.
Fan-out follows Prebid Server's timeout handling: one FANOUT_BUDGET_MS (900 ms) budget; each source gets a tmax equal to what is left, minus FANOUT_RESERVE_MS for scoring and the response, minus its network_allowance_ms. A source whose tmax would fall below MIN_SOURCE_TMAX_MS is skipped. Late replies are aborted and ignored. SUPPLY_TIMEOUT_MS is a backstop per source. A failing or late source is listed in stats.supply_errors and timings.dropped_sources, and the query clears from the others. GET /v1/admin/timings shows latency percentiles and drop rates per source.
Complete fan-outs are cached per normalised query for ROUTE_CACHE_TTL_SECONDS (60). The query response's routing block shows every decision.
Ranking and selection
Sources' self-scores are not trusted. Every candidate is re-scored by the exchange's RelevanceScorer on the same text (title and snippet for remote sources), so all sources compete on one scale. The default scorer is BM25 over the query's candidate pool, mapped with s / (s + 6) so scores stay absolute rather than "best = 1.0", then scaled by query coverage: x (0.5 + 0.5 x coverage), where coverage is the IDF-weighted share of the query's terms the candidate contains (terms no candidate contains are ignored). IDF grows with the pool, so without coverage one rare word shared with an off-topic query could lift a candidate over the relevance floor once the pool holds a few hundred items; a candidate that matches the whole query is unaffected. Routing scores profiles with plain saturated BM25. Then:
- a source's
self_scoreis a weak feature, weighted bySELF_SCORE_WEIGHTand capped at ±SELF_SCORE_CAP(0.05), and discounted by the source's overclaim; - a source whose claims exceed the exchange's score by more than
DIVERGENCE_TOLERANCE(now, or as a moving average) losesDIVERGENCE_PENALTY× overclaim; - the telemetry prior adds
PRIOR_WEIGHT× (prior − 0.5); - when the query asks for a period ("today", "this month", "latest", "2009"), recency boosts items inside it and demotes stale or undated ones (
src/exchange/recency.ts, see API: Recency and freshness).
Each offer's scoring shows the parts.
Relevance first; price competes only inside the band.
- Candidates failing a constraint, or below the floor (
BAND_FLOOR, default 0.5, or the query's highermin_relevance), are dropped however cheap. - Each is priced from its rate card (Rate cards); the fee goes on top.
- Only candidates with relevance ≥
BAND_RATIO(0.9) × the top candidate form the band; the rest are not offered. The top is taken before the price cap, so a cheap second-best item cannot become "the best" because the best one is over budget. - In-band offers whose all-in price exceeds
max_priceare excluded, not down-ranked, and summarised inprice_hint. - The rest are the offers, sorted by relevance (price breaks exact ties only). The cheapest is
selected, and is always returned even iflimitwould cut it. - If nothing is left, the response says
"result": "no_good_match"with ano_matchreason instead of offering low-quality content (API: No good match).
Worked example (test/selection.test.ts): cap $0.010; A 0.92 at $0.008, B 0.88 at $0.004, C 0.60 at $0.001. The band threshold is 0.828, so C is out; B wins on price: publisher $0.004, fee $0.0004, buyer pays $0.0044. Had B cost $0.011 it would break the cap and A would win.
When relevant offers were excluded only on price, price_hint gives the price and relevance of the cheapest and the most relevant of them. Offers another constraint would still exclude are left out of the hint.
Relevance is replaceable. RelevanceScorer (src/relevance/scorer.ts) is async and returns scores in [0, 1]. An embedding or re-ranker scorer can be passed through buildExchange(config, { scorer }).
Pricing and clearing
- A rate card is a ceiling: sources may discount below it, never charge above it.
- The clearing fee sits on top of the publisher price: fee =
floor(publisher × CLEARING_FEE_BPS / 10000)(default 1000 bps, 10%, a placeholder to test). The buyer sees one all-in price. - The split is fixed at offer time, signed into the entitlement (
cx.clearing) and recorded in the ledger on first retrieval (gross= all-in,net= publisher,fee). - The ledger is idempotent per entitlement. Zero-price content creates no entry. A failed retrieval records nothing.
- No money moves; the ledger is a record.
Three separate formats
| Format | Purpose | Refers to the others by |
|---|---|---|
Rate card (clear-exchange.rate-card 1.x) | The publisher's price list | — |
Entitlement (JWS, cx-entitlement+jwt) | The resolved terms that were sold | rate_card_ref (id@vN) |
| Telemetry (SPUR v1.0) | What the agent did with the content | license_ref = entitlement jti, terms_ref = rate card ref |
Each can evolve without breaking the others.
Persistence
| Data | Where it lives |
|---|---|
| Sessions, offers (and whether each was accepted), issued entitlements | ExchangeState (src/storage/state.ts): memory, journalled to VAR_DIR/state.jsonl, replayed and compacted at startup. |
| Ledger and telemetry | Memory, plus append-only JSONL files in VAR_DIR, reloaded at startup. Seen telemetry event ids are rebuilt from the telemetry file. |
| Routing profiles and stats, candidate cache, query timings | Process memory only. Rebuilt from traffic after a restart. |
| Rate cards, catalogue, sources | Files under data/ (and any SOURCES_FILE), read at startup. |
VAR_DIR defaults to ./var (git-ignored); empty means memory only. Production sets /data, a Fly volume, so state survives restarts and deploys (Durable state). A volume attaches to one machine, so the demo runs exactly one. A shared database behind AppendLog and ExchangeState would replace the files without changing callers, and allow more machines. See Roadmap.
Key decisions
- TypeScript on Node 22, no runtime dependencies. Node runs
.tsdirectly (type stripping), so there is no build step.node:cryptoprovides Ed25519,node:httpserves the API andnode:testruns the tests. A small dependency surface also matters for the planned confidential VM, where everything in the image is part of the attested measurement. - One interface for all supply. Every source plugs in through
SupplyAdapter, so adding a source never changes routing, ranking, pricing or entitlement code (Adding a source). - Integer money. All prices and ledger values are integer micros.
- Keys behind interfaces. Token code depends only on
SignerandVerificationKeyResolver, so an attested signer can be added later. - One composition root.
src/app.tsis the single place where the signer, storage, scorer and adapters are chosen.