The integration contract: what each party speaks¶
funduq is a relay. It carries runs to agents, completions to LLM providers, and answers back to callers — and it does not intervene in what any of them says: it never decides on a provider's behalf, never interprets a payload that belongs to two other parties, and never records an outcome it has not observed. Every other promise on this page is a consequence of that one.
The contract splits by role, because the promises differ. Callers get standards, untouched. Providers speak standard shapes, and funduq opens the doors for them — with plumbing that is funduq's own, mandatory, and published so nobody has to read funduq's source to implement it.
| role | you speak | funduq provides | funduq-invented parts |
|---|---|---|---|
| caller (human UI) | AG-UI, any standard client | the AG-UI endpoint, threads, history | all opt-in |
| caller (agent-to-agent) | A2A, any standard client | the A2A endpoint, task lineage | all opt-in |
| agent provider | AG-UI shapes: run input in, event stream out | AG-UI and A2A facades, both opened by funduq | mandatory, published as data |
| LLM provider | OpenAI chat-completion shapes: requests in, chunks out | the OpenAI-compatible endpoint agents call | mandatory, published as data |
Callers: standards, and nothing else required¶
The user–agent seam is AG-UI; the agent–agent seam is A2A. A standard client of either — unmodified, no SDK of funduq's, no extra field — works. That is a hard rule with a test behind it, and it has teeth in both directions: when funduq seems to need a new field or endpoint, the first question is whether the protocol already has one, and the answer has changed designs before.
Everything funduq adds beyond the two standards is opt-in: binding a
run to a KYOK offering, attaching an actor chain, any metadata mechanism
funduq invents. Opting out costs nothing — the run behaves as plain
AG-UI/A2A — and opting in is always the caller's explicit act, never an
inference funduq makes. The largest opt-in is the responsibility chain:
a thread whose first run carries an actor chain binds the chain's head
at birth — thereafter only the head or the serving provider may write
to it, and a paused ask on a chained run is answered only with a
signature from those keys (metadata.resolution, the
funduq-resolve:{run_id}:{timestamp} payload), and stopping one of
its runs takes the same authority (metadata.cancel, the
funduq-cancel:{run_id}:{timestamp} payload — A2A's CancelTaskRequest
carries metadata even though it carries no message, so a standard
client has the slot). A session delegation certificate under
metadata.delegation lets an ephemeral session key act for a durable
one. A thread opened without a chain keeps the open
behavior on this page forever — a later chained writer cannot lock it.
The mechanics are in
Responsibility chains.
One small carve-out keeps the record honest: the metadata keys funduq
itself writes into a run's record — interrupts, pendingToolCalls,
failureReason, and funduq, held in reserve — are stripped from
caller-supplied metadata at the doors (props.RESERVED_METADATA_KEYS,
stripped in one place because every door funnels through
doors.verify_caller). A caller cannot plant a fake failure reason
wearing funduq's handwriting.
Everything else is relayed verbatim, and that is a promise, not an
implementation detail. Metadata under any other key, and the free-form
contents of forwardedProps, reach the serving agent exactly as sent:
funduq neither reads, rewrites, summarizes, nor drops them. It is what
makes the slot usable for mechanisms funduq does not provide — the
worked example is a voucher, since funduq cannot know that a key
belongs to a particular employee and a deployment's own SSO can (see
responsibility chains). Anything
built on this passthrough is building on the contract.
What funduq adds to forwardedProps it adds beside the caller's own
values, never over them: a KYOK grant when the run opted in, the actor
chain, a relayed delegation certificate, and addressedRunId when the
caller declared an interjection. The reserved list above is the whole of
what is ever taken away; if that list grows, it is a contract change and
belongs in the changelog.
Agent providers: speak AG-UI shapes, funduq opens the doors¶
An agent provider hosts nothing and opens no port. It connects out to
funduq — from a laptop, behind NAT, inside a private subnet — and its whole
protocol obligation is a shape: accept a run input that is an AG-UI
RunAgentInput, produce AG-UI events back. Both of funduq's caller-facing
doors are opened by funduq on the provider's behalf: the AG-UI endpoint,
and the A2A endpoint — an agent becomes A2A-callable without its author
writing a line of A2A, because funduq translates every A2A call into the
same AG-UI-shaped run input before dispatch. One shape in, two protocols
served.
Around that shape sits plumbing no external standard defines, so funduq defines it: an Ed25519 identity, signed registration, a challenge-answer proof when a link opens, a three-valued answer to an offered run. These are not opt-in — they are how a provider exists at all — and the promise that replaces opt-in is threefold:
- minimal — funduq invents only where no standard exists, and the invented surface stays as small as the job allows;
- published as data — every payload, model and byte a provider must
produce or validate is exported by the provider SDK and pinned in
contract-vectors.json, replayable in any language; an implementation never needs funduq's source; - guarded — tests fail funduq's own CI when an invented surface goes unpublished, so the contract cannot silently grow a private corner.
LLM providers: serve OpenAI shapes, funduq exposes the endpoint¶
The same pattern, one seam over. An LLM provider (the party holding a real key — see Keep your own key) also connects out and also promises only a shape: receive a completion request, stream back OpenAI chat-completion chunks. The OpenAI-compatible endpoint that agents call is funduq's to expose; the provider behind it is resolved per call. Policy — serving, refusing, pricing, whose budget a run spends — is entirely the provider's; funduq relays a structured refusal as data and never reads it.
The plumbing is the same machinery agent providers use (identity is identity; one keypair may be both), under the same threefold promise.
What a standard A2A client observes¶
"A standard client works unmodified" is a promise about what funduq requires, not a claim that every A2A concept has a funduq meaning. These are the identifier rules and the current gaps, stated so nobody has to discover them from behavior.
Identifiers are funduq's to mint. A task id is funduq's run_id; a
contextId is funduq's thread_id. Neither is caller-choosable. Omitting
contextId on the first call is the correct pattern — funduq generates
one and returns it, and the caller passes it back to continue the
thread. An unknown contextId is refused rather than created, because
accepting arbitrary caller-chosen ids would let any party claim a thread
it did not originate. A contextId belonging to a different agent is
refused for the same reason.
referenceTaskIds is lineage, not continuity. The first reference
resolves to its thread and becomes the new thread's parent, which is how
a delegated call hangs off the thread that spawned it. It does not
continue that conversation — for that, pass the contextId. It grants
nothing, either: a run spends against the KYOK opt-in its own caller
submitted, and citing a funded task confers no funding.
Message.taskId references an existing task. It resolves to that
task's thread, and an unknown one is a JSON-RPC -32001. Note it is read
from the message, not from params; a params-level taskId is
ignored and the call starts a fresh thread.
A resumed run keeps its task id. A pause does not end a task and resuming does not mint a successor, so a stored task id stays valid across the pause.
Current gaps, stated plainly¶
These are not design positions. They are what the code does today, and a client author needs them.
- Addressing a paused task over A2A rides
taskId, notelicitationId. A message whosetaskIdnames the thread'sinput-requiredtask resumes it with whatever the message says — funduq never checks that it answers the question; a redirection or an overrule rides the same road, and the provider judges it from the thread's shape. Who may do so is gated by nothing more than knowing the id, the same capability-by-identifier trust every thread reference carries today (a recorded contradiction, not a position). When A2A v1.1'selicitationIdlands, that is the marker this interim rule yields to. - A message sent while a run is active becomes its own task, delivered alongside. It is a new task on the thread — never merged into the active run and never dropped — and funduq offers it to the provider in arrival order without waiting for the active turn to end: funduq does not pace a provider's conversation, so whether the new turn runs at once, waits, or is folded into the turn in flight is the agent's own decision. (The gate-retirement record explains why funduq once made that decision itself, and stopped.)
- Asking to join a turn in flight is a declared extension, never an
inference. Under the interjection extension
(
https://github.com/hukaichun/funduq/ext/interjection/v1), a caller puts the target task's id in the message'smetadataunder<uri>/addressedRunId; funduq relays it to the agent asforwardedProps.addressedRunIdand holds no opinion about the target's state — the agent judges from its own loop, and an ask that lands after the target ended degrades to an ordinary next turn. This is intent, distinct from AG-UI'sparentRunId(plain continuation, relayed untouched): ataskIdnaming a running task declares nothing, because v1.0 defines no meaning for it and funduq will not guess. Yields to whatever mid-task carrier A2A ships. - The thread's pending buffer is bounded (
thread_queue_limit, default 8). At the limit a new message is refused loudly —ThreadQueueFull, meaning NOT accepted, retry after the thread drains — never accepted-then-expired. Answering a paused task viataskIdis exempt: the reply is how the buffer drains. - funduq mints every thread id, on both doors — the id in funduq's reply
is the one to continue with. An unseen AG-UI
threadIdgets a new thread under funduq's own id (carried on every returned event); the id you sent is deliberately not adopted, so a client that keeps resending its own invented id gets a fresh thread each call. A2A's unknowncontextIdis a plainThreadNotFound, per its spec's server-assigned ids. The asymmetry is each protocol's own grammar; the shared rule and its reasoning — funduq is a relay between two owners, and a caller's naming rights have no caller identity to scope them to yet — are in the design record. - An offline agent looks like a failed task, not an error. The run
is recorded
failedwithagent_offline, and the task comes backFAILEDwith no message part. - Core writes no JSON-RPC at all — not the envelopes, not the method
names, not the codes. What it does write is A2A's own error types, so
a transport needs no translation table of funduq's: a task id naming
nothing raises
TaskNotFoundError, a task that is not this agent's comes back asNone(what A2A's request-handler interface means by not-found), and an unknowncontextId, one belonging to another agent, akyokopt-in naming an unregistered offering, or a message that will not build a run input all raiseInvalidParamsErrorcarrying funduq's own message. Two stay funduq's on purpose, because A2A has no word for either:AgentNotFound(the agent is the endpoint, so an unknown one is a routing answer) andThreadQueueFull(backpressure, which is the gateway's 429). An invalid actor chain escapes as a Python exception. Which code or HTTP status a caller sees is the gateway's choice, and it has to be: the protocol version a caller speaks rides anA2A-Versionheader, so the gateway is the only party holding the evidence for that decision (see writing a transport). - Non-lifecycle AG-UI events ride status updates under a funduq metadata key. A standard client ignores them, which means tool-call events are not visible over A2A.
Where the inventions live¶
What the plumbing actually is — the six signed payload families, the link-open challenge, actor chains and what they do and do not prove — is Identity is an Ed25519 keypair and Actor chain. How to carry all of it over a wire of your own is Writing a transport. This page is the contract; those are the mechanisms it obliges funduq to publish.