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_decisionwhen the user makes a decision, because the tool says what it is for in the agent’s own terms. A generalwire_writeleaves 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 with a nested near object and an object filter the agent has to remember to send.
How a custom tool is defined
Section titled “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. |
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
Section titled “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
Section titled “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, and find_decisions searches only that object.
log_decision
Section titled “log_decision”{ "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:
{ "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.
tagsis a whole-value substitution, so the list arrives as a list, not as text. - An omitted optional.
ownerwas not sent. Insidecontentit became empty text; as the whole value offields.ownerit was left out of the call. - A fixed value.
objectisdecisionson every call, so every decision lands in the same place and the agent never has to name it. - A
resultmapping. The agent gets back{ "decisionId": "…", "title": "Ship the beta on Tuesday" }, read from the write’sentryIdwith{{tool.entryId}}, instead of the full write response.
find_decisions
Section titled “find_decisions”{ "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
Section titled “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.
{ "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:
{ "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 for what each combination does.
Example: find a place through its notes
Section titled “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:
{ "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:
{ "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 for the rules.
Example: notes linked to a place
Section titled “Example: notes linked to a place”add_note writes a note and links it to the place it’s about. remove_place deletes a place together with its notes.
{ "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" }] } }}{ "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:
{ "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.
Annotations and output schemas
Section titled “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, 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.
"result": { "places": "{{tool.rows}}", "_meta": { "columns": "{{tool.columns}}" } }- 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
Section titled “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:
inputSchemasupportstype,properties,required,additionalProperties,items,enum,const,default, number, length and item-count bounds, andanyOf/oneOf/allOf, plus annotations such asdescriptionandtitle. Any other keyword, includingpatternand$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
requiredmust be a defined property. tool.argscan only use the built-in tool’s own parameters.tags, for example, is awire_writeparameter and is refused onwire_search.- An object input passed whole, such as
"near": "{{input.near}}", needsadditionalProperties: falseand 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
searchorwrite, because REST calls tools by that name.
Visibility and permission
Section titled “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 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 to them.
Billing
Section titled “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 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
Section titled “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.
While a connected agent manages the 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, 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
Section titled “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 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 |
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_decisionsA refused definition answers 400 with every problem found, each naming its path:
{ "ok": false, "error": "invalid custom tool definition", "errors": [{ "path": "tool.args.tags", "message": "…" }] }A key limited 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.