
# 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.