Skip to content

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 with a nested near object and an object filter the agent has to remember to send.

A custom tool is a JSON definition with five fields.

FieldRequiredWhat it is
nameYesWhat agents call. Letters, digits and underscores, starting with a letter. The wire_ prefix is reserved for built-in tools.
descriptionYesWhat agents read in tools/list to decide when to call it.
inputSchemaYesA JSON Schema object describing the tool’s arguments.
toolYesThe built-in tool it runs: { "name": "wire_search", "args": { … } }.
resultNoHow to shape the built-in tool’s result. Without it, the result is returned unchanged.
outputSchemaNoA 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.

A substitution is a path in double braces.

SubstitutionWhere it can appearWhat it reads
{{input.x}}tool.args and resultThe argument x the agent sent
{{tool.x}}result onlyThe 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.

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.

{
"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. 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.
{
"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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

MethodPathAccessWhat it does
GET/custom-toolsviewerList custom tools
GET/custom-tools/{name}viewerRead one
PUT/custom-tools/{name}editorCreate or replace it. The body is the definition; name can be left out.
DELETE/custom-tools/{name}editorDelete it
POST/custom-tools/validateeditorCheck a definition without saving it
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:

{ "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.