Server mode: one port, WebSocket relay
September 11, 2026 · View on GitHub
Status: implemented (souk_server/ws_provider.py,
souk_server/ws_kyok.py), gRPC removed. Defines what this gateway
serves and over which transports. Supersedes the inherited HTTP+gRPC
split.
Upstream is the published funduq packages now (funduq 0.0.11,
funduq-provider-sdk[llm] 0.0.9, funduq-contract 0.0.13 — the repo is
hukaichun/funduq), and the signed
payloads on this wire are theirs, pinned at a
named contract revision (currently 23, vendored in
docs/upstream-contract-vectors.json).
The framing — which frames exist, what each carries, the handshake's
shape on a socket — remains this repo's to decide, and this gateway has
no deployments outside this repo, so a wire change is still a hard
cutover selected by the version field rather than a staged migration.
Revisions 8–23 changed nothing about the frame vocabulary. The wire
is still v4 — same frames, same handshake_version, and every signed
payload upstream defines byte-identical since revision 16. What moved is
underneath and beside: the types the frames validate into, which door a
verdict enters core by (17), where a caller's declarations sit in the bag
it sends (18 and 20), what a run is (19), and how a read is authorized
(21, and then 22, which handed the question over entirely).
Revision 22 gave this gateway a rule to own. Core no longer decides
who may read: Funduq.as_reader and the Reader class are gone, and
what is left is Funduq.parties_of(thread_id) — the head, the provider
serving the agent, every key on the thread's runs' chains, or None for
a thread nobody bound. Deriving that set stays core's, because the chains
are verified there and only there; what follows from it is the wire's.
Upstream's argument is worth carrying here, because it is also the reason
this repo did not simply inline the check at each door: core's denial
returned [] or None, which is what "there is nothing there" also
looks like, so a serving layer that wanted a wider rule was overruled
with no signal — and the only lever a door held was which key it
passed, so the one way to widen was to pass a key already on the chain,
which is to impersonate a party. This gateway keeps revision 21's circle
exactly, in one place (souk_server/reads.py), read by all three of its
read doors. See "The proofs: read, cancel, resolve" below.
Revision 21 is the one that changed this gateway's own surface rather
than its plumbing, and it did so by asking a question this document had
been answering with "nobody": who is presenting this request. Core
now refuses an actor chain handed to a door that cannot name its
presenter, so this gateway grew the one thing operational-limits §1 has
called the serving layer's job for three rounds — an authenticating
seat, Funduq-Presenter, described under "The A2A door" and published in
docs/wire-vectors.json. It is also the revision
that let a header go: X-Funduq-View is deleted, because the hook it
fed is gone and one mechanism now answers reads and writes alike. This
repo is down to one invented header, and it is the seat upstream asked
for rather than a bridge over a gap.
The decision
One listener. Everything — callers, providers, KYOK bridges — arrives on a single HTTP port. Outbound-claim stays (souk never connects to anyone; that is the architecture, forced by NAT topology, and it is not up for revision). What changes is the carrier for the two claim-based edges: a persistent WebSocket each, replacing gRPC entirely.
| Who | Surface | Transport | Status |
|---|---|---|---|
| Callers | AG-UI (/agui/*, /threads/*), A2A (/a2a/*), registry (GET /agents, GET /llm-providers), health | HTTP + SSE | exists |
| Callers | MCP (the docent) | streamable HTTP at /mcp, same listener | built; see "MCP: the docent" below |
| Providers, both kinds | admission | POST /tickets | built; the out-of-band half of the handshake — see below |
| Providers | work relay + registration | WS /ws/provider | built |
| LLM providers (KYOK) | completion relay + registration | WS /ws/kyok | built |
| Provider's model client | POST /kyok/v1/chat/completions | HTTP (OpenAI-compatible by definition) | exists, unchanged |
gRPC is removed, not demoted to an option: grpc_server.py,
grpc_gen/, the proto generation step, the grpcio/protobuf
dependencies, port 50051, and the Dockerfile's stub-gen step all went. A
transport with zero users is not an option worth maintaining; the wire
semantics it carried were kept, because those were the hard-won part —
and they have since been restated as upstream's contract vectors, which
is a better record than a proto file ever was.
The signed registration and deletion HTTP routes are removed the same
way, one redesign later: registration is an operation on an open,
authenticated link now (see "Registration on the open link"), so
POST /agents/register, POST /llm-providers/register and the signed
deletion routes are gone, and only the read-only roster GETs remain.
What one port buys: one TLS certificate, one load-balancer rule, no HTTP/2 requirement on proxies (wss is an HTTP/1.1 upgrade), and a browser can be a provider — which is the audience that makes WebSocket the right default rather than a fallback.
Core is untouched throughout. The provider port is core's to state, and
it has since been inverted again — funduq offers a run and the provider
answers, rather than the provider asking for work — so what a transport
carries today is broker.ConnectedProvider: who you are, how much you
will take at once, how to hand you a run, how to ask you to stop one. A
transport is just a carrier for that port, and this is the third carrier
after in-process and gRPC. The KYOK edge swaps the same way because the
LLM-provider link is likewise a transport-free port; only this repo's
serving layer changes.
Shapes, not a machine — and where the orderings live
This repo asked upstream for a sans-io protocol machine
(hukaichun/funduq#213):
frames in, frames and events out, so that the orderings a transport must
respect were code rather than prose in two repositories. It shipped in
funduq-provider-sdk 0.0.5 and was withdrawn at contract revision
11, along with the published frame vocabulary and its codec. Upstream's
reason, from the changelog, is worth quoting rather than paraphrasing:
funduq_provider_sdk.protocolandfunduq_provider_sdk.llm.protocolare gone, machines and all — over a wire the provider-initiated calls are plain request/response, so there is nothing left for a machine to order.
This document is where the consequence lands: almost nothing upstream
enforces the orderings on this wire, so they are stated here and tested
here. Welcome before anything else, a roster that replaces rather than
appends, an answer accepted only on the connection its request was
delivered to — every one of them is this repo's to keep, in the sections
below, in docs/wire-vectors.json, and in the three suites plus the Go
probe that replay it.
"Almost", because one of them went home. The rule that a provider's
events cannot overtake its own verdict was ours to enforce for exactly
one release — a bounded retry around funduq's claim window — and at
contract revision 17 upstream took it back and made it structural, by
routing the verdict through answer_offer so that both roads into core
become one. See "The verdict does not ride a return value" below. The
episode is the best argument in this document for where such rules
belong: the window lived entirely inside core, was invisible in every
frame, and cost a full stack run to find; any transport that ever exists
would otherwise have paid for it separately. What is left here is the
framing — genuinely ours, because we chose it — rather than the
happens-before rules of core's own bookkeeping.
What was adopted instead is the half of #213 that survived, and it is a real improvement:
- Every crossing shape is a pydantic model in
funduq_contract, defined once and imported by both ends:Connect,Offer,Verdict,DeliveredRun,DeliveredCompletion,Registration,Refusal. They arefrozen=True,populate_by_name=Trueand — the part with teeth —extra="forbid", so a misspelt key is a validation error naming the field at the door instead of travelling intact and being dropped in silence by a reader that cannot tell a typo from an omission. The gateway's own restatements of these shapes (a localAgentRegistrationmodel, a claimed-run translation) are deleted rather than kept in step. A2ARequestHandleris upstream's reala2a.server.RequestHandler, and the gateway's hand-rolled equivalent is gone — see "The A2A door".
The envelopes came home
Revision 11 took the frame vocabulary and its codec out of upstream and
left two things behind in contract-vectors.json: byte-pinned samples of
delivered-run and delivered-completion, with a note saying to produce
them with model_dump(by_alias=True). They sat beside six payload
families that a signature is computed over, in a file whose whole purpose
is that being wrong by one colon means a signature does not verify.
Nothing signs an envelope. What those two entries pinned was how a model is spelled on a wire, down to the capitalisation of its field names — which is framing, which is the thing revision 11 had just given away. Filed as funduq#282; contract revision 23 deleted the section, and stated the rule that keeps it deleted: an entry is published there because getting it wrong fails a signature check, asserted now by a test on the file's own top-level shape.
The two frames are in docs/wire-vectors.json's
envelopes key instead, byte-identical, replayed by souk-agent-sdk,
souk-client-sdk and the Go pod-probe exactly as before. What changed is
whose statement they are.
This wire still carries camelCase, and the reason is now the honest
one: this file says so, in the same breath as every other frame. The
aliases remain upstream's and remain fingerprinted — model_validate
accepts either spelling — so a transport whose wire is snake_case is
upstream-compatible too. It simply is not this one.
Upstream declined the available one-line fix — serialize_by_alias=True
on Shape — precisely because it would have settled field naming for
every transport there will ever be. So the flag stays at every call site,
and so does the reason to be careful with it: the dump rules two sections
above are this document's to state because nothing upstream states them.
Our envelopes stay flat, so both ends strip the transport key. A run
frame is {"type": "run", **DeliveredRun}, not
{"type": "run", "run": {…}} — the frame shape predates the models and
did not change with them. Because the models forbid extras, the
transport's own type (and requestId, on a completionRequest) has to
come off the mapping before model_validate, or a perfectly good frame
fails validation on the very field that routed it. Nothing in the models
says so, and it is the kind of detail that costs a debugging round, so it
is written down here.
The two dump rules, which pull opposite ways. Upstream's withdrawn codec used to enforce both; nothing does now, which is exactly why they belong in the spec of record.
| what | how | what breaks otherwise |
|---|---|---|
a frame envelope (DeliveredRun, DeliveredCompletion) | model_dump(by_alias=True), never exclude_none | RunAgentInput has required fields that are legitimately null (state, forwardedProps); stripping them yields a runInput the far side cannot rebuild, and a perfectly good run comes back as a permanent refusal |
| a typed AG-UI event | model_dump(…, exclude_none=True) | timestamp: null and rawEvent: null are injected into the caller's stream |
They live one file apart here — ws_provider.SocketProvider.deliver and
api_agui.encode_event — and each says so where it is written, because
the rule that is right in one place is wrong in the other.
Core's environment is read here now
CoreSettings.from_env was removed at revision 14, and core reads no
environment at all: configuration is an argument, and a deployment that
keeps it in the environment reads it itself. That deployment is this
gateway, so souk_server/config.py:core_settings_from_env() is where the
FUNDUQ_* names live now. The names did not change — compose,
.env.example and every existing deployment keep working — and neither
did any default: only variables actually present are passed, so each
field keeps core's own default and there is no second copy of one here.
An empty string is unset, which is what an operator means by
FUNDUQ_DB_SCHEMA= in a compose file.
Two things this repo now states rather than inherits. The pairs are
written out (("db_schema", "FUNDUQ_DB_SCHEMA", str), …) instead of
derived from the model's field names, because the environment is a
published surface: a field renamed upstream must be a visible edit here
and not a silently renamed variable in everybody's deployment. And the
broker's three waits became CoreSettings fields at the same revision —
unserved_timeout_seconds (45), deliver_timeout_seconds (5),
undelivered_window_seconds (1800) — so they are optional FUNDUQ_*
knobs here too, documented in .env.example. That closes the last live
item of the adopter review this repo filed as
hukaichun/funduq#181.
Addressing: an agent is (provider, name)
Every surface in that table takes both halves, and none takes anything
souk minted. There is no agent_id — core stopped minting one, because a
provider holding identifiers only souk could issue lost its whole
vocabulary whenever the database was replaced, with no way to rebuild it.
/a2a/{provider}/{name}/rpc /a2a/{provider}/{name}/.well-known/agent-card.json
/agui/{provider}/{name} /threads/{provider}/{name}
{provider} is the provider's Ed25519 public key or its 16-hex
fingerprint (funduq_contract.provider_fingerprint, sha256(key)[:16]);
core tells them apart by length. This gateway puts the fingerprint in
URLs and the roster carries both, with provider_key the one to
compare — the fingerprint is derived from it and never authoritative.
The by-name routes are deleted, not deprecated. A display name is not
unique: two identities may both register translator, and that is
allowed. A route taking a bare name therefore had to guess or refuse, and
guessing is how a caller reaches an agent it never meant to reach.
Resolving a name is still ordinary, and still supported — it is
GET /agents, done once by whoever holds the name, after which the pair
is what goes on the wire. Both SDKs do exactly this (SoukClient.resolve,
and the demo providers' sub-agent resolver), and both surface ambiguity to
their caller rather than picking. The difference from the old route is
only where the choice is made: somewhere the asker can answer it.
That is also why an address cannot be written into a config file. The
provider half is the callee's own key fingerprint, which does not exist
until that provider has started once and written its key — so a static
a2a_url for a sibling agent is unwriteable in principle, not merely
inconvenient. Providers resolve lazily, on first delegation, so a
delegation edge does not become a boot order that depends_on cannot
express.
Provider relay: WS /ws/provider
Frames are JSON text messages, camelCase — matching the AG-UI/A2A wire
style, readable in devtools, and free for the browser providers that
justify ws in the first place. The frame vocabulary is published in
docs/wire-vectors.json and asserted equal to the
gateway's dispatch sets in tests, along with the two delivery envelopes;
the signed payloads inside the frames are upstream's, vectored in
docs/upstream-contract-vectors.json.
funduq hands work over; it does not wait to be asked for it. There is
no claim loop on either side. The broker knows which provider serves
which agent, offers each run to it, and the answer comes back as a frame.
The socket carries the offer; funduq_provider_sdk.ProviderRuntime, on
the far side, decides.
Opening a socket: the ticket handshake
Handshake v4: two frames, with the admission step moved off the
socket entirely. The signed payloads are funduq_contract's connect
family, and souk_server/handshake.py re-exports them beside the
version number (object identity asserted in tests, so they cannot drift
into local restatements — the same rule that once dissolved three
packages' private copies of these bytes).
Step zero is POST /tickets, and it is not on the socket. The
provider posts its public key and receives
{"ticket": ..., "funduqPublicKey": ...} — a single-use, ~60-second
ticket naming the key it admits, minted by core's Funduq.issue_ticket.
Upstream keeps issue_ticket off the link's operation set on purpose: a
ticket obtained over the link would mean the link existed before
anything authorised it. Issuing is the admission decision — a key
with no ticket cannot connect at all — which makes this endpoint the
edge-auth plug point: a deployment that gates who may serve gates it
here. It is unauthenticated today, deliberately; this souk is an open
market.
Holding the ticket and funduq's public key, the provider computes its proof before connecting, and the socket exchange collapses to:
provider → hello { version: 4, publicKey, ticket, nonce,
maxConcurrentRuns?, proof } # /ws/kyok: no maxConcurrentRuns
souk → welcome { funduqPublicKey, answer }
proof = identity.sign( provider_connect_payload(funduq_public_key, ticket, nonce) )
answer = funduq.sign( funduq_connect_payload(ticket, nonce) )
The gateway relays the hello's ticket, nonce and proof into
funduq.attach_provider(...) (attach_llm_provider on the KYOK
socket); core is the verifier, exactly as it was for the old
challenge. attach returns answer — funduq's counter-signature under
its own role tag — and the welcome frame relays it. Every handshake
refusal closes with 1008 (policy violation) and a reason string; 1011
stays reserved for server-side failure the client didn't cause.
Why each piece is there:
- The ticket is the freshness, and the verifier chose it. A recorded exchange is worth nothing: the ticket is single-use and destroyed by the handshake that answers it. This is the property v1's challenge-response bought with two extra frames; the ticket buys it out of band, and a leaked ticket is worthless besides — only the named key can sign the answer, and a stranger cannot even burn it (the name is matched before the ticket is destroyed).
- The proof 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.
- The connect payloads carry no names. v2 signed the sorted agent names into the proof so a captured proof could not be replayed to serve a different roster; a ticket issued to one key cannot be replayed at all, so the names left the handshake and moved to where they now belong — registration frames on the open link.
- The role tags (
funduq-connect-provider/funduq-connect-funduq) mean neither signature can be presented as the other.
"souk signs first" changed shape, not substance. The old handshake
had souk sign before the provider produced anything worth stealing, so a
provider could walk away from a souk it did not recognise. In v4 the
provider signs first on the socket — but it learned the funduq key over
TLS at ticket time and bound its proof to that key, so the proof is
worthless to any other funduq; and the answer in the welcome proves
the far side actually holds the key the ticket response named. The SDK
verifies it (confirm_connect, raising WrongFunduq) before treating
the link as open — upstream hands the answer over before the link is
recorded open, so a provider that raises there never appears in the
roster and never receives a run. The mutual-identity property survives;
the frames that carried it do not.
Pinning is still the recommended shape: SoukProvider(souk_public_key=…)
pins one funduq, and the same channel that carries the URL carries the
fingerprint. Unpinned, the provider verifies the answer against the key
POST /tickets returned — enough to notice a broken souk, not enough to
notice one substituted before ticket time — and logs the fingerprint so
the value to pin is in reach. TOFU is deliberately not built, for
the reason it never was: souk's key is provisioned, so a rotation would
jam every provider at once with per-provider pin-clearing as the
recovery; a configured key costs one line and has no such state.
Channel binding is out — decided, not overlooked. It is the standard answer to a relay, and unusable here: a Zscaler-class proxy terminates and re-originates TLS by design, so the two sides never derive the same value and the check fails every time. Enforcing it would not harden the deployment; it would lock out every enterprise running one, which is the deployment this exists for. It was also never the fix — the defect is a stealable credential, and the ticket flow closes that with an intercepting proxy in the path:
| before | after | |
|---|---|---|
| see the traffic | yes | yes — it terminates TLS, that is its job |
| capture the credential, connect later as that provider | yes | no — the ticket is single-use and the proof names its funduq |
| tamper with frames on a live connection | yes | yes |
The bottom row stays open, deliberately: run inputs and events are not
individually signed, an intercepting proxy is in the trust model by
construction (the enterprise installed it and pushed its CA — see
docs/threat-model.md), and signing every frame is a large cost against
a threat the operator chose.
version selects the handshake, and there is no compatibility
branch. A v2/v3 client is refused by name ("this souk speaks wire v4,
the ticket handshake"), not by a bare signature failure — which is what
an attack looks like too, and would send whoever is debugging it
somewhere unhelpful. Dual-shape acceptance was skipped for the same
reason as every previous bump: every provider that exists is in this
repo behind one SDK, plus one Go probe whose whole point is tracking the
current wire.
welcome is queued before attaching completes, and that ordering is
still load-bearing for the frames behind it — everything this link will
ever receive queues after it. One old race is now structurally gone
rather than merely tested for: attach is nameless, so a fresh link
serves nothing and no run frame can precede the first registered,
where under v2 a provider with work waiting could legally see run
arrive as the first post-proof frame. The reconnect tests stay anyway;
orderings that are "structurally impossible" are exactly the ones worth
a regression net.
Registration on the open link
Attach carries no names. What a link serves is what it last published, via frames — the signed registration/deletion payloads (and their HTTP routes) are gone upstream, because the key was proved once, when the link opened, and a per-operation signature would only re-prove it. What that asks of this transport is the ordinary thing: an open link stays the party that opened it — one read loop, one identity, established at the handshake and never rebound.
provider → register {agents: [{name, description?, agentCardExtra?,
metadata?, takesInterjections?}, …],
providerName?} # /ws/provider
provider → register {models: [name, …], metadata?} # /ws/kyok
souk → registered {names: [...]} # echo of what is now live
provider → deleteAgent {name} / deleteModel {name}
souk → deleted {name}
souk → error {message, …} # answered; the socket stays open
The semantics are core's, restated here because they shape the wire:
-
Not registered is offline. An open link that has published nothing serves nothing; runs are only offered after the first
register. -
A roster replaces, it does not append.
registercarries the full roster, and publishing a shorter one takes the omitted names offline. The SDK re-registers its whole roster on every reconnect, which is what makes reconnection self-repairing. -
Delete is refused for an agent with a conversation behind it — the one guard a deletion still has. Core's refusal reaches the provider verbatim in an
errorframe; a registration mistake is a caller error on an authenticated link, not a breach, so it is answered and the socket stays. -
An agent entry is
funduq_contract.Registration(name, description,agentCardExtra,metadata,takesInterjections). TheREGISTRATION_FIELDSlist this used to be compared against was withdrawn at revision 11, and the gateway's own validation model with it: both ends validate into the one model, which isextra="forbid", so a misspelt key is answered with anerrorframe naming the field. Two things the model does not police and this transport does:agentsbeing a non-empty list at all, and the name being URL-shaped ([A-Za-z0-9_-]{1,128}) — core validates none of it, and a name with a slash in it would register happily and then be unaddressable on every road this gateway serves. Skills still reach souk only throughagentCardExtra; everything else in the card is dropped silently by core, exactly as before. -
takesInterjectionsis per agent, and the serving party answers it (revision 12). Core callsconnection.takes_interjections(agent_name)insideregister_agentsand overwrites whatever the incomingRegistrationcarried, so the capability on the agent card is derived from whoever actually serves the agent rather than typed by an author. Over a wire that party is on the far side of the socket, so this frame field is the only thing the gateway can honestly answer from: the SDK derives it from the presence of the runtime's interjection hook, andSocketProvider.takes_interjectionsreturns what the last roster declared (Falsefor a name this link never published — declaring a capability for a name nobody registered would be a guess). The A2A card announces it in the standard slot,capabilities.extensionswith the interjection extension's URI; the card declares understanding, not acceptance, since whether an interjection is taken is still the verdict's call at delivery time.The trap worth naming:
takes_interjectionsis not on theConnectedProviderprotocol, so a connection object that omits it type-checks, attaches, and raisesAttributeErrorat the first registration, three layers from the cause. That is whySocketProviderasserts its own surface at construction (below), and why the SDK's runtime hook being a method rather than a property matters: read as an attribute it is a truthy bound method, which declares every agent interjection-capable and never fails loudly.
Once attached
| direction | frame | carries |
|---|---|---|
| ↑ | {"type": "register", "agents": […], "providerName"?} | the full roster this link serves — see above |
| ↓ | {"type": "registered", "names": […]} | what is now live, sorted |
| ↑ | {"type": "deleteAgent", "name"} | remove one record outright |
| ↓ | {"type": "deleted", "name"} | it is gone |
| ↓ | {"type": "run", **DeliveredRun} | an offer: the frame carries upstream's model — DeliveredRun.model_dump(by_alias=True) and never exclude_none (runId, agentName, runInput, threadId, metadata), rebuilt on the far side with model_validate once the transport's type key is stripped. Since revision 11 core hands deliver the DeliveredRun itself, so there is no translation left on either side. Canonical frame in docs/wire-vectors.json's envelopes key — it was upstream's wire section until contract revision 23 deleted that; see "The envelopes came home" above. Neither end hand-writes the mapping |
| ↑ | {"type": "ack", "runId", "accepted", "reason"?} | whether this provider took it — and the answer is a receipt, produced from the provider's own state without asking the agent anything (upstream holds the next utterance of the same conversation until it lands, so a link that waits for the agent to start turns that round-trip into startup time). A bare accepted: false is how a full one says so — transient, souk re-offers later. reason makes the decline permanent (an input that does not parse): souk fails the run with the provider's words recorded verbatim and stops re-offering. souk invents no reason vocabulary; the string is the provider's own. The three names are funduq_contract.Verdict's (accepted / declined / refused); the v4 frame keeps its boolean-plus-reason spelling and the gateway translates, so the meaning has one definition and the frame stays what every shipped provider sends |
| ↑ | {"type": "event", "runId", "event"} | one AG-UI event; authorized against the run's claim |
| ↑ | {"type": "finish", "runId"} | that run's stream ended |
| ↓ | {"type": "cancel", "runId"} | a request, not an order — outcome decided when the stream ends |
| ↑ | {"type": "query", "queryId", "method", "params"} | a question about the work souk gave this provider |
| ↓ | {"type": "queryResult", "queryId", "result"?, "error"?} | its answer, correlated by queryId |
| ↓ | {"type": "error", "message", "runId"?/"name"?} | server-side rejection of a frame (bad runId, not the holder, a refused registration or deletion) |
Queries: the one thing here that expects an answer
Every other frame is fire-and-forget. query is not, and it is worth
being explicit about why it earns the machinery — a correlation id, a
pending map, a timeout, and a rule for a socket that dies mid-question.
A provider sees exactly what the caller sent for its run, and nothing
more. An AG-UI client resends its whole history every turn by
convention; A2A's message/send carries one message. The same agent,
unchanged, cannot tell a tenth turn from a first — and souk has held the
thread the whole time. thread_messages is the question, and this is how
it crosses a wire.
↑ {"type": "query", "queryId": "9f3c…", "method": "thread_messages",
"params": {"threadId": "thread_…", "limit": 20}}
↓ {"type": "queryResult", "queryId": "9f3c…", "result": [ …messages… ]}
-
limitis applied by souk, not by the caller on return. The parameter exists to keep the response frame bounded; trimming after receiving would bound nothing and put a months-old thread on the wire to do it. -
Two checks stand in front of this read, and both earn their place. The party rule —
souk_server.reads.may_read, revision 21's circle kept where revision 22 left it — is entered as the key this socket proved at the handshake, so a thread bound to a responsibility segment answers only its parties.In front of it stands this gateway's older rule: a provider may only read threads for agents it serves. It stays, because the party rule is the weaker of the two — an unbound thread is readable by whoever holds its id, so the party rule alone would let any connected provider read any unbound thread whose id it once saw. Thread ids are not guessable, but unguessable is not an authorization rule: a provider that served one run knows that thread id permanently, and would otherwise keep reading the conversation after being de-listed, or after the agent moved to another stall. The two compose cleanly because both answer "not yours" and "no such thread" identically — telling them apart would confirm a thread's existence to somebody who may not read it, which is the whole of what either check is for.
-
A malformed query is answered, not dropped. The far side is waiting on that
queryId; silence costs it the full timeout for a mistake souk could see at once. -
A dead socket fails its outstanding queries immediately, rather than leaving them to time out. A question was asked of this connection and nothing will ever answer it. It is not retried on reconnect either: the agent asked mid-run, and whether it still wants the answer is the agent's to decide.
-
What may be asked is deliberately short. This is not a mirror of souk's API: every method admitted is one more frame type every transport must carry. The gateway used to read upstream's
contract.LINK_QUERY_METHODS, which was withdrawn at revision 11 with the rest of the field-list constants; it then asserted its one method was a subset ofFunduqLink.__abstractmethods__, on the reasoning that the link ABC was the surface a second query would grow on. Revision 21 ended that too, and the tripwire is deleted rather than repaired:thread_messagesleft the link ABC entirely, because reading the record is every party's job and does not belong on the provider's private channel. So the method list is simply named inws_provider.QUERY_METHODSnow, with nothing upstream to check it against — an assertion that says the opposite of what it was written to say is worse than no assertion.The
query/queryResultframes stay. Core stopped requiring the verb on the link; it did not stop a provider needing the answer, and the way it says a transport should provide it is exactly this — "over the wire the serving layer exposes the read surface as an endpoint authenticated with the link's key". This repo owns both ends of every wire it defines, so the frame is ours to keep and the shape does not change; what changed is what stands behind it (see the two checks above).
Adding frames like these does not bump version. They are additive:
a provider that never asks is unaffected, and an older gateway answers an
unknown frame type with error. The version selects the handshake,
which is the part that genuinely cannot interoperate across shapes.
Which object is which
FunduqLink is one provider joined to one funduq — both directions, one
object — and the socket client in souk-agent-sdk is one, because over a
wire that is literally true: run frames arrive on the same socket event
frames leave by. Its thread_messages is no longer one of the link's
verbs (revision 21 took it off the ABC); the SDK keeps the method as
its own addition, over the query frame, because an agent still needs
the answer and this wire is where it crosses.
The gateway's SocketProvider is not one, and upstream's own docstring
says so. It sits on souk's side, holds an outbound queue and no runtime,
and only carries work outward. It satisfies souk's ConnectedProvider
protocol structurally, and checks itself against a named
_PROTOCOL_SURFACE at construction — because souk sizes a
capacity bucket from max_concurrent_runs, and a connection that forgets
it attaches perfectly well and then fails inside the broker, three layers
from the cause. That list used to be upstream's
contract.CONNECTED_PROVIDER_ATTRS, withdrawn at revision 11; the lesson
it encoded outlived it, so the surface is named locally — and it is
deliberately wider than the protocol declares, because
takes_interjections is not on ConnectedProvider at all and core calls
it during registration. The check is against the class rather than the
instance: public_key and max_concurrent_runs are properties, so
asking self inside __init__ would run their getters against fields
not yet assigned and report every one of them missing. It deliberately exposes no sign_connect: core would
otherwise mint a ticket and sign on this object's behalf, and this object
cannot sign — the only holder of the key is the real provider on the
far side of the socket, which is the whole point of the handshake.
A declined offer costs the run nothing: it stays queued and is offered again when something changes — a run arriving, a provider registering, one of this provider's runs ending. Not immediately, deliberately: asking again at once is asking a provider that just said no, with nothing about the answer having changed.
The conversation gate moved from claim to finish (revision 11). One
thread has one active run, as before; what changed is when the next
utterance is offered — at the end of the turn rather than the moment the
current run is claimed. Nothing on this wire spells it, and that is the
point: a provider sees only that the next run frame for a thread
arrives after it sent finish for the previous one. The single bypass is
a declared interjection naming the thread's claimed head
(forwardedProps.addressedRunId on AG-UI, the A2A extension's metadata
key); naming anything else is rejected at the door, and a declaration
whose target settles before delivery degrades to an ordinary next turn.
Flow control is maxConcurrentRuns, declared once at hello. souk keeps a
bucket that size and offers nothing while it is empty. No credit frames
and no counting in the transport — the number is a fact about the
provider, and souk sees for itself when a run ends. Leaving it unset
declares unlimited, and upstream takes that at its word: an unlimited
provider that declines is counted misdeclared. Pacing is declared, not
improvised.
The verdict does not ride a return value, and the transport keeps no
per-offer state. deliver hands the offer to the wire and returns
nothing (contract revision 17); the ack frame comes back as an
ordinary inbound frame and the read loop posts it through
Funduq.answer_offer(run_id, answer, provider_key=…), in the handler
that read it. Because everything this connection says about a run enters
by the same door and is judged by the run's one owner in arrival order,
a provider's events queue behind its own verdict and cannot overtake
it. There is no ack timeout here any more, and nothing to time out —
RunBroker.deliver_timeout_seconds (5s, a CoreSettings field since
revision 14) remains souk's own, protecting its single delivery loop.
This is the one ordering that went home. Until revision 17 the
verdict rode deliver's return while events walked in through
report_event, and nothing sequenced the two roads: a provider that
accepted and streamed in the same breath — which an SDK-less one does,
because nothing on this wire tells it to wait — had its opening events
refused by a run souk had just given it, and was marked abandoned for
the privilege. Every SDK-backed provider hid it (the runtime awaits the
agent first), and this repo's suite passed with the hole in it; only the
Go probe running under docker compose was fast enough to fall in. It
was reported as
funduq#249 — where
upstream granted that revision 11's rationale for withdrawing the
protocol machines, that "over a wire the provider-initiated calls are
plain request/response, so there is nothing left for a machine to
order", had missed exactly this happens-before, which lives in no frame
— and fixed by removing the second road rather than pacing it. The
bounded retry this gateway carried in the meantime is deleted with the
window.
Answering late is not a path — it is a guard. accept_late_ack and
the answered_late counter were withdrawn at revision 11: a verdict for
a run souk no longer holds matches nothing and is answered false. souk
has already taken the run back and will simply offer it again, so a
false from answer_offer is normal rather than an error, and the
gateway deliberately drops that answer instead of sending an error
frame — which would teach every provider to log a scare on its own slow
morning.
event/finish refusal means one thing now: no such run. Since
revision 17 report_event and finish_run mean "attributed and
posted", and answer false only for a run funduq does not know;
whether the reporting key holds it is judged by the run's owner, and a
misattributed report is dropped and logged there rather than answered
synchronously. The frame this side sends back therefore no longer
doubles as "not yours", which makes it something a provider can act on.
cancel returns a receipt, not an outcome. It is async and returns
bool since revision 11 — "the ask is on the wire" — because funduq
decides the outcome from what the run's stream does next. Returning
None logs a warning on every cancel, and would be a lie in the other
direction if it tried to mean more.
Liveness is not a heartbeat. online is registered on a live
link: souk holds a connection serving that agent or it does not, so a
link's first register is what brings its agents online and a dropped
socket takes them offline in the same instant. There is no window to age
out of and no sweep behind the boolean. Because a link publishes one
roster, its agents go online and offline together, by construction.
last_seen_at is still recorded and still worth reading, but it answers
a different question: how long since anybody was here, which online no
longer says anything about. WebSocket ping/pong keeps intermediaries from
reaping idle sockets and is not the liveness signal.
A dropped socket now ends every run it was holding — a real behavior
change, recorded honestly. Under this repo's earlier wire, "a dropped
socket ends nothing" was a property probed and kept from the gRPC days:
events are addressed by runId, so a provider could reconnect and
report the rest, including how runs ended. funduq 0.0.4 decides
differently, and as the published core it wins: liveness is a fact funduq
holds, not a deduction from a timestamp, and a provider that stops
serving while still holding a claimed run has taken work and never ended
it — the run fails at once as provider_left_holding_it, and the same
observation increments the provider's abandoned quality counter
(enough of those and the provider is withdrawn from service). Only
queued runs survive a drop: nothing had been promised about them, so
they wait for the reconnect and are offered again. What reconnect-and-
finish used to cover — a blip mid-run — is now a failed run and a mark
against the provider, and an agent author should know that a provider's
socket is part of its run's fate. This repo's position — that a
grace window would let a reconnect finish honest work without reviving
the two-clocks problem upstream removed — is argued in
hukaichun/funduq#214;
until that lands, this document describes the published behavior, not
the preferred one.
At-least-once delivery (an ack per event) remains expressible and
remains unbuilt — the ack frame above answers an offer, not an event.
The reserved 5 lesson travels as words here: a retired frame type's
name is never reused.
KYOK relay: WS /ws/kyok
The socket an LLM provider connects out on — the party upstream's
KYOK redesign made first-class (upstream docs/integration-contract.md;
the per-mechanism pages it used to live in are gone).
The agent-provider-facing POST /kyok/v1/chat/completions endpoint is
untouched — an OpenAI-compatible URL is the whole point of that side.
This section previously described a different wire: an anonymous
"bridge" that rendezvoused with souk over a caller-minted sessionId,
with a paragraph of apology for everything a routing key that is secretly
a credential cannot do (who may open a session, whether two sockets are
the same party, what a token may safely carry). Upstream's answer was not
a better session id but an identity: the party answering completions
registers Ed25519 offerings like any provider and attaches like any
provider, and every one of those questions became answerable. The
session_routing_key fix this document used to describe — souk hashing
the session id before putting it in the token — is gone along with the
session id itself: a KYOK token now carries {runId, providerKey, agentName, exp} and nothing caller-side at all.
The arrival is the agent provider's, rule for rule: the same
POST /tickets, the same two-frame handshake (same handshake.py
payloads, same version — the hello merely omits maxConcurrentRuns,
which a completion relay has no use for), and then a register frame on
the open link, {"models": [...], "metadata"?} where the provider
socket says agents. The signed POST /llm-providers/register road is
gone upstream — sign_llm_registration no longer exists — and the same
roster-replace and delete semantics apply (deleteModel, refused by
core while a live run is bound to the offering). Names are deliberately
not exclusive across identities: two providers both offering gpt4 is
normal, and an offering is (provider_key, name) exactly as an agent
is. A socket that drops takes its offerings offline in the same instant,
and a later attach with the same identity takes the offering over.
A caller opts a run in with
metadata: {"kyok": {"llmProvider": {"providerKey", "name"}, "context"}}
— no extra connection, no SDK required. souk binds the run at start,
strips context from everything it persists, and resolves
binding → attached link per completion call; not attached is a fast 503,
the same shape as an offline agent (souk-client-sdk's KyokBridge is
the reference LLM provider and builds that metadata via
run_metadata()).
| direction | frame | carries |
|---|---|---|
| ↑ | {"type": "register", "models": […], "metadata"?} | the offering roster this link serves |
| ↓ | {"type": "registered", "names": […]} | what is now live |
| ↑ | {"type": "deleteModel", "name"} | remove one offering's record |
| ↓ | {"type": "deleted", "name"} | it is gone |
| ↓ | {"type": "completionRequest", "requestId", **DeliveredCompletion} | upstream's declared envelope — DeliveredCompletion.model_dump(by_alias=True) (runId, providerKey, agentName, body, llmName, context, actorChain — the chain rides the envelope since contract revision 7): the run, the proven calling agent, which of this provider's models was addressed, the caller's opaque context, the actor chain, and the OpenAI-shaped body. Since revision 11 core hands complete this model itself — its own CompletionRequest and the SDK's from_request translation are both gone — and body is validated as OpenAI's own chat-completion request shape at the door, model among its required fields, with extension keys passed through verbatim: a body that is not a chat-completion request is a 400 from KyokAdapter.complete and never reaches this socket. requestId is this transport's, so it comes off the mapping before model_validate. Canonical frame in docs/upstream-contract-vectors.json |
| ↑ | {"type": "chunk", "requestId", "data"} | one OpenAI chat.completion.chunk; validated on souk's side, an invalid one fails the completion |
| ↑ | {"type": "done", "requestId"} | end of that response |
| ↑ | {"type": "error", "requestId", "message", "refusal"?} | provider-side failure or refusal, so the waiting completion fails fast instead of timing out — policy (throttling, billing, refusing a chain it does not recognise) is the LLM provider's, and this frame is how it says no. refusal is a structured payload relayed to the calling agent intact (in-stream as the {"error": ...} value, or as error on the non-streaming 502 body) — the envelope souk guarantees; the vocabulary inside is the two roles' own |
| ↓ | {"type": "error", "requestId"?, "message"} | server-side rejection of a frame (unknown type, a refused registration, or a requestId not in flight on this connection) — answered, not a teardown, same as the provider socket |
requestId multiplexing means one socket serves concurrent completions.
A gap of CHUNK_GAP_TIMEOUT_SECONDS (120s) between frames of one answer
fails that completion — not a per-completion deadline, a
provider-is-gone detector for the case the socket has not noticed.
Connection semantics, carried over or sharpened:
- An answer is accepted only on the connection its request was
delivered to. This survived the redesign because it was the security
fix worth keeping, and it now holds against a stronger intruder than
the old socket ever faced: a second connection with the same identity,
attached for the same offering — every credential check passes — is
still refused an in-flight requestId it was not delivered.
tests/test_ws_kyok.pydrives exactly that. Membership in the connection's in-flight table, not anything a frame carries, is what authorizes an answer; a requestId is a multiplexing key within the connection that received it, never a bearer capability on an open route. - A socket dropping mid-answer fails its in-flight completions immediately — a truncated answer must never pass as a complete one. Requests delivered but unanswered when a socket dies are not re-queued.
- Two sockets, one identity, one offering: the later attach takes over the offering for future completions (core's relay maps each offering to one live link). In-flight answers stay bound to their own socket, per the rule above.
Provider and KYOK stay two endpoints, not one multiplexed socket: one carries runs for agents, the other completions for model offerings — different roster, different frames, and one identity may hold both at once. Merging them buys one route at the cost of role-dispatch on every frame.
What souk still deliberately does not do: validate the LLM output a
provider returns (a provider must treat KYOK output as untrusted input
regardless), or impose a spend ceiling. The ceiling belongs to the LLM
provider, which is now an identified party with the material to enforce
one — the run id, the proven calling agent, the caller's context and the
actor chain arrive on every completionRequest frame
(hukaichun/funduq#26).
A run is a run, and a task is a lineage
Revision 19 collapsed two models of a run that funduq had been stacking
on each other — AG-UI's, where every RunAgentInput is a run, and A2A's
task, which pauses in input-required and continues under the same id.
There is one model now, AG-UI's, and four consequences land on this
gateway's doors.
input-requiredis not a run status. A run that finishes asking — an interrupt outcome, a tool call nobody answered — iscompleted, as AG-UI says. Nothing is reopened.- The answer is a new run, with
parentRunIdnaming the one that asked. An A2A task is the lineage those links form: task id = the root run's id, task state = the tail run's, and every event of a later run reaches the A2A client labelled with the task's id. - Whether a thread is waiting is read from its latest run's events
(
funduq.pause.open_asks), never from a status name and never from run metadata. Both doors here do exactly that —api_agui._with_outstanding_asksoffrepo.latest_run_for_thread,api_a2a._annotate_asksofffunduq.lineage(task.id)[-1]— and the reason is not tidiness: the status this used to be read from was deleted, and a status name may spell a pause differently again. - The record lost four columns.
RunRecordhas nometadata, nohead_key, noprotocol, noinput_json;runsisRunAgentInputone column per field plus funduq's own state. The head of a run's chain is computed —doors.head_key_of(run)— rather than stored, which is the honest version: it was always derivable from the chain.
Reading the tail is the part that a first-turn test passes by
accident. A resolution proof is signed over the task/root id plus
the open ask ids, and on a first pause the root run is the waiting run,
so root-vs-tail cannot be told apart. Read get_run(task.id) instead of
the lineage's last entry and a second pause is answered with the first
one's ask ids, which no proof will ever match. The suite therefore has a
two-pause test (test_a_resolution_answering_a_second_pause_signs_the_task_id)
whose entire job is to make the two ids differ.
One key for what funduq adds, and it is request-level
Revisions 18 and 20 between them fixed a namespace and a level, and both are things a caller has to get right or its declaration is silently not one.
The key (18). Everything funduq writes into somebody else's bag now
lives under one funduq key, unconditionally its own, never relayed from
a caller:
| surface | what sits under funduq |
|---|---|
AG-UI forwardedProps | kyok, actorChain, addressedRunId |
A2A task / status-update metadata | agui_event, agui_events, interrupts, cancelRequested (was the flat funduq/cancelRequested) |
The gateway's own A2A key moved with them: outstanding ask ids are at
metadata.funduq.outstandingAsks, not the flat funduq/… it used.
That spelling was copied from core's old flat key, and leaving it behind
after core moved would make a gateway key look like a caller's — the
exact confusion revision 18 set out to end, since a caller who typed
forwardedProps.addressedRunId itself used to be indistinguishable from
funduq having verified one.
Merge into metadata.funduq; never assign over it. A protobuf
Struct field is replaced wholesale, so task.metadata.update({"funduq": {...ours}}) silently drops interrupts and cancelRequested — the two
keys a caller most needs beside the ask ids. _annotate_asks reads
what core wrote (funduq_metadata_of(task)) and merges; the failure it
avoids is invisible in every test that does not assert on core's keys.
The level (20). The caller's bag — its declarations to funduq
(actorChain, kyok, resolution, the interjection target) — is
request-level on both doors: forwardedProps on RunAgentInput, the
request's metadata on A2A's SendMessageRequest. funduq reads
nothing from a message's own metadata; Message.metadata is carried to
the agent on that message and stored with it, and that is all it is for.
An A2A caller that puts its chain on the message declares nothing and is
told nothing — the request simply proceeds as an anonymous one.
The A2A door
Core no longer speaks JSON-RPC at all. funduq.protocols.a2a.A2AAdapter
hands back A2A's own messages — AgentCard, Task, the update events —
and writes no envelopes, no method names, no error codes. The gateway
mounts a2a-sdk's JsonRpcDispatcher over a RequestHandler
(souk_server/api_a2a.py), which puts the whole protocol vocabulary in
the package where it is versioned instead of hand-written here.
The handler between them is upstream's now (funduq#225).
A2ARequestHandler is a real a2a.server.RequestHandler bound to one
agent: it owns MessageToDict, validate_request_params, the
configuration.return_immediately / configuration.history_length
mapping — so a Task now comes back carrying history when the caller asked
for it — and which of A2A's operations funduq offers at all. This file
had a hand-rolled copy of that, and a copy of a mapping is a copy that
drifts: the configuration fields upstream deliberately does and does not
honour were simply absent from ours. SoukA2ARequestHandler subclasses
it for exactly two things, which are the two a transport owns — the
errors that leave A2A's vocabulary as HTTP statuses, and the paused-run
ask ids A2A has no field for. Everything else is inherited.
enable_v0_3_compat=True, and forgetting it drops every v0.3 client. Which protocol version a request speaks rides theA2A-VersionHTTP header, and no header means 0.3 — with the flag on, the dispatcher accepts the v0.3 method names and converts the shapes. That header is exactly why this cannot live in core: only the transport ever sees one, and the version decision belongs to the party holding the evidence.- Three errors deliberately leave A2A's vocabulary, because A2A has
no word for any of them and one that means something else would be
worse:
AgentNotFoundis a 404 on the route — the agent is the endpoint, resolved from the path before the dispatcher runs, never a JSON-RPC error inside a 200 —ThreadQueueFullis a 429 withRetry-After(backpressure, the request was not accepted, and accept-then-expire is the lie this refuses to tell), andPresenterRequiredis a 401, in upstream's own words "map it to authentication required, not bad request". All three ride out as StarletteHTTPExceptions, the one type the dispatcher re-raises instead of converting to a JSON-RPC internal error inside a 200. - Cancel metadata passes through whole. 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 rides in
CancelTaskRequest.metadata(metadata.cancel, withmetadata.resolutionbeside it). Drop the field and every cancel on a bound thread is refused; forge nothing — funduq verifies the signature, not the envelope. There is nometadata.delegation: the session delegation certificate was removed at revision 15 and nothing here signs, sends or relays one — see "The proofs" below. - The seat is built — see "The authenticating seat" below. This
paragraph used to say
presenter_key=Noneand record the exposure as chosen rather than missed; revision 21 turned the choice into a refusal, so the seat is a plug point no longer. - A task is a lineage here, not a run. The ask ids this door
surfaces come off the lineage's tail (
funduq.lineage(task.id)[-1]), while the proof answering them is signed over the task id — the root. See "A run is a run, and a task is a lineage" above. - Events are dumped
exclude_none=True, and unknown event types are relayed untouched. A default dump injectstimestamp: nullinto the caller's stream; and funduq is a relay, so a provider on a newer AG-UI must not be cut off by an event type nobody here has heard of.
Interjection rides the standard extension point — the A2A extension's
metadata key, on the request's metadata since revision 20, reaching
the agent as forwardedProps.funduq.addressedRunId — and funduq handles
it; the gateway just relays. The agent card announces which agents
understand one in capabilities.extensions, from what the serving link
declared at registration. Only this door sets it: the AG-UI door
never does, which is why souk-client-sdk's addressed_run_id argument
was removed rather than kept as a promise no door delivers.
The authenticating seat
Core's caller doors are not independently safe, and never claimed to be
(upstream's operational-limits §1). Verifying an actor chain proves the
head's key signed hop zero; it proves nothing about possession, because
the chain is not a secret — every serving provider receives it verbatim.
For three revisions the answer was "a deployment puts an authenticating
seat in front of the doors", and this gateway wrote down that it had not.
Revision 21 stopped accepting that: a chain handed to a door that cannot
name its presenter is refused, by name, with PresenterRequired.
The scope is deliberately small, and this is the sentence to read
first: a caller without a chain is unaffected and still anonymous. No
header, presenter_key=None, no change, exactly as before. So this is
not "the gateway grew auth"; it is "a party that already holds an
Ed25519 key may prove it at the door". Today that party is always a
provider delegating to another agent — it has a key because it needed one
to attach, and souk-agent-sdk's a2a_client is the only thing in the
tree that sends an actorChain at all.
The proof rides in a header, because A2A's read requests carry no caller data whatsoever and there is nowhere in the protocol for it to travel:
Funduq-Presenter: {"publicKey":"…","timestamp":…,"signature":"…"}
compact JSON, the same three fields as every other proof in this system, signed over
funduq-server-presenter:{public_key}:{timestamp}:{sha256hex(body)}
No X- prefix: RFC 6648 deprecated that in 2012, and the two
X-Souk-Kyok-* headers here predate this repo owning the question
(renaming those is its own round). Three properties, each earning its
place:
- the body hash binds the proof to this request. Be precise about
what that buys, because upstream pushed back on an earlier and looser
claim here (funduq#269): a captured chain is already worthless to
whoever cannot sign the next hop —
verify_chainrefuses a hop following a dispatch hop unless the party that dispatch named signed it — so replay of the chain is not what this closes. What it closes is narrower and real: the chain is not a secret, so an attacker holding a captured request holds the header too, and without the binding could swap that header onto a different body inside the freshness window — "B asked for a translation" replayed as B asking for something else. This gateway wants request binding for that reason, which is its own, not because chains require it. Modelled onkyok_call_payload, the one payload upstream already uses to authenticate an HTTP call with a body — and it is whyview_payloadcould not be reused: that binds onerun_id, and a request that opens a run has no run id yet; - the timestamp bounds capture-and-replay of the same request to the
60-second window the cancel family already uses
(
funduq.identity.is_timestamp_fresh, so there is one window here, not a second one); - the public key inside the payload means the proof names the key it claims, so it cannot be presented as an answer to a different question.
The domain tag is ours on purpose. funduq-server-presenter:, not
funduq-presenter:. The funduq-* payload namespace is upstream's, and
this payload has no upstream definition — funduq_contract publishes six
payload builders and none authenticates a write. Squatting the namespace
would mint a name that looks canonical and is not, which is the exact
failure mode the contract vectors exist to prevent. So it is implemented
under this repo's own tag, published in docs/wire-vectors.json beside
the frames, and proposed upstream
(funduq#269) — because
every transport that authenticates callers needs this payload, and if
each invents its own, a provider SDK cannot delegate across two funduq
deployments. When upstream ships a presenter_payload, this family is
deleted rather than kept beside it.
A bad header is None, never an error. Absent, unparseable, stale
and forged all read the same way in souk_server/presenter.py, and the
refusal that matters belongs to core: a chain with no presenter is
PresenterRequired (401), a chain whose last hop is not this key is
InvalidChain. One refusal in one place beats two that can disagree —
and a chainless caller with a broken header keeps working, which is
right, because nothing it sent depended on the proof.
The hook is per-request, closed over the body — read once on the
route, before the dispatcher parses anything, so the signature covers the
very bytes the dispatcher goes on to read and the two can never disagree.
On the AG-UI door the same function is called directly with
request.body() and the key handed to AGUIAdapter.run(presenter_key=…).
One header, not two. X-Funduq-View is deleted along with the
view_metadata_of hook it fed: revision 21 made presenter_key_of serve
reads and writes alike, and revision 22 left that unchanged — what moved
was who judges the key, not who establishes it. For a write the answer
is the key the chain's last hop must match, checked in core; for a read
it is simply who is looking, checked here.
funduq_contract.view_payload survives upstream as a payload a transport
may have a reader sign to establish a key for one read; nothing here
signs it, and souk-agent-sdk's view_headers() is gone.
Which funduq error becomes which status
One mapping, registered once for the whole app
(souk_server/deps.install_error_handlers), because which status a
failure deserves is a property of the failure and not of the route that
hit it. The rows that matter for this revision are the singular acts:
InvalidCancel and InvalidResolution are plain ValueErrors upstream,
so without a row each one reaches a caller as a 500 — a server fault
for a caller mistake, saying nothing about what to send instead.
| error | status | why that one |
|---|---|---|
InvalidCancel, InvalidResolution | 401 | the act was refused for want of a valid proof from one of the run's authorities. There is no InvalidView row any more: verify_view and InvalidView left core at revision 21, and an unauthorized read is now absence, which raises nothing |
PresenterRequired | 401 | a chain arrived at a door that could not say who presented it. Its own row rather than folded into InvalidChain, so the message and the log say which of the two it is: an unverifiable chain and an unauthenticated presenter are different mistakes with different fixes |
ThreadMembershipRequired | 403 | writing to a thread bound to a responsibility segment while being neither its head nor its serving provider. Not 401 — the caller identified itself perfectly well, its chain verified; it simply is not a member of this conversation. A bare Exception upstream, not even a ValueError, so it is the likeliest of these to have surfaced as a 500 |
InvalidChain | 401 | a tampered actor chain, refused at the door (funduq_contract.InvalidChain, which replaced core's InvalidActorChain) |
| a KYOK body that is not a chat-completion request | 400 | model is required, and the body is validated as OpenAI's own request shape before any socket is touched |
ThreadQueueFull | 429 | backpressure; the A2A door maps it itself before the JSON-RPC dispatcher can swallow it |
The proofs: read, cancel, resolve
Two singular acts on a chain-bound run, and one read that is no longer an act at all.
A read takes a key, not a signed act (revision 21). The view proof is
gone: GetTask, SubscribeToTask and ListTasks take the key the
transport authenticated, under one rule — a thread nobody bound is
readable by whoever holds its id; a bound thread by its parties (the
head, the provider serving its agent, every key on its runs' chains); to
anyone else it does not exist. The read circle is still wider than the
act circle, and it is still stated once.
Where that once is, is what revision 22 changed — and only that.
The whole inventory of who decides what, across every door this gateway
serves, is docs/authorization.md; this section is
the read row of it.
Core answers Funduq.parties_of(thread_id) and stops; the rule reading
that answer is souk_server/reads.py:may_read, and the three read
operations above are its only callers on the A2A door (the provider
socket's thread_messages query is the fourth). Cancel is not among
them: it is an act, its own proof governs it, and the Task it returns
is the snapshot the act was made against.
Two things about that move are worth writing down rather than rediscovering:
parties_ofsaysNonefor two different situations — a thread nobody bound, and a thread that does not exist. Revision 21'sReadertold them apart, admitting the first and denying the second. Reading the ambiguity the convenient way turns every id that names nothing into a readable thread, quietly, in a suite that stays green;may_readasksget_threadfirst for exactly this, andtests/test_reads.pyfails without it.- An empty page is how
ListTaskssays no. Holding thecontextIdis what makes a thread's tasks addressable (A2A §3.1.4, and upstream's remaining half of the rule); being a party is what sees them. The refusal is shaped like a thread with no tasks, for the same reasonGetTaskanswers absence.
Absence is still the answer to an unauthorized read, and that has not
softened: a bound run read by a stranger is "not found", because
existence is part of what is guarded. It fails quietly by design — the
read looks like a run that is not there — and this is exactly why the
transport must never raise on a bad Funduq-Presenter: a 400 would tell
a caller holding a broken proof that there was something behind the id
worth fixing it for.
A resolve proof signs the ask, not the clock (revision 16), and since
revision 19 it signs the task — the lineage's root. The signed bytes
are funduq-resolve:{root_run_id}:{sha256 hex of the outstanding ask ids, sorted and NUL-joined}, canonicalized inside
funduq_contract.resolve_payload and nowhere else, and the wire proof
is {publicKey, signature} — no timestamp, and the 60-second freshness
window does not apply to resolve at all. Instance binding replaces the
clock: a later pause has new ids, so the proof never verifies against any
ask but the one it was signed for. Cancel and the presenter header keep
the timestamp family and the window; resolve was the one act where replay
changed what happened, and it is now the one act that cannot be replayed.
The root-versus-tail split is the trap here: the ids come from the tail run, the id signed over is the root's, and on a first pause the two are the same run. See "A run is a run, and a task is a lineage" above.
So a paused run must say what it is waiting on — the one genuinely
new capability this gateway grew for revision 16, rather than an edit. A
caller that cannot enumerate the ask ids cannot build a proof at all, and
the pause is unanswerable. Core has them in the run's events
(funduq.pause.open_asks — revision 19 took away the run metadata this
used to be read off, and the input-required status it used to be found
by) and neither protocol has a field for them, which makes surfacing them
this seat's job:
| door | where the ids appear |
|---|---|
| A2A | metadata.funduq.outstandingAsks on the Task, merged into whatever core wrote under funduq, on every Task this door hands back. The ids are the lineage tail's; the proof answering them is signed over the task id, which is the root's |
| AG-UI / threads | waiting_run: {run_id, outstanding_asks} on GET /threads/{id} |
waiting_run, not active_run: a run that finished asking is
completed and therefore is not the thread's active run — there is
nothing in flight to hang the ids on. The question is about the thread's
latest run and its events, and the key is absent when nothing is
outstanding, because an empty list would read as "a pause with no asks",
which is a pause nobody could ever resolve.
Both sides are read off the run's events rather than off a status name,
so they answer the question actually asked — is anything outstanding —
and both are sorted, matching the canonical order the payload hashes in,
so a signer has one fewer thing to get wrong. (souk-client-sdk also tracks the
ids from the stream as it goes — announced tool calls not answered, plus
the interrupts the RUN_FINISHED outcome names — and exposes them as
last_outstanding_asks, for the caller that never left the stream.)
Delegation is deleted (revision 15). delegation_payload, the
funduq-delegate tag, sign_delegation, metadata.delegation and
forwardedProps.delegation are all gone, along with
SESSION_TOKEN_TTL_SECONDS; a proof now counts for exactly the key that
signed it. Upstream's reason is one this document agrees with from the
other side of the boundary: the certificate was the one piece of core's
identity machinery that manufactured authority ("B counts as A until
T") rather than recording a fact and demanding proof from the key it
names — an unscoped grant is policy, and policy belongs at the
authenticating seat, which holds the keys and decides which one signs.
That seat is this gateway, and as of revision 21 it is occupied rather
than reserved. Nothing here ever issued a certificate, so nothing here
had to be migrated; what changes is that a deployment wanting delegation
builds it behind Funduq-Presenter — where it knows which key signed —
not by minting a certificate core would honour.
Health and lifecycle
Liveness and readiness stay two endpoints (/healthz touches nothing;
/readyz maps Health.ready to a status code). Ready is core's
conjunction of three facts — database answers ∧ schema_current ∧
dispatching — and background_running is gone from Health because
the health sweeps it reported no longer exist: upstream removed the
paused-run deadline and the sweep loop with it, so a pause now waits
indefinitely, across restarts. It costs a row, not a slot; the parties
that hold the lever (the asking provider's Interrupt.expires_at, the
caller that owes the answer) are the ones funduq's clock could only have
overruled.
A pause costs even less than it used to. Revision 19 deleted the
input-required status: a run that finishes asking — an interrupt
outcome, a stream that ends on RUN_FINISHED with unanswered tool calls
— is completed, as AG-UI says, and nothing is held open to be reopened.
The answer arrives as a new run naming it with parentRunId. So "is
this thread waiting" is a question about the latest run's events, not
about a status, and a restart has no half-open run to reconcile.
Funduq.start() returns the ids of runs it had to fail as orphaned by a
previous process; the gateway logs them (souk_server/server.py) so a
restart that ended work says which work it ended.
MCP: the docent
MCP is served on the same HTTP listener, as an adapter in this repo, not in core: the official SDK drags transport dependencies core is forbidden to have, and MCP has no in-process consumer — its only rung is the wire, so the "protocol translation is core" rule does not bind it. Written as two layers (pure mapping over souk's types + SDK binding) so the mapping can be promoted to core if a second consumer ever appears.
Scope: discovery, not invocation. Talking to an agent is A2A's
job — souk already serves cards and JSON-RPC for exactly that, and
wrapping start_run in an MCP tool would build a second, lossier
invocation path beside a standard one (an earlier draft of this section
did exactly that, and it was scoped out on review). What MCP adds is
the thing A2A assumes you already did: knowing what is in this souk.
MCP hands out the map; A2A does the walking.
Built as souk_server/mcp_docent.py, mounted at /mcp on the same
listener (tests/test_mcp_docent.py; probed end to end with a real MCP
client over a real socket). The audience is a docent — the guide who
walks a visitor through the market — not an operator, which is what
fixes the surface below at "who is here, what do they do, where do I go".
-
Tools (read-only,
read_only_hintdeclared):browse_souk(the market grouped by stall),search_agents(query)(over name, description, skills and tags),describe_agent(name, provider=""),describe_stall(provider_key). Tools rather than resources alone because most MCP clients wield tools far more readily.browse_souktakes a requiredonly_onlinerather than no arguments at all, and that is a workaround, not a design: a live model called the zero-argument version with{}""— invalid JSON — retried identically, and killed the run, while every tool here that takes a parameter was called cleanly. Optional was not enough; the model still chose to send nothing, and sending nothing is what it does badly. -
Resources.
souk://providers(stall-shaped) andsouk://agents(flat, each record still carrying its provider), plus thesouk://agent/{provider}/{name}template. -
Every answer carries directions and a provider key. The
a2a_endpointis the pair route,/a2a/{fingerprint}/{name}/rpc, because that is the only kind of address there is: a display name is not unique across providers, so a direction built from one leads somewhere only by luck. The provider'spublic_keyrides on every agent record beside the fingerprint, because that is what makes an answer placeable — souk-directory groups by stall, and the AI-town layout derives a stall's map coordinate by hashing that key. The fingerprint is derived from the key and is never the thing to compare. -
Ambiguity is handed back, not guessed. A duplicate display name returns the candidates, each with its own provider and address, rather than picking one. There is no route left that could pick one, which is the point: the refusal moved to where the asker can answer it.
-
onlinenever travels alone. Each record pairs it withlast_seenin words ("40s ago", "3d ago"), because the boolean cannot separate "stepped away" from "gone for a week" and that is the difference a visitor deciding whether to wait is asking about. -
Notifications. Not built. If built, necessarily paired with a poll: a change hook fires for registrations and de-listings, but a boolean derived from a live mapping has transitions no hook names — a directory that advertises live updates off registration events alone would miss the ones its users care most about. Not load-bearing either way.
-
Reads with no rule in front of them, and that is an open decision.
browse_soukand friends go throughFunduq's plain roster methods, and so doGET /threads/{id}andGET /threads/{id}/treeon the AG-UI door. For the docent the question barely arises — the roster is public by construction and it serves nothing thread-shaped — but the two thread reads are thread-shaped, and the party rule (an unbound thread is readable by whoever holds its id; a bound one only by its parties) is not applied to them. Since revision 22 there is no ambiguity left about whose omission this is: the rule is this repo's, written once insouk_server/reads.py, and these two doors do not call it. Passing them the key the presenter header proves is the obvious next round; it is listed here as not done, so that nobody reads the seat above as covering more than it does. -
Not exposed: invocation (A2A's job), registration/identity (provider business), KYOK (bridge business), threads/runs (run observation is a different feature with a different audience — add it later if wanted, deliberately absent now), admin (deployment policy — the managed-gateway example's job).
Core serves all of it from list_agents alone
(hukaichun/funduq#31, now
closed: typed query models landed, enumeration was withdrawn as
unneeded). Search filters that roster in Python rather than querying —
a market's worth of stalls is not a log, and if a deployment ever
outgrows it, that is when a core query earns its place.
The docent is also a stall.
providers/pydantic-ai-agent/config.docent.yaml runs it as an ordinary
provider — its own key, a row in the roster, runs served over
/ws/provider — reaching the market through /mcp as a real MCP
client rather than through GET /agents. Two things that buys: every
frontend gets a guide instead of each one building its own, and the
surface above acquires a consumer, so a question /mcp cannot answer
shows up as a guide that cannot answer it, in a running process rather
than in review. (souk_tools.py — the same capability as plain
function tools — named this exact moment as when to prefer a real MCP
server, and stays off in that config so the model has one way to ask,
not two.) It runs unthrottled on purpose: backpressure is a feature at a
working stall and a bad front door at the gate.
The one question the docent cannot answer: "are they busy right
now?" Capacity is per-stall in souk's model (maxConcurrentRuns is a
provider's budget across everything it hosts), and the roster carries
nothing about it — so "you'll have to wait, they're serving someone",
one of the few genuinely market-shaped answers a guide could give, is
unavailable. Note where the data actually is before reaching upstream
for it: this gateway knows each connected provider's declared budget (it
arrives in the hello frame) and the broker knows what is in flight, so
the honest version is per-process serving state, not a core projection —
and it would read as authoritative while being blind to providers
connected to another replica.
Serving state stays out of core's database
Today the gateway persists nothing. Its only database access is
deps.get_session, which borrows core's session and hands it to a route;
every table in the deployment belongs to funduq, whose alembic chain
ships inside the wheel — python -m funduq.migrate is the whole
migration story, there is no alembic.ini anywhere in this repo, and the
one-shot souk-migrate compose service runs exactly that command,
reading FUNDUQ_DATABASE_URL / FUNDUQ_DB_SCHEMA from its environment.
Tests migrate through the same door (funduq.migrate.migrate(url)).
That is not an accident to preserve by luck — it is the state this rule
protects.
The revision-19 upgrade is destructive, by design. Alembic revision
a1f4c9d27e3breshapedrunsaroundRunAgentInput— droppinginput_json,metadata,head_key,protocoland theinput-requiredstatus — and upstream chose not to migrate rows: it deletes every row inruns,run_eventsandthread_messages. Agents, providers and threads survive; conversation history does not. Nothing is published from these packages that would make that a data-loss event upstream cares about, and a restart already voids held runs — but a deployment with a database it wants to keep must know this beforedocker compose up --buildrunssouk-migratefor it.
When serving does need persistence, it is isolated from core's, and the isolation is structural rather than a naming convention. Whatever it turns out to be — edge-auth records, rate limits, admin state, an MCP event store for resumable streams — it does not get a table in core's schema, and obviously never gets a revision into the wheel's chain: the rule is not about a directory, it is that no serving table lives in core's schema.
Three reasons, in the order they bite:
- A shared migration chain merges the two projects. The chain is upstream's, versioned with core and shipped in its wheel. A gateway table added to core's schema outside that chain makes the schema a thing no single migration owns; added through it would need a fork of the package.
- It would break core's own readiness answer.
Funduq.healthcompares the database's revision against the one this build expects (schema_current). A chain carrying gateway revisions would move past what core expects, and core would report a database it is perfectly able to serve as not ready. - The DDL/DML split is already load-bearing here.
souk-migrateexists so DDL runs with credentials the server itself never holds (README). Serving tables mean a second migrate step with the same split, not a merged one.
Follow upstream's mechanism rather than inventing one (see
funduq.db_schema and the wheel's alembic/env.py): a schema namespace
read from the environment (FUNDUQ_DB_SCHEMA), quoted in exactly one
place, ignored on SQLite (which has no schema namespace at all), with a
dependency-free module holding the constants so both the app and its
env.py can import them without dragging in required settings. The
serving version is the same shape under its own names — a
SOUK_SERVER_DB_SCHEMA, an alembic/ in this repo, its own
expected-revision check.
Whether the two live in one database under separate schemas, or in two databases entirely, is a deployment choice and both must keep working — which sets the real test, and it is not the schema name:
No code path may put core state and serving state in one transaction.
A shared session or a single begin() spanning both makes them one
database in practice however they are namespaced, and forecloses the
split deployment silently. Serving persistence therefore gets its own
engine and sessionmaker, never core's session.
Known limits, recorded rather than papered over
Three things this gateway does not do, each because working around it would be worse than the limit.
- A v0.3 A2A caller cannot receive the 401 — nor the 429. a2a-sdk's
V03JsonRpcAdapterconverts every exception,HTTPExceptionincluded, into a JSON-RPCInternalErrorinside a 200, so a status can only escape on the native v1.0 path. This is the same limitation the 429 has had since it was added, and the tests say so in as many words by sending both in the v1.0 vocabulary. Escaping it would mean this gateway reaching inside a2a-sdk's compat adapter, which is the one place in this system whose whole value is being the package's business and not ours. historyLength: 0— fixed upstream, and worth keeping the story. Revision 19 said the field's presence was read and 0 meant no history; the releasedhistory_ofstill endedhistory[-limit:] if limit else history, so 0 fell through the falsy branch and returned everything. It was reported rather than patched here (funduq#268) because a gateway-side special case would have made this door disagree with the same adapter used in-process, and two answers to one question is worse than one wrong answer in one place. Fixed infunduq0.0.9, which this repo now pins. The correction this repo offered was itself wrong and is the more useful half of the lesson:if limit is not Nonefixes nothing, because-0 == 0andhistory[-0:]ishistory[0:]. The two cases have to be split before the slice. A one-line fix proposed without running it, in a repo whose first rule is that reading produces confident wrong answers.- Two thread reads still apply no party rule —
GET /threads/{id},GET /threads/{id}/treeand the MCP docent go through the unchecked facade rather thansouk_server.reads.may_read, which the A2A door and the provider socket both call. See "MCP: the docent" above; it is an open decision, not a finished one, anddocs/authorization.mdis where it sits in the larger picture — one of four doors whose real question belongs to a layer this repo has no way to express yet.
Where examples live
Split by what an example teaches, not by where it happens to run — and since upstream became a set of PyPI packages, everything network-shaped lives here, full stop:
| example | teaches | lives | why |
|---|---|---|---|
agent-template, providers/* | writing a provider against souk-agent-sdk | this repo | the SDK they consume is this repo's; upstream ships libraries, not examples of serving them |
the demo compose profile (gateway + docent + three stalls, one command) | what the whole system looks like running | this repo, docker-compose.yml | only this repo has a gateway to demo against |
providers/pod-probe-agent (Go, no SDK) | the frame protocol in this document, directly | this repo | the frames are authored here, so their conformance probe belongs here — it replays docs/wire-vectors.json and the vendored upstream vectors, and it is the living proof that the wire is implementable from the documents alone |
managed-gateway embedding (create_app wrapped in edge auth + an admin router over the Funduq facade) | how a deployment adds management without this repo shipping policy | this repo, examples/ (unbuilt) | the embedding surface (create_app, app.state.souk) is this repo's contract |
What moved out, and where upstream went
The gRPC removal list (listener, stubs, grpcio/protobuf, port 50051,
the stub-gen Docker layer) is history now — see the repo log if the
details matter.
The larger move since: upstream stopped being a submodule at all.
funduq core arrives as PyPI packages (funduq,
funduq-provider-sdk[llm], funduq-contract), the AgentSouk/
directory is gone, and the boundary is unchanged in spirit — upstream is
the domain and the contract, published as wheels; this repo is every
socket, both SDKs (souk-agent-sdk/, souk-client-sdk/), the reference
providers and the directory UI, because a wire client is network code
and upstream keeps none: this repo owns both ends of every wire it
defines. What used to be "pinned by commit" is now pinned by version
bounds in each pyproject.toml, and what used to be a path into the
submodule (vectors, scripts, docs) now has an in-repo home:
docs/upstream-contract-vectors.json (vendored at contract revision 21,
regenerated by re-vendoring when the pin moves) and
scripts/gen_dev_tls_cert.py.
Upstream's own documents thinned out with the machines.
docs/writing-a-transport.md and docs/link-protocol-machine.md were
deleted at revision 11 — the second described the machine that was
withdrawn, and the first described a wire upstream no longer publishes.
What is left to read there is docs/provider-link.md (the settled link
design), docs/integration-contract.md, docs/operational-limits.md and
docs/contract-changelog.md, which is the file to open first when a pin
moves: it says what an implementation has to change, not merely what
was committed. Everything transport-shaped that those two pages used to
carry is in this document now, which is the honest arrangement — this
repo writes the frames.