Skip to content

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:

  1. minimal — funduq invents only where no standard exists, and the invented surface stays as small as the job allows;
  2. 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;
  3. 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, not elicitationId. A message whose taskId names 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). A taskId naming 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 with contextId alone. When A2A v1.1's elicitationId lands, 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's metadata — the bag — under <uri>/addressedRunId; funduq relays it to the agent as forwardedProps.funduq.addressedRunId and 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's parentRunId (plain continuation, relayed untouched): a taskId naming 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 via taskId is 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 threadId gets 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 unknown contextId is a plain ThreadNotFound, 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 failed with agent_offline, and the task comes back FAILED with 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 as None (what A2A's request-handler interface means by not-found), and an unknown contextId, one belonging to another agent, a kyok opt-in naming an unregistered offering, or a message that will not build a run input all raise InvalidParamsError carrying funduq's own message; a message carrying a part the card does not accept (anything but text/plain) raises ContentTypeNotSupportedError rather than losing the part on the way to the agent; the push-notification operations raise PushNotificationNotSupportedError, the word §3.3.4 names for a card without that capability. A chain presented with no authenticated presenter raises funduq's own PresenterRequired — 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) and ThreadQueueFull (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 an A2A-Version header, so the gateway is the only party holding the evidence for that decision.
  • How a transport establishes presenter_key is its own business, and the contract publishes no payload for it on purpose. A session, mTLS, or a signature over view_payload for 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.
  • ListTasks is scoped by contextId. The id a caller holds is what makes a thread's tasks visible to it; without a contextId nothing is visible and the page is empty (nextPageToken is 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; artifacts are omitted unless includeArtifacts asks (§3.1.4).
  • One stream per task. A2A leaves serving several concurrent streams a MAY (§3.5.2); funduq serves one, so a SubscribeToTask on a task whose stream is already open is refused with UnsupportedOperationError rather 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.