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.
- Human error messages describe what went wrong; LLM error messages should prescribe what to do next
# 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.