# Custom tools

A custom tool is a tool you define on a container: its own name, description and input schema, wrapped around exactly one of the container's built-in tools. When an agent calls it, Wire fills in the built-in tool's arguments from the agent's input and the values you fixed, runs the built-in tool, and returns its result.

Custom tools make a container easier for an agent to use correctly:

- **A task-shaped name and description.** An agent picks `log_decision` when the user makes a decision, because the tool says what it is for in the agent's own terms. A general `wire_write` leaves the agent to work out when and how to use it.
- **Fewer, flatter parameters.** The agent fills in the handful of fields the task needs, not every option the built-in tool accepts.
- **Filters and fixed values the agent can't forget.** An object name, a tag or a result limit is set once in the definition and sent on every call.
- **The option to hide the general tools.** Turn the built-in tool off and leave your custom tool on, so agents see only the narrower task.

For example, a `search_places` tool that takes `lat`, `lng` and an optional description is easier to get right than [`wire_search`](/reference/tools/#wire_search) with a nested `near` object and an `object` filter the agent has to remember to send.

## How a custom tool is defined

A custom tool is a JSON definition with five fields.

| Field | Required | What it is |
|-------|:-:|------------|
| `name` | Yes | What agents call. Letters, digits and underscores, starting with a letter. The `wire_` prefix is reserved for built-in tools. |
| `description` | Yes | What agents read in `tools/list` to decide when to call it. |
| `inputSchema` | Yes | A JSON Schema object describing the tool's arguments. |
| `tool` | Yes | The built-in tool it runs: `{ "name": "wire_search", "args": { … } }`. |
| `result` | No | How to shape the built-in tool's result. Without it, the result is returned unchanged. |
| `outputSchema` | No | A JSON Schema object (`"type": "object"`) describing the result. See [Annotations and output schemas](#annotations-and-output-schemas). |

The values in `tool.args` are either fixed values, which are sent as they are on every call, or substitutions, which are filled in from the call.

### Substitutions

A substitution is a path in double braces.

| Substitution | Where it can appear | What it reads |
|--------------|--------------------|---------------|
| `{{input.x}}` | `tool.args` and `result` | The argument `x` the agent sent |
| `{{tool.x}}` | `result` only | The field `x` of the built-in tool's result |

A substitution that is the whole value keeps its JSON type: `"limit": "{{input.limit}}"` sends a number when the agent sent a number, and `"tags": "{{input.tags}}"` sends a list. A substitution inside a longer string is written into the string as text: `"Decision: {{input.title}}"`.

When an optional input is missing, a whole-value substitution is left out of the call entirely, so the built-in tool behaves as if that argument was never sent. Inside a longer string, a missing input becomes empty text.

## Example: log and find decisions

A pair of custom tools turns a container into a decision log. `log_decision` records each decision as a row of the `decisions` [object](/getting-started/concepts/#objects), and `find_decisions` searches only that object.

### `log_decision`

```json
{
  "name": "log_decision",
  "description": "Record a decision the user or team has made, with why and who owns it. Use it whenever a choice is settled.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "title": { "type": "string", "description": "The decision, in one line" },
      "rationale": { "type": "string", "description": "Why it was made" },
      "owner": { "type": "string", "description": "Who is responsible for it" },
      "tags": { "type": "array", "items": { "type": "string" } }
    },
    "required": ["title", "rationale"]
  },
  "tool": {
    "name": "wire_write",
    "args": {
      "content": "Decision: {{input.title}}. Why: {{input.rationale}} Owner: {{input.owner}}",
      "object": "decisions",
      "fields": { "title": "{{input.title}}", "rationale": "{{input.rationale}}", "owner": "{{input.owner}}" },
      "tags": "{{input.tags}}"
    }
  },
  "result": { "decisionId": "{{tool.entryId}}", "title": "{{input.title}}" }
}
```

A call with `{ "title": "Ship the beta on Tuesday", "rationale": "QA signed off.", "tags": ["release"] }` runs `wire_write` with:

```json
{
  "content": "Decision: Ship the beta on Tuesday. Why: QA signed off. Owner: ",
  "object": "decisions",
  "fields": { "title": "Ship the beta on Tuesday", "rationale": "QA signed off." },
  "tags": ["release"]
}
```

What each rule did here:

- **Type-keeping.** `tags` is a whole-value substitution, so the list arrives as a list, not as text.
- **An omitted optional.** `owner` was not sent. Inside `content` it became empty text; as the whole value of `fields.owner` it was left out of the call.
- **A fixed value.** `object` is `decisions` on every call, so every decision lands in the same place and the agent never has to name it.
- **A `result` mapping.** The agent gets back `{ "decisionId": "…", "title": "Ship the beta on Tuesday" }`, read from the write's `entryId` with `{{tool.entryId}}`, instead of the full write response.

### `find_decisions`

```json
{
  "name": "find_decisions",
  "description": "Find decisions that were logged earlier, by what they were about.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "query": { "type": "string", "description": "What the decision was about, for example \"release timing\"" }
    },
    "required": ["query"]
  },
  "tool": {
    "name": "wire_search",
    "args": {
      "query": "{{input.query}}",
      "object": "decisions",
      "limit": 10
    }
  }
}
```

A call with `{ "query": "release timing" }` runs `wire_search` with `{ "query": "release timing", "object": "decisions", "limit": 10 }`. The `object` filter is fixed, so the search never wanders into the rest of the container. With no `result` mapping, the agent gets the search results unchanged.

## Example: search near a place

`search_places` shows flat inputs mapped into a nested argument. The agent sends `lat`, `lng` and, optionally, `radius_km` and a description; the tool builds `wire_search`'s `near` object from them.

```json
{
  "name": "search_places",
  "description": "Find saved places near a point. Add a description to rank by what the user is looking for, and a radius to exclude anything farther away.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "lat": { "type": "number" },
      "lng": { "type": "number" },
      "query": { "type": "string", "description": "What kind of place, for example \"burger\"" },
      "radius_km": { "type": "number" }
    },
    "required": ["lat", "lng"]
  },
  "tool": {
    "name": "wire_search",
    "args": {
      "query": "{{input.query}}",
      "object": "places",
      "near": { "lat": "{{input.lat}}", "lng": "{{input.lng}}", "radius_km": "{{input.radius_km}}" }
    }
  }
}
```

A call with only `{ "lat": 40.7716, "lng": -73.9591 }`, a point on the Upper East Side of New York, runs `wire_search` with `{ "object": "places", "near": { "lat": 40.7716, "lng": -73.9591 } }`, which returns the nearest saved places first. A saved J.G. Melon, at 1291 3rd Ave, would be near the top. A call with `{ "lat": 40.7716, "lng": -73.9591, "query": "burger", "radius_km": 1 }` runs:

```json
{ "query": "burger", "object": "places", "near": { "lat": 40.7716, "lng": -73.9591, "radius_km": 1 } }
```

That keeps only places within 1 km of the point, ranked by how well they match "burger". See [searching near a place](/reference/tools/#searching-near-a-place) for what each combination does.

## Example: find a place through its notes

A place's own record often says little more than its name, and what the user remembers ("the one with the good cortado") is in a note or a visit about it. With `matchLinked`, `search_places` finds the place through those records too:

```json
{
  "name": "search_places",
  "description": "Find saved places near a point, through their notes, visits, and events too.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "query": { "type": "string", "description": "What the user is looking for, for example \"cortado\"" },
      "lat": { "type": "number" },
      "lng": { "type": "number" },
      "radius_km": { "type": "number" }
    },
    "required": ["lat", "lng"]
  },
  "tool": {
    "name": "wire_search",
    "args": {
      "query": "{{input.query}}",
      "object": "place",
      "near": { "lat": "{{input.lat}}", "lng": "{{input.lng}}", "radius_km": "{{input.radius_km}}" },
      "matchLinked": { "objects": ["note", "visit", "event"] }
    }
  }
}
```

`query` is optional: a call with only `lat` and `lng` ignores `matchLinked` and returns the nearest saved places. A call with `{ "query": "cortado", "lat": 37.7825, "lng": -122.4074 }` returns saved places, nearest breaking close calls, and a place whose note says "best cortado in town" is among them, with that note in `matchedVia`. Only links written with `wire_write` `links` count, such as the `about` link `add_note` writes below.

To search everything the agent saved instead, give `object` a list:

```json
{
  "name": "search_saved",
  "description": "Search everything saved: places, notes, visits, and events.",
  "inputSchema": { "type": "object", "properties": { "query": { "type": "string" } }, "required": ["query"] },
  "tool": { "name": "wire_search", "args": { "query": "{{input.query}}", "object": ["place", "note", "visit", "event"] } }
}
```

Each match says which kind of record it is in `object`. See [finding a record through what's linked to it](/reference/tools/#finding-a-record-through-whats-linked-to-it) for the rules.

## Example: notes linked to a place

`add_note` writes a note and [links](/reference/tools/#linking-entries) it to the place it's about. `remove_place` deletes a place together with its notes.

```json
{
  "name": "add_note",
  "description": "Add a note about a saved place.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "place_id": { "type": "string", "description": "The place's id, from search_places" },
      "text": { "type": "string" }
    },
    "required": ["place_id", "text"]
  },
  "tool": {
    "name": "wire_write",
    "args": {
      "content": "{{input.text}}",
      "object": "notes",
      "fields": { "place_id": "{{input.place_id}}" },
      "links": [{ "to": "{{input.place_id}}", "type": "about" }]
    }
  }
}
```

```json
{
  "name": "remove_place",
  "description": "Remove a saved place and every note about it.",
  "inputSchema": { "type": "object", "properties": { "place_id": { "type": "string" } }, "required": ["place_id"] },
  "tool": {
    "name": "wire_delete",
    "args": { "entryId": "{{input.place_id}}", "withLinked": { "types": ["about"], "direction": "incoming" } }
  }
}
```

`place_id` has to be required in `add_note`'s input, because a link needs a target: Wire refuses to save the definition otherwise.

To read a place back with everything about it, map a `get_place` tool straight onto `wire_navigate`. It returns the place with its fields, and every note or visit that links to it, whole and newest first:

```json
{
  "name": "get_place",
  "description": "A saved place, with every note and visit about it, newest first.",
  "inputSchema": { "type": "object", "properties": { "place_id": { "type": "string" } }, "required": ["place_id"] },
  "tool": {
    "name": "wire_navigate",
    "args": { "entryId": "{{input.place_id}}", "type": ["about", "visited", "held_at"], "direction": "incoming" }
  }
}
```

`direction: "incoming"` keeps the records that point at the place, and `type` keeps only those link types, so the provenance and entity graphs' edges stay out of the answer. See [Connected records](/reference/tools/#connected-records).

## Annotations and output schemas

MCP clients read two things from a tool before they call it: its `annotations`, hints about how careful to be, and its `outputSchema`, the shape of its result. Wire sets both for every custom tool.

**Annotations are inherited.** A custom tool gets its built-in tool's hints: one over `wire_search` is read-only, and one over `wire_delete` is destructive. A tool that calls an action on an agent's server is also marked open world, because it reaches something outside the container. An agent's manifest can add `annotations` to a tool, but only toward caution. It may mark a tool destructive or open world, or give it a `title`, and a manifest that claims less than the tool does, such as `readOnlyHint: true` on a tool over `wire_write`, is refused. Clients skip asking the user on the strength of these hints, so they can never understate.

**An output schema is declared or inherited.** A tool with its own `outputSchema` is listed with it, and every result is checked against it: a result that doesn't match comes back as an error rather than as data the schema didn't promise. Without one, a tool that returns its built-in tool's result unchanged (no `result` mapping and no action) inherits that tool's schema, and any other tool has none.

**Data only a view needs goes in `result._meta`.** When a tool shows an agent's [interactive view](/build/sdk/interactive-views/), anything the view needs but the model doesn't, such as column names or display settings, belongs under `_meta` in the `result` mapping. It reaches the view as the result's `_meta.view` and is never part of the data the model reads.

```json
"result": { "places": "{{tool.rows}}", "_meta": { "columns": "{{tool.columns}}" } }
```

## Rules

- **One built-in tool per custom tool.** A custom tool runs exactly one of the container's built-in tools. It cannot call another custom tool.
- **Map, never compute.** A custom tool fills in arguments and shapes results. It does not do arithmetic, conditionals or string processing. If a task needs that, the built-in tool is missing a parameter.
- **Checked when you save.** Wire refuses a definition whose substitutions name an input the schema does not have, whose arguments do not fit the built-in tool's parameters, or whose input types conflict with the parameter they fill. Each error names the path at fault, for example `tool.args.limit`.

### What a definition can contain

Validation is stricter than plain JSON Schema, so that every call a definition accepts is one the built-in tool accepts too:

- `inputSchema` supports `type`, `properties`, `required`, `additionalProperties`, `items`, `enum`, `const`, `default`, number, length and item-count bounds, and `anyOf` / `oneOf` / `allOf`, plus annotations such as `description` and `title`. Any other keyword, including `pattern` and `$ref`, is refused, because Wire checks every call against the schema itself and will not accept a rule it does not enforce.
- Every name in `required` must be a defined property.
- `tool.args` can only use the built-in tool's own parameters. `tags`, for example, is a `wire_write` parameter and is refused on `wire_search`.
- An object input passed whole, such as `"near": "{{input.near}}"`, needs `additionalProperties: false` and must require everything the built-in tool requires inside it.
- Anything the built-in tool requires must be fixed, or come from a required input or one with a default.
- A name cannot be a built-in tool's short name, such as `search` or `write`, because REST calls tools by that name.

## Visibility and permission

A custom tool runs its built-in tool whether or not that built-in tool is visible to the caller. That is what lets you [turn `wire_search` off](/reference/tools/#activating-and-deactivating-tools) and leave `find_decisions` on, so agents search the decision log and nothing else.

Permission still applies. A custom tool over a tool that changes data, such as `wire_write` or `wire_delete`, needs the same permission as calling that tool directly, so someone with viewer access cannot record a decision through `log_decision`.

To give a particular agent only your custom tools, [limit its API key](/mcp/authentication/#limiting-a-key-to-specific-tools) to them.

## Billing

A custom tool costs what its built-in tool costs. `find_decisions` costs 5 credits per call, the same as `wire_search`, and `log_decision` is free, the same as `wire_write`. See the [tool reference](/reference/tools/#tool-reference) for each tool's cost.

The container's activity records the custom tool's name along with the built-in call it made.

## Managing custom tools

Custom tools are listed in their own section of the container's **Tools** page in the dashboard. From there you can:

- **Create or edit** a tool in a JSON editor that checks the definition as you type.
- **Test** a tool with sample input, which calls it on the container and shows the result. A test call is a real call, and is billed and recorded like any other.
- **Turn it on or off** for MCP and REST, like any other tool. As with every tool, this takes admin access to the container.
- **Delete** it. Agents connected over MCP are told the tool list changed.

Creating, editing and deleting a custom tool needs editor or admin access to the container. Anyone with access can see the definitions. See [Roles & Permissions](/reference/roles/).

While a [connected agent manages the container](/guides/connecting-agents/#an-agent-manages-its-container), custom tools can't be created, edited, or deleted. The ones you made before that agent was installed are kept, hidden from every connection, and come back when you uninstall it.

A custom tool created by an editor starts no more visible than the tool it runs. If an admin has turned that tool off, or it is off by default like [`wire_query`](/reference/tools/#wire_query), the new custom tool starts off too, until an admin turns it on. An admin's custom tools start on.

If a built-in tool changes so that a saved definition no longer fits it, the custom tool is marked **Broken** on the Tools page and agents cannot call it until it is edited.

## Managing custom tools through the API

The container serves the same operations at `https://YOUR_ORG_SLUG.api.usewire.io/container/YOUR_CONTAINER_ID/custom-tools`, with an [API key](/mcp/authentication/#using-api-keys) or OAuth token in the usual headers.

| Method | Path | Access | What it does |
|--------|------|--------|--------------|
| `GET` | `/custom-tools` | viewer | List custom tools |
| `GET` | `/custom-tools/{name}` | viewer | Read one |
| `PUT` | `/custom-tools/{name}` | editor | Create or replace it. The body is the definition; `name` can be left out. |
| `DELETE` | `/custom-tools/{name}` | editor | Delete it |
| `POST` | `/custom-tools/validate` | editor | Check a definition without saving it |

```bash title="Create find_decisions"
curl -X PUT \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d @find_decisions.json \
  https://YOUR_ORG_SLUG.api.usewire.io/container/YOUR_CONTAINER_ID/custom-tools/find_decisions
```

A refused definition answers `400` with every problem found, each naming its path:

```json
{ "ok": false, "error": "invalid custom tool definition", "errors": [{ "path": "tool.args.tags", "message": "…" }] }
```

A key [limited to specific tools](/mcp/authentication/#limiting-a-key-to-specific-tools) can read the custom tools on its list but cannot create, change or delete any, since a custom tool would otherwise let it reach a tool outside its list. Call a custom tool like any other: `POST /container/YOUR_CONTAINER_ID/tools/find_decisions` on REST, or `tools/call` with `"name": "find_decisions"` on MCP.