Skip to content

Broker Overview

The FORA Broker is the optional demand-side router in the FORA protocol. It sits with or near the AI agent, queries multiple Exchanges in parallel, compares offers, enforces budget, and selects the best deal. It is the “DSP” of metered resource access.

The Broker is optional. Agents can talk directly to Exchanges using the same DiscoverResources/ExecuteTransaction/UsageReport RPCs. The Broker adds value through:

  • Multi-exchange comparison — fan out DiscoverResources to N Exchanges, pick cheapest
  • Multi-exchange purchase — buy offers from several Exchanges in one BrokerService.ExecuteTransaction call, with the results combined in request order
  • Budget filtering — Resolve drops offers that would exceed the agent’s budget (per offer, per session, per period) and answers a URI left with none with OFFER_ABSENCE_REASON_BUDGET_EXCEEDED. How it computes the budget is its own choice, not part of the protocol
  • Resource deduplication — same article from two Exchanges, compare price
  • Attestation-aware selection (v1.0) — prefer offers with higher attestation levels (Level 2 > Level 1 > Level 0)
  • Selection transparency — logged rationale for every selection decision

The RPCs are identical whether the request comes from a Broker or directly from an agent. The Exchange authenticates whichever party signed the request: the agent on a direct call, the Broker on a request the Broker sent. On a purchase the Broker relays, the agent’s consent arrives in the body as its acceptance signatures, and the purchase is the agent’s, billed to the agent’s account at each Exchange (see Accounts). Usage reports and disputes are not relayed: the agent files them itself, with the Exchange that issued the offer, for a purchase made through the Broker as for a direct one.

The Broker is composed of four internal components. Each is a Go interface with a concrete implementation. No inter-component communication uses the network; everything is in-process function calls.

ComponentResponsibilityState
Exchange RegistryKnows which Exchanges exist and their health status. Bootstraps from Exchange Manifest (/.well-known/fora.json) instead of HEAD probes — extracting endpoints, capabilities, and accepted verifiers in a single fetchLong-lived, refreshed periodically
Supply DiscoveryFans out DiscoverResources RPCs to N Exchanges concurrentlyPer-request, stateless
Selection EngineDeduplicates, ranks, filters, and selects the best offerPer-request, stateless
Budget TrackerTracks spend per session/period, and filters out offers that would exceed the budgetPer-session in-memory, per-period in Redis

Resolve is discovery-only. It gathers offers across Exchanges, selects the best per URI, and returns them as a DiscoveryResponse (one OfferGroup per requested URI). It does not execute or deliver — committing to an offer is a separate call: ExchangeService.ExecuteTransaction at the offer’s Exchange, or BrokerService.ExecuteTransaction at the Broker (see Execution below).

func (o *Broker) Resolve(ctx context.Context, req *forav1.DiscoveryRequest) (*forav1.DiscoveryResponse, error) {
// 1. Resolve which Exchanges to query
exchanges := o.registry.Resolve(req.Constraints)
// 2. Fan out DiscoverResources to all Exchanges (multi-URI: all URIs in one query per MP)
responses := o.discovery.FanOut(ctx, exchanges, req)
// 3. Build one OfferGroup per requested URI: dedup, rank, and budget-filter
// the gathered offers for that URI. A URI with no viable offer is not an
// error — its group carries absence_reason and an empty offers list
// (mirrors OfferGroup from DiscoverResources). Non-OK errors are reserved
// for malformed requests, auth failures, and internal faults.
groups := make([]*forav1.OfferGroup, 0, len(req.Uris))
for _, uri := range req.Uris {
selection, err := o.selector.SelectForURI(ctx, uri, responses, req.Constraints, o.budget)
if err != nil {
groups = append(groups, &forav1.OfferGroup{
Uri: uri,
AbsenceReason: o.absenceReason(err).Enum(),
})
continue
}
groups = append(groups, &forav1.OfferGroup{
Uri: uri,
Offers: selection.Offers, // full signed Offers, forwarded unchanged
})
}
// 4. Return the offers to the agent. The agent (or the Broker, on the agent's
// behalf) commits separately via ExecuteTransaction — see below.
return &forav1.DiscoveryResponse{
OfferGroups: groups,
}, nil
}

After the agent picks offers from the DiscoveryResponse, committing to them is a distinct exchange on the execute path. The execute target of each offer is Offer.exchange — the canonical Exchange domain carried inside the signed Offer bytes, so a relaying Broker cannot redirect execution without invalidating the signature. The agent buys either way:

