Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3.0 KiB
a2a-n8n-adapter
An A2A facade in front of an n8n workflow. One image, one instance per agent, configured entirely by environment.
The repo's notes proposed serving this from the gateway instead — a static Agent
Card and a route to the webhook, no new component. That does not reach. A2A is
not request/response: a caller gets a Task with an id, a context and a state
that can be input-required or failed, and none of that can be synthesised
from a webhook's single reply by a proxy. Templating the whole lifecycle in
gateway configuration is an adapter, written in the wrong language.
So: the alternative those same notes anticipated, written once for both agents.
It owns the protocol and nothing else
It holds no Keycloak client and no credentials. It forwards the end user's token
to the workflow in X-Subject-Token — a header rather than the body, so a
workflow that logs its input does not log the token with it — and the workflow
exchanges it, because the component that can mint tokens should be the one that
uses them.
The workflow answers {"answer": "...", "escalate": false}. Anything else is
returned as raw text rather than guessed at: a workflow that has drifted from
the contract should look wrong, not be quietly reinterpreted.
escalate: true fails the task rather than completing it. An autonomous agent
that meets a rule needing approval must hand it back, never decide, and a
completed task is the wrong shape for "a human has to look at this".
Configuration
ADAPTER_AGENT_NAME |
supply |
ADAPTER_AGENT_DESCRIPTION |
what the card says it is |
ADAPTER_SKILL_ID |
check_availability |
ADAPTER_SKILL_NAME, _DESCRIPTION, _EXAMPLES |
the card's one skill; examples are newline-separated |
ADAPTER_WEBHOOK_URL |
the workflow's production webhook |
ADAPTER_PUBLIC_URL |
what callers dial — the Service DNS name, not the bind address |
ADAPTER_CA_BUNDLE |
the lab CA, so TLS is verified rather than disabled |
One skill per instance, deliberately. A second one is a card change, which is the moment to ask whether the agent has quietly become a data proxy.
Four outcomes, and only one of them completes
HTTP 200 from n8n does not mean the workflow answered. It returns 200 with an empty body when a node throws before the Respond node runs — a refused token exchange is the common way in. The adapter used to read that as a completed task carrying "(the workflow returned nothing)", which told the caller the task had succeeded: the one thing certainly false.
Outcome |
A2A task state |
|---|---|
ANSWERED — the contract, escalate: false |
completed |
ESCALATED — the contract, escalate: true |
failed |
SILENT — 200 with an empty body |
failed |
OFF_CONTRACT — a body that is not {"answer", "escalate"} |
failed |
Off contract is returned raw so a workflow that has drifted is visible — but
never as agreement. Escalation is failed for the older reason: an autonomous
agent meeting a rule that needs approval must fail cleanly, never decide.