# Tools API

> Give a voice agent HTTP tools it can call mid-conversation — create, update and delete functions, with placeholder substitution and response mapping.

Source: https://edesy.in/docs/voice-agent/api/tools

A **tool** is an HTTP endpoint your agent can call mid-conversation — look up an order,
check availability, write to your CRM. The agent decides *when* to call it from the
`description` and `parametersSchema`, so those two fields are what actually drive
behaviour. Write the description the way you would brief a new colleague.

Tools are called `functions` in the API paths.

| Method | Path | Scope |
|--------|------|-------|
| `GET` | `/api/v1/functions` | `functions:read` |
| `POST` | `/api/v1/functions` | `functions:write` |
| `GET` | `/api/v1/functions/{id}` | `functions:read` |
| `PATCH` | `/api/v1/functions/{id}` | `functions:write` |
| `DELETE` | `/api/v1/functions/{id}` | `functions:write` |

## Create a tool

`POST /api/v1/functions` → **201 Created**

<!-- tabs -->

```bash
curl -X POST https://voice-agent.edesy.in/api/v1/functions \
  -H "Authorization: Bearer $EDESY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": 1042,
    "name": "lookup_order",
    "description": "Look up the status of a customer order. Call this as soon as the customer gives an order number.",
    "parametersSchema": {
      "order_id": { "type": "string", "description": "The order number, digits only" }
    },
    "requiredParams": ["order_id"],
    "httpMethod": "POST",
    "httpUrl": "https://api.example.com/orders/lookup",
    "httpHeaders": { "Content-Type": "application/json" },
    "httpBody": "{\"orderId\": \"{{order_id}}\"}",
    "responseMapping": { "status": "data.status", "eta": "data.expected_delivery" },
    "isActive": true
  }'
```

```python
import os
import requests

response = requests.post(
    "https://voice-agent.edesy.in/api/v1/functions",
    headers={"Authorization": f"Bearer {os.environ['EDESY_API_KEY']}"},
    json={
        "agentId": 1042,
        "name": "lookup_order",
        "description": "Look up the status of a customer order. Call this as soon as the customer gives an order number.",
        "parametersSchema": {
            "order_id": {"type": "string", "description": "The order number, digits only"}
        },
        "requiredParams": ["order_id"],
        "httpMethod": "POST",
        "httpUrl": "https://api.example.com/orders/lookup",
        "httpHeaders": {"Content-Type": "application/json"},
        # A JSON *string*, not an object — the placeholder is substituted at call time.
        "httpBody": '{"orderId": "{{order_id}}"}',
        "responseMapping": {"status": "data.status", "eta": "data.expected_delivery"},
        "isActive": True,
    },
    timeout=30,
)
response.raise_for_status()

tool = response.json()["data"]
print(tool["id"])
```

```javascript
const response = await fetch("https://voice-agent.edesy.in/api/v1/functions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.EDESY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    agentId: 1042,
    name: "lookup_order",
    description:
      "Look up the status of a customer order. Call this as soon as the customer gives an order number.",
    parametersSchema: {
      order_id: { type: "string", description: "The order number, digits only" },
    },
    requiredParams: ["order_id"],
    httpMethod: "POST",
    httpUrl: "https://api.example.com/orders/lookup",
    httpHeaders: { "Content-Type": "application/json" },
    // A JSON *string*, not an object.
    httpBody: '{"orderId": "{{order_id}}"}',
    responseMapping: { status: "data.status", eta: "data.expected_delivery" },
    isActive: true,
  }),
});

if (!response.ok) {
  throw new Error(`Edesy API ${response.status}: ${await response.text()}`);
}

const { data: tool } = await response.json();
console.log(tool.id);
```