BrokerService.ExecuteTransaction: re-packaging

Section titled “BrokerService.ExecuteTransaction: re-packaging”

The Broker re-packages the agent’s purchase. What holds the set together is TransactionRequest.agent_request_acceptance — a detached Ed25519 signature by the agent over the complete ordered list of (offer signature, exchange) references plus the requester and the idempotency key — and each item’s agent_acceptance.

  1. The agent signs one AgentAcceptance per offer and one AgentRequestAcceptance over every selected offer, in order, across all Exchanges, and sends the request to the Broker, RFC 9421-signed for the Broker’s URL. The request terminates at the Broker.
  2. The Broker verifies the agent’s signature and checks that requester.domain is the agent’s verified signing directory (the host of the WBA directory the covered Signature-Agent header names). It checks that every item’s Exchange is one it can route to and approves for purchases. It refuses anything else before it sends a single sub-request.
  3. It groups the items by offer.exchange and sends one ExchangeService.ExecuteTransaction to each Exchange. Each sub-request is a new HTTP request the Broker authors and signs with its own key. It carries only the items addressed to that Exchange, in request order, with their acceptances, plus the unchanged AgentRequestAcceptance, requester and idempotency key. See Projected execute requests for who signs what and how the Exchange resolves the acceptance verification key.
  4. Each Exchange verifies the acceptance signatures and requires its sub-request to equal the complete in-order projection of the signed set addressed to itself — so the Broker cannot drop, reorder, append, or split the items for that Exchange without that Exchange seeing it. A sub-request that is not that projection has every item denied with DENIAL_REASON_SIGNATURE_INVALID, in the body, and nothing is bought.
  5. The Broker combines the answers into one BrokerTransactionResponse: one result per request item in request order, one ExchangeOutcome per Exchange contacted, and charged totals per currency.

Why re-packaging is safe. Each item is atomic and its integrity is per resource. Each offer carries its own Exchange’s signature, each acceptance binds the agent to that one offer, and each item’s result comes from the Exchange that owns the resource. The Broker therefore assembles independent per-resource purchases. It cannot alter any of them without breaking that item’s Exchange signature or the agent’s acceptance, and no cross-item integrity is needed. In a result item, the one signed value is the retrieval_endpoint, signed by the issuing Exchange; the other fields are the Broker’s unsigned report of what each Exchange answered.

Upstream refusals ride in the body. An Exchange that refuses the Broker’s whole sub-request, or does not answer it, never becomes an error of the Broker’s own. Each affected item carries TransactionResultItem.refusal, an UpstreamRefusal whose party is the Exchange, with the Connect code it answered with and its ErrorDetail unchanged, and the other Exchanges’ results come back unchanged. With the code unavailable or deadline_exceeded the item’s outcome is unknown, since the Exchange may have bought it before the answer was lost, and the charged totals do not count it; retrying the same request with the same key settles it, through this Broker, another one or directly. With any other code the item was not purchased. DENIAL_REASON_CONTENT_UNAVAILABLE is never used as a catch-all for an upstream failure. Every per-item denial is the Exchange’s result item, in the body, also for a one-item sub-request. Whether relayed purchases are accepted is decided per item, by the provider of each offer’s resource: an item whose provider does not accept them is denied with DENIAL_REASON_RELAY_NOT_ACCEPTED, the other items of the sub-request may succeed, and the agent buys that offer directly.

The Broker’s own refusals are non-OK errors with an ErrorDetail: unauthenticated with request_auth_failure for a bad agent signature, and SIGNATURE_INVALID for a requester.domain that is not the signing directory; invalid_argument for a malformed request; failed_precondition for an Exchange it cannot route to or does not approve.

Idempotency. The Broker forwards the agent’s idempotency_key unchanged to every Exchange; it cannot change it, because the acceptances sign it. A retry through the Broker therefore reaches each Exchange as a replay and is answered from that Exchange’s stored result. Each Exchange scopes the key to the agent whatever the route, so a retry may go through this Broker, another one or directly, two agents behind one Broker never collide, and the same key with different items is refused with already_exists.

Delegation. The Broker is the wire signer of every sub-request, so each Exchange checks a delegation’s cnf.jkt against the key the item’s AgentAcceptance verifies under, not against the Broker. The agent does not need to delegate to the Broker.

