Skip to main content

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 resolversWellKnownKeyResolver (well-known JWKS, TTL-cached) and WBAKeyResolver (Web Bot Auth directory, revocation- and expiry-aware, with a background poller).
  • Endpoint resolverWellKnownEndpointResolver discovers 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: ManifestVersionRefusedError when the document carries a ver this reader does not accept or none at all, NoEndpointError when it advertises none, and EndpointRefusedError when 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 keysCachedOfferKeyResolver caches an Exchange's offer-signing key with an expiry clamped to the key's own not_after, and create_wba_offer_directory_fetch is the fetch it runs on. active_ed25519_key_screened and its siblings are the underlying window-and-revocation selection.
  • Registration requirementsWellKnownRequirementsReader reads 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 clientguarded_client and guarded_async_client are the one construction path a third-party-influenceable fetch uses. ssrf_guard and async_ssrf_guard are 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 — a SigningTransport over 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 CallErrorKindNOT_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/go oracle. 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

fora_protocol_sdk-1.0.4.tar.gz (355.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fora_protocol_sdk-1.0.4-py3-none-any.whl (206.6 kB view details)

Uploaded Python 3

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

Hashes for fora_protocol_sdk-1.0.4.tar.gz
Algorithm Hash digest
SHA256 c71d7ca11ea06a1e16165a7149985f0e848de02dc382045f1291fd6bbe9b5ef4
MD5 bb37b41470617988ee440a43395f0fad
BLAKE2b-256 81b36855fb51ec917a8514f7c9a368aae8d545697193b389dca32575c5177097

See more details on using hashes here.

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

Hashes for fora_protocol_sdk-1.0.4-py3-none-any.whl
Algorithm Hash digest
SHA256 64e58b66a7889f2bdd0cdfda1f3e19fdd4956858d10069d91525a9f31a807791
MD5 ff69583dfa536870dbd9b052f7d42879
BLAKE2b-256 afb2f8f64ea601342f56a34fa213909e3491c8a397dd60d61e05072c252f9f2a

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

1.0.4 This release

2 files

1.0.3

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page