fora-protocol-sdk
FORA protocol libraries for Python, co-located with the contract (ADR-020). One
distribution, the fora_sdk package, layered so that the parts holding no keys and doing
no I/O can be used on their own.
| Layer | Module | What it is |
|---|---|---|
| L0 | wire.models, vocab.* |
generated wire types, from the separate fora-protocol distribution (consumed, never rebuilt) |
| L1 | fora_sdk (top level) |
stateless, IO-free protocol mechanics — RFC 9421/7638 crypto, offer and acceptance signatures, signed URLs, validation. Includes fora_sdk.core (transport-neutral: Verifier, VerifiedOffer, DiscoveryResult), fora_sdk.window (Window) and fora_sdk.server_verify (the server side of RFC 9421). Byte-parity-guarded against the sdk/go oracle |
| L2 · I/O | fora_sdk.resolvers |
the only tier that dials the network: Web Bot Auth directories, well-known JWKS and fora.json, plus the SSRF-guarded HTTP client. Which faces take that client by default is decided by URL provenance, below — WellKnownEndpointResolver is the one request-derived face that still defaults to a plain client |
| L2 · transport | fora_sdk.client (the async Connect-unary JSON client: the agent verbs discover · execute · report_usage · dispute · fetch, the account-setup verbs register · get_account_status, the broker verb resolve and the publisher verbs push_resources · remove_resources · refresh_catalog) · fora_sdk.sync (the same faces, blocking) |
state is injected, never owned |
pip install fora-protocol-sdk
Python 3.11 or later. The package ships py.typed, so mypy reads its annotations.
httpx and httpcore are installed with every consumer, including one that uses only
the IO-free mechanics: keeping them non-optional is what makes their version ceilings,
which guard the resolvers' SSRF seam, bind every install.
The generated wire types are a separate distribution, fora-protocol: Pydantic v2 models
at wire.models and the registered vocabulary at vocab.*. This package pins it to its
own version and installs it as a dependency. It owns the top-level import names wire and
vocab, so it can collide with another distribution that owns either name; install it
into an environment that does not. Its README explains the tradeoff.
A complete agent
Everything below is one working client: identity from a seed, offers verified against keys fetched from the issuing Exchange, a purchase, a bound fetch, and a usage report. The rest of this document explains the pieces it composes.
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"]),
)
)
fora_sdk.client.Client is the same surface with await; nothing else changes. That one
is the core, and fora_sdk.sync is a blocking facade over a synchronous httpx client
rather than an asyncio.run wrapper, which would break inside a running event loop.
That example is not decoration: a test extracts this exact block from this file, runs it against an in-process Exchange, and fails if any name in it stops resolving.
L1 — the protocol mechanics
Stateless, no network I/O, no secret custody, no state. The same code the Broker, Exchange, MCP adapter and edge worker build on.
from fora_sdk import thumbprint, sign_request, verify_request
RFC 9421 request signing and verification. sign_request covers the exact body
bytes; verify_request checks a received one against a key you hold, and
fora_sdk.server_verify.verify_request_server is the framework-agnostic server face,
including the multi-signature relay chain:
now = int(time.time())
signed = sign_request(
method="POST", url=url, body=body,
authorization="", # covered, and sent even when empty
signer_seed=seed, keyid=keyid,
created=now, expires=now + 300, # the freshness window, minted by the caller
signature_agent=agent_directory, # covered; empty means no resolvable key
)
verdict = verify_request_server(
method="POST", url=url, body=body, headers=headers,
resolver=resolver, replay_store=replay_store, now=now,
)
Offer authenticity. The signature covers the offer's pricing, terms and expiry, so an
offer must be verified before anything selects on it. That is what fora_sdk.core.Verifier
does in bulk, and sign_offer_jcs / verify_offer_acceptance_jcs are the primitives
underneath it. Canonicalization is RFC 8785 JCS through a vetted library, never
hand-rolled.
Signed delivery URLs and proof of possession. Byte-identical with the edge worker:
url = sign_ed25519_signed_url(raw_url, seed=seed, kid="ex.v1", agent_id=tp, exp=exp)
verdict = verify_ed25519_signed_url(url, now=now, resolve_key=resolve)
proof = verify_agent_binding(method="GET", url=url, headers=headers,
agent_id=verdict.agent_id, now=now)
The covered set for the proof is exactly @method + @target-uri: a GET has no body to
digest, and the signed URL is itself the credential.
License-term pre-check. The two tiers an Exchange applies to a pushed catalog entry, runnable by a publisher before signing — the wire rules over the entry as given, then canonicalization and registry membership over a copy of its terms:
verdict = validate_resource_entry(entry) # never modifies entry
normalize_resource_entry(entry) # the form the Exchange stores
Money. Exact decimal in and out, canonical decimal string on the wire:
parse_money, format_money, canonicalize_money. Never floats.
Registration schema. An Exchange may publish a JSON Schema for the
registration_data it expects. Both ends validate against it, so the rules live in one
place: 2020-12 only, same-document $ref only, size, depth and evaluation caps, and a
pattern alphabet all three SDK languages express identically.
schema, verdict = compile_registration_schema(raw)
Do not discard that verdict. The two callers read a non-accepted one differently, and getting it backwards is the easy mistake. A client pre-checking a payload skips the check and sends anyway, because the Exchange's enforcement decides and a local check that could not run must not veto your own user. An Exchange compiling its own configured schema treats anything but accepted or not-published as a misconfigured deployment, and must not advertise a schema it is not enforcing.
Routing and audience. is_bare_host and host_anchored are the pure checks that
precede a signed call to an address a network party named. check_audience is the other
direction: a request arrived, does it name this Exchange? The signature does not answer
that — it proves the sender signed the URL it dialled, and that URL came out of a fetched
manifest, so a poisoned resolution redirects the request while every signature still
verifies.
Also: errordetail (the typed error taxonomy and its constructors),
generate_idempotency_key, apply_scopes, hash_url and monotonic_window.
L2 · I/O — fora_sdk.resolvers
The network-fetching tier. Everything that dials a host a third party can influence lives
here, so the pre-auth-reachable network surface never enters the pure core. Almost all of it
dials through one SSRF-guarded HTTP client; WellKnownEndpointResolver is the exception
and still defaults to a plain one, which is a known gap rather than a decision. Pass it
http=guarded_client() until that default changes, as the example above does.
- Key resolvers —
WellKnownKeyResolver(well-known JWKS, TTL-cached) andWBAKeyResolver(Web Bot Auth directory, revocation- and expiry-aware, with a background poller). - Endpoint resolver —
WellKnownEndpointResolverdiscovers an Exchange's own service endpoint from/.well-known/fora.json, host-keyed and cached. Its exits are worth knowing apart, because the difference decides whether a caller should retry:ManifestVersionRefusedErrorwhen the document carries averthis reader does not accept or none at all,NoEndpointErrorwhen it advertises none, andEndpointRefusedErrorwhen it advertises one this resolver will not hand back. All three are verdicts and therefore final; anything else is a transport failure and worth retrying. - Offer keys —
CachedOfferKeyResolvercaches an Exchange's offer-signing key with an expiry clamped to the key's ownnot_after, andcreate_wba_offer_directory_fetchis the fetch it runs on.active_ed25519_key_screenedand its siblings are the underlying window-and-revocation selection. - Registration requirements —
WellKnownRequirementsReaderreads what one Exchange asks of a registration from the same manifest, and holds no document cache: the contract requires the terms digest to come from a freshly fetched document. - The guarded client —
guarded_clientandguarded_async_clientare the one construction path a third-party-influenceable fetch uses.ssrf_guardandasync_ssrf_guardare the transports underneath, for a caller wiring its own.
Which default a resolver takes follows its URL's provenance, and that is the whole
rule. A fixed, operator-chosen address — an on-premise JWKS — may legitimately be
private, and the operator rather than an attacker chose it. A request-derived host — the
directory named by a Signature-Agent header, an Exchange domain read off an offer —
takes the guarded client, because the party choosing the address is not the party running
the process.
Redirects: the guarded client follows, a signed leg refuses. Following a bounded chain is right for a public well-known document, where the address is re-pinned and the scheme re-vetted at every hop. It is wrong for anything carrying a credential, so the content fetch and the RPC legs install their own refusal: following a redirect either replays a proof bound to the old URL, or hands a fresh proof of possession of the agent's key to whatever host the first hop named.
Two orthogonal environment flags drive the guard, both defaulting to the guarded posture:
SKIP_SSRF drops the dial-time address check, and ALLOW_INSECURE permits plaintext
http. The transport caps redirect depth, bounds well-known bodies at 1 MiB, and fails
closed if any resolved address of a host is reserved.
L2 · transport — fora_sdk.client and fora_sdk.sync
ClientConfig is the whole of a client's wiring, and three of its fields are things the
client refuses to guess:
signer— aSigningTransportover the agent's own key. The SDK holds one agent key: the one the request is signed with.verifier— fail-closed by default. An unconfigured client gets a verifier over a resolver that resolves nothing, so it rejects every offer, with a reason. That is the correct default and on a first run it looks exactly like a broken stack.endpoint_resolver— turns the exchange domain inside a signed offer into the origin that Exchange advertises for itself. A usage report goes where the signed offer says, never where configuration says, so this is not an optional convenience.
The rest are bounds and seams with working defaults:
| Field | Default | What it bounds |
|---|---|---|
call_timeout_sec |
30.0 |
one RPC, end to end |
max_rpc_read_bytes |
1048576 (1 MiB) |
an RPC response body |
content_timeout_sec |
30.0 |
the delivery fetch as a whole; its remainder is carried across the legs of one fetch |
max_content_bytes |
8388608 (8 MiB) |
one fetched body, buffered whole |
proof_window |
30 s | validity of the proof of possession a bound fetch presents |
sign_window |
the signer's own window (600 s) | validity of an outbound request signature. Set it to a monotonic_window, one instance per client, if your peer screens replays by (key id, signature) — timestamps have one-second resolution, so two identical requests inside a second otherwise sign to the same bytes |
request_id |
None, meaning no header is sent |
mints the X-Request-ID correlation value |
validation |
"strict" |
whether an outbound message is checked against its generated model before it is sent. Orthogonal to offer verification, which is about what comes back |
registration_requirements |
a reader built on the guarded client, once per client | where register reads an Exchange's terms revision and registration schema. It holds no document cache on purpose: the contract requires the terms digest to come from a freshly fetched manifest |
Build a client once and reuse it. ClientConfig, the resolvers and the Verifier
are all designed to be shared: the endpoint resolver caches each Exchange's manifest
behind its own locks, the offer-key resolver caches keys with a TTL, and the client owns
HTTP connection pools that close() releases. One per process, or one per Exchange, is
the shape to aim for — not one per purchase. The one thing that must not be shared across
processes is the signing key.
The verbs
Requests are plain dicts in proto-JSON snake_case; a camelCase key is refused as
malformed rather than silently ignored. Responses are the generated Pydantic models from
wire.models.
| Verb | Send | Get back |
|---|---|---|
discover(query) |
exchange, uris, optional filters |
DiscoveryResult: groups (one per requested URI, each with uri, result.verified, result.rejected, absence_reason), plus exchange and rate_limit. verified() and rejected() flatten across groups |
execute(offer, *, idempotency_key=None) |
a VerifiedOffer — nothing else is accepted |
TransactionResponse: items, each with transaction_id, billing_id, retrieval_endpoint, expires_at, cost |
fetch(signed_url) |
one retrieval_endpoint |
Content: url, mime_type, body |
report_usage(report, *, idempotency_key=None) |
exchange, transaction_id, billing_id, usage |
UsageReportResponse: report_id, which a later dispute must cite |
dispute(request, *, idempotency_key=None) |
exchange, transaction_id, report_id, reason |
DisputeResponse |
register(request) / get_account_status(request) |
account setup with an Exchange | RegisterResponse / GetAccountStatusResponse |
A rejected offer keeps both the offer and the reason it was refused, so a caller can
tell a bad signature from an unresolvable Exchange. A group with no offers at all keeps
its absence_reason instead, and the distinction matters: "not in the catalogue" means
give up, "scope insufficient" means acquire an entitlement and retry, and "content
blocked" means never retry.
Where a URI comes from is not the SDK's job. discover asks one Exchange about URIs
you already have — from your own crawl frontier, a publisher's catalogue, or a Broker.
BrokerClient.resolve is the fan-out case: it asks a Broker, which queries the Exchanges
it knows and relays back what they offered, so a caller with no idea which Exchange sells
a resource starts there rather than with discover.
Failures arrive as one CallError carrying a CallErrorKind — NOT_SENT, REFUSED,
UNREACHABLE, MALFORMED, TOO_LARGE, NOT_SIGNABLE, UNKNOWN — plus the peer's own
reason token and, when the peer sent one, a typed ErrorDetail. One failure type for
every verb, so a caller branches in one place. NOT_SENT is worth singling out: it means
the client refused before anything left the process, so retrying without changing
something will fail the same way.
BrokerClient carries resolve for brokered discovery, and CatalogClient carries the
publisher verbs. Both take the same ClientConfig, because a publisher addresses a
different endpoint with a different key.
Running against a local Exchange
Three environment variables. WHEN each is read matters, because setting one after the object that reads it was built has no effect:
| Variable | Effect |
|---|---|
FORA_WELLKNOWN_SCHEME=http |
the resolvers read manifests and directories over plaintext. Consumer-side: the SDK never reads it for you, which is why the example above passes scheme= explicitly |
ALLOW_INSECURE=true |
the scheme gate permits a plaintext http origin. Needed for the RPC and delivery legs, which check the scheme ABOVE the transport, so injecting a client is not enough |
SKIP_SSRF=true |
the dial-time address guard is dropped, so loopback and private addresses are reachable. Read when guarded_client BUILDS a client, so set it before constructing a resolver |
All three default to the guarded, https-only posture. Set them for a sandbox, never in production — together they remove the whole pre-auth SSRF defence.
Guarantees
- Byte-parity with Go. Thumbprints, RFC 9421 signature bases, acceptance payload
bytes, delivery-proof values and money formatting are byte-for-byte identical to the
sdk/gooracle. If those bytes differ, signatures do not verify and nothing works. Go emits the vectors; this package's parity suites replay them. - The public surface is gated. Every public Go symbol is either mapped to its Python and TypeScript counterparts or recorded as a deliberate divergence with a reason, and the allowlist may only shrink.
- Fail-closed by default. An unresolvable Exchange is absent rather than trusted, an unverified offer is rejected rather than dropped, and a directory fetch that fails returns nothing rather than an empty document.
- Library-first. JCS canonicalization, JSON Schema evaluation, the HTTP client and the cryptography are vetted libraries. The SDK owns the protocol, not the primitives.
Source, tests and the release process are in
FORA-Protocol/protocol under sdk/python and
gen/python. Licensed under Apache-2.0.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fora_protocol_sdk-1.0.4.tar.gz.
File metadata
- Download URL: fora_protocol_sdk-1.0.4.tar.gz
- Upload date:
- Size: 355.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.11 {"installer":{"name":"uv","version":"0.12.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c71d7ca11ea06a1e16165a7149985f0e848de02dc382045f1291fd6bbe9b5ef4
|
|
| MD5 |
bb37b41470617988ee440a43395f0fad
|
|
| BLAKE2b-256 |
81b36855fb51ec917a8514f7c9a368aae8d545697193b389dca32575c5177097
|
File details
Details for the file fora_protocol_sdk-1.0.4-py3-none-any.whl.
File metadata
- Download URL: fora_protocol_sdk-1.0.4-py3-none-any.whl
- Upload date:
- Size: 206.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.11 {"installer":{"name":"uv","version":"0.12.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
64e58b66a7889f2bdd0cdfda1f3e19fdd4956858d10069d91525a9f31a807791
|
|
| MD5 |
ff69583dfa536870dbd9b052f7d42879
|
|
| BLAKE2b-256 |
afb2f8f64ea601342f56a34fa213909e3491c8a397dd60d61e05072c252f9f2a
|