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.
A Broker client sits beside it with two verbs: resolve, discovery across the
Exchanges the Broker knows, and execute, which buys offers from several Exchanges in
one call (BrokerService.ExecuteTransaction). The exchange client’s execute also takes
several offers from one Exchange (ExecuteBatch in Go).
Both execute verbs return every retrieval URL exactly as the Exchange issued it, and fetch dials a URL as given with the agent’s proof of possession. Checking the URL is the delivery edge’s job: it verifies the URL signature and, where it can, the agent binding against that proof. The client does not check the URL first. The Exchange has already charged by the time the answer arrives, so a local refusal would only lose the purchase answer, and the client cannot read every URL an edge accepts (a CloudFront RSA signed URL, for one).
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, the Connect code of its answer (Code), the status and
header fields of a refusal (Status, Header) 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: the Web Bot Auth key directory at# {AGENT_DIRECTORY}/.well-known/http-message-signatures-directory, served as# application/http-message-signatures-directory+json and signed by the key it lists.# Every request signature names it in its own covered Signature-Agent member,# sig1="https://agent.example"; the Exchange fetches that directory and looks for the# key whose RFC 7638 thumbprint equals the keyid below. It is the https origin alone,# with no path. Publish before you call.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 required: # a signer given none refuses to sign, and the call fails locally as MALFORMED # before anything is sent. 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 and exchange against that exchange's own # revocation list, 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 # predicate `revoked(thumbprint, exchange)` that answers from that exchange's own # list (`WBAKeyResolver.revoked` has this shape, and knows a directory's list once # it has fetched that directory), 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. # The delivery edge checks the URL and the proof. 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.
Testing a FORA Service Through the SDK
Section titled “Testing a FORA Service Through the SDK”The same clients serve a conformance or end-to-end harness. A harness drives a service through the SDK, builds a malformed request only where it means to, and checks every answer through the SDK’s own signer and decoder, instead of hand-writing payloads, parsing responses itself or re-implementing request signing.
| Capability | Go (sdk/go) | TypeScript (sdk/ts) | Python (fora_sdk) |
|---|---|---|---|
| Pre-signing hook: change a request after the SDK built it, before it is signed | connect.WithBeforeSign | beforeSign in ClientOptions | ClientConfig.before_sign |
| Raw mode: send a body exactly as given, still signed and decoded | connect.WithRawBody | new RawBody(body) in place of the request | RawBody(body) in place of the request |
Strict decoding: refuse an unknown field, a field-level or a cross-field violation, in a success answer or in an error envelope and its ErrorDetail | connect.WithStrictDecoding | strict: true | ClientConfig(strict=True) |
| Strict requests: refuse a plain dict, and a request with an unknown member or a cross-field violation, before it is signed | — (verbs take the generated messages) | — (verbs are typed with the request input types) | ClientConfig(strict_requests=True) |
| The Connect code of a refusal, on its own field | CallError.Code | ForaCallError.code | CallError.code |
The header fields of a refusal, such as Accept-Signature or Retry-After, and its status | CallError.Header, CallError.Status | ForaCallError.headers, ForaCallError.status | CallError.headers, CallError.status |
The operator RPCs: AdminService and domain verification | connect.NewAdminClient | createAdminClient | AdminClient |
| Mint a throwaway agent: key, directory document, signer | helpers.GenerateKey, helpers.DirectoryDocument, core.SigningTransportFor | generateKey, directoryDocument, signingTransportFor | generate_key, directory_document, signing_transport_for |
| Sign an acceptance that names an empty requester, to test that a verifier refuses it | helperstest.SignOfferAcceptanceAnyRequester, helperstest.SignRequestAcceptanceAnyRequester | signOfferAcceptanceAnyRequester, signRequestAcceptanceAnyRequester (./testing) | sign_offer_acceptance_any_requester, sign_request_acceptance_any_requester (fora_sdk.testing) |
| Read a document a party publishes and check it: the well-known manifest, the WBA key directory, the key revocation list, the license document | resolvers.ReadManifest, resolvers.ReadWBADirectory, resolvers.ReadRevocationList, resolvers.ReadLicenseDocument | readManifest, readWBADirectory, readRevocationList, readLicenseDocument | read_manifest, read_wba_directory, read_revocation_list, read_license_document |
| Tell a missing document or a refused redirect from an outage | resolvers.ErrDocumentNotFound, resolvers.ErrRedirectRefused | DocumentNotFound, RedirectRefused | DocumentNotFoundError, RedirectRefusedError |
| Check a message against the strict contract | helpers.CheckStrict | checkStrict | check_strict |
| Sign the proof of possession for a delivery fetch | helpers.SignAgentBinding | signInbound | sign_agent_binding |
| Read the discovery headers of an edge’s 403, and check the hinted Exchange against the publisher manifest | helpers.ParseDiscoveryHint, helpers.ReconcileDiscoveryHint | parseDiscoveryHint, reconcileDiscoveryHint (./discovery-hint) | parse_discovery_hint, reconcile_discovery_hint |
A few rules hold in every language:
- The hook and raw mode cannot bypass signing or decoding. The SDK signs the exact
bytes it sends and decodes the answer with the same code every verb uses. A hook may
not change the method or the URL, because the address checks already ran against them,
and may not set a header the signer writes. Raw mode skips only what the SDK would
have added to the message or refused about it; a verb that routes by the message still
reads its destination from the body’s
exchangeand resolves it as usual. - Strict decoding defines no shape of its own. It uses the published strict JSON
Schema of each response message and the cross-field rules the proto states (see
JSON Schemas); Go reads the same shape from the
descriptor. A server emits unpopulated fields, so a
nullmember is read as an absent one. - Strict decoding checks error answers too. The Connect error envelope may carry
only
code,messageanddetails, must name a known Connect code, and must carry well-formed details. EveryErrorDetailin it, the decoded binaryvalueand thedebugprojection alike, is checked against the strictErrorDetailschema and its rules. A refused envelope is malformed, with the peer’s Connect code, HTTP status and header fields still on the error, and no detail. An empty or non-JSON error body comes from a gateway rather than the service, so its HTTP status still classifies it. - Strict requests are Python’s. A Python verb also takes a plain dict, and the
generated models ignore a member they do not declare, so a misspelled key would be
signed, sent and discarded by the server.
strict_requestsrefuses the dict before anything is resolved or signed, and checks the message as stamped, with the configured requester and a purchase’s reflected offers in it, against the published strict JSON Schema of the request and the cross-field rules.RawBodyis still sent exactly as given. A Go verb takes the generated message, which has no member the proto does not declare, and a TypeScript verb is typed with the request input types, so a misspelled key in an object literal does not compile. - A document reader returns the document or a typed error, never nothing. By default
each reader fetches through the SDK’s guarded client, so the SSRF guard and the
https-only scheme guard apply;
SKIP_SSRFandALLOW_INSECURErelax them for a sandbox. A caller may pass its own client instead. The TypeScript edge entry has no default: it reads through thefetchit is given, so guarding an address another party named is the caller’s job there. The manifest must be served asapplication/jsonand the WBA directory asapplication/http-message-signatures-directory+json; the protocol names no media type for a revocation list or a license document, so their label is reported and not checked. The manifest’sveris read before any other member. Every document must then pass the strict check, and the reader returns the parsed generated message with the URL, the bytes and the media type. A WBA directory is fetched with no redirect, and its response must carry a valid response signature by every key it lists. The license reader checks theLicenseitself first, then verifies the fetched bytes againsturi_digest. A failed fetch, a non-200 answer, a body that is not JSON and a body over 1 MiB are all reported as an unavailable document. Two of those failures also say which, while staying an unavailable document: a404is a missing document, and a key directory that answers with a redirect is a refused one. Whether either is final is the caller’s decision. The resolvers read the same documents through the same fetch and decode, without the strict check, because a router must accept a newer minor version. They still read a WBA directory under the Web Bot Auth profile: no redirect, the profile’s media type, and only the keys that signed the response. - One strict check per language. Client strict decoding, the error-envelope check and the document readers all call the same check. In Python and TypeScript it is the published strict JSON Schema of the message plus the cross-field rules. In Go it decodes the proto-JSON, refuses unknown fields, a lowerCamelCase member and a 32-bit number or a bool written as a string, and runs protovalidate. A shared corpus holds the three readers to one verdict per document. The Python and TypeScript readers return the generated model, which names an enum by its value name, so they refuse a document that writes an enum as its number, which the Go reader accepts.
- The delivery-fetch proof is the public signer. The Python client’s
proof_headersis internal; a harness signs a fetch withsign_agent_binding(orSigningTransport.sign_agent_binding), which returns anAgentBindingwith the four header values: the agent key,Signature-Agent,Signature-InputandSignature. - Error details are read from the binary
value. A Connect error carries its typed reason asdetails[].value, the binaryErrorDetail. All three SDKs read it; the JSON SDKs fall back to the lowerCamelCasedebugprojection only whenvalueis absent. - A refusal carries its answer’s header fields, and only a refusal does. An answer of
status 400 or above, on an RPC leg or the delivery fetch, hands its header fields to the
failure: a verifier’s
Accept-Signature, a gateway’sRetry-After, an edge’s discovery headers, which the discovery-hint parser reads with the failure’s status (0 when it has none). A local failure, a transport failure, a 2xx the client could not read and a refused redirect carry none; a redirect’sLocationnames a URL the peer chose. The fields are a copy, looked up by any spelling of the name, a field sent on several lines joined with ”, ”, and never rendered into the message. Go keeps each line in itshttp.Header. - The test signers are not protocol signers. A protocol signer refuses to sign an
acceptance that names an empty requester, so a verifier’s refusal of one can only be
tested with a signer that does not. The test-support packages hold twins of the four
acceptance functions that skip that refusal and keep every other one, reproducing the
refusedrows of the shared acceptance corpora. Nothing in the SDK imports them, and an acceptance they sign is one a conforming party must never send. - The delivery edge checks the delivery URL. A purchase returns each URL as the
Exchange issued it, and fetch sends any URL it is given, so a harness can put any URL
in front of an edge. The edge’s refusal comes back with a
retrieval_auth_failurereason in the edge’s own vocabulary.
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. | Chooses the Offer whose term the agent can honour (one offer per term). | +-- 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 identical
The concrete implementations differ in persistence strategy (in-memory vs Redis), but the interfaces are shared. The SDK also has a UsageReporter, which files the agent’s usage reports with each issuing Exchange. The standalone Broker has no counterpart, because an agent always files its own reports, for a purchase made through a Broker too.
Relationship to the Broker
Section titled “Relationship to the Broker” +-----------------------------------------+ | Shared Go packages | | | | pkg/registry/ ExchangeRegistry | | pkg/selection/ SelectionEngine | | pkg/budget/ BudgetTracker | | 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 files it with the issuing Exchange; the Broker relays nothing |
| 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.2.0 | 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.2.0 | 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 operator role: an admin client (connect.NewAdminClient, createAdminClient, fora_sdk.client.AdminClient) for fora.admin.v1.AdminService and the two domain-verification RPCs, built against the configured base URL. AdminService is not served on the public agent-facing listener, so a deployment that serves it apart from the Exchange endpoint builds one admin client per address.
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