```php
<?php
$ch = curl_init('https://voice-agent.edesy.in/api/v1/functions');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('EDESY_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'agentId'          => 1042,
        'name'             => 'lookup_order',
        'description'      => 'Look up the status of a customer order. Call this as soon as the customer gives an order number.',
        'parametersSchema' => [
            'order_id' => ['type' => 'string', 'description' => 'The order number, digits only'],
        ],
        'requiredParams'  => ['order_id'],
        'httpMethod'      => 'POST',
        'httpUrl'         => 'https://api.example.com/orders/lookup',
        'httpHeaders'     => ['Content-Type' => 'application/json'],
        // A JSON *string*, not an array.
        'httpBody'        => '{"orderId": "{{order_id}}"}',
        'responseMapping' => ['status' => 'data.status', 'eta' => 'data.expected_delivery'],
        'isActive'        => true,
    ]),
]);

$response = curl_exec($ch);
$status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($status !== 201) {
    throw new RuntimeException("Edesy API {$status}: {$response}");
}

$tool = json_decode($response, true)['data'];
echo $tool['id'];
```

<!-- /tabs -->

<!-- response 201 -->
```json
{
  "success": true,
  "data": {
    "id": 318,
    "name": "lookup_order",
    "description": "Look up the status of a customer order. …",
    "agentId": 1042,
    "parametersSchema": { "order_id": { "type": "string", "description": "The order number, digits only" } },
    "requiredParams": ["order_id"],
    "httpMethod": "POST",
    "httpUrl": "https://api.example.com/orders/lookup",
    "httpHeaders": { "Content-Type": "application/json" },
    "httpBody": "{\"orderId\": \"{{order_id}}\"}",
    "responseMapping": { "status": "data.status" },
    "credentialRefs": [],
    "isActive": true,
    "templateId": null,
    "categoryId": null,
    "createdAt": "2026-08-10T09:20:11.004Z",
    "updatedAt": "2026-08-10T09:20:11.004Z"
  }
}
```

A newly created tool is visible to **live calls immediately** — the agent's cached call
context is invalidated as part of the write.

**Errors:** `400 VALIDATION_ERROR`, `404 AGENT_NOT_FOUND` (agent missing or in another
workspace), `409 DUPLICATE_FUNCTION_NAME` (name already used on that agent),
`409 NO_ATTRIBUTABLE_USER`.

## Field reference

| Field | Type | Notes |
|-------|------|-------|
| `agentId` | integer | Required on create. The agent this tool belongs to |
| `name` | string (≤64) | Required. Must match `^[a-zA-Z_][a-zA-Z0-9_]*$` — it is handed to the LLM as a tool name |
| `description` | string (≤1000) | **Drives when the agent calls the tool.** Be explicit |
| `parametersSchema` | object | A JSON-Schema **properties map**: `{ "<param>": { "type": …, "description": … } }`. Do not wrap it in `{"type":"object","properties":…}` — that wrapper is added for you |
| `requiredParams` | string[] | Which parameters are mandatory |
| `httpMethod` | `GET` \| `POST` \| `PUT` \| `PATCH` \| `DELETE` | |
| `httpUrl` | string | Your endpoint. Supports placeholders |
| `httpHeaders` | object | Header name → value. Supports placeholders |
| `httpBody` | string | Request body template, as a **string**. Supports placeholders |
| `responseMapping` | object | Reshapes your response before the agent sees it |
| `credentialRefs` | object[] | `{ "placeholder": "ORDER_API_TOKEN", "vaultSlug": "acme-orders" }` — injects a secret from the credential vault at call time, so tokens never live in the tool definition. Reference it as `{{vault.acme-orders}}` |
| `isActive` | boolean | Default `true`. Inactive tools are not offered to the agent |
| `templateId` / `categoryId` | string | Optional grouping metadata |

Tool names are unique per agent (`agentId` + `name`).

## Placeholders

`httpUrl`, `httpHeaders` and `httpBody` are templates. Both `{{name}}` and `{name}` are
accepted. Three kinds of value resolve:

| Placeholder | Source |
|-------------|--------|
| `{{order_id}}` | A parameter the agent supplied, from your `parametersSchema` |
| `{{call.id}}` | Call context injected by the platform |
| `{{vault.acme-orders}}` | A secret from the credential vault, resolved just before the request |

