---
title: Tools
description: How a HermesOS tool declares its arguments, its metadata, and what it needs — so a model can call it correctly.
url: https://hermesos.thally.app/concepts/tools
---

# Tools

How a HermesOS tool declares its arguments, its metadata, and what it needs — so a model can call it correctly.

A tool is a capability an agent can call. In HermesOS a tool declares its own
arguments, so a model is told what a tool takes rather than guessing.

## The gap the tool framework closes

The kernel's `Tool` is a name, a description, two optional validators, and
`execute`. It carries no parameter schema and no tags, and listing tools returns
only `{ name, description }`.

That is correct for the kernel, which dispatches by name and refuses to know
what a payload means. But one layer up it leaves a real hole: without a schema,
**a model is told a tool exists and never what arguments it takes.**

`@hermes/tools` closes that gap without changing the kernel. A `HermesTool`
*is* a kernel `Tool` — assignable, registrable — with extra fields the kernel
never reads.

## Declare a tool

```ts
import { defineTool, s } from '@hermes/tools';

const readFile = defineTool({
  name: 'fs.read',
  description: 'Read a UTF-8 text file from disk.',
  tags: ['filesystem', 'read'],
  permissions: ['fs:read'],
  idempotent: true,
  input: s.object({
    path: s.string({ description: 'Absolute path to the file.' }),
    maxBytes: s.withDefault(s.number({ integer: true, minimum: 1 }), 1_000_000),
  }),
  output: s.string(),
  examples: [{ description: 'Read a config', input: { path: '/etc/hosts' } }],
  execute: async ({ path, maxBytes }, ctx) => read(path, maxBytes, ctx.signal),
});
```

`execute` is typed from the schema — `path` is a `string`, `maxBytes` a
`number` — with no generic to write and no cast.

## One declaration, two consumers

A `ToolSchema` is a validator **and** carries `jsonSchema`. The thing that
enforces the arguments and the thing that describes them to a model are the same
object, so they cannot drift apart.

## Tool metadata

Every metadata field is optional. A tool with no metadata is exactly a kernel
tool, so adopting the framework is never a rewrite — you add what you have.

| Field | What it does |
| --- | --- |
| `tags` | Free-form routing tags, conventionally a domain (`filesystem`, `network`) |
| `permissions` | What this tool needs permission to do — declared here, enforced elsewhere |
| `version` | Semantic version of the tool's *contract*, not its implementation |
| `deprecated` | Why it is deprecated and what to use instead |
| `examples` | Worked examples, rendered into the model's tool description |
| `idempotent` | Whether calling twice is the same as calling once |
| `metadata` | Anything else — carried, never interpreted |

> **Tip:**
  `examples` is the highest-leverage field here and the one most likely to be
  skipped. A model reading one worked example gets the arguments right far more
  often than one reading a JSON Schema, because an example resolves ambiguities
  a schema cannot express — is `path` absolute? is `query` a sentence or
  keywords?

### Why `deprecated` is a string

`deprecated: true` tells a model nothing it can act on, and a model reading tool
descriptions is the main consumer of the field. A sentence saying what to use
instead is something it can follow.

## Middleware

A tool middleware wraps a call. It sits at the boundary that owns the *effect*,
which is what makes it different from an agent middleware:

| | Agent middleware | Tool middleware |
| --- | --- | --- |
| Wraps | a decision | an effect |
| Can refuse | before anything happened | before this call happens |
| Sees | what the agent wants | what it was actually given |

Both are needed and neither replaces the other. An agent middleware guards what
an agent *intends*, which is the cheapest place to say no — but it only sees
agents. A tool middleware also catches the call a plan made directly, a host
made in a script, or another tool made in a composition.

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

const traced = withMiddleware(readFile, [
  async (input, ctx, next) => {
    ctx.logger.info('calling', { tool: 'fs.read' });
    return await next(input, ctx);
  },
]);
```

The **first** middleware is the outermost — it sees the call first and the
result last.

## Next steps

#### [Permissions](/concepts/permissions)

    Grant a toolset what it is allowed to do.

#### [Write a tool](/guides/write-a-tool)

    A step-by-step walkthrough.