Error Handling
I kept watching AI agents make the same mistake: a tool would fail, the agent would get a generic error string, and then it would either retry something that was never going to work or give up on something that would have succeeded on the second try. It was maddening. So I built ToolError — a structured error class that carries machine-readable metadata (what failed, why, and whether retrying is worth it) through the entire dispatch chain to the AI agent.
The Problem with Unstructured Errors
If your tool throws a plain Error, the AI agent sees something like this:
Tool dispatch error: Request failed
That's it. No context. The agent doesn't know if this is temporary (retry might work) or permanent (don't bother). It doesn't know if the user needs to re-authenticate, or if the input was just wrong. So it guesses. And in my experience, it almost always guesses wrong — retrying auth failures in a loop, or giving up on a rate limit that would have cleared in two seconds.
ToolError
ToolError fixes this by adding three pieces of metadata to every error:
category— what kind of failure (auth,rate_limit,not_found,validation,timeout,internal)retryable— whether retrying might succeedretryAfterMs— how long to wait before retrying (optional)
import { ToolError } from '@opentabs-dev/plugin-sdk';
throw new ToolError('Channel not found', 'CHANNEL_NOT_FOUND', {
category: 'not_found',
retryable: false,
});Now the AI agent receives both a human-readable message and a machine-readable JSON block:
[ERROR code=CHANNEL_NOT_FOUND category=not_found retryable=false] Channel not found
```json
{"code":"CHANNEL_NOT_FOUND","category":"not_found","retryable":false}
No guessing. The agent knows exactly what happened and what to do next.
<ErrorCategories />
## Factory Methods
I designed these so you don't have to remember which categories are retryable and which aren't. Instead of constructing `ToolError` directly, use the factory methods — they encode the right defaults so you can focus on the error message.
### `ToolError.auth(message, code?)`
The user isn't authenticated, or the session expired. Not retryable — the user needs to log in.
```typescript
import { ToolError } from '@opentabs-dev/plugin-sdk';
async function handle() {
const token = getLocalStorage('auth_token');
if (!token) {
throw ToolError.auth('Not logged in — open the app and sign in first');
}
// ...
}
| Property | Value |
|---|---|
code | AUTH_ERROR |
category | auth |
retryable | false |
ToolError.notFound(message, code?)
The requested resource doesn't exist. Not retryable. Accepts an optional domain-specific code (defaults to NOT_FOUND).
import { ToolError } from '@opentabs-dev/plugin-sdk';
async function handle({ channelName }: { channelName: string }) {
const channel = findChannel(channelName);
if (!channel) {
throw ToolError.notFound(
`Channel #${channelName} not found`,
'CHANNEL_NOT_FOUND',
);
}
// ...
}| Property | Value |
|---|---|
code | Custom or NOT_FOUND |
category | not_found |
retryable | false |
ToolError.rateLimited(message, retryAfterMs?, code?)
The API is rate limiting requests. Retryable — the agent should wait and try again. If the API sends a Retry-After header, pass it through instead of making up a number.
import { ToolError } from '@opentabs-dev/plugin-sdk';
async function handle() {
const response = await fetch('/api/search', { credentials: 'include' });
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000;
throw ToolError.rateLimited('Too many requests — try again shortly', delayMs);
}
// ...
}| Property | Value |
|---|---|
code | RATE_LIMITED |
category | rate_limit |
retryable | true |
retryAfterMs | Custom (milliseconds) |
ToolError.validation(message, code?)
The input is invalid. Not retryable with the same input — the agent needs to fix the parameters.
import { ToolError } from '@opentabs-dev/plugin-sdk';
async function handle({ emoji }: { emoji: string }) {
if (emoji.length > 50) {
throw ToolError.validation('Emoji name must be 50 characters or fewer');
}
// ...
}| Property | Value |
|---|---|
code | VALIDATION_ERROR |
category | validation |
retryable | false |
ToolError.timeout(message, code?)
The operation took too long. Retryable — transient network or server slowness.
import { ToolError } from '@opentabs-dev/plugin-sdk';
async function handle() {
try {
const data = await fetchJSON('/api/report', {
signal: AbortSignal.timeout(10_000),
});
return data;
} catch (err) {
if (err instanceof DOMException && err.name === 'TimeoutError') {
throw ToolError.timeout('Report generation timed out after 10 seconds');
}
throw err;
}
}| Property | Value |
|---|---|
code | TIMEOUT |
category | timeout |
retryable | true |
ToolError.internal(message, code?)
Something unexpected broke — the page structure changed, a DOM element is missing, or something the plugin assumed is no longer true. Not retryable; this is a bug that needs a code fix.
import { ToolError } from '@opentabs-dev/plugin-sdk';
async function handle() {
const el = document.querySelector('.dashboard');
if (!el) {
throw ToolError.internal('Dashboard container not found — page structure may have changed');
}
// ...
}| Property | Value |
|---|---|
code | INTERNAL_ERROR |
category | internal |
retryable | false |
Error Categories at a Glance
| Factory Method | Category | Retryable | When to Use |
|---|---|---|---|
ToolError.auth() | auth | No | User not logged in, session expired, permission denied |
ToolError.notFound() | not_found | No | Channel, user, page, or resource doesn't exist |
ToolError.rateLimited() | rate_limit | Yes | API returned 429, too many requests |
ToolError.validation() | validation | No | Invalid input that Zod didn't catch |
ToolError.timeout() | timeout | Yes | Operation took too long, network slow |
ToolError.internal() | internal | No | Unexpected failure, page structure changed, bug |
How Errors Reach the AI Agent
One thing I wanted to get right: you throw a ToolError in your tool handler, and it arrives at the AI agent intact — no metadata lost along the way, no special plumbing on your end. Here's the path it takes:
- Tool handler throws
ToolErrorin the page context - Browser extension serializes the error, preserving
code,category,retryable, andretryAfterMs - MCP server formats the error into a human-readable prefix line and a machine-readable JSON block
- AI agent receives both formats and uses the structured fields to decide what to do next
You don't have to think about any of those hops. The AI agent sees a response like this:
[ERROR code=RATE_LIMITED category=rate_limit retryable=true retryAfterMs=2000] Too many requests — try again shortly
```json
{"code":"RATE_LIMITED","category":"rate_limit","retryable":true,"retryAfterMs":2000}
With this, the agent knows to wait 2 seconds and retry — instead of giving up or hammering the API immediately. That's the whole point.
## Custom Error Codes
The factory methods cover the common cases, but sometimes you need a more specific error code for your domain. Construct `ToolError` directly:
```typescript
import { ToolError } from '@opentabs-dev/plugin-sdk';
throw new ToolError('Message is too long for this channel', 'MESSAGE_TOO_LONG', {
category: 'validation',
retryable: false,
});
The code field is a machine-readable string. Use UPPER_SNAKE_CASE by convention. The notFound factory also accepts a custom code:
throw ToolError.notFound('Project not found', 'PROJECT_NOT_FOUND');Best Practices
These come from watching a lot of AI agents interact with a lot of plugins. Every one of these is a mistake I've seen in the wild.
Be specific in error messages. The message is shown to both the AI agent and the user. Include what failed and what to do about it — the agent is surprisingly good at following instructions if you actually give it some.
// Good — tells the agent what to do
throw ToolError.auth('Not logged in — open app.example.com and sign in');
// Bad — vague
throw ToolError.auth('Auth failed');Use retryAfterMs when you know the delay. If the API tells you when to retry (via a Retry-After header), pass it through instead of hardcoding a guess. The agent uses this to wait the right amount of time — and a real number from the server is always better than one you made up.
throw ToolError.rateLimited('Rate limited by Slack API', 3000);Prefer factory methods over the constructor. I put the best practices into the factory methods so you don't have to think about them — rateLimited is always retryable, auth never is. Use the constructor only when you need a custom code with a standard category.
Let Zod handle input validation. Your tool's Zod schema validates input before handle() runs. Use ToolError.validation() only for semantic validation that Zod can't express — like checking that a referenced resource actually exists.
Don't catch errors you can't handle. If something unexpected blows up, let it propagate. The platform wraps uncaught errors in a generic error response automatically. Only catch errors when you can add real context by throwing a ToolError instead — otherwise you're just adding noise.
Next Steps
If you haven't already, I'd start with the Plugin Development guide — it walks through building a plugin from scratch, and you'll see where ToolError fits into the bigger picture. When errors alone aren't telling you enough, the Logging & Debugging guide covers the tools I use to figure out what's actually happening. And for the full API surface, the SDK Reference: Error Types has every field and method.
Last Updated: 10 Mar, 2026