Writing a transport¶
funduq's core is network-free: it hands back objects and pure functions, and putting them on a wire is a downstream job. This page is what that job actually involves — the handshake in order, with the exact calls, and the parts funduq deliberately leaves to you.
Everything a transport must produce or validate is pinned in
contract-vectors.json, so you can implement
this in any language without reading funduq's source.
The opening handshake, in order¶
Five steps, and the order is the security property. This is the one exchange that decides whether either side is talking to who it thinks — and, since nothing a provider does to its own roster is signed any more, it is the only place a key is ever proved. The payload bytes and why each field is in them are in Contract and identity; what follows is the call sequence a transport has to relay.
1. funduq issues a ticket to the key, over a channel that is not the link.
ticket = funduq.issue_ticket(provider_public_key)
Single-use, valid for 60 seconds, destroyed by the handshake that answers it. Issuing is the admission decision — a key with no ticket cannot connect at all — so whoever calls this is the party that decides who may serve here.
The ticket names the key it admits, and that is what makes it safe to hand across a channel funduq does not control: a leaked ticket is worthless, because only the named key can sign the answer, and a stranger cannot burn it either (the name is matched before the ticket is destroyed). It exists at all because a signature whose only liveness is a self-chosen timestamp is replayable by anyone on the path — see the design record.
Do not fetch it over the link being opened. Core keeps the verb off the link's operation set so it cannot be, and a ticket obtained over the link would mean the link existed before anything authorised it. Which channel you use instead is yours — an enrolment endpoint, an operator console, out-of-band provisioning.
2. The provider signs, naming the funduq it means to reach.
proof = identity.sign_connect(funduq_public_key, ticket, provider_nonce)
The provider contributes a nonce of its own and names the recipient: the pinned funduq key goes into the signed bytes, so a proof one funduq coaxes out cannot be relayed to attach at another. The verifying funduq builds the payload with its own key, and a mismatch simply fails the signature. Pass an empty string for a funduq with no identity.
What the link will serve is deliberately not in there. The names used to be, so that a captured proof could not be replayed to serve a different agent; a ticket issued to one key cannot be replayed at all.
3. Open the link, and relay funduq's answer back.
answer = await funduq.attach_provider(
provider,
ticket=ticket, provider_nonce=provider_nonce, proof=proof,
)
attach_provider returns funduq's own signature over both nonces under a
distinct role tag, so neither side's proof can be reflected back as the
other's. Relaying that answer to the provider is the transport's job —
it is the half of the handshake that protects the provider, and it is the
whole point of funduq having a keypair.
A funduq with no identity key configured answers None. It cannot prove
itself, and only a provider that pinned a key treats that as a failure.
4. The provider checks the answer before producing anything.
A connection that exposes confirm_connect(funduq_nonce, provider_nonce, answer)
is handed the answer before the link is recorded open, so a provider that
raises there never appears in the roster and never receives a run. The
provider SDK raises WrongFunduq for a mismatch. Both in-process links
implement the hook, so in-process goes through the identical ceremony
automatically — sharing a process is not a reason to skip identity.
If your transport verifies out-of-band instead, verify before sending anything worth stealing:
from funduq_provider_sdk import verify_signature, funduq_connect_payload
assert verify_signature(funduq_public_key, answer, funduq_connect_payload(ticket, provider_nonce))
There is no way to switch any of this off: a connection that exposes no
sign_connect and supplies no proof is refused.
5. Publish, on the open link.
await funduq.register_agents(link, [{"name": "translator", ...}])
Nothing here is signed, and that is the point of everything above: the key was proved once, when the link opened, and a per-operation signature would only re-prove it. What this asks of you in return is the ordinary thing — an open link stays the party that opened it. If your transport can let someone else speak into an established connection, this is where that becomes a roster it does not own.
An open link that has published nothing serves nothing; not registered
is offline, so the names a link serves are exactly the ones it last
published, and publishing a shorter roster takes the omitted ones off.
Deleting a record is the same shape — funduq.delete_agent(link, name),
on the link that serves it — and it is refused for an agent with a
conversation behind it, which is the one guard a deletion still has.
Reconnecting without killing your replacement¶
funduq.detach_provider(public_key, connection=old_link)
Naming the connection is required, because cleanup that does not name what it is cleaning up takes down the thing that replaced it: funduq holds one connection per role, so a re-attach replaces the old link, and a whole-key detach fired by the old link's teardown would take the live replacement offline — exactly the case a reconnect produces. Scoped to the connection, the cleanup of a replaced link is a no-op. The compare and the withdraw run with no await between them, so a replacement cannot slip in mid-detach.
Evicting a key outright — every agent and offering, whichever
connections serve them — is a different, deliberately louder verb:
funduq.detach_all_for(public_key). Both detach forms, and
detach_llm_provider, are synchronous.
Carrying a run down, and the ack back¶
An offer is one call carrying the run envelope, and the answer is
three-valued: accepted, declined-because-full, or permanently
refused with a reason. A transport that collapses this into one bit
re-creates a bug funduq already had — runs re-offered forever, reading as
queued from every vantage point while only the provider's log knew the
truth. Whatever framing you choose, all three values must survive it.
The answer is a receipt, and it must come from your own state. Whether the run arrived, whether there is room for it, and whether its input is valid are all known the moment it lands; none of them requires asking the agent anything. Answer then — the provider SDK's own runtime does not await at all on this path, and the agent's code is nowhere near it.
This is the one timing funduq depends on. It holds the next utterance of the same conversation until this answer lands, which is how a thread's delivery order survives a transport that guarantees none — and there is no such guarantee to lean on instead: an offer is an independent call with no position in it, so the only thing that can say "this one came first" is that its answer came back first. A link that waits for the agent to start turns that round-trip into the agent's startup time. Nothing wider than the conversation waits: other threads, other agents and other providers hand over meanwhile.
funduq cannot check this across a wire it does not own, so it is recorded as an assumption it rests on rather than a rule it enforces — including what a violation costs, which is bounded to the one conversation.
Everything else about how the answer travels is yours: framing, correlation, backpressure, reconnect policy. funduq asks a question and reads an answer; it has no opinion on the envelope.
Serving the A2A door¶
Core hands back A2A's own messages — AgentCard, Task,
TaskStatusUpdateEvent, TaskArtifactUpdateEvent — and nothing else. It
writes no JSON-RPC: no envelopes, no method names, no error codes. Mount
the package's own dispatcher over a thin handler:
from a2a.server.request_handlers.request_handler import RequestHandler
from a2a.server.routes.jsonrpc_dispatcher import JsonRpcDispatcher
class FunduqRequestHandler(RequestHandler):
async def on_message_send(self, params, context):
return await adapter.send_task(agent, MessageToDict(params.message))
async def on_get_task(self, params, context):
return await adapter.get_task(agent, params.id) # None = not found
...
dispatcher = JsonRpcDispatcher(
request_handler=FunduqRequestHandler(),
enable_v0_3_compat=True, # ← see below
)
get_task and cancel_task return None for a task that is not this
agent's, which is what the handler interface means by not-found; the
dispatcher turns it into the right error for the binding. An id the
caller sent that names nothing at all raises A2A's own
TaskNotFoundError, for the same reason. cancel_task on a task that
has already ended raises TaskNotCancelableError, which is what A2A's
own server does there.
Pass CancelTaskRequest.metadata through. A run on a thread that
bound an authority at birth can only be stopped by one of that thread's
authorities, and the proof — a signature over
funduq-cancel:{run_id}:{timestamp} — rides in that field. A2A's cancel
carries no message, but it does carry request metadata, so nothing is
invented and a standard client has somewhere to put it. Drop the field
and every cancel on a bound thread is refused; forge nothing, because
funduq verifies the signature, not the envelope. A cancel that carries
no authority for a bound run raises funduq's own InvalidCancel —
alongside InvalidResolution and InvalidChain, the family A2A
has no word for at all.
Caller mistakes come back in A2A's words too — an unknown contextId,
one belonging to another agent, a kyok opt-in naming an offering that
is not registered, or a message that will not build a run input all
raise InvalidParamsError, carrying funduq's own message so the caller
still learns which value was wrong. funduq writes no codes: the number
comes from the package's JSON_RPC_ERROR_CODE_MAP.
Two are deliberately left as funduq's, because A2A has no word for either and one that means something else would be worse:
| escapes as | what it means | the answer that fits |
|---|---|---|
AgentNotFound |
the agent is the endpoint, resolved from the route before the adapter runs — an unknown one means the address does not exist | 404 on the route, not a JSON-RPC error inside a 200 |
ThreadQueueFull |
backpressure: the thread's buffer is full and the request was not accepted | 429, and say retry — never accept-then-expire |
enable_v0_3_compat is off by default, and forgetting it drops every v0.3 client
Measured against a2a-sdk 1.1.2: which protocol version a request
speaks rides the A2A-Version HTTP header, and no header means
0.3. With the flag off, message/send answers -32601 and
SendMessage without the header answers -32009. With it on, the
dispatcher accepts the v0.3 names and converts the shapes
("state": "completed", parts with "kind"), which a v0.3 client
parses.
That header is why this cannot live in core: core never sees one. funduq used to hand-write the method table here, and it answered v0.3's names with v1.0's shapes — a v0.3 client rejected the reply outright. Deciding a caller's version is the transport's job because only the transport holds the evidence.
Relaying events¶
Dump typed events with exclude_none=True. A default dump injects
timestamp: null and rawEvent: null into the caller's stream; with
that flag the round trip is byte-identical to the input. Read only the
fields you are deciding on.
Validation is three-way, and a transport should not add a fourth. An
event whose type funduq knows is validated strictly, and a failure ends
the run. An event carrying a type string funduq does not know is
relayed untouched — funduq is a relay, and a provider on a newer AG-UI
must not be cut off by an event type funduq has not heard of. An event
with no type string at all still ends the run, because there is
nothing to relay it as.
So do not filter unknown event types on the way through, and do not
wrap them: see
the design record
for why RawEvent is the wrong shape for this.
Prove it against the vectors¶
contract-vectors.json publishes the seven
signed payload families, the two wire envelopes (delivered-run,
delivered-completion) and the actor-chain form, each with deterministic
signatures under a published test key. funduq's own suites replay them,
and so do both SDKs' — as independent twins that do not import funduq. If
your transport reproduces the vectors byte-for-byte, it is correct by
the same standard funduq holds itself to.