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. The host is the fourth role and the one nobody speaks to: it holds the library and owns every socket, and what it owes the other three is its own page.
| 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 |
| host (whoever deploys this) | Python — you hold the library, and every socket in front of it | Funduq, the doors, the record; no transport |
mandatory, and split: enforced or on your honour |
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 an ask left open on a chained run is answered only with a
signature from those keys (resolution in the caller's bag, signed over
the task id — the asking run's lineage root — and its open ask ids via the
funduq-resolve payload; the ask ids are new for every pause, so the proof
is good for exactly that ask and no other), 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 thread opened without a chain keeps the open
behavior on this page forever — a later chained writer cannot lock it.
The caller's bag. Everything a caller says to funduq — actorChain,
the kyok opt-in, resolution, an interjection target — rides in one
place, and that place is request-level on both doors: forwardedProps
on AG-UI (the protocol's own field for a caller's free-form bag) and the
request's metadata on A2A (SendMessageRequest.metadata), which the A2A
door translates to forwardedProps one-to-one. Levels correspond: an A2A
Message.metadata is message-level, so it becomes the AG-UI message's own
metadata, carried to the agent on that message and stored with it —
funduq reads nothing from it. There is no metadata field on
RunAgentInput; funduq adds none. The bag reaches the agent as sent, with
funduq's one key added (below).
The mechanics are in
Responsibility chains.
One small carve-out keeps the record honest: everything funduq itself
writes into a run's record sits under one metadata key, funduq — the
pause it observed (interrupts, pendingToolCalls), the reason it
failed a run (failureReason), who answered an ask or asked for a cancel
(answeredBy, cancelRequestedBy) — and that one key is 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; the same word outside that key is the
caller's own and is kept.
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 under the same one key,
forwardedProps.funduq: a KYOK grant when the run opted in, the actor
chain, and addressedRunId when the caller declared an interjection.
That key is funduq's unconditionally — present when funduq has something
to say, absent when it does not, and never the caller's value — so an
agent reading it knows funduq put it there. Every other key in
forwardedProps is the caller's, untouched. The same rule holds on the
A2A side: what funduq writes into a task's or status update's metadata
(agui_event, agui_events, interrupts, cancelRequested) sits under
metadata.funduq. One reserved key, on every surface, is the whole of
what is ever taken away; if that 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 the bytes a
signature covers are pinned in
contract-vectors.json, replayable in any language; an implementation never needs funduq's source. How those models are spelled on a wire is the transport's, so no envelope is published; - 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 task is a lineage of runs, and keeps its id across every answer.
Every input is a run (AG-UI's model). A run that finishes asking is
completed; the answer is the next run, its parentRunId naming the one
that asked. An A2A task is the lineage those links form: the task id is
the root run's id, the task's state is the tail run's — INPUT_REQUIRED
while the tail has asks open — and every event of a later run reaches the
client labelled with the task's id. GetTask reads the whole lineage.
Current gaps, stated plainly¶
These are not design positions. They are what the code does today, and a client author needs them.
- Answering a waiting task over A2A rides
taskId, notelicitationId. A message whosetaskIdnames a task waiting for input answers it with whatever the message says — a new run on the lineage; 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 on an unbound thread, the same capability-by-identifier trust every thread reference carries today (a recorded contradiction, not a position). AtaskIdnaming a task that has ended, or one still working, is refused (UnsupportedOperationError, A2A §3.1.1): the one follow-up the spec defines is the answer, and funduq no longer invents a meaning for the rest — continue withcontextIdalone. 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 request'smetadata— the bag — under<uri>/addressedRunId; funduq relays it to the agent asforwardedProps.funduq.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 waiting 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; a message carrying a part the card does not accept (anything buttext/plain) raisesContentTypeNotSupportedErrorrather than losing the part on the way to the agent; the push-notification operations raisePushNotificationNotSupportedError, the word §3.3.4 names for a card without that capability. A chain presented with no authenticated presenter raises funduq's ownPresenterRequired— nothing the caller sent is wrong, the transport has not said who is at the door — which a gateway maps to "authentication required", not 400. 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. - How a transport establishes
presenter_keyis its own business, and the contract publishes no payload for it on purpose. A session, mTLS, or a signature overview_payloadfor one read all satisfy it, and so does whatever a deployment already runs. What makes standardising it unnecessary is that the presenter check is not a freshness check: it refuses a chain whose last hop the door did not authenticate, so replaying a captured chain means authenticating as the key that signed that hop — which is being that party. A delegating agent therefore needs nothing beyond the hop it signs, and may sign it hours into a run, because no clock is involved.operational-limits.md§1 has the reasoning and the probe. - 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.
ListTasksis scoped bycontextId. The id a caller holds is what makes a thread's tasks visible to it; without acontextIdnothing is visible and the page is empty (nextPageTokenis always present,""when there is no more). On a bound thread the reader — the key the transport authenticated — must be a party, or the page is empty likewise. Tasks come newest status first, cursor-paged;artifactsare omitted unlessincludeArtifactsasks (§3.1.4).- One stream per task. A2A leaves serving several concurrent streams
a MAY (§3.5.2); funduq serves one, so a
SubscribeToTaskon a task whose stream is already open is refused withUnsupportedOperationErrorrather than handed half the events. When that stream ends, a live task can be subscribed to again.
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 a provider and funduq talk over a wire of your own is The provider link. This page is the contract; those are the mechanisms it obliges funduq to publish.