Skip to content

Agent SDK Overview

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:

AspectAgent SDKSidecar BrokerHosted Broker
Process modelIn-process librarySeparate process, localhostRemote service
CommunicationFunction callsConnect RPC over localhostConnect RPC over HTTPS
Latency overhead0ms (in-process)~1ms (localhost)~5-20ms (network)
Multi-tenancyN/A (one license per instance)N/A (one agent per sidecar)Required
ConfigurationStruct literal in codeYAML + env varsAPI
Content fetchBuilt-in (SDK fetches signed URL)Agent fetches signed URLAgent fetches signed URL
Usage reportingAutomatic (background, non-blocking)Agent submits via RPCAgent submits via RPC
Who operatesAgent developerAgent operator (ops team)SaaS provider

The SDK is the lowest-friction deployment model: one dependency, no sidecar process, no YAML, no infrastructure.

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 CallErrorKindCallNotSent, CallRefused, CallUnreachable, CallMalformed, CallTooLarge, CallNotSignable — plus the peer’s own reason token and, when it sent one, a typed detail reachable through connect.ErrorDetailFrom.

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.

import asyncio
import os
import sys
import time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fora_sdk.core import Mode, StaticOfferKeyResolver, Verifier
from fora_sdk.resolvers import (
CachedOfferKeyResolver,
WellKnownEndpointResolver,
create_wba_offer_directory_fetch,
guarded_client,
)
from fora_sdk.signing_transport import SigningTransport
from fora_sdk.sync import Client, ClientConfig
from fora_sdk.thumbprint import thumbprint
from 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.

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_at freshness against the agent’s configured staleness threshold.

Attestation verification is automatic when Config.VerifyAttestations is true (default). Results are available on FetchResult.AttestationResult.

When attestation verification detects a content integrity violation, the SDK can automatically file a dispute:

  1. The SDK files a UsageReport (required before any dispute — the dispute chain requires report_id).
  2. The UsageReportResponse returns a report_id.
  3. The SDK files a DisputeRequest referencing transaction_id and report_id, with the appropriate DisputeReason (e.g., CONTENT_MISMATCH, DELIVERY_FAILED).
  4. The DisputeResponse returns a dispute_id and 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)
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
}
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"},
})
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
}
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
}
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
}

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.

The SDK reuses the same Go interfaces defined for the standalone Broker:

  • ExchangeRegistry interface is identical
  • SelectionEngine interface is identical (same SelectionPolicy abstraction)
  • BudgetTracker interface is identical
  • ReportingRelay becomes UsageReporter (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.

+-----------------------------------------+
| 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 | | |
+-------------------------+ +-------------------------+
ConcernSDKBroker
Content fetchSDK does it (returns content)Agent does it (Broker returns signed URL)
Usage reportingSDK does it automaticallyAgent submits via RPC, Broker forwards
Budget persistenceLocal file (single process)Redis (multi-tenant)
ConfigurationStruct literal in codeYAML + env vars + API
Exchange trustAll configured = verifiedGraduated trust model
Process lifecycleTied to agent processIndependent process

Same logic, different packaging. The selection algorithm, unit_cost comparison, ResourceIdentity deduplication, subscription preference ranking, and budget arithmetic are identical.

LanguageSourceStatusDependencies
Gogen/go/ (types) + sdk/go/ (the shipped hand-written SDK: helpers, resolvers, core, connect, connectserver)Shipped and CI-gatedconnectrpc.com/connect + google.golang.org/protobuf + stdlib
TypeScriptgen/ts/ (types) + sdk/ts/ (hand-written SDK: src, core, resolvers, client, hono)Shipped; published to npm as @fora-protocol/sdk 1.0.4zod + undici + ajv + canonicalize (hono optional)
Pythongen/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.4pydantic + 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.

ComponentShared (from proto)Language-specific
Protobuf typesGenerated from .proto
Exchange RPC clientGenerated 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 APIIdiomatic wrappers
  • SDK version follows semver: v1.x.y
  • Protocol version is independent: ver: "1.0" in messages
  • SDK version v1.x supports protocol ver: "1.0"
  • Breaking API changes increment major version
  • New protocol features (e.g. new DenialReason, DisputeReason) are minor version bumps