A single-Exchange purchase is the degenerate case: the projection is the whole set, and the SDK clients attach the acceptance there too.

FORA uses a two-layer model: the agent’s request signature authenticates the agent to the Broker, and the Broker’s own signature authenticates the Broker to the Exchange. On a purchase, the agent’s identity and consent travel to the Exchange in the body, not in the headers.

The Requester message carries identity and entitlements only (id, domain, type, scopes, optional delegation) — it has no signature field. The request is authenticated by an RFC 9421 HTTP Message Signature (alg=ed25519) carried in the headers and made to the Web Bot Auth profile. A verifier checks it against the public key published in the signer’s key directory at {origin}/.well-known/http-message-signatures-directory, with the origin taken from the Signature-Agent member the signature covers.

The Broker passes Requester through unchanged — it never modifies the requester’s identity, scopes, or delegation — with one exception on discovery: every query it originates names, as requester.domain, the host of the directory the agent’s verified signature names, and a Resolve naming another host is answered all the same. On a purchase the agent’s acceptances sign requester.domain, so there the Broker refuses a mismatch instead (Direct and relayed requests).

The Broker never forwards an agent’s request unchanged, so it never covers an agent’s signature (Multi-hop forwarding):

  • Discovery is Broker-led. The agent does not know the Exchange at discovery time; that is the point of the Broker. The Broker chooses the Exchanges and originates its own DiscoverResources queries, signed by the Broker alone, so an Exchange does not honour a delegation on them (Which signature binds in a brokered request). How an agent proves a holder-of-key entitlement at discovery time through a Broker is an open protocol question.
  • Execute is re-packaged. The Broker builds a fresh TransactionRequest for each Exchange and signs it alone.

Each signature the Broker makes has:

  • Label and member — its own label and its own Signature-Agent member, naming its directory. It is the only member on the request.
  • keyid — the Broker’s RFC 7638 thumbprint, resolved in the key directory its Signature-Agent member names
  • created — when the Broker sent the request
  • the covered components — its own request components (@method, @target-uri, content-digest, authorization) and its own Signature-Agent member

Because every Broker signature covers exactly one Signature-Agent member, single-member WBA verifiers such as Cloudflare’s web-bot-auth library accept it.

// Broker signs a request it originates (discovery) or re-packages (execute).
func (o *Broker) signOutbound(req *http.Request) {
label := "sig1"
// The Broker's own member is the only Signature-Agent member on the request.
req.Header.Set("Signature-Agent", label+`="https://`+o.domain+`"`)
// Covers @method, @target-uri, content-digest, authorization and
// "signature-agent";key="sig1".
sigInput, sig := o.rfc9421Sign(req, label) // Ed25519, keyid=o.thumbprint, tag="web-bot-auth"
req.Header.Set("Signature-Input", sigInput)
req.Header.Set("Signature", sig)
}

The Exchange verifies:

  1. The Broker’s signature — against the key its keyid thumbprint selects in the directory named by the Broker’s Signature-Agent member. The Exchange authenticates the caller as the Broker.
  2. The agent’s identity and consent, on a purchase — each item carries the agent’s AgentAcceptance, a detached Ed25519 signature over {offer_sig, requester_id, requester_domain, idempotency_key} verified against the agent’s registered key, which the Exchange resolves from Requester.domain. The same key gives the delivery-URL binding (agent_id is its RFC 7638 thumbprint), and a delegation’s cnf.jkt is checked against it, so a delegation stays bound to the agent’s private key through the Broker. agent_request_acceptance additionally binds the ordered set of offers. These are application-level signatures, not WBA transport signatures, and the Web Bot Auth profile does not change them. See Projected execute requests.

The Broker publishes its keys in its WBA directory (the JWK Set at {domain}/.well-known/http-message-signatures-directory) — RFC 7517 Ed25519 JWKs, each identified by its RFC 7638 thumbprint (the RFC 9421 keyid). Its fora.json manifest uses the same format as an agent’s, with role=ROLE_BROKER. The Broker names that directory in its Signature-Agent member on every request it sends, so an Exchange resolves the Broker’s key the same way it resolves any signer’s. A request the Broker sends names the agent in requester.domain, which differs from the Broker’s own directory, so the Exchange treats it as relayed and acts on it only when it admits the Broker. Admission is the Exchange’s own policy, for example requiring the Broker to be registered with it; the manifest role never decides it. See Direct and relayed requests.

