Skip to content
MeridFlow AiFlow v8.x • self-hosted

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.

POST /api/v1/agents/{agent_id}/custom-tools

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

GET /api/v1/agents/{agent_id}/custom-tools
curl https://api.your-domain.com/api/v1/agents/1/custom-tools \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.get(
    "https://api.your-domain.com/api/v1/agents/1/custom-tools",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
custom_tools = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/custom-tools",
  { headers: { Authorization: `Bearer ${adminToken}` } },
);
const customTools = await response.json();

Returns a list of the same shape shown in Create above, ordered by id.

Update a custom tool

PATCH /api/v1/agents/{agent_id}/custom-tools/{tool_id}

Every field from the field reference is optional here. Send only what you're changing.

curl -X PATCH https://api.your-domain.com/api/v1/agents/1/custom-tools/4 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"enabled": false}'
import httpx

response = httpx.patch(
    "https://api.your-domain.com/api/v1/agents/1/custom-tools/4",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={"enabled": False},
)
response.raise_for_status()
custom_tool = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/custom-tools/4",
  {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${adminToken}`,
    },
    body: JSON.stringify({ enabled: false }),
  },
);
const customTool = await response.json();

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

DELETE /api/v1/agents/{agent_id}/custom-tools/{tool_id}
curl -X DELETE https://api.your-domain.com/api/v1/agents/1/custom-tools/4 \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.delete(
    "https://api.your-domain.com/api/v1/agents/1/custom-tools/4",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/custom-tools/4",
  {
    method: "DELETE",
    headers: { Authorization: `Bearer ${adminToken}` },
  },
);

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.