Available call-context values:

| Placeholder | Value |
|-------------|-------|
| `{{call.id}}` / `{{call.conversation_id}}` | Conversation id — the same id `GET /api/v1/calls/{id}` takes |
| `{{call.sid}}` | Carrier Call SID (Twilio `CA…`, etc.) |
| `{{call.phone_number}}` / `{{call.from}}` | The other party's number |
| `{{call.agent_id}}` | Agent id |
| `{{call.workspace_id}}` | Workspace id |

Flat spellings `{{call_id}}`, `{{conversation_id}}` and `{{call_sid}}` also resolve.

Two behaviours worth knowing: an agent-supplied parameter **wins** over a call-context
value of the same name, and an **unknown placeholder is left in place verbatim** rather
than blanked — so if you receive a literal `{{call.sid}}` in a request, that value was
unavailable for that call, not empty.

## Response mapping

By default the agent receives your endpoint's response as-is. `responseMapping`
reshapes it first. **The two runtimes accept different shapes**, so pick based on where
your agent runs — or omit it entirely and let the agent read the raw response, which
behaves identically everywhere.

Flat form — `{"targetKey": "dot.path"}`, builds a new object from extracted paths:

```json
{ "responseMapping": { "status": "data.status", "eta": "data.expected_delivery" } }
```

Wrapped forms — `extract` returns a single value, `transform` builds an object:

```json
{ "responseMapping": { "extract": "data.status" } }
```

```json
{ "responseMapping": { "transform": { "status": "data.status" } } }
```

| Runtime | Flat `{key: path}` | `{"extract": …}` | `{"transform": {…}}` |
|---------|--------------------|------------------|----------------------|
| Go (default) | Yes | No — treated as a literal key | No |
| Python | No — **silently ignored** | Yes | Yes |

Paths use dot notation and support array indices: `data.items.0.name`. A path that
doesn't match is dropped from the result. If mapping fails for any reason, the raw
response is passed through rather than erroring.

> Because an unsupported shape is ignored rather than rejected, a mismatch shows up as
> "the agent behaves as if I never configured mapping". If in doubt, omit
> `responseMapping` and return exactly the JSON you want the agent to read.

## Update a tool

`PATCH /api/v1/functions/{id}` → **200 OK**

Partial update. Every field above is optional.

```bash
curl -X PATCH https://voice-agent.edesy.in/api/v1/functions/318 \
  -H "Authorization: Bearer $EDESY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "httpUrl": "https://api.example.com/v2/orders/lookup" }'
```

**HTTP fields merge into the existing implementation.** Changing `httpUrl` alone does
not drop your headers or body template — only what you send is replaced.

A tool can be moved to another agent by sending a new `agentId`, as long as the target
agent is in the same workspace. Both agents' caches are refreshed.

**Errors:** `400 VALIDATION_ERROR`, `404 NOT_FOUND`, `404 AGENT_NOT_FOUND`
(reassignment target outside your workspace), `409 DUPLICATE_FUNCTION_NAME` (rename
collides on the target agent).

## List, read and delete tools

`GET /api/v1/functions`

| Query | Type | Description |
|-------|------|-------------|
| `agentId` | integer | Restrict to one agent |
| `active` | `true` | Only active tools |
| `limit` / `offset` | integer | Pagination (max 100) |

<!-- response 200 -->
```json
{ "success": true, "data": { "functions": [], "total": 4, "limit": 50, "offset": 0 } }
```

`GET /api/v1/functions/{id}` returns a single tool.
`DELETE /api/v1/functions/{id}` returns `{ "success": true }`. Both return
`404 NOT_FOUND` for an unknown id.

## Next steps

- [Agents](https://edesy.in/docs/voice-agent/api/agents) — configure the agent that owns these tools
- [Calls](https://edesy.in/docs/voice-agent/api/calls) — place a call and watch the tool fire
