Руководство разработчика
What it is. A thin layer that makes the capabilities on an AIMarket hub appear as native tools inside LangChain/LangGraph, CrewAI, AutoGen and Microsoft Agent Framework.
Why it exists. Before it, a developer wanting to buy a verifiable random draw from their LangGraph agent had to read the protocol spec, write an HTTP client, and handle payment channels, signed receipts and verification — a day of work before the first useful call. Now:
from aimarket_bridges.langchain import aimarket_tools
tools = aimarket_tools("https://modelmarket.dev", intent="verifiable randomness")
The marketplace has 47 capabilities and, at the time of writing, one paying external consumer. The bottleneck is demand, not supply, and this is the only piece of work that addresses it: it turns "learn a protocol" into "install a package".
---
flowchart TB
subgraph consumer["Consumer process"]
agent["Agent<br/>(LangGraph · CrewAI<br/>AutoGen · Agent Framework)"]
subgraph bridge["aimarket-bridges"]
adapter["Framework adapter<br/>langchain.py · crewai.py<br/>autogen.py · agent_framework.py"]
core["Shared core<br/>catalog · schema · client · receipts"]
end
end
hub[("AIMarket Hub<br/>modelmarket.dev")]
p1["Oracle family<br/>42 federated capabilities"]
p2["Local capabilities<br/>5"]
agent -->|"tool call"| adapter
adapter -->|"Capability + args"| core
core -->|"GET /manifest, /search"| hub
core -->|"POST /invoke"| hub
hub -->|"routes"| p1
hub -->|"serves"| p2
p1 -.->|"GET /.well-known<br/>(signing key)"| core
style bridge fill:#1f2937,stroke:#60a5fa,color:#e5e7eb
style hub fill:#0f766e,stroke:#5eead4,color:#ecfeff
The dotted arrow is the part most easily got wrong, and section 4 is about it.
Each framework declares a tool differently; nothing else about a tool call differs. So the money, the refusals and the receipt live in the core, once, and each adapter is a thin translation of one interface into another.
| Layer | File | Responsibility |
|---|---|---|
| Catalogue | catalog.py | manifest → Capability records, framework-safe names |
| Schema | schema.py | JSON Schema → pydantic model, for frameworks that demand one |
| Invoke | client.py | one call: budget, refusal, receipt |
| Trust | receipts.py | resolve the signing key of a capability's origin |
| Adapters | langchain.py, crewai.py, autogen.py, agent_framework.py | one framework each |
---
sequenceDiagram
autonumber
participant U as Your code
participant C as catalog.fetch_catalog
participant H as Hub
U->>C: fetch_catalog(url, intent="consensus", max_price_usd=0.01)
C->>H: GET /ai-market/v2/manifest
H-->>C: 47 tools, each with input_schema
opt intent given
C->>H: GET /ai-market/v2/search?intent=consensus
H-->>C: ranked capability_ids (no schemas)
Note over C: join on capability_id —<br/>search ranks, the manifest describes
end
C->>C: derive tool names, filter by price
C-->>U: list[Capability]
Two measured facts shaped this.
/search does not return input_schema. Only the manifest does. A tool without an argument schema is a tool no model can call correctly, so the manifest is the only viable source; search contributes ranking, joined back on capability_id. The search parameter is named intent, not q — a hub given anything else answers with an unfiltered top-N, which reads like broken search when it is a different parameter name.
Not one of the 47 manifest names is usable as a tool name. They contain dots and @, and several contain spaces (prod-skopos.Security posture@v1), while tool names must generally match ^[A-Za-z0-9_-]{1,64}$. Names are therefore derived from capability_id (sortes.draw@v1 → sortes_draw_v1) and de-duplicated deterministically: a saved agent graph stops matching its tools if names shuffle between runs.
fetch_catalog raises when the hub is unreachable. It does not return an empty list. An agent that boots believing it has no capabilities is a far worse failure than one that refuses to boot — and the reference SDK's discover() swallows every exception and answers [], which is exactly the failure being avoided here.
---
sequenceDiagram
autonumber
participant M as Model
participant T as Tool
participant C as HubClient
participant H as Hub
M->>T: call(args)
T->>C: invoke(capability, args)
C->>C: reserve price under a lock
alt ceiling would be exceeded
C--xT: BudgetExceeded — the hub is never contacted
else within ceiling
C->>H: POST /invoke
alt success
H-->>C: output + signed receipt
C-->>T: InvokeResult(ok=True)
else capability refuses
H-->>C: {ok: false, error: "'count' must be an integer, got str"}
C->>C: release the reservation if nothing was billed
C-->>T: InvokeResult(ok=False) — readable text
else transport fails
H--xC: timeout / reset
C->>C: release the reservation
C--xT: HubUnavailable
end
end
The reservation happens before the call, under a lock. Reserving afterwards would let two concurrent calls both pass the same check — and LangGraph and CrewAI both run tool calls from worker threads, which is exactly when a spend counter matters. A 40-thread test proves a $0.10 ceiling permits exactly ten $0.01 calls.
budget_usd=0 means spend nothing. None means no ceiling. This is worth stating because it was wrong: _reserve tested if self.budget_usd and …, so a falsy budget skipped the check entirely. An operator writing 0 to mean "spend nothing" got unlimited spend, while remaining_usd reported $0.00 for the whole run. All three adapters had independently grown a guard against it, which is the clearest possible sign the defect was one level down.
max_price_usd and free_only filter at build time, which is the only honest place for a limit: once a tool is in the agent's registry the agent decides when to call it, so a capability the operator cannot afford must never be handed over.
A refused call releases its reservation only if nothing was billed. When a refusal comes back with a receipt, the call *was* metered, and pretending otherwise would let a loop of refusals spend invisibly.
Nothing, for 42 of the 47 capabilities. price_per_call_usd in the manifest is a list price, and the hub only collects it for a federated capability when the operator has declared AIMARKET_SELLS_FOR for that peer — which is unset on modelmarket.dev. So the $0.006 a tool description quotes for aestus.seal@v1 is what the call *would* cost, not what left anyone's balance. remaining_usd moving in a bridge run is this client's own bookkeeping against budget_usd, not a debit.
Two things follow for a bridge author:
proof of a working free tier. The paid path is exercised by the escrow tests, not by this.
402. The two capabilities that sellcomputation cap what an unpaid caller may ask for — chronos.eval@v1 at difficulty=100000, aestus.seal@v1 at T=1000000 — and answer 402 payment_required above it, carrying the ceiling in free_tier. InvokeResult surfaces this as payment_required and, specifically, as a refusal about the input rather than as "the operator has to fund a channel": lowering the field is the fix, and the model can do that itself. Collapsing the two would tell a model to give up on a call it could have made. The ceilings are published in the manifest, so max_price_usd/free_only filtering can read them at build time. Full detail: free-and-paid-tiers.
If AIMARKET_SELLS_FOR is ever set, every one of those 42 starts answering 402 to an invoke with no X-Payment-Channel, in the same minute, with no grace period. A bridge that handles payment_required today keeps working; one that treats it as fatal stops.
---
sequenceDiagram
autonumber
participant C as HubClient
participant H as Hub
participant O as Origin<br/>(oracle family)
participant R as OriginKeyResolver
C->>H: POST /invoke (capability_id, source_hub)
H->>O: routes the call
O-->>H: output + receipt signed with O's key
H-->>C: relays it unchanged
C->>R: check(receipt, source_hub=O)
R->>O: GET {source_hub}/.well-known/ai-market.json
O-->>R: signer_public_key = YkAOwWNb…
R->>R: Ed25519 verify over the 7-field canonical
R-->>C: verified=True
A hub is a broker. When it routes an invoke to a federated provider, what comes back carries the *provider's* signature, not the hub's — that is the design, and it is what lets a buyer check the work without trusting the middleman.
So the key depends on where the capability lives. Measured on modelmarket.dev:
| Origin | signer_public_key |
|---|---|
hub modelmarket.dev | sVjlCo52rBsmBH69iSXQ3oIB3LbWo4BgXT3iBhabDeM= |
oracles.modelmarket.dev/family | YkAOwWNbRFti2cqEzD6zfuI4OTLsGUoObpCmlwZqaTQ= |
42 of 47 capabilities are federated, so verifying everything against the hub's key reports invalid-signature for 89% of the catalogue — on receipts that are perfectly valid. The reference SDK did exactly that up to and including 2.1.2; aimarket-agent 2.2.0 fixes it, and this package fixes it independently because its floor is >=2.1 and 2.1.x is what is installed on any machine that has not upgraded.
What a verified receipt does and does not prove. It proves that *the party publishing a key at that URL* signed this exact 7-field record: nonce, product, capability, price, timestamp, success, latency. It does not prove the party is honest, that the computation was correct, or that the price matches what any ledger charged. A federated provider can publish any key and sign with the matching secret. The signature establishes attribution and non-repudiation, not virtue — and for the mathematical claims, several oracles ship a separate verify capability precisely so the *answer* can be checked independently of the receipt.
Three states, not two. ReceiptCheck.verified is True, False, or None for "not checked". Collapsing None into False is how the SDK's false alarm stayed invisible: "we could not look" and "the signature is wrong" call for opposite reactions.
The receipt is kept out of the tool's text result. Pushing it into the content would spend model context on a blob no model reads. It travels through each framework's own metadata channel and through HubClient.last_receipt.
---
flowchart LR
call["Tool call"] --> q{"What happened?"}
q -->|"capability rejected<br/>the input"| refuse["Readable text to the model<br/>'…must be an integer, got str'"]
q -->|"hub blocked it<br/>(safety gate)"| refuse
q -->|"budget ceiling"| exc1["BudgetExceeded"]
q -->|"transport / config"| exc2["HubUnavailable"]
refuse --> retry["Model corrects the argument<br/>and calls again"]
exc1 --> stop["The graph sees it"]
exc2 --> stop
style refuse fill:#065f46,stroke:#6ee7b7,color:#ecfdf5
style exc1 fill:#7c2d12,stroke:#fdba74,color:#fff7ed
style exc2 fill:#7c2d12,stroke:#fdba74,color:#fff7ed
A model told 'count' must be an integer, got str fixes the argument on the next turn. Raising instead aborts the surrounding graph or crew over something the model could have repaired itself. Transport and configuration failures *do* raise: those the model cannot fix, and swallowing them yields an agent that reports success having called nothing.
---
flowchart TB
cap["Capability.input_schema<br/>(JSON Schema from the hub)"]
cap -->|"straight through"| lc["langchain-core 1.5.2<br/>StructuredTool(args_schema=dict)"]
cap -->|"model_from_schema()"| cw["crewai 1.15.8<br/>BaseTool(args_schema=Model)"]
cap -->|"model_from_schema()"| ag["autogen-core 0.7.5<br/>BaseTool(args_type=Model)<br/>async run()"]
cap -->|"schema as published"| af["agent-framework-core 1.15.0<br/>FunctionTool(input_model=schema)<br/>async invoke()"]
style lc fill:#1e3a8a,stroke:#93c5fd,color:#eff6ff
style cw fill:#7c2d12,stroke:#fdba74,color:#fff7ed
style ag fill:#4c1d95,stroke:#c4b5fd,color:#f5f3ff
style af fill:#0f4c4a,stroke:#5eead4,color:#ecfeff
Every statement below was measured by introspection against the installed version, not taken from documentation — all four had moved past what the docs implied.
langchain-core 1.5.2 accepts a raw JSON Schema dict as args_schema. Nothing to convert.
crewai 1.15.8 accepts a dict too — and converts it with its own create_model_from_schema, which refuses union types: Unsupported JSON schema type: ['string', 'integer']. Ten of the 47 live capabilities die there (percola, fermat, ablation, landauer and fourier, each producer and its verifier). schema.py builds all ten. Even on the 37 that survive, crewai's converter knows nothing of the alias inversion below.
autogen-core 0.7.5 derives a schema from a function's *type annotations*, so FunctionTool cannot express a capability whose shape arrives at runtime; BaseTool with an explicit args_type is the right door, and its run() is async.
agent-framework-core 1.15.0 takes either a pydantic model or a raw JSON Schema — and only the schema works here. Given a model it advertises pydantic's by-alias schema and then validates the *un-aliased* dump against it, so fourier.verify@v1 is uncallable through that door: TypeError: Missing required argument(s): lambda. Handed the hub's schema it validates required arguments and scalar types before the call (neither costs money) but forwards undeclared properties, which the adapter drops itself. Sync tools are offloaded to asyncio.to_thread — the loop's shared default executor — so the adapter passes a coroutine and uses its own bounded pool instead.
The trap that would have cost the most:
| Capability | Property | Problem |
|---|---|---|
fourier.verify@v1 | lambda — required | a Python keyword |
fermat.route@v1 | from, nested in an edge | a Python keyword |
fermat.verify@v1 | from, nested in an edge | a Python keyword |
A pydantic field cannot be called lambda, so it becomes lambda_. Stopping there produces a tool that advertises an argument no capability accepts and sends one no capability reads — a refusal on a call that was already billed. schema.py attaches a pydantic alias, so the rewrite round-trips: model_json_schema() shows lambda and model_dump(by_alias=True) emits lambda.
The same trap is why the Agent Framework adapter refuses a pydantic model altogether: agent-framework 1.15.0 advertises model_json_schema() (aliased, lambda) but validates model_dump(exclude_unset=True) (un-aliased, lambda_) against it, so the round-trip is broken inside the framework rather than in the bridge. There the fix is to hand over the hub's schema and never build a model at all.
Proven end to end over the live network, not in a stub: fourier.spectrum@v1 → fourier.verify@v1, keys on the wire ['edges', 'lambda', 'laplacian', 'tol', 'vector'], verifier answering valid: True, residual 2.3e-16, both receipts verified against their origin's key.
BaseTool.run merges run_manager and its RunnableConfig over the model's arguments, keyed on what it finds on _run's signature — and StructuredTool._run declares both. A capability property named config or run_manager therefore never reached the hub. With an *optional* colliding property the paid call goes out silently missing an argument the model supplied: billed, wrong answer, nothing raised, because a dict args_schema validates nothing. With a *required* one the capability is uncallable. The adapter subclasses StructuredTool with a _run that declares neither name.
tool_usage.py wraps the invoke in try: tool.invoke(...) except Exception: tool.invoke(...), and the ReAct loop retries three times. A hub that times out *after* the provider already ran is indistinguishable from one that never answered, so a single tool call could bill the hub six times while the bridge's own counter showed nothing spent. The adapter catches HubUnavailable inside _run.
A cache keyed on arguments would sell the same sortes.draw@v1 draw twice. Per framework, as measured on the installed versions: crewai's cache_function is disabled for every tool; langgraph 1.2.10 *does* have a cache layer (StateGraph.compile(cache=…) plus a per-node cache_policy) but create_react_agent reaches neither; autogen's tool results are not cached by the agent loop.
---
flowchart TB
subgraph trusted["You chose to trust"]
yourcode["Your code"]
hubchoice["The hub URL you passed"]
end
subgraph untrusted["Authored by third parties, relayed by the hub"]
srchub["source_hub URL<br/>→ your process FETCHES it"]
schema["input_schema<br/>→ becomes a runtime pydantic model"]
desc["description<br/>→ goes into your model's prompt"]
output["output<br/>→ goes into your model's context"]
end
hubchoice --> untrusted
untrusted --> yourcode
style untrusted fill:#7f1d1d,stroke:#fca5a5,color:#fef2f2
style trusted fill:#064e3b,stroke:#6ee7b7,color:#ecfdf5
42 of the 47 capabilities are federated: a third party writes their metadata and the hub relays it. Four fields cross that boundary into your process, and it is worth naming them plainly rather than discovering them later.
source_hub is a URL your process fetches to resolve a signing key. Federation withstrangers is the product, so fetching peer URLs is inherent, but the request is constrained: the fragment and query are dropped so the path cannot be steered (a # used to suppress the appended suffix entirely and give exact path control), only http/https are fetched, and redirects are not followed. It is deliberately not an address filter — refusing loopback and private ranges would refuse this project's own documented deployments and silently downgrade every receipt in a self-hosted stack from verified to "unchecked". source_hub is also hub-authored: the crawler overwrites whatever a peer claims with the URL it actually crawled, and screens that against its own SSRF guard before indexing.
input_schema becomes a pydantic model at tool-build time. schema.py reports anythingit cannot model (unsupported_keywords) rather than dropping it silently, because a tool advertising an interface it does not honour fails far from the cause.
description reaches your model's prompt. Nothing sanitises it, and nothing can, ingeneral: it is prose whose purpose is to persuade a model to call the tool. Treat a hub's catalogue with the same care as any other prompt content you did not write.
output reaches your model's context whole.The bridge's own guards against a hostile *peer* are: price ceilings at build time, a spend ceiling enforced before each call, a refusal path that cannot abort your graph, and verification bound to the origin that signed. What it deliberately does not do is decide which peers deserve trust — that is the hub operator's job, and the hub exposes trust scores and stake for it.
---
All three adapters take the same arguments, so moving a graph from one framework to another changes the import and nothing else:
aimarket_tools(
base_url, # "https://modelmarket.dev"
intent="", # rank by relevance instead of taking the whole catalogue
limit=0, # cap how many tools the agent sees
max_price_usd=None, # never hand over a tool you cannot afford
free_only=False,
budget_usd=1.0, # 0 = spend nothing · None = no ceiling
)
intent, limit, max_price_usd and free_only filter at build time, which is the only honest place: once a tool is in the agent's registry the agent decides when to call it. budget_usd is a ceiling across every tool in the returned list — they share one HubClient — enforced before each call and safe across threads.
Install instructions and a worked example per framework are in §9.
---
pip install "aimarket-bridges[langgraph]"
pip install "aimarket-bridges[crewai]"
pip install "aimarket-bridges[autogen]"
pip install "aimarket-bridges[agent-framework]"
Install only the extra you use — this package keeps no framework in its own dependencies, because their pydantic floors have collided before and a bridge has no business dragging one framework into another's environment. Measured 2026-08-24: crewai 1.15.17, autogen-core 0.7.5 and agent-framework-core 1.15.0 do currently resolve together on pydantic 2.12.5, which is how CI runs all four suites in one job.
The bridge requires aimarket-agent>=2.2, because 2.1.x verifies every receipt against the hub's key and knows nothing of the v2 canonical, so it answers invalid-signature for all 42 federated capabilities and for every rejection receipt. Until 2.2.0 is on PyPI, install both from a checkout, SDK first:
pip install ./aimarket-agent ./aimarket-bridges
from aimarket_bridges.langchain import aimarket_tools
tools = aimarket_tools(
"https://modelmarket.dev",
intent="verifiable randomness", # rank by relevance; omit for the whole catalogue
budget_usd=0.50, # shared ceiling across every call these tools make
max_price_usd=0.01, # never hand over a tool dearer than this
)
Hand them to an agent the ordinary way:
from langgraph.prebuilt import create_react_agent
agent = create_react_agent(your_model, tools)
result = agent.invoke({"messages": [("user", "draw a verifiable random number")]})
That emits a deprecation warning on LangGraph 1.x — it moved to from langchain.agents import create_agent, which needs the umbrella langchain package that langgraph alone does not pull in. It is still the shorter path if you have only langchain-core and langgraph installed, and it is what the live run in §11 used; switch to create_agent when you add langchain for other reasons. Either accepts these tools unchanged — nothing in the bridge depends on which one builds the graph.
Or call one directly, which is what the model does:
tool = {t.name: t for t in tools}["sortes_draw_v1"]
output = tool.invoke({"alpha": "my-seed"})
The receipt travels as the tool's artifact, so it never costs the model context:
message = tool.invoke(
{"args": {"alpha": "my-seed"}, "id": "call_1", "name": tool.name, "type": "tool_call"}
)
message.artifact["receipt_verified"] # True
message.artifact["price_usd"] # 0.006
message.artifact["receipt"]["nonce"]
tool.metadata carries capability_id, price_usd, source_hub and product_id, so a graph can route or filter on them without parsing the description.
from aimarket_bridges.crewai import aimarket_tools
from crewai import Agent
tools = aimarket_tools("https://modelmarket.dev", budget_usd=0.50)
researcher = Agent(
role="Researcher",
goal="Draw randomness nobody can grind",
backstory="Buys verifiable capabilities rather than trusting a coin flip.",
tools=tools,
llm=your_llm,
)
Calling one directly, and reading the provenance afterwards:
tool = next(t for t in tools if t.capability.capability_id == "sortes.draw@v1")
output = tool.run(alpha="my-seed")
tool.last_result.receipt_verified # True
tool.last_result.price_usd # 0.006
tool.client.spent_usd # running total across every tool in this list
Caching is disabled on every tool (cache_function=never_cache) and that is deliberate: sortes.draw@v1 and platon.random@v1 return fresh randomness, so a cache keyed on arguments would sell the same draw twice.
from aimarket_bridges.autogen import aimarket_tools
from autogen_agentchat.agents import AssistantAgent
tools = aimarket_tools("https://modelmarket.dev", budget_usd=0.50)
assistant = AssistantAgent("buyer", model_client=your_client, tools=tools)
Calling one directly — use run_json, which is the entry point AutoGen itself uses:
import asyncio
from autogen_core import CancellationToken
tool = next(t for t in tools if t.capability.capability_id == "sortes.draw@v1")
result = asyncio.run(tool.run_json({"alpha": "my-seed"}, CancellationToken()))
result.output # the capability's own answer
result.receipt_verified # True
tool.return_value_as_string(result) # what the model reads
run(args, token) takes an instance of the args model. tool.args_type() returns the class, not an instance — it is a method in autogen-core — so build it with tool.args_type()(**kwargs) or use run_json, which does that for you.
from agent_framework import ChatAgent
from aimarket_bridges.agent_framework import aimarket_tools
tools = aimarket_tools("https://modelmarket.dev", budget_usd=0.50)
agent = ChatAgent(chat_client=your_client, instructions="Buy only what you need.", tools=tools)
Calling one directly — invoke is async and takes the arguments as a mapping:
import asyncio
tool = next(t for t in tools if t.capability.capability_id == "sortes.draw@v1")
contents = asyncio.run(tool.invoke(arguments={"alpha": "my-seed"}))
contents[0].text # exactly what the model reads
tool.last_result.output # the capability's own answer
tool.last_result.receipt_verified # True
This adapter passes the hub's JSON Schema to FunctionTool, not a pydantic model. Given a model, agent-framework 1.15.0 advertises the by-alias schema and then validates the un-aliased dump against it, so a capability whose argument is called lambda — fourier.verify@v1 is one — can never be called: TypeError: Missing required argument(s): lambda. Handing over the schema avoids the round-trip entirely and shows the model the hub's own contract.
Two consequences worth knowing. The framework refuses a missing required argument and a wrong scalar type *before* the call, so neither costs money. It does not reject a property the schema never declared — the adapter drops those itself, because sending an invented argument means paying for a call the capability will refuse.
from aimarket_bridges import fetch_catalog, HubClient
caps = fetch_catalog("https://modelmarket.dev", intent="consensus")
with HubClient("https://modelmarket.dev", budget_usd=0.50) as hub:
result = hub.invoke(caps[0], {"values": [1.0, 2.0, 3.0, 100.0]})
print(result.output, result.receipt_verified)
Nothing raises when a capability rejects its input. The tool returns a sentence the model acts on:
sortes.draw@v1 refused this input: 'num_bytes' must be an integer, got str
BudgetExceeded and HubUnavailable do raise — a spend ceiling and an unreachable hub are not things a model can fix by rewriting an argument.
---
530 tests in this package, and 734 across everything the bridge touches. The core suite is parametrized over the 47 real capabilities captured in tests/live_manifest.json — the actual manifest of modelmarket.dev — rather than hand-written fixtures, because every interesting problem here came from what the real catalogue contains: names with spaces, union types, oneOf nested inside items, keyword property names, two properties that sanitise to the same identifier, and 42 of 47 entries signed by somebody other than the hub. No unit test touches the network.
| Suite | Tests |
|---|---|
core (schema, catalog, client, receipts) | 234 |
| langchain / langgraph | 172 |
| crewai | 58 |
| autogen | 66 |
| agent-framework | 34 |
Four more suites guard the contracts this package shares with the rest of the ecosystem, and all of them now run in CI — they were executed only by hand until 2026-07-30:
| Suite | Tests | What it would catch |
|---|---|---|
aimarket-agent | 43 | origin-key resolution, the v1 and v2 canonicals |
| protocol vectors ↔ 4 implementations | 23 | a canonical string drifting in any of them |
| hub escrow bridge | 119 | spend ceilings, the replay guard, key handling |
| oracle distribution names | 19 | a dependency name a stranger owns on PyPI |
---
Everything below was run against the production hub https://modelmarket.dev on 2026-07-29 and 2026-07-30. It is recorded because a passing unit suite proves the adapters agree with a stub, and what a buyer needs to know is whether they agree with the network.
The capability prices quoted are list prices, and on the federated catalogue nothing collected them — see "What \"$0.006 billed\" actually means" below, which is the part most easily misread.
47 capabilities 5 local · 42 federated, all from https://oracles.modelmarket.dev/family
hub signing key sVjlCo52rBsmBH69iSXQ3oIB3LbWo4BgXT3iBhabDeM=
origin signing key YkAOwWNbRFti2cqEzD6zfuI4OTLsGUoObpCmlwZqaTQ=
Two distinct keys, which is the whole reason §4 exists. Note also that the 42 "federated" capabilities all come from the operator's own satellite: today there is no third-party-authored source_hub, input_schema or description in production. The trust boundary in §7 is real but currently unexercised.
All three built 47 tools from the live manifest and invoked platon.state@v1 at $0.001.
| Adapter | Entry point used | Result | Receipt |
|---|---|---|---|
| LangChain | tool.invoke({}) | dict output | artifact.receipt_verified = True |
| CrewAI | tool.run() | dict output | last_result.receipt_verified = True |
| AutoGen | tool.run_json({}, token) | CapabilityResult | receipt_verified = True |
The description each model would see, identical across the three:
[$0.0010 per call · via https://oracles.modelmarket.dev/family] Snapshot of the 32D universe
— telemetry, oscillators, projection…
LangChain's metadata, for a graph that wants to route rather than read prose:
{'capability_id': 'platon.state@v1', 'price_usd': 0.001,
'source_hub': 'https://oracles.modelmarket.dev/family', 'product_id': 'prod-platon'}
CrewAI reported cache_function = never_cache and a running total of $0.0010 against a $0.02 ceiling. AutoGen used its dedicated 8-thread pool, built on first use.
fourier.spectrum@v1 computes a graph's Fiedler pair; fourier.verify@v1 checks it. The second has a required property named lambda — a Python keyword — so a pydantic field cannot carry that name and the alias has to invert on the way out. If it does not, every call to that capability is a billed, guaranteed refusal.
Input for the verifier was built through the generated args model, the way an agent would:
keys on the wire: ['edges', 'lambda', 'laplacian', 'tol', 'vector']
lambda, not lambda_. The verifier's answer:
{"valid": true, "residual": 2.2887833992611197e-16,
"orthogonality": 1.719950113979704e-16, "is_eigenpair": true}
Both receipts verified against the origin's key. $0.0060 for the pair.
The same federated invoke through aimarket-agent directly:
2.1.2 receipt_verified = False invalid-signature
2.2.0 receipt_verified = True ok
Nothing about the call changed. 2.1.2 verified against the hub's key, and the signer was the oracle — so it reported a forgery on 42 of 47 capabilities. The same run also resolved both origins to their own distinct keys, which is the check that could not have passed before.
Local capabilities require payment; federated ones went through on the free trial. Both skopos.fleet.status@v1 and security-rules.sec-feed@v1 answered:
{"success": false, "error": "payment_required",
"detail": "X-Payment-Channel required for paid capability invoke", "needed": 0.01}
while platon.state@v1 — federated, also paid — completed. So the trial tier covers the 42 federated capabilities and not the 5 local ones. Whether that asymmetry is intended is a question for the hub operator; it is recorded here because it changes what a new consumer experiences on their first call.
tool.args_type() in autogen-core is a method returning the class, not a constructor. Passing its result to run() produced TypeError: BaseModel.model_dump() missing 1 required positional argument: 'self' from deep inside the adapter, pointing at the wrong place entirely. Found by driving the adapter by hand, which is exactly who hits it; the adapter now answers with a message naming run_json.
The runs above call the tools directly, the way a framework does. What they do not prove is that a *model* can pick the right tool out of a catalogue and fill its arguments — so that was run too, with minimax/minimax-m3 over OpenRouter, temperature 0, against the same production hub. Each framework was handed the five tools that intent="verifiable randomness" ranks highest and asked for a draw with a specific seed.
LangGraph (create_react_agent, 5 tools):
tools offered sortes_draw_v1 · platon_random_v1 · platon_beacon_v1 · platon_commit_v1 · platon_reveal_v1
AIMessage CALL sortes_draw_v1({'alpha': 'aicom-live-test'})
ToolMessage {"suite": "ECVRF-EDWARDS25519-SHA512-TAI", "public_key": "3c813559787c5cda…
receipt verified: True | $0.006
AIMessage 8da29499522bdb49b8802fab3e2ccd8e0b596f5cf101cea51140ef965dba5640…
CrewAI (Agent + Task + Crew.kickoff(), 4 tools): the agent selected sortes_draw_v1, the receipt verified (ok), $0.0060 of a $0.05 ceiling spent, and the crew returned the beta hex.
AutoGen (AssistantAgent with reflect_on_tool_use=True, 4 tools):
ToolCallRequestEvent sortes_draw_v1({"alpha":"aicom-autogen-test"})
ToolCallExecutionEvent {"suite": "ECVRF-EDWARDS25519-SHA512-TAI", …
TextMessage **Beta value:** 20d23772153ba0f1b2fefc1e24f41fb53d2a3b85…
receipt verified=True (ok) $0.006
Three different frameworks, three different agent loops, one model, and in every case: the right tool chosen out of several, the argument named correctly (alpha), a real ECVRF draw from the oracle, and a receipt that verified against the origin's key rather than the hub's.
Nothing was charged to anybody, and the figure needs saying carefully because the bridge prints it as if it were a charge.
$0.006 is the capability's list price from the catalogue. The bridge subtracts it from its own budget_usd ceiling and reports it as price_usd, and the oracle states it inside the signed receipt. None of that moves money. Verified on the hub after the runs: the escrow authorisation store still held exactly the same three pending rows and 240000 units as before, and the channel ledger the same 4 channels and 2 debited receipts — no channel was opened, no debit authorisation stored, no ledger entry made, no USDC anywhere.
The reason is in the hub's invoke path. Its payment gate — 402 payment_required without an X-Payment-Channel — sits inside the source_hub == "local" branch. On the federated branch the hub only *passes through* a 402 that the peer itself returns; it does not charge on the peer's behalf. The oracle family does not return one. So the five local capabilities are gated (measured: skopos.fleet.status@v1 and security-rules.sec-feed@v1 both answer payment_required) and the 42 federated ones are, today, free to call while advertising a price.
For a consumer that is simply the current state of the network. For an operator it is worth knowing that budget_usd bounds what the catalogue *says* the calls cost, not what leaves a wallet — the two coincide only once the provider enforces payment.
Worth noting what these runs do *not* show, either. One model, one temperature, one prompt shape, and a capability whose argument is a single string. A capability with a nested oneOf argument — a fermat.route@v1 edge, say — asks considerably more of a model, and nothing here measures how often it gets that right. What is settled is that the bridge is not the obstacle.
langchain-core 1.5.2 · langgraph 1.2.10 · crewai 1.15.9 · autogen-core 0.7.5
agent-framework-core 1.15.0
pydantic 2.12.5 (with crewai) · 2.13.4 (with autogen)
for the live model runs: langchain-openai 1.4.1 · autogen-ext 0.7.5 · minimax/minimax-m3
The adapters were written against crewai 1.15.8 and pass on 1.15.9, which is the useful fact. The two pydantic lines above are historical: measured again on 2026-08-24, crewai 1.15.17, autogen-core 0.7.5 and agent-framework-core 1.15.0 resolve together on pydantic 2.12.5, and CI now runs all four suites — 582 tests — in one environment.
Apache-2.0.