Writing error messages for LLMs, not just humans

How Bindly designs API error responses with LLM consumers in mind: errors that tell the model what to do next, not just what went wrong. The patterns that make MCP tool failures recoverable instead of dead ends.


# Writing error messages for LLMs, not just humans Error messages are part of the API. For human-facing APIs, we've mostly figured this out: show a message the user can understand, maybe with a link to documentation. For LLM-facing APIs, the bar is different — and higher. An LLM that hits an error will include the error message in its reasoning about what to do next. A good error message gives the model enough information to recover. A bad error message leaves the model stuck, forcing it to either give up or try random things. We got this wrong in v0. Most errors were generic HTTP responses — "Not found", "Unauthorized", "Bad request". Useful for a developer reading logs. Useless for an LLM mid-conversation. ## The difference between human and LLM error messages **Human error message:** ```text Space not found. ``` A human reads this and knows to check the URL, maybe go back and look for the space again. **LLM error message:** ```text Space 'my-space' not found. Call mcp_list_spaces() to see all spaces you have access to, or check if the slug is spelled correctly. Note: personal space is accessed as 'personal', not '@personal'. ``` The LLM reads this and knows: 1. The specific slug that failed (`my-space`) 2. The exact tool to call next (`mcp_list_spaces()`) 3. A common mistake to check (the personal space slug format) One message is a description. The other is a recovery guide. ## Bindly's error response format Every MCP error response follows this structure: ```typescript return { content: [{ type: "text", text: formatError({ code: 'SPACE_NOT_FOUND', message: `Space '${slug}' not found.`, hint: `Call mcp_list_spaces() to see all accessible spaces, or verify the slug is correct.`, input: { slug } }) }], isError: true // tells the MCP client this is an error } ``` The rendered text: ```text [Error: SPACE_NOT_FOUND] Space 'my-space' not found. → Hint: Call mcp_list_spaces() to see all accessible spaces, or verify the slug is correct. → Input: { "slug": "my-space" } ``` Three components: a stable error code, a specific message with the failing value, and a hint with the next step. ## Recoverable vs unrecoverable errors The most important distinction in LLM error design: can the LLM recover within the current workflow, or does it need to stop and explain to the user? **Recoverable — the LLM should try something different:** ```text [Error: BINDING_NOT_FOUND] Binding 'my-binding' not found in space '@my-space'. → Hint: Call mcp_search({ query: "my-binding", spaceId: "<id>" }) to find bindings by content, or mcp_list_bindings({ spaceId: "<id>" }) to browse all bindings in this space. → Input: { "slug": "my-binding", "spaceId": "spc_abc" } ``` **Unrecoverable — the LLM should stop and inform the user:** ```text [Error: PERMISSION_DENIED] You don't have permission to edit binding 'some-binding' in space '@their-space'. → This binding was created by another member. Only admins and owners can edit it. Ask the space admin for elevated permissions, or work with the binding author directly. → Input: { "bindingId": "bnd_xyz", "action": "update" } ``` **Auth errors — requires user action, not LLM action:** ```text [Error: UNAUTHORIZED] Authentication required. Your session may have expired. → The user needs to reconnect Bindly. In Claude Desktop: Settings → Connections → Bindly → Reconnect. In Cursor: restart the app to re-authenticate. → If using a User Key, check that it hasn't been revoked at bind.ly/settings. ``` The hint for auth errors doesn't tell the LLM to call a tool — there's nothing the LLM can do programmatically. It tells the LLM to explain the problem to the user and exactly how to fix it. ## Including the failed input Every error response includes the input that caused the failure: ```text → Input: { "slug": "my-space", "action": "get" } ``` Two purposes: **Self-diagnosis**: The LLM can see exactly what it sent. If it sent `"my-space"` but should have sent `"@my-space"`, it can correct itself without asking the user anything. **User explanation**: When the LLM reports the error, it can say "I tried to access 'my-space' but it wasn't found" — specific, not vague. Without the input in the error, the LLM has to reconstruct what it tried from its own context, which may be stale if several tool calls have passed. ## Error codes as a stable contract Error codes (`SPACE_NOT_FOUND`, `PERMISSION_DENIED`, `BINDING_NOT_FOUND`) are part of the API contract. They should be: - **Stable** — don't rename them between versions - **Specific** — `BINDING_VERSION_NOT_FOUND` instead of `NOT_FOUND` - **Action-oriented** — the code should hint at what kind of problem it is A well-crafted system prompt can include: "If you receive BINDING_NOT_FOUND, search for it with `mcp_search` before giving up." This kind of error-handling logic only works if the error codes are predictable. If we rename `BINDING_NOT_FOUND` to `RESOURCE_MISSING`, those system prompts break silently. ## The 410 Gone pattern for share links Expired share links are a specific case where the error design matters for trust: ```text [Error: SHARE_EXPIRED] This share link expired on 2026-04-01. → The content it pointed to may still exist in the original space. Ask the link creator to generate a new share link, or request access to the space directly. → Share ID: b/abc123 ``` Compare to a generic 404: ```text Not found. ``` The 410 response tells the LLM (and the user) that this content *existed* but the link is no longer valid. Materially different from "this never existed." HTTP 410 Gone is the right status code precisely because of this distinction. ## Validation errors with field-level detail For creation and update operations, validation errors identify the specific field: ```text [Error: VALIDATION_FAILED] Request validation failed. → Fields: - title: Required. Must be 1-200 characters (got 0). - sourceType: Must be one of: url, text, file, conversation (got 'document'). → Fix these fields and retry. ``` More work to implement than a generic "invalid request" — it requires field-level validation with specific messages per field. But it's essential for LLM consumers. The LLM can read this and know exactly what to change in its retry. A generic "invalid request" leaves it guessing which field is wrong, often resulting in a cascade of failed retries burning context. ## What makes LLM error design hard The hardest part isn't the technical implementation — it's discipline. When building quickly, it's easy to write `return c.json({ error: 'Not found' }, 404)` and move on. The human-facing UI can add context. The documentation can explain the error codes. For LLM-facing APIs, the error message *is* the documentation, because the LLM reads it at the moment it's deciding what to do. There's no separate step where it looks up the error in a docs site. The useful question to ask when writing an error message: *"If this error appears in the middle of a multi-step workflow, what does the LLM need to know to continue — or to explain the failure to the user?"* That question usually points directly to the right message.