fix(api): use a neutral catalog pointer in the call_api protocol refusal (#20191)

## Thinking Path

- Paperclip agents reach REST routes through the `call_api` tool;
protocol endpoints (MCP, OAuth, WebSocket) are refused there with
guidance.
- #20170 changed that refusal to tell the agent the endpoint's catalog
description says who can use it.
- Review on #20170 found that some protocol endpoints' descriptions (for
example the WebSocket entries) do not say who may call them, so the
refusal could send the agent to look for information that is not there.
- The fix (e7ff8ba610) was pushed three minutes after #20170 merged, so
it never reached master. This PR carries it.

## Linked Issues or Issue Description

Refs #20170

**What happened:** after #20170, a `call_api` refusal for a protocol
endpoint says the catalog description names who can use it. Some
descriptions, such as `GET /api/companies/{companyId}/events/ws`
("WebSocket upgrade. Not callable through call_api."), do not, so an
agent following the refusal looks for an answer the catalog does not
give.

**Expected behavior:** the refusal points the agent at the catalog
description neutrally ("Read its catalog description for details.")
without promising it names who may call the endpoint.

**Steps to reproduce:**
1. As an agent, call `call_api` with `GET
/api/companies/{companyId}/events/ws`.
2. Read the refusal text: it claims the description says who can use the
endpoint.
3. Read the catalog entry: it does not.

## What Changed

- `server/src/services/native-runtime/runner-api-client.ts`: the
protocol-endpoint refusal now says "Read its catalog description for
details." instead of promising the description names who can call it.
- `server/src/__tests__/assistant-connections-guidance.test.ts`: adds a
case on the WebSocket events endpoint asserting the neutral wording.

## Verification

- `cd server && npx vitest run
src/__tests__/assistant-connections-guidance.test.ts` (4 passed).
- Commit was reviewed on #20170's branch by Ponytail (the finding that
prompted it).

## Risks

- Low: wording-only change to one refusal message, plus a test. Agents
may read slightly less specific guidance for endpoints whose
descriptions do name the caller; the description itself is unchanged.

## Model Used

- Provider: Anthropic. Model: Claude Opus 5.5 (`claude-opus-5-5`), via
Claude Code, with tool use and code execution. The change was authored
by a Claude subagent on #20170 and carried over here by Claude.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-09 00:31:49 -06:00
1 parent 7086019d4b
commit 7d92b6a7cd
2 files changed
+9 -2

No files matched your search

@@ -21,7 +21,7 @@ it("tells agents searching for assistant disconnection that only the person can
it("says the call_api protocol rejection is final instead of inviting another search", () => {
expect(() => validateRunnerApiCall({ operationId: "GET /api/mcp/connections" }, context))
.toThrow(/no other API route substitutes for it\. Its catalog description says who can use it\./);
.toThrow(/no other API route substitutes for it\. Read its catalog description for details\./);
});
it("points at the catalog description only when the protocol endpoint has one", () => {
@@ -32,3 +32,10 @@ it("points at the catalog description only when the protocol endpoint has one",
expect(message).toContain("no other API route substitutes for it.");
expect(message).not.toContain("catalog description");
});
it("does not claim a non-guidance protocol description says who can use the endpoint", () => {
let message = "";
try { validateRunnerApiCall({ operationId: "GET /api/companies/{companyId}/events/ws" }, context); } catch (error) { message = (error as Error).message; }
expect(message).toContain("Read its catalog description for details.");
expect(message).not.toContain("who can use it");
});
@@ -54,7 +54,7 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext)
if (!parsed.success) throw badRequest("Invalid call_api arguments", { issues: parsed.error.issues });
const input = parsed.data;
const operation = runnerApiOperation(input.operationId);
if (operation.transport !== "rest") throw unprocessable(`This endpoint requires its existing protocol client; call_api supports REST only and no other API route substitutes for it.${operation.description.trim() ? " Its catalog description says who can use it." : ""}`);
if (operation.transport !== "rest") throw unprocessable(`This endpoint requires its existing protocol client; call_api supports REST only and no other API route substitutes for it.${operation.description.trim() ? " Read its catalog description for details." : ""}`);
if (input.responseText && operation.method !== "GET") throw badRequest("responseText requires a GET operation; read the saved artifact instead of repeating a mutation");
if (!operation.allowedModes.includes(context.workMode)) throw forbidden("call_api permits only reads in Ask and Plan modes; use the permitted dedicated tools");
if (input.pathParams?.companyId && input.pathParams.companyId !== context.companyId) throw forbidden("API call belongs to another company");