Repository navigation
GET /ai/agents is SDK-addressable but unenveloped, and the SDK compensates — making it conformant without a coordinated change silently returns an empty agent list #4053
Description
Activity
This is now enforced rather than just written down — #4056 extends
scripts/check-route-envelope.mjsto the dispatcher domains and declaresai.tsas a ratchet pointing here:✓ Dispatcher domains — 16 audited: 11 helper-only, 5 with declared hand-built responses (1 ratcheted) ⚠ ratchet #4053: packages/runtime/src/domains/ai.ts — one passthrough of the AI service result; one is the unenveloped `{ agents: [] }` fallback for GET /ai/agents — an SDK-addressable route whose conversion must land with cloud and the SDK together or `ai.agents.list()` silently returns []The count is pinned at the current 2 hand-built responses, so the guard fails the moment someone touches them — which puts this issue in front of exactly the person who needs it, at the moment they need it, instead of relying on them finding it first.
That matters more here than for the usual ratchet. The failure mode this issue describes is silent and looks identical to the legitimate "no AI service configured" state, so there is no downstream signal that would catch a partial conversion — not a failing test, not an error log, not a visible difference in the console. The guard is the only thing standing between a well-intentioned envelope sweep and an empty agent list in production.
The conversion still needs all three steps in one batch (cloud
buildAgentRoutes, the framework fallback, the SDK read). When that lands, drop theratchetand lowerhandBuiltto 1 — the remaining hand-built response inai.tsis the AI-service passthrough, which is legitimately not the platform's envelope to own.
Generated by Claude Code
- added a commit that references this issue
on Jul 30, 2026 Correction — I named the wrong exposure path above, and the error points the wrong way.
The issue says the risk lives in the SDK's compensation:
const body = await this.unwrapResponse<AiAgentsResponse>(res); return body?.agents ?? [];
I checked, and
client.ai.agents.list()has zero callers. Not in objectui, not in cloud — the only cloud hits are the route-ledger declaration itself (ai-route-ledger.ts:196) and its conformance test. That SDK method is dead code today.That matters because of which way the mistake cuts. Anyone following the issue as written would look at the SDK, find nothing calls it, reasonably conclude "low risk", and close the tab — skipping the path that actually breaks.
The real consumer chain
objectui does not go through the SDK. It has its own
fetch:GET /ai/agents → packages/plugin-chatbot/src/useAgents.ts raw fetch → packages/app-shell/src/hooks/useAiSurface.ts:72 → enabled = agents.length > 0 ← the entire AI surface gateand it parsed exactly two shapes —
{ agents }or a bare array — with no envelope branch.The consequence is bigger than I wrote
I described this as
ai.agents.list()returning an empty list. It is not a list method going quiet; it is the whole AI UI disappearing for every user — FAB, top-bar link, designer "Ask AI" — becauseuseAiSurfaceEnabledtreats an empty catalog as "AI is not available to this caller".And
useAiSurface's own header explains why that gate is deliberate rather than incidental: the route is access-filtered per caller, making it the only signal that is both edition- and user-aware (ADR-0068). A seat-less user is supposed to get an empty catalog and a hidden surface. Which is precisely why the failure is unfindable:what the user sees seat-less user (correct) AI surface hidden CE deployment, no service-ai(correct)AI surface hidden parse missed the envelope (bug) AI surface hidden No error, no 403, no failed request, no log. There is no downstream signal at all.
Status
The consumer half is done — objectui#2992 teaches
useAgentsall four shapes (bare array,{ agents },{ success, data: [...] },{ success, data: { agents } }), detecting the envelope the wayunwrapResponsedoes so the two readers agree. It changes nothing against any server shipping today, and it removes the lockstep requirement: the server side can now convert on its own schedule.What remains is the producer conversion, and it is smaller than the original "three repos in one batch" framing suggested — the catalog is a fixed, first-party pair (
ask/build) with no third-party agent definitions planned, so there is nobody else's shape to coordinate with:- cloud
buildAgentRoutes— emit{ success: true, data } - framework
runtime/src/domains/ai.ts:47— same shape for the degraded fallback, so the two surfaces stay in agreement - framework SDK —
ai.agents.listhas no callers, so correct it or delete it outright (and update the ledger'sdisposition: 'sdk'if deleted)
Still open, still ratcheted in
check-route-envelope.mjs(#4056) — but the sharp edge is off it.
Generated by Claude Code
- cloud
The conversion is complete on both producers. Recording the end state, since the two comments above describe a hazard that no longer exists and a scope that has since shrunk twice.
the issue's list where it landed cloud buildAgentRoutesemits{ success: true, data }cloud#929 framework runtime/src/domains/ai.tsfallback, same shape#4124 framework SDK — "correct it or delete it outright" no change needed, see below AiAgentsResponseSchemadescribesdata, not the whole body#4238 Plus objectui#2992 on the consumer side, and objectui#3080 correcting its record.
The SDK row is the one worth being explicit about
"No diff" is indistinguishable from "nobody looked", and this issue was filed precisely because that distinction is invisible here.
unwrapResponsereturnsbody.datawhen a body carries a booleansuccessand adatakey, sobody?.agentsreads.agentsoff{ agents }whichever producer answered. That holds only because the conversion relocated the payload; the flatten this issue warned about would have made that exact line readundefined.So the SDK is correct as written, and the reasoning now lives on the line itself rather than in a closed PR body.
What #4238 adds beyond the docstring move
The three-repo hazard is gone, but the thing that made it a hazard is not: no single gate covers both producers. Each pins its own body in its own repo and neither can pin the other's, and
scripts/check-route-envelope.mjsreaches neither — dispatcher domains return{ status, body }for a central sender (#4049), and cloud is outside the framework guard entirely.What both producers do share is
packages/spec. So the pin goes there, asserting both halves of the contract —envelopeViolationsclean anddataparsing asAiAgentsResponseSchema— with the flatten, the bare body, the mirrored-agentsshim and acapabilities-less row pinned as negatives.The flatten is the case that justifies the second half:
const body = { success: true, data: [ASK, BUILD] }; envelopeViolations(body) // [] — the envelope is FINE AiAgentsResponseSchema.safeParse(body.data).success // false — the payload is not
A conformance check leading with
envelopeViolationsalone passes that body. That is exactly how it would have shipped.The stale records mattered more than usual
Four comments across
runtimeandclient, and the whole header of objectui'suseAgents, still said the servers were unenveloped and the migration in flight — including{ agents }labelled "today's shape, both producers", which is now nobody's shape. On a route whose failure mode is a hidden UI rather than an error, a reader who believes that is one step from narrowing objectui's shape tolerance and breaking every pre-conversion deployment, with no error, no 403 and no log to notice it by.Closing once #4238 and objectui#3080 merge.
Generated by Claude Code
- added a commit that references this issue
on Jul 31, 2026 Closed — #4238 and objectui#3080 merged.
Final state of the four things this issue asked for:
step where cloud buildAgentRoutesemits{ success: true, data }cloud#929 framework fallback, same shape #4124 framework SDK no change needed — the relocation kept body?.agentscorrectAiAgentsResponseSchemadescribesdata, not the whole body#4238 Plus objectui#2992 teaching the consumer all four shapes, and objectui#3080 correcting its record afterwards.
The hazard this issue was filed about did not materialise, and the reason is worth keeping. The original framing said the conversion "must land with cloud and the SDK together". That was true of the flatten (
data: [...], #3983's precedent) and false of the relocation (data: { agents }, #3843's). Choosing the relocation is what let three repos land three separate PRs on their own schedules with nothing breaking in between — the second comment above got that half right, and #4124 proved it.What #4238 leaves behind so the next reader does not have to re-derive it:
- The declaration says which half of the body it is, and why the flatten was rejected — on
AiAgentsResponseSchemaitself, not only at the two producers. packages/spec/src/api/ai-agents-envelope.test.tsasserts both halves —envelopeViolationsclean anddataparsing as the declared payload. The flatten passes the first and fails the second, so a suite leading withenvelopeViolationsalone would have shipped it. That pin lives inspecbecause neither producer's repo can pin the other's, andcheck-route-envelope.mjsreaches neither.- The stale comments are gone. On a route whose failure mode is a hidden UI, a reader who still believed "cloud is unenveloped" was one step from narrowing objectui's shape tolerance and breaking every pre-conversion deployment — with no error, no 403 and no log to notice it by.
Not fixed here, and not this issue's scope: cloud#929 recorded that
agent-routes.tsstill answers{ error: '<string>' }on eleven error paths — the pre-#3675 dialect wherebody.error.messagereadsundefined— two of them withcodeas a sibling oferror. Real drift with its own consumer question; it needs its own issue rather than being folded in here.
Generated by Claude Code
- The declaration says which half of the body it is, and why the flatten was rejected — on
- added a commit that references this issue
on Oct 7, 2026
Not a bug today. Filing it because the fix is the hazard: this route is one of the last unenveloped SDK-addressable surfaces, and converting it the way #3675 → #3843 → #3983 converted the others would break
client.ai.agents.list()silently unless three repos move together.Found while checking whether the dispatcher domains hide more envelope drift after #4038/#4049. They mostly don't — of 10 hand-built responses across 16 domain files,
auth.ts(×2) is better-auth's own wire format andmcp.tspasses an upstreamResponsethrough, both legitimately outside the REST envelope, andkeys.ts/share-links.tshand-build only to get a 201 (deps.successhardcodes 200). This is the one left over.Current state — consistent, and that is why it is easy to miss
The payload is declared, and declared as the whole body:
Both producers emit exactly that, bare — no
success, nodata:packages/runtime/src/domains/ai.ts:47, the degraded fallback when no AI service is registered (open-source default;service-aiis Cloud/EE). Returns{ status: 200, body: { agents: [] } }.service-ai'sbuildAgentRoutes. Its own test reads the body as the bare shape:chatbot-features.test.ts:876→const body = resp.body as { agents: Array<…> }.So unlike
/share-linksbefore #3983, the two surfaces agree. Nothing is broken.The trap
The route is
disposition: 'sdk'withclient: 'ai.agents.list'(cloud/packages/service-ai/src/ai-route-ledger.ts:196), and the SDK reads it like this:unwrapResponsereturns the body verbatim when there is no booleansuccess, sobodyis{ agents: [...] }andbody?.agentspulls the array out. Correct — but it is a consumer-side accommodation for an unenveloped producer, the same class of shim Prime Directive #12 says to remove by fixing the producer.Now add the envelope to either producer without touching the SDK:
{ success: true, data: { agents: [...] } }unwrapResponsesees a booleansuccess→ returnsdata{ agents: [...] }body?.agentson that…which is the lucky case, because the payload schema nests under
agents. But the natural conversion — the one every other module did — puts the payload directly indata:{ success: true, data: [...] }(data IS the array, as #3983 did)unwrapResponsereturnsdata[...]body?.agentson an arrayundefined→[]client.ai.agents.list()then returns an empty list. No error, no 4xx, no log — the console just stops showing AI affordances, which is also exactly what the legitimate "no AI service configured" state looks like. That is the worst possible failure mode for this particular route: the broken state and the normal state are visually identical.Why this is a real risk and not a hypothetical
share-link-routes.tswas missed by #3843's hand survey and only turned up when the guard went repo-wide (#3983). This route can't be caught that way at all:scripts/check-route-envelope.mjsscans route modules that write viares.json(...), and dispatcher domains return{ status, body }for a central sender — noted in #4049. And the real handler lives in cloud, outside the framework guard's reach entirely.So the next person doing an envelope sweep sees an unenveloped SDK route, converts it the way the other five were converted, gets green CI in whichever repo they are in, and ships a silent regression.
What a correct conversion looks like
Three repos, one change:
service-aibuildAgentRoutes— emit{ success: true, data }.runtime/src/domains/ai.ts:47— same shape for the degraded fallback, so the two surfaces stay in agreement (they do today; that is worth preserving).packages/client/src/index.ts:3802— drop thebody?.agentsaccommodation to match whateverdatacarries.And decide one thing explicitly: does
datacarry{ agents: [...] }or the array itself? #3983 set the precedent thatdatacarries the payload directly, which argues for the array — but that is the variant that breaks the current SDK read, so it must land with step 3, not before it.AiAgentsResponseSchemathen describesdatarather than the whole body — the same relocationSettingsNamespacePayloadSchemawent through in #3843 (see the note inservice-settings/src/envelope.conformance.test.ts:182), which is the precedent to follow for keeping the move a relocation rather than a reshape.Meanwhile
Nothing needs doing. Both producers agree and the SDK works. This issue exists so that whoever converts it does all three steps, and so a partial conversion is recognised as the cause when the agent list mysteriously empties.