Last validated: Langflow 1.12.x (nightly 1.12.0.dev18)
Issue: #1242 · Scoped by: #1195 → a2a-coverage-scope.md (row T4) ·
Depends on: #1240 · Jira: epic LE-1588, transport LE-1805,
public-endpoint hardening LE-2081
POST /api/v1/a2a/{flow_id}/jsonrpc is the only way a remote agent actually
runs a published Langflow flow. Everything else in the area — the card, the
catalog, the UI's publish switch — exists to lead a caller here. This spec proves
the endpoint honours the two halves of the JSON-RPC contract:
- The round-trip really executes the flow. A
message/sendcarrying a per-run sentinel comes back as a task in a terminalcompletedstate whose artifact contains that sentinel. The published flow is a Chat Input → Chat Output passthrough, so the sentinel is echoed verbatim — the assertion is causal, not a "something came back" smoke check, and it needs no LLM. - Protocol errors are JSON-RPC errors, not HTTP errors. An unknown method,
a malformed envelope and unparseable JSON all return HTTP 200 with an
errorobject carrying the spec's code. A caller that treats HTTP status as the verdict sees success on every one of them, so this is the half most likely to regress unnoticed.
If the round-trip breaks, every A2A consumer gets a task that never completes or
an artifact with the wrong content. If the error envelopes break — a 500 where
-32600 belongs, or an HTTP 4xx where 200 belongs — every spec-compliant
client mis-handles failure, and the public endpoint's error surface is exactly
where LE-2081 (RCE on the public A2A endpoint) lived.
@stable @api @release @a2a
@api— REST/JSON-RPC only, no UI.@release— running a published agent is the feature; nothing ships if this is red.@a2a— area tag (added by #1242).@stable— validated by the team and promoted in #1349: the batch ran 51/51 green (17 tests × 3,--retries=0) on nightly1.12.0.dev18, with no leaked flow and no backend error logged.
Round-trip: a message/send whose single text part is
a2a-e2e-<uuid> returns HTTP 200, body.result.status.state === "completed",
body.result.id a non-empty string, and the serialized result contains that
exact sentinel. A second run with a different sentinel returns a different
result.id, proving the task is per-call rather than cached.
Error envelopes: each of the following returns HTTP 200 with the stated
error.code —
| Request | error.code |
|---|---|
method: "does/notExist" |
-32601 |
envelope missing method |
-32600 |
envelope missing jsonrpc |
-32600 |
jsonrpc: "1.0" |
-32600 |
method: "message/send" with no params |
-32600 |
| body that is not valid JSON | -32700 |
Assertions are on error.code only. The message/data strings are
implementation text — the -32700 case returns a raw Python parser message
(Expecting property name enclosed in double quotes: line 1 column 2 (char 1))
and -32600 sometimes embeds a Pydantic validation dump. Matching those would
break on any upstream dependency bump while the contract held.
LANGFLOW_A2A_ENABLED=true(#1240); enforced byrequireA2aEnabled().- No LLM, no provider key, no external network. The flow is
tests/assets/flows/chat-io-ok-trace-fixture.jsonviacreateRunnableChatFlowViaApi(); Chat Output echoes Chat Input. - Auto-login superuser. The flow's project stays
auth_type=none, so the endpoint is public and nox-api-keyis sent — the authenticated variant is a separate spec (a2a-server-auth-apikey), because it needs the project's auth changed and a real API key.
- A2A-enabled Langflow at
PLAYWRIGHT_BASE_URL. - A run takes seconds, not milliseconds. The passthrough builds a real graph;
budget a request timeout well above the default assertion timeout rather than
polling.
message/sendis synchronous here — it returns the finished task, so notasks/getloop is needed (that is the lifecycle spec's job).
Three tests. Every test creates and publishes its own flow via
createRunnableChatFlowViaApi() + PATCH, and deletes it by id in afterEach.
Test 1 — message/send runs the flow and echoes the sentinel back
requireA2aEnabled(request).- Create the passthrough flow;
PATCH{ flow_type: "agent", a2a_enabled: true }. - Build
sentinel = "a2a-e2e-" + randomUUID(). POST /api/v1/a2a/{flowId}/jsonrpcwith{ jsonrpc: "2.0", id, method: "message/send", params: { message: { role: "user", messageId, parts: [{ kind: "text", text: sentinel }] } } }via thepostA2AJsonRpc()helper.- Assert HTTP
200; assertbody.erroris undefined; assertbody.result.status.state === "completed"; assertbody.result.idis a non-empty string; assert the sentinel appears in the result. - Delete the flow.
Test 2 — each call produces its own task
- Publish a flow as above.
- Send two
message/sendcalls with different sentinels. - Assert both reach
completed, each result contains its own sentinel and not the other's, and the tworesult.idvalues differ. - Delete the flow.
Test 3 — protocol errors come back as JSON-RPC errors over HTTP 200
- Publish a flow as above.
- For each row of the Validation criterion error table, POST that body and
assert HTTP
200and the expectederror.code. The invalid-JSON case sends a raw string body withContent-Type: application/json, bypassing serialization. - In the same test, send one valid
message/sendand assert it still returnsresultwith noerror— a positive control, so an endpoint that answered-32600to everything cannot pass. - Delete the flow.
| # | Test | Observable |
|---|---|---|
| 1 | round-trip | 200, no error, result.status.state === "completed", sentinel present in the result |
| 2 | per-call task | two distinct result.ids; each result carries only its own sentinel |
| 3 | error envelopes | 6 malformed/unknown requests → 200 + the exact error.code; one valid request still returns result |
- Every protocol error came back HTTP 200, including unparseable JSON. There is no HTTP-status signal to assert on at all.
-32600 "Invalid Request"covers four distinct shapes (missingmethod, missingjsonrpc, wrongjsonrpcversion, missingparams), each with a differentdatapayload — one of them a full Pydantic error dump. Hence code-only assertions.- The error event's message object elsewhere in this stack carries
"error": false(seeflow-error-policy.tsinCLAUDE.md); do not infer failure from a truthyerrorfield anywhere in the result — the JSON-RPC envelope's top-levelerrorkey is the only verdict. message/sendis synchronous on this path: the response already carries the terminal state, so there is nothing to poll.tasks/get/tasks/canceland the streaming variant belong to the lifecycle spec, which is a separate issue.- The response's
result.contextIdis server-minted and is reused when passed back on a later call — measured, but that is row T5's contract and is not asserted here.