---
title: Tool errors
description: Every error the HermesOS tool framework throws on purpose, with the stable code you branch on.
url: https://hermesos.thally.app/reference/tool-errors
---

# Tool errors

Every error the HermesOS tool framework throws on purpose, with the stable code you branch on.

Every tool error carries a stable machine-readable `code`. Branch on the code,
never on the message wording — messages stay free to change.

## Error codes

| Code | Class | Thrown when |
| --- | --- | --- |
| `SCHEMA_INVALID` | `SchemaError` | A value did not match its schema |
| `INPUT_INVALID` | `InputInvalidError` | A tool was called with input its schema rejected |
| `OUTPUT_INVALID` | `OutputInvalidError` | A tool returned something its own schema rejected |
| `PERMISSION_DENIED` | `PermissionDeniedError` | A tool was called without a permission it declares |
| `TOOL_NOT_FOUND` | `ToolNotFoundError` | No tool with that name is registered |
| `INVALID_DEFINITION` | `InvalidDefinitionError` | A tool declaration is malformed |

All of them extend `ToolError`, which extends `Error`. The hierarchy extends no
other package's — a tool error that were `instanceof KernelError` would claim
the kernel threw it.

## These messages have a second audience

Everywhere else in HermesOS an error message is for a human reading a log. Here
it is often for a **model**, reading the failed observation of a tool it just
asked for and deciding what to send next.

That changes what a good message is. It must say which field, what was wrong
with it, and what would have been right — because the model's next turn is a
rewrite of the argument this one rejected, and "invalid input" gives it nothing
to rewrite toward.

## `SchemaError`

Carries `path` and `detail` separately rather than one formatted string, because
a nested schema does not know where it sits. `string()` throws "must be a
string" knowing only itself; `object()` catches it and prefixes the field name,
and `array()` prefixes the index. By the time it surfaces it reads:

```text
"files.0.path" must be a string
```

No schema had to be told its own address.

## `InputInvalidError` and `OutputInvalidError`

These are deliberately distinct, and the distinction matters more than it looks.

The kernel validates a tool's output with the same `parse` contract as its
input. Without attribution, an **output** failure surfaces as `input must be a
string` — exactly backwards. A model told "input" about an output fault will
rewrite its arguments forever.

| | Means | What the model should do |
| --- | --- | --- |
| `INPUT_INVALID` | You sent the wrong thing | Rewrite the arguments |
| `OUTPUT_INVALID` | The tool is broken | Nothing — it cannot be fixed by retrying |

`OutputInvalidError` says so explicitly: *"This is a fault in the tool, not in
how it was called."*

## `PermissionDeniedError`

Carries the missing `permission` rather than only saying no, because the two
audiences need different things from it. An operator needs to know which grant
to add; a model needs to know this is not a rewrite-your-arguments problem.

```text
fs.write requires the "fs:write" permission, which was not granted.
Retrying with different arguments will not help
```

## `InvalidDefinitionError`

Thrown at `defineTool`, which is module-load time — so a bad declaration is a
crash on boot rather than a failure on the first call. A tool that can never
work is a wiring mistake, and it should fail where the wiring is.

Common causes:

- An empty name
- An empty description — enforced, not merely typed, because the description is
  what a model reads to decide whether to call the tool at all
- A `version` that is not semantic (`1.0.0`)

## Handling errors

```ts
import { ToolError } from '@hermes/tools';

try {
  await runtime.call('fs.write', { path, contents });
} catch (thrown) {
  if (thrown instanceof ToolError) {
    switch (thrown.code) {
      case 'PERMISSION_DENIED':
        // A wiring fact. Add the grant and re-run.
        break;
      case 'INPUT_INVALID':
        // The arguments were wrong. Rewriting them may help.
        break;
      default:
        break;
    }
  }
}
```