Skip to content

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

@os-zhuang

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 and mcp.ts passes an upstream Response through, both legitimately outside the REST envelope, and keys.ts / share-links.ts hand-build only to get a 201 (deps.success hardcodes 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:

// packages/spec/src/api/protocol.zod.ts:1078
export const AiAgentsResponseSchema = lazySchema(() => z.object({
  agents: z.array(AgentSummarySchema).describe('Agents this caller may chat with'),
}));

Both producers emit exactly that, bare — no success, no data:

  • framework — packages/runtime/src/domains/ai.ts:47, the degraded fallback when no AI service is registered (open-source default; service-ai is Cloud/EE). Returns { status: 200, body: { agents: [] } }.
  • cloud — service-ai's buildAgentRoutes. 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-links before #3983, the two surfaces agree. Nothing is broken.

The trap

The route is disposition: 'sdk' with client: 'ai.agents.list' (cloud/packages/service-ai/src/ai-route-ledger.ts:196), and the SDK reads it like this:

// packages/client/src/index.ts:3802
list: async (): Promise<AiAgentSummary[]> => {
  const res = await this.fetch(`${this.baseUrl}${route}/agents`);
  const body = await this.unwrapResponse<AiAgentsResponse>(res);
  return body?.agents ?? [];
},

unwrapResponse returns the body verbatim when there is no boolean success, so body is { agents: [...] } and body?.agents pulls 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:

step result
producer emits { success: true, data: { agents: [...] } }
unwrapResponse sees a boolean success → returns data { agents: [...] }
SDK does body?.agents on that ✅ still works

…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 in data:

step result
producer emits { success: true, data: [...] } (data IS the array, as #3983 did)
unwrapResponse returns data [...]
SDK does body?.agents on an array undefined → []

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.ts was 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.mjs scans route modules that write via res.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:

  1. cloud service-ai buildAgentRoutes — emit { success: true, data }.
  2. framework 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).
  3. framework packages/client/src/index.ts:3802 — drop the body?.agents accommodation to match whatever data carries.

And decide one thing explicitly: does data carry { agents: [...] } or the array itself? #3983 set the precedent that data carries 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.

AiAgentsResponseSchema then describes data rather than the whole body — the same relocation SettingsNamespacePayloadSchema went through in #3843 (see the note in service-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.

Activity

  1. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    This is now enforced rather than just written down — #4056 extends scripts/check-route-envelope.mjs to the dispatcher domains and declares ai.ts as 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 the ratchet and lower handBuilt to 1 — the remaining hand-built response in ai.ts is the AI-service passthrough, which is legitimately not the platform's envelope to own.


    Generated by Claude Code

  2. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    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 gate
    

    and 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" — because useAiSurfaceEnabled treats 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 useAgents all four shapes (bare array, { agents }, { success, data: [...] }, { success, data: { agents } }), detecting the envelope the way unwrapResponse does 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:

    1. cloud buildAgentRoutes — emit { success: true, data }
    2. framework runtime/src/domains/ai.ts:47 — same shape for the degraded fallback, so the two surfaces stay in agreement
    3. framework SDK — ai.agents.list has no callers, so correct it or delete it outright (and update the ledger's disposition: 'sdk' if deleted)

    Still open, still ratcheted in check-route-envelope.mjs (#4056) — but the sharp edge is off it.


    Generated by Claude Code

  3. self-assigned this
    on Jul 30, 2026
  4. added 2 commits that reference this issue on Jul 30, 2026
  5. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    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 buildAgentRoutes emits { success: true, data } cloud#929
    framework runtime/src/domains/ai.ts fallback, same shape #4124
    framework SDK — "correct it or delete it outright" no change needed, see below
    AiAgentsResponseSchema describes data, 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. unwrapResponse returns body.data when a body carries a boolean success and a data key, so body?.agents reads .agents off { 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 read undefined.

    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.mjs reaches 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 — envelopeViolations clean and data parsing as AiAgentsResponseSchema — with the flatten, the bare body, the mirrored-agents shim and a capabilities-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 envelopeViolations alone passes that body. That is exactly how it would have shipped.

    The stale records mattered more than usual

    Four comments across runtime and client, and the whole header of objectui's useAgents, 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

  6. os-zhuang commented on Jul 31, 2026

    @os-zhuang
    ContributorAuthor

    Closed — #4238 and objectui#3080 merged.

    Final state of the four things this issue asked for:

    step where
    cloud buildAgentRoutes emits { success: true, data } cloud#929
    framework fallback, same shape #4124
    framework SDK no change needed — the relocation kept body?.agents correct
    AiAgentsResponseSchema describes data, 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 AiAgentsResponseSchema itself, not only at the two producers.
    • packages/spec/src/api/ai-agents-envelope.test.ts asserts both halves — envelopeViolations clean and data parsing as the declared payload. The flatten passes the first and fails the second, so a suite leading with envelopeViolations alone would have shipped it. That pin lives in spec because neither producer's repo can pin the other's, and check-route-envelope.mjs reaches 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.ts still answers { error: '<string>' } on eleven error paths — the pre-#3675 dialect where body.error.message reads undefined — two of them with code as a sibling of error. Real drift with its own consumer question; it needs its own issue rather than being folded in here.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions