---
title: Write a tool
description: Declare a tool, give it a schema and permissions, test it the way the kernel calls it, and register it.
url: https://hermesos.thally.app/guides/write-a-tool
---

# Write a tool

Declare a tool, give it a schema and permissions, test it the way the kernel calls it, and register it.

By the end of this guide you will have a working tool that describes its own
arguments to a model, declares what it needs permission to do, and has a test
that calls it exactly the way the kernel does.

## Before you begin

You need the workspace built — see the [Quickstart](/quickstart).

## Declare the tool

Start with `defineTool`. Everything except `name`, `description`, and `execute`
is optional.

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

export const writeFile = defineTool({
  name: 'fs.write',
  description: 'Write a UTF-8 text file to disk, creating parent directories.',
  tags: ['filesystem', 'write'],
  permissions: ['fs:write'],
  idempotent: false,
  input: s.object({
    path: s.string({ description: 'Absolute path to write to.' }),
    contents: s.string({ description: 'UTF-8 text to write.' }),
  }),
  output: s.string(),
  examples: [
    {
      description: 'Write a config file',
      input: { path: '/tmp/config.json', contents: '{}' },
    },
  ],
  execute: async ({ path, contents }, ctx) => {
    await write(path, contents, ctx.signal);
    return `wrote ${path}`;
  },
});
```

### Write the description for a model

The description is enforced to be non-empty, because a tool that cannot describe
itself will either never be chosen or be chosen at random. Say what the tool
does and when to reach for it.

### Honour the signal

`ctx.signal` aborts when the mission is cancelled. Pass it into whatever does
the actual work. A tool that ignores it keeps running after the mission is gone.

### Use the injected clock

If your tool needs to wait, use `ctx.clock.sleep(ms, ctx.signal)` rather than
`setTimeout`. It rejects when the signal aborts and never leaves a timer behind,
and it is what makes your tool testable without real time passing.

## Test it the way the kernel calls it

`callTool` mirrors the kernel's own invocation line for line — parse the input,
execute, parse the output.

```ts
import { callTool, testContext } from '@hermes/tools';

it('writes the file', async () => {
  const result = await callTool(writeFile, {
    path: '/tmp/x.txt',
    contents: 'hello',
  });

  expect(result).toBe('wrote /tmp/x.txt');
});
```

> **Tip:**
  Do not call `tool.execute(input, ctx)` directly in a test. That skips both
  validators — and the validators are the half of a tool most worth testing,
  because the input came from a model and the output is what the model reads
  next.

`callTool` takes `unknown` input on purpose, so you can pass the malformed thing
a model would actually send rather than only the well-typed thing TypeScript
allows.

Need a context with specific fields? `testContext({ attempt: 3, signal, clock })`
builds one with every field real — a tool that reads `ctx.logger` gets a logger,
not a crash.

## Audit the declaration

`auditTool` checks the things that are technically legal and always mistakes —
examples that violate the tool's own schema, a description too short to choose
by. It returns the problems rather than throwing, so a test can assert on the
list and a host can log it at boot without refusing to start.

```ts
expect(auditTool(writeFile)).toEqual([]);
```

## Register it

Group related tools into a toolset and grant permissions once, at the
composition root.

```ts
import { PermissionSet, toolset } from '@hermes/tools';

runtime.use(toolset({
  name: 'filesystem',
  tools: [writeFile, readFile],
  granted: PermissionSet.none().grant('fs:read', 'fs:write'),
}));
```

## What success looks like

- `auditTool` returns an empty list
- Your test passes through `callTool`, not `execute`
- Calling the tool without the grant throws `PermissionDeniedError`

## Next steps

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

    How grants are decided and enforced.

#### [Tool errors](/reference/tool-errors)

    What your tool can throw, and who reads it.