The Broker does not route queries by profile. It forwards the agent’s declared profiles, and each Exchange answers for its own.

  1. Agent declares profiles — DiscoveryRequest.supported_profiles lists which domain extension profiles the agent understands (e.g., ["fora-academic-v1"])
  2. Broker forwards them — the Broker MUST copy the declared profiles unchanged into ResourceQuery.supported_profiles on every Exchange it queries, and forward an absent list as absent. It chooses which Exchanges to query on other grounds, never by profile
  3. Each Exchange answers for its own profiles — an Exchange includes the profile-specific ext fields of the profiles it declares in its WellKnownManifest.supported_profiles; an Exchange that declares none of the agent’s profiles answers with the core fields

An agent working on a literature review sends:

{
"supported_profiles": ["fora-academic-v1"]
}

The Broker queries its Exchanges as it would without the profile, and each query carries supported_profiles: ["fora-academic-v1"]. An Exchange that declares fora-academic-v1 returns offers with academic metadata (e.g., retraction status); one that does not returns its offers with the core fields only.

Scope Forwarding, Delegation Pass-Through, and Quota Aggregation

Section titled “Scope Forwarding, Delegation Pass-Through, and Quota Aggregation”

The Broker forwards Requester.scopes to every Exchange it queries. Scopes decide which non-gated terms each Exchange presents; they are the agent’s statement, which the Broker passes on under its own signature, and the Exchange does not verify them. The Broker does not modify scopes; it passes them through as-is. For a term an Exchange gates behind a verified delegation, the stated scopes count for nothing: the Exchange reads scopes from the verified delegation only, and since it honours no delegation on a query the Broker originated, a gated term stays hidden on that query.

The Broker passes Requester.delegation through unchanged, and the agent remains the holder. A Broker is never delegated to. On a purchase, the Exchange matches the delegation’s cnf.jkt against the key that signed the agent’s AgentAcceptance, so the delegation stays bound to the agent’s private key through the Broker, exactly as on a direct call. On a query the Broker originates, the Broker signs alone, so nothing can pass the delegation’s holder binding: the Exchange does not honour it and answers as if none were present. Through a Broker, the delegation counts only on a purchase, through the agent’s acceptances. How an agent proves a holder-of-key entitlement at discovery time through a Broker is an open protocol question. An Exchange that requires a verified delegation before it presents a scoped term presents no such term on the Broker’s query.

The Broker cannot narrow a delegation: only the key named in the chain’s cnf can issue a valid child token, and that is the agent’s key, not the Broker’s. Narrowing is done by a holder in the chain, as Delegation describes: the child carries its complete parent in fora_parent, so the Broker also cannot swap a parent, and the Exchange verifies the root with a key it has configured, so the Broker cannot introduce one.

When multiple Exchanges return SubscriptionQuotaInfo on their offers or transaction responses, the Broker MAY aggregate this quota information before returning it to the agent. This gives the agent a consolidated view of remaining quota across Exchanges, enabling proactive throttling without the agent needing to track per-Exchange quota independently.

Agents can discover content through the Broker in three ways, depending on how much they know about what they need. All three modes feed into the same Selection Engine — the Broker compares offers identically regardless of how they were discovered.

The agent already knows which resources it wants. It sends DiscoveryRequest.uris with one or more specific content identifiers (DOIs, URLs, etc.). The Broker forwards these as a ResourceQuery to each Exchange and collects offers.

{
"uris": ["doi:10.1038/s41586-024-07386-0", "doi:10.1126/science.adq3854"]
}

This is the simplest mode — the agent has exact references and wants the best price across Exchanges.

The agent knows what kind of content it needs but not the exact URIs. It sends DiscoveryRequest.search_filters with profile-specific key-value pairs. The Broker maps these to Exchange-specific query parameters and discovers matching resources.

{
"search_filters": {
"academic.topic": "CRISPR gene therapy",
"academic.year_min": 2023,
"academic.peer_reviewed": true
}
}

Filter keys are profile-specific: academic.topic, news.category, legal.jurisdiction, medical.specialty, etc. The Broker translates these into the native query format of each Exchange it contacts.

The agent describes what it needs in free text. It sends DiscoveryRequest.query with a natural-language description. The Broker interprets the query and searches across Exchanges on the agent’s behalf.

{
"query": "Recent peer-reviewed studies on mRNA vaccine efficacy in immunocompromised patients"
}

This mode is useful for exploratory research where the agent cannot formulate structured filters. The Broker resolves the intent into Exchange queries.

All three modes can be combined in a single request. For example, an agent can send specific URIs it already knows about, plus a search query to discover additional relevant resources:

{
"uris": ["doi:10.1038/s41586-024-07386-0"],
"query": "Related studies on CRISPR delivery mechanisms",
"search_filters": {
"academic.year_min": 2022
}
}

The Broker merges results from all modes, deduplicates across them, and feeds everything into the Selection Engine for comparison and ranking.

The Broker’s internal SupplySource interface is the extension point for content discovery beyond Exchange queries. In v1, all URIs come from the agent’s request. In v2, the Broker can discover URIs on the agent’s behalf:

// SupplySource abstracts where content offers come from.
// v1: only ExchangeSource (queries an Exchange).
// v2: SearchSource (Exa, Tavily), RecommendationSource, etc.
type SupplySource interface {
// Query returns offers from this source matching the request.
Query(ctx context.Context, req *forav1.ResourceQuery) (*forav1.ResourceResponse, error)
// Domain returns the source identifier (exchange domain or service name).
Domain() string
// Kind returns the source type for selection policy routing.
Kind() SupplySourceKind // Exchange, SearchEngine, Archive, etc.
}

The flow: search/recommendation produces URIs, the Broker looks up an Exchange for each URI’s domain (via fora.json), the Exchange provides pricing/offers, and the standard transaction flow continues. Discovery is upstream of transactions — it produces URIs, not offers. The OfferGroup.discovery_method field tells the agent how each URI was found.

This positions the Broker as the natural integration point for agentic search — agents that need content don’t just request known URLs, they ask “find me articles about X” and the Broker discovers and prices the matching offers, returning them for the agent to commit to via a separate ExecuteTransaction call.

Full request lifecycle from agent to content delivery:

DecisionRationale
Broker is optionalAgents can query Exchanges directly. The Broker adds multi-exchange intelligence but is not required by the protocol.
Same protocol, Broker signs aloneThe Broker calls the Exchange with the same RPCs an agent uses and signs each request alone. A purchase the Broker relays is signed by the Broker, and the Exchange verifies the agent’s consent from the body acceptances.
Signed offer pass-throughExchange signatures cover pricing fields. The Broker cannot inflate prices without invalidating the signature.
Budget as an offer filterResolve drops offers that would exceed the agent’s budget and reports OFFER_ABSENCE_REASON_BUDGET_EXCEEDED when none remain. It never charges. How the budget is computed is the Broker’s own behaviour, not part of the protocol. The Exchange’s balance check stays the authoritative gate on what is charged.
Ed25519 request signatures, consent in the bodyRequester signs the HTTP request with an RFC 9421 HTTP Message Signature that follows the Web Bot Auth profile (covering @method/@target-uri/content-digest/authorization and its Signature-Agent member). The Broker originates discovery queries and re-packages execute requests, and signs each alone, covering its own request and only its own Signature-Agent member. The Exchange authenticates the Broker and, on a purchase, takes the agent’s identity and consent from the AgentAcceptance in the body. Public keys discoverable via the WBA directory (the JWK Set at /.well-known/http-message-signatures-directory).
Partial results are normalIf 3 of 5 Exchanges respond within the deadline, the Broker proceeds with 3 responses. Availability of any individual Exchange does not block the system.
github.com/FORA-Protocol/protocol/broker/
cmd/
broker/ # Sidecar binary entrypoint
main.go
pkg/
broker/ # Core broker logic (used by all deployment modes)
broker.go # Broker struct + Resolve
config.go # Configuration loading
registry/ # Exchange Registry
registry.go # ExchangeRegistry interface + in-memory impl
crawler.go # fora.json crawler
health.go # Health checker
discovery/ # Supply Discovery (parallel fanout)
fanout.go # FanOut implementation
clientpool.go # Connect client pool management
selection/ # Selection Engine
engine.go # SelectionEngine interface + default impl
pipeline.go # Filter, dedup, rank stages
policy.go # Pluggable selection policies
budget/ # Budget Tracker
tracker.go # BudgetTracker interface + in-memory impl
metrics/ # Prometheus metrics
metrics.go # All metric definitions
internal/
testutil/ # Test helpers