Agent SDK Overview
What the SDK Provides
Section titled “What the SDK Provides”The FORA Agent SDK is a library that agent developers embed directly in their application process, in Go, TypeScript or Python. What ships today is the client below: four verbs — discover, execute, fetch, report — with offer verification, RFC 9421 request signing and a bound delivery fetch built in. A convenience layer above it, collapsing all four into a single Fetch(url) that also enforces a spend cap, is designed but not implemented. The sections from v1.0 Capabilities to Relationship to the Broker describe that layer; Language Bindings and Version Strategy below them describe what ships today.
The SDK IS a single-tenant Broker embedded as a library. It contains the same components (Exchange Registry, Supply Discovery, Selection Engine, Budget Tracker, Usage Reporter) and the same logic as the standalone Broker. The difference is deployment model, not architecture:
| Aspect | Agent SDK | Sidecar Broker | Hosted Broker |
|---|---|---|---|
| Process model | In-process library | Separate process, localhost | Remote service |
| Communication | Function calls | Connect RPC over localhost | Connect RPC over HTTPS |
| Latency overhead | 0ms (in-process) | ~1ms (localhost) | ~5-20ms (network) |
| Multi-tenancy | N/A (one license per instance) | N/A (one agent per sidecar) | Required |
| Configuration | Struct literal in code | YAML + env vars | API |
| Content fetch | Built-in (SDK fetches signed URL) | Agent fetches signed URL | Agent fetches signed URL |
| Usage reporting | Automatic (background, non-blocking) | Agent submits via RPC | Agent submits via RPC |
| Who operates | Agent developer | Agent operator (ops team) | SaaS provider |
The SDK is the lowest-friction deployment model: one dependency, no sidecar process, no YAML, no infrastructure.
Public API
Section titled “Public API”The shipped client is the same four verbs in all three languages: discover an offer, execute the purchase, fetch the delivered bytes, report what was used. Each returns the offers already sorted into verified and rejected, so an offer that did not verify is never handed back as usable.
Full per-language references live beside the code: Go, Python.
None of the three blocks below is written on this page. Each one is pulled in from a
file the build already checks: the Go example is compiled by go build ./..., the
TypeScript example by tsc --strict --noEmit, and the Python example is extracted and
RUN against an in-process Exchange by the package’s test suite. A rename in the SDK that
an example does not follow fails a gate instead of reaching a reader.
forav1 "github.com/FORA-Protocol/protocol/gen/go/fora/v1""github.com/FORA-Protocol/protocol/gen/go/vocab/functiontokens""github.com/FORA-Protocol/protocol/sdk/go/connect""github.com/FORA-Protocol/protocol/sdk/go/helpers""github.com/FORA-Protocol/protocol/sdk/go/resolvers"
// The endpoint an Exchange serves comes from its own /.well-known/fora.json,// never from configuration; the same resolver routes usage reports back to// whichever Exchange issued the offer.endpoints := resolvers.NewWellKnownEndpointResolver(resolvers.WellKnownOptions{})
client := connect.NewClient(baseURL, connect.WithSigner(signer), // RFC 9421 request signing; custody stays yours connect.WithAgentKey(agentPublic), // the public half a bound delivery fetch presents connect.WithRequester(requester), // who this agent says it is connect.WithOfferKey(exchangePublic), // the key this Exchange signs offers with connect.WithEndpointResolver(endpoints), // The WBA directory where THIS agent publishes its own signing key, as a JWK // Set at {origin}/.well-known/http-message-signatures-directory. The Exchange // reads it off the covered Signature-Agent header and looks there for the key // whose RFC 7638 thumbprint equals the keyid. Signature-Agent is covered // whether or not it is set, so leaving this out signs an EMPTY value that no // Exchange can resolve a key from, and the call is refused with a 401 after it // was routed, signed and sent. Publish the directory before you call. connect.WithSignatureAgent("https://agent.example"),)
// 1. Discover. Offers arrive already sorted into verified and rejected, and a// rejected one keeps the reason it was refused.found, err := client.Discover(ctx, &forav1.ResourceQuery{ Exchange: "exchange.example", Uris: []string{"https://publisher.example/article"}, // Which domains you work in is a property of the query rather than of the // client, so it goes here. SupportedProfiles: []string{ "fora-news-v1", // articles, podcasts, broadcasting "fora-academic-v1", // journal papers, preprints, datasets "fora-legal-v1", // legislation, case law, patents },})if err != nil { return err}offers := found.Verified()if len(offers) == 0 { return fmt.Errorf("no verifiable offer: %v", found.Rejected())}
// 2. Buy. Execute accepts only a verified offer, so an unverified one cannot be// paid for by mistake.tx, err := client.Execute(ctx, offers[0])if err != nil { return err}item := tx.GetItems()[0]
// 3. Fetch. The delivery URL is bound to the agent's key and the client presents// the matching proof of possession, so a copied link fetches nothing.content, err := client.Fetch(ctx, item.GetRetrievalEndpoint())if err != nil { return err}
// 4. Report what was used. ConsumedQuantity is the billed quantity, so a report// without it bills nothing. Content.Body holds the fetched bytes, and function// tokens come from the generated vocabulary rather than a string literal.resp, err := client.ReportUsage(ctx, &forav1.UsageReport{ Exchange: "exchange.example", // the Exchange that issued the offer TransactionId: item.GetTransactionId(), BillingId: item.GetBillingId(), Usage: &forav1.Usage{ ConsumedQuantity: int32(len(content.Body)), Function: []string{functiontokens.AiInput}, },})if err != nil { return err}// resp.GetReportId() is what a later dispute references.Discover returns a core.DiscoveryResult: one group per requested URI, each carrying
its verified offers and, for the rest, the reason they were refused. Failures across every
verb arrive as one connect.CallError with a CallErrorKind — CallNotSent,
CallRefused, CallUnreachable, CallMalformed, CallTooLarge, CallNotSignable —
plus the peer’s own reason token and, when it sent one, a typed detail reachable through
connect.ErrorDetailFrom.
TypeScript
Section titled “TypeScript”import { createClient } from "@fora-protocol/sdk/client";import { rejectedOffers, verifiedOffers } from "@fora-protocol/sdk/core";import { createCachedOfferKeyResolver, createWBAOfferDirectoryFetch, createWellKnownEndpointResolver,} from "@fora-protocol/sdk/resolvers";
// Offer-signing keys come from the issuing Exchange's Web Bot Auth directory --// the only place they are published. The directory fetch is SSRF-guarded by// default, because the exchange domain arrives inside an offer.const offerKeys = createCachedOfferKeyResolver({ fetch: createWBAOfferDirectoryFetch({}),});
const client = createClient(baseURL, { signer: { privKey, keyid }, // a non-extractable CryptoKey; key bytes never enter the SDK agentPublicKey, // the public half a bound delivery fetch presents requester, // who this agent says it is resolveOfferKey: async (exchange) => offerKeys.resolve(exchange), endpointResolver: createWellKnownEndpointResolver({}), // The WBA directory where THIS agent publishes its own signing key, as a JWK // Set at {origin}/.well-known/http-message-signatures-directory. The Exchange // reads it off the covered Signature-Agent header and looks there for the key // whose RFC 7638 thumbprint equals the keyid. Signature-Agent is covered // whether or not it is set, so leaving this out signs an EMPTY value that no // Exchange can resolve a key from, and the call is refused with a 401 after it // was routed, signed and sent. Publish the directory before you call. signatureAgent: "https://agent.example",});
// 1. Discover. Offers arrive already sorted into verified and rejected, and a// rejected one keeps the reason it was refused.const found = await client.discover({ exchange: "exchange.example", uris: ["https://publisher.example/article"], // Which domains you work in is a property of the query rather than of the // client, so it goes here. supported_profiles: [ "fora-news-v1", // articles, podcasts, broadcasting "fora-academic-v1", // journal papers, preprints, datasets "fora-legal-v1", // legislation, case law, patents ],});// Take the offer out of the array and check THAT, rather than checking the// length and indexing after. The SDK compiles under noUncheckedIndexedAccess,// where offers[0] is VerifiedOffer | undefined however the length was checked,// so the second form needs a non-null assertion and this one needs nothing.const offer = verifiedOffers(found)[0];if (offer === undefined) { throw new Error(`no verifiable offer: ${JSON.stringify(rejectedOffers(found))}`);}
// 2. Buy. Execute accepts only a verified offer, so an unverified one cannot be// paid for by mistake.const tx = await client.execute(offer);const item = tx.items?.[0];if (!item?.retrieval_endpoint) { throw new Error("the Exchange delivered no retrieval endpoint");}
// 3. Fetch. The delivery URL is bound to the agent's key and the client presents// the matching proof of possession, so a copied link fetches nothing.const content = await client.fetch(item.retrieval_endpoint);
// 4. Report what was used. consumed_quantity is the billed quantity, so a report// without `usage` bills nothing. Content.body holds the fetched bytes.// `function` is optional and is omitted here: the published package exposes no// vocabulary entry point, and a token spelled by hand is exactly what the typed// constants exist to prevent.await client.reportUsage({ exchange: "exchange.example", transaction_id: item.transaction_id, billing_id: item.billing_id, usage: { consumed_quantity: content.body.length },});Offer verification defaults to "strict", and a client given no way to resolve offer keys
resolves none — so it rejects every offer, with a reason. That is the fail-closed default,
and on a first run it looks exactly like a broken stack.
Python
Section titled “Python”import asyncioimport osimport sysimport time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fora_sdk.core import Mode, StaticOfferKeyResolver, Verifierfrom fora_sdk.resolvers import ( CachedOfferKeyResolver, WellKnownEndpointResolver, create_wba_offer_directory_fetch, guarded_client,)from fora_sdk.signing_transport import SigningTransportfrom fora_sdk.sync import Client, ClientConfigfrom fora_sdk.thumbprint import thumbprintfrom vocab.functiontokens import AI_INPUT
# This agent's identity, as it states it to an Exchange.AGENT = {"id": "agent-1", "domain": "agent.example", "type": "REQUESTER_TYPE_AGENT"}
# Where THIS agent publishes its own signing key, as a JWK Set at# {AGENT_DIRECTORY}/.well-known/http-message-signatures-directory. The Exchange reads# this value off the covered Signature-Agent header, fetches that directory and looks# for the key whose RFC 7638 thumbprint equals the keyid below. Publish before you# call: an agent that names no directory has no key an Exchange can resolve, and the# call is refused with a 401 after it was routed, signed and sent.AGENT_DIRECTORY = f"https://{AGENT['domain']}"
# https in production. A local sandbox serving plaintext sets FORA_WELLKNOWN_SCHEME=http,# and ALLOW_INSECURE=true for the guarded transports.SCHEME = os.environ.get("FORA_WELLKNOWN_SCHEME", "https")
def buy_and_fetch(*, exchange: str, uri: str, seed: bytes) -> bytes: """Discover an offer for `uri`, buy it, fetch the bytes, and report the usage.""" # 1. Identity. The RFC 9421 keyid IS the RFC 7638 thumbprint of the agent's public # key, which is also the value a delivery URL gets bound to. One key, one name. # # signature_agent names the directory that key is published in. It is a COVERED # component, so the signature binds it whether or not it is set, and leaving it # unset signs an EMPTY value that no Exchange can resolve a key from. public = Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw() signer = SigningTransport( signer_seed=seed, keyid=thumbprint(public), signature_agent=AGENT_DIRECTORY )
# 2. Where this Exchange serves its API, read from its own /.well-known/fora.json # rather than from configuration. The same resolver later routes the usage report # back to whichever Exchange issued the offer. # # The client is passed in rather than left to default. This resolver's host comes # off an offer, so a third party chose it, and its default is still the plain # client — the one request-derived face that has not caught up with the rule. endpoints = WellKnownEndpointResolver(scheme=SCHEME, http=guarded_client())
# 3. Offer-signing keys, from the Exchange's Web Bot Auth directory — the only place # they are published. Fetched and TTL-cached with each entry's expiry clamped to # the key's own not_after, then frozen into the map the (synchronous) Verifier # resolves against. STRICT plus a map that resolves nothing rejects every offer: # that is the fail-closed posture, not a bug. # # `revoked` is NOT passed, and that is a choice worth making deliberately. It # screens a candidate key by thumbprint against a revocation snapshot, so leaving # it out waives emergency revocation. It is defensible here because this function # fetches the directory and spends the keys inside one call. A client that holds # its key map for hours must pass a revoked-set predicate, and must re-run the # prefetch rather than freeze one map for its lifetime — a frozen map keeps # serving a key after its TTL and its not_after have both passed. directory = CachedOfferKeyResolver(fetch=create_wba_offer_directory_fetch(scheme=SCHEME)) keys = asyncio.run(directory.prefetch([exchange])) verifier = Verifier( mode=Mode.STRICT, resolver=StaticOfferKeyResolver(keys), now=lambda: int(time.time()), )
config = ClientConfig( base_url=endpoints.resolve_endpoint(exchange), signer=signer, requester=AGENT, verifier=verifier, endpoint_resolver=endpoints, )
with Client(config) as client: # 4. Discover. Every offer arrives already sorted into verified or rejected, and # a rejected one keeps its reason instead of being dropped silently. found = client.discover({"exchange": exchange, "uris": [uri]}) offers = found.verified() if not offers: refused = [r.reason for r in found.rejected()] raise RuntimeError(f"no verifiable offer for {uri}: {refused}")
# 5. Buy it. execute() accepts only a verified offer, so an unverified one # cannot be paid for by mistake. item = client.execute(offers[0]).items[0]
# 6. Fetch. The delivery URL is bound to the agent's thumbprint and the client # presents the matching proof of possession, so a copied link fetches nothing. content = client.fetch(item.retrieval_endpoint)
# 7. Report what was used. It goes to the Exchange the offer named, resolved the # same way as step 2 — never to whatever base_url happened to be configured. client.report_usage( { "exchange": exchange, "transaction_id": item.transaction_id, "billing_id": item.billing_id, "usage": {"consumed_quantity": len(content.body), "function": [AI_INPUT]}, } )
return content.body
if __name__ == "__main__": sys.stdout.buffer.write( buy_and_fetch( exchange=os.environ["FORA_EXCHANGE"], # a bare domain, e.g. "exchange.example" uri=sys.argv[1], seed=bytes.fromhex(os.environ["FORA_AGENT_SEED"]), ) )That block is the Python README’s example, pulled in from that file rather than copied. The package’s own test suite extracts the same block and runs it against an in-process Exchange, so a name that stops resolving fails a test.
fora_sdk.client.Client is the same surface with await; fora_sdk.sync is a blocking
facade over a synchronous HTTP client rather than an asyncio.run wrapper, which would
break inside a running event loop.
v1.0 Capabilities
Section titled “v1.0 Capabilities”Attestation Verification
Section titled “Attestation Verification”The SDK verifies content attestations after fetching content. Each Offer may carry ResourceAttestation entries — signed claims from providers or third-party verification vendors about the content at the delivery URI. After fetching, the SDK:
- Level 1 (self-attested): computes SHA-256 of the received bytes and compares against the attested
content_hash. A mismatch triggers an automatic dispute. - Level 2 (third-party): trusts the attestation without re-verifying the hash (the agent cannot replicate the vendor’s extraction algorithm). The SDK checks
attested_atfreshness against the agent’s configured staleness threshold.
Attestation verification is automatic when Config.VerifyAttestations is true (default). Results are available on FetchResult.AttestationResult.
Dispute Filing
Section titled “Dispute Filing”When attestation verification detects a content integrity violation, the SDK can automatically file a dispute:
- The SDK files a
UsageReport(required before any dispute — the dispute chain requiresreport_id). - The
UsageReportResponsereturns areport_id. - The SDK files a
DisputeRequestreferencingtransaction_idandreport_id, with the appropriateDisputeReason(e.g.,CONTENT_MISMATCH,DELIVERY_FAILED). - The
DisputeResponsereturns adispute_idand resolution status.
When Config.AutoDispute is true (default: false), the SDK files disputes automatically on content hash mismatch or delivery failure. When false, FetchResult.AttestationResult provides the data needed for the caller to file manually via client.Dispute().
// Dispute files a content dispute with the Exchange.// Requires a prior UsageReport (report_id is mandatory in DisputeRequest).func (c *Client) Dispute(ctx context.Context, req DisputeRequest) (*DisputeResult, error)Key Types
Section titled “Key Types”FetchResult
Section titled “FetchResult”type FetchResult struct { URL string // The original URL requested Content string // Fetched content (HTML, JSON, or text) ContentType string // Content type from CDN (e.g. "text/html") Cost Cost // Transaction cost. Zero for subscription-based access BillingID string // Billing record for this transaction (not the account handle) TransactionID string // Exchange-assigned transaction identifier ReportID string // Exchange-assigned report ID (from UsageReportResponse) Exchange string // Which Exchange fulfilled this request SubscriptionID string // Subscription ID if fulfilled under a subscription deal Identity *ResourceIdentity // Content identity for deduplication (when available) Attestations []ResourceAttestation // Attestations from the Offer (v1.0) AttestationResult *AttestationVerification // Result of post-fetch verification (v1.0) Err error // Per-result error (used in FetchBatch)}
type Cost struct { Amount float64 Currency string UnitCost float64 // effective cost per token}Configuration
Section titled “Configuration”type Config struct { // REQUIRED. The agent's identity: id + the domain serving its Web Bot // Auth key directory ({domain}/.well-known/http-message-signatures-directory). // The Exchange bills the account it resolves from the verified request // signature. AgentID string AgentDomain string
// REQUIRED. Ed25519 private key for agent request signatures. SigningKey string
// Budget constraints. Budget Budget
// Exchanges the agent has existing subscription relationships with. PreferredExchanges []string
// Manually configured Exchange endpoints. // If empty, the SDK discovers Exchanges via fora.json. Exchanges []ExchangeConfig
// Exchange discovery settings. Discovery DiscoveryConfig
// Usage reporting settings. Reporting ReportingConfig
// HTTP client settings. HTTP HTTPConfig
// AISystem metadata for protocol messages. AISystem *AISystemConfig}Minimal configuration as the deferred layer designs it. fora.NewClient and
fora.Budget do not exist; the shipped constructor is connect.NewClient(baseURL, opts...), shown under Public API above.
// Planned layer. Not callable today.client, _ := fora.NewClient(fora.Config{ AgentID: "research-bot", AgentDomain: "agent.example.com", SigningKey: os.Getenv("FORA_SIGNING_KEY"), Budget: fora.Budget{MaxPerRequest: 0.10, Currency: "USD"},})Budget Configuration
Section titled “Budget Configuration”type Budget struct { // Maximum cost per individual request. MaxPerRequest float64
// Maximum cumulative spend per session (in-memory, resets on restart). MaxPerSession float64
// Maximum cumulative spend per period (persisted). MaxPerPeriod float64
// Period duration for MaxPerPeriod (e.g. 720h = 30 days). Period time.Duration
// Budget scope identifier for per-period tracking. // E.g. "user:u-12345" for per-user, "team:eng" for per-team. Scope string
// ISO 4217 currency code. Default: "USD". Currency string}Discovery Configuration
Section titled “Discovery Configuration”type DiscoveryConfig struct { // Whether to auto-discover Exchanges via fora.json. Default: true. AutoDiscover bool
// Cache TTL for fora.json responses. Default: 1 hour. ParseJSONCacheTTL time.Duration
// Whether to use 403 X-Content-Rules header as fallback. Default: true. FallbackOn403 bool}HTTP Configuration
Section titled “HTTP Configuration”type HTTPConfig struct { // Timeout for Exchange RPC calls. Default: 500ms. RPCTimeout time.Duration
// Timeout for content fetch (signed URL). Default: 30s. FetchTimeout time.Duration
// Maximum number of retries for transient failures. Default: 2. MaxRetries int
// Custom HTTP client. If nil, a default client is created. Client *http.Client}Internal Architecture
Section titled “Internal Architecture”The SDK contains the same components as the Broker, organized as in-process collaborators:
Agent SDK (planned layer) | +-- ExchangeRegistry | Discovers and caches Exchange endpoints. | Sources: manual config, fora.json auto-discovery, 403 fallback. | In-memory LRU with TTL-based refresh. | +-- SupplyDiscoverer | Queries Exchanges via DiscoverResources RPC (Connect HTTP/JSON). | Handles single-URI and multi-URI (batch) queries. | Parallel fanout to multiple Exchanges. | +-- SelectionEngine | Ranks offers: subscription > preferred MP > lowest unit_cost. | Deduplicates by ResourceIdentity across Exchanges. | Self-selects the Offer.terms[] term whose restrictions the agent can honour. | +-- BudgetTracker | Per-request: reject if offer > MaxPerRequest. | Per-session: in-memory cumulative spend (resets on restart). | Per-period: persisted to local file (or Redis for shared state). | Checks budget BEFORE ExecuteTransaction, not after. | +-- TransactionExecutor | Sends ExecuteTransaction RPC to winning Exchange. | Stateless offer verification via exchange_signature. | Idempotency key on every request. | +-- ContentFetcher | Fetches content from signed URL in TransactionResponse. | Presents agent public key + RFC 9421 signature for binding verification. | Handles both DELIVERY_METHOD_DIRECT and DELIVERY_METHOD_INSTRUCTIONS. | +-- UsageReporter | Auto-submits UsageReport after each successful Fetch. | Background goroutine with in-memory queue. | Tracks reporting deadlines per obligation. | Retries on failure (non-blocking, never delays Fetch). | +-- RequestSigner Signs ResourceQuery and TransactionRequest with agent's Ed25519 private key. Private key never logged, never serialized, never exposed.Component Interface Reuse
Section titled “Component Interface Reuse”The SDK reuses the same Go interfaces defined for the standalone Broker:
ExchangeRegistryinterface is identicalSelectionEngineinterface is identical (sameSelectionPolicyabstraction)BudgetTrackerinterface is identicalReportingRelaybecomesUsageReporter(same logic, different name because the SDK owns the full lifecycle)
The concrete implementations differ in persistence strategy (in-memory vs Redis), but the interfaces are shared.
Relationship to the Broker
Section titled “Relationship to the Broker” +-----------------------------------------+ | Shared Go packages | | | | pkg/registry/ ExchangeRegistry | | pkg/selection/ SelectionEngine | | pkg/budget/ BudgetTracker | | pkg/reporting/ ReportingRelay | | pkg/signing/ RequestSigner | | pkg/discovery/ SupplyDiscoverer | +----------+--------------+---------------+ | | +----------------+ +----------------+ | | v v +-------------------------+ +-------------------------+ | Agent SDK | | Broker | | | | | | fora.NewClient(cfg) | | cmd/broker/ | | client.Fetch(url) | | main.go | | client.FetchBatch(urls)| | | | | | Connect RPC server | | In-process library | | YAML configuration | | No network boundary | | Multi-tenant (SaaS) | | Auto content fetch | | Agent fetches content | | Auto usage reporting | | | +-------------------------+ +-------------------------+| Concern | SDK | Broker |
|---|---|---|
| Content fetch | SDK does it (returns content) | Agent does it (Broker returns signed URL) |
| Usage reporting | SDK does it automatically | Agent submits via RPC, Broker forwards |
| Budget persistence | Local file (single process) | Redis (multi-tenant) |
| Configuration | Struct literal in code | YAML + env vars + API |
| Exchange trust | All configured = verified | Graduated trust model |
| Process lifecycle | Tied to agent process | Independent process |
Same logic, different packaging. The selection algorithm, unit_cost comparison, ResourceIdentity deduplication, subscription preference ranking, and budget arithmetic are identical.
Language Bindings
Section titled “Language Bindings”| Language | Source | Status | Dependencies |
|---|---|---|---|
| Go | gen/go/ (types) + sdk/go/ (the shipped hand-written SDK: helpers, resolvers, core, connect, connectserver) | Shipped and CI-gated | connectrpc.com/connect + google.golang.org/protobuf + stdlib |
| TypeScript | gen/ts/ (types) + sdk/ts/ (hand-written SDK: src, core, resolvers, client, hono) | Shipped; published to npm as @fora-protocol/sdk 1.0.4 | zod + undici + ajv + canonicalize (hono optional) |
| Python | gen/python/ (types) + sdk/python/ (hand-written SDK: fora_sdk, fora_sdk.core, fora_sdk.resolvers, fora_sdk.client, fora_sdk.sync) | Shipped; published to PyPI as fora-protocol-sdk 1.0.4 | pydantic + httpx + cryptography |
Every language also ships the account-setup role: Register and GetAccountStatus on the agent client (Register/GetAccountStatus, register/getAccountStatus, register/get_account_status), addressed by the Exchange domain on the request rather than by a configured origin, alongside the reader that fetches an Exchange’s published registration requirements afresh on every read (resolvers.NewWellKnownRequirementsReader, createWellKnownRequirementsReader, WellKnownRequirementsReader). That reader applies the manifest version gate before it reads anything else, the same rule and the same shared corpus as the endpoint resolver — a document whose layout no reader can classify supplies neither the published schema nor the terms digest a registration echoes. A payload the client’s own schema pre-check refuses comes back carrying the same typed RegistrationFailure detail the Exchange would have attached, so one renderer serves both sides of the refusal.
Every language also ships the publisher role: a catalog client (connect.NewCatalogClient, createCatalogClient, fora_sdk.client.CatalogClient) and the Exchange’s two-tier entry pre-check (helpers.ValidateResourceEntry, validateResourceEntry, validate_resource_entry) — see Source 7: CatalogService API Push.
What Is Language-Specific vs Shared
Section titled “What Is Language-Specific vs Shared”| Component | Shared (from proto) | Language-specific |
|---|---|---|
| Protobuf types | Generated from .proto | — |
| Exchange RPC client | Generated Connect client | — |
| Selection engine | — | ~200 lines per language |
| Budget tracker | — | ~150 lines per language |
| Usage reporter | — | ~100 lines per language |
| Request signer | — | ~50 lines per language (Ed25519) |
| Public API | — | Idiomatic wrappers |
Version Strategy
Section titled “Version Strategy”- SDK version follows semver:
v1.x.y - Protocol version is independent:
ver: "1.0"in messages - SDK version
v1.xsupports protocolver: "1.0" - Breaking API changes increment major version
- New protocol features (e.g. new DenialReason, DisputeReason) are minor version bumps