Custom tools¶
A custom tool backs a new tool with any HTTP endpoint you control, with no AiFlow code changes required. You define the name, the parameters the model fills in, and the HTTP request AiFlow makes when the model calls it (method, URL, headers, body), all admin-configured, per agent. This is one of two ways to extend an agent beyond the built-in tool catalog; the other is MCP servers, which connects to a server you don't have to build yourself.
Admin role required
GET /api/v1/agents/{agent_id}/custom-tools needs any logged-in
admin (Owner, Admin, Editor, or Viewer). POST, PATCH, and
DELETE all need Owner or Admin.
Field reference¶
| Field | Type | Required on create | Default | Description |
|---|---|---|---|---|
name |
string | Yes | None | Must match ^[a-zA-Z_][a-zA-Z0-9_]{0,63}$: starts with a letter or underscore, followed by up to 63 more letters, digits, or underscores. This is the identifier the model calls as a function, it must be a valid Gemini function name. |
display_name |
string or null |
No | null |
Up to 120 characters. Label shown in the admin dashboard; has no effect on the model. |
description |
string | Yes | None | 1 to 500 characters. Sent to the model as the function's description, this is what the model reads to decide when to call the tool, write it the same way you'd write a built-in tool's description. |
parameters_schema |
object | Yes | None | A Gemini function-calling JSON schema (type, properties, required, using Gemini's OBJECT/STRING/INTEGER/etc. type names) describing the arguments the model fills in when it calls this tool. See Google's function calling guide for the full schema format. Get this wrong and the model either can't call the tool correctly or invents arguments that don't match what your endpoint expects. |
enabled |
boolean | No | true |
Whether the model can call this tool right now. |
http_method |
string | No | "POST" |
One of GET, POST, PUT, PATCH, DELETE, QUERY. See the table below for what each means here specifically. |
url |
string | Yes | None | 1 to 500 characters. The endpoint AiFlow calls. Supports Jinja2 templating against the model's arguments, e.g. https://api.acme.example.com/orders/{{ order_number }}. |
headers |
object of string to string, or null |
No | null |
Static HTTP headers sent with every call, e.g. {"Authorization": "Bearer acme-api-key"}. Not templated. |
body_template |
string or null |
No | null |
Real Jinja2, not just {{ variable }} substitution: filters, conditionals, and loops all work. Rendered against the model's call arguments to build the request body. Ignored entirely for GET, which sends no body. |
timeout_seconds |
integer | No | 10 |
1 to 60. How long AiFlow waits for your endpoint to respond before treating the call as failed. |
response_mode |
string | No | "raw_text" |
"raw_text" returns your endpoint's response body to the model verbatim. "json_field" parses the response as JSON and extracts one field, see response_field_path below. |
response_field_path |
string or null |
No | null |
Only read when response_mode is "json_field"; a dot-path into the parsed JSON response, e.g. status or order.status. Nothing in the schema enforces that you've set this when response_mode is "json_field", if you omit it, the tool has no field to extract and returns an error to the model at call time instead of a value. |
http_method¶
| Method | Behavior |
|---|---|
GET |
No request body is sent; body_template is ignored even if set. |
POST |
The default. |
PUT |
Sent as a full-resource replace, by convention on your side, AiFlow doesn't enforce any particular semantics. |
PATCH |
Sent as a partial update, by convention on your side. |
DELETE |
By convention carries no body, but AiFlow still sends body_template if you've set one. |
QUERY |
Safe and idempotent like GET, but carries a request body like POST. Useful for a lookup whose filter criteria don't fit cleanly in a URL. Still an active IETF Internet-Draft as of this writing, not a finalized RFC, your own endpoint needs to understand the QUERY HTTP method explicitly for this to work. |
AiFlow passes whatever method you configure straight through to your url,
with no per-method special-casing beyond what's in this table. This list
reflects what AiFlow's own request validation accepts, not a constraint
your endpoint has to honor beyond understanding the method itself.
Create a custom tool¶
Capped by your licence
This deployment's licence sets a custom_tools limit. Creating one more
past that cap returns 402 rather than succeeding; see
Licence limits.
This example gives an agent a lookup_order tool backed by a GET
request, extracting one field (status) from the JSON response:
curl -X POST https://api.your-domain.com/api/v1/agents/1/custom-tools \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{
"name": "lookup_order",
"description": "Looks up an order by its order number.",
"parameters_schema": {
"type": "OBJECT",
"properties": {"order_number": {"type": "STRING"}},
"required": ["order_number"]
},
"http_method": "GET",
"url": "https://api.acme.example.com/orders/{{ order_number }}",
"headers": {"Authorization": "Bearer acme-api-key"},
"response_mode": "json_field",
"response_field_path": "status"
}'
import httpx
response = httpx.post(
"https://api.your-domain.com/api/v1/agents/1/custom-tools",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"name": "lookup_order",
"description": "Looks up an order by its order number.",
"parameters_schema": {
"type": "OBJECT",
"properties": {"order_number": {"type": "STRING"}},
"required": ["order_number"],
},
"http_method": "GET",
"url": "https://api.acme.example.com/orders/{{ order_number }}",
"headers": {"Authorization": "Bearer acme-api-key"},
"response_mode": "json_field",
"response_field_path": "status",
},
)
response.raise_for_status()
custom_tool = response.json()
const response = await fetch(
"https://api.your-domain.com/api/v1/agents/1/custom-tools",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify({
name: "lookup_order",
description: "Looks up an order by its order number.",
parameters_schema: {
type: "OBJECT",
properties: { order_number: { type: "STRING" } },
required: ["order_number"],
},
http_method: "GET",
url: "https://api.acme.example.com/orders/{{ order_number }}",
headers: { Authorization: "Bearer acme-api-key" },
response_mode: "json_field",
response_field_path: "status",
}),
},
);
const customTool = await response.json();
Returns 201 Created:
{
"id": 4,
"agent_id": 1,
"name": "lookup_order",
"display_name": null,
"description": "Looks up an order by its order number.",
"parameters_schema": {
"type": "OBJECT",
"properties": { "order_number": { "type": "STRING" } },
"required": ["order_number"]
},
"enabled": true,
"http_method": "GET",
"url": "https://api.acme.example.com/orders/{{ order_number }}",
"headers": { "Authorization": "Bearer acme-api-key" },
"body_template": null,
"timeout_seconds": 10,
"response_mode": "json_field",
"response_field_path": "status"
}
A Jinja2 body_template for a POST, PUT, or PATCH tool can use
filters, conditionals, and loops against the call arguments. This example
rejects an order update unless a reason argument was actually supplied:
{
"body_template": "{ \"order_number\": \"{{ order_number }}\", \"note\": \"{% if reason %}{{ reason }}{% else %}No reason given{% endif %}\" }"
}
List an agent's custom tools¶
Returns a list of the same shape shown in Create
above, ordered by id.
Update a custom tool¶
Every field from the field reference is optional here. Send only what you're changing.
Returns the full updated resource, same shape as create. A tool_id that
doesn't belong to this agent, whether the wrong agent or one that doesn't
exist, returns 404.
Delete a custom tool¶
Returns 204 No Content. This is a hard delete: there is no soft-disable
path the way Agents has. Set enabled: false via PATCH
instead if you want to temporarily turn a custom tool off without losing
its configuration.
Next¶
- Tools: the built-in catalog every agent starts with.
- MCP servers: connect to an existing external server's tools instead of building your own HTTP endpoint.