# Custom Tools

> Give Cephable's on-device agent your own tools over the local API — either by pointing it at an MCP server you run, or by declaring tools it calls back to your process to execute, which is what makes LangChain's bind_tools work unmodified.

Source: https://developers.cephable.com/docs/automate-http-server/custom-tools

---
The agent ships with ~55 built-in tools, plus whatever AI Skills and MCP servers the user has configured. Over the local API you can add your own, per run, without the user configuring anything.

There are two mechanisms, and they suit different integrations:

| | [Inline MCP servers](#inline-mcp-servers) | [Caller-executed tools](#caller-executed-tools) |
|---|---|---|
| Where the tool runs | In an MCP server you host | In your own process, inline |
| What you implement | An MCP server | An HTTP handler for one JSON shape |
| Request shape | `mcpServers: [...]` | `clientTools: [...]`, or OpenAI `tools: [...]` |
| Run lifecycle | One blocking request | Request returns, you execute, you resume |
| Good for | Tools you already expose over MCP; long-lived integrations | LangChain `bind_tools`, eval harnesses, anything already written against OpenAI tool-calling |

You can use both in the same run. Neither writes anything to the user's configured Tools library — both are scoped to the run that declared them.

---

## Inline MCP servers

Declare an MCP server on the request and Cephable connects to it for that run. Its tools arrive namespaced `mcp__<name>__<tool>`, exactly like a server the user configured in **Extensions → Tools**.

```jsonc
// POST /v1/runs
{
  "prompt": "Look up order 4471 and draft the customer a reply about the delay.",
  "mcpServers": [
    {
      "name": "my-app",
      "description": "Order lookup and customer records for the ACME store",
      "transport": "http",
      "url": "http://127.0.0.1:9123/mcp",
      "headers": { "Authorization": "Bearer internal-token" }
    }
  ]
}
```

The agent now has `mcp__my-app__lookup_order`, `mcp__my-app__get_customer`, and whatever else your server advertises — alongside its own file, web, and email tools, so it can chain yours with the rest.

### Fields

| Field | Type | Notes |
|---|---|---|
| `name` | `string` | **Required.** The config key *and* the `mcp__<name>__<tool>` prefix. Lowercase letters, numbers and hyphens, 1–64 chars; no leading/trailing hyphen and no `--` |
| `description` | `string` | **Required.** What the server does and when to use it. Max 1024 chars |
| `transport` | `"stdio" \| "http" \| "sse"` | **Required** |
| `url` | `string` | http/sse only. Must be a valid `http://` or `https://` URL |
| `headers` | `Record<string, string>` | http/sse only. Where your own auth goes |
| `command` | `string` | stdio only. **Required** for stdio |
| `args` | `string[]` | stdio only |
| `env` | `Record<string, string>` | stdio only |
| `requireApproval` | `boolean` | Default **`false`** — see below |
| `disabledTools` | `string[]` | Bare tool names to exclude, to keep a small model's context manageable |

At most **8** inline servers per run.

### `requireApproval` defaults to false here

A server the *user* configured defaults to `requireApproval: true`, because it is a third party they added. An inline server's tools were written by whoever holds the access key, and an API run has no one to show an approval dialog to — so defaulting to `true` would simply auto-reject every call unless the run also set `allowDestructiveTools: true`.

If you do want the gate, set `requireApproval: true` **and** `allowDestructiveTools: true`; otherwise every call from that server is rejected.

### Name collisions are rejected, not resolved

If `name` matches an MCP server the user already has configured, the request fails with `400`:

```jsonc
{ "error": { "message": "mcpServers[0].name collides with an MCP server already configured in Cephable: my-app. Rename the inline server, or select the configured one with selectedMcpServerIds instead.", "type": "invalid_request_error" } }
```

Silently letting one win would hand your run tools it never asked for, and `mcp__my-app__*` names in the step list would be ambiguous. Namespace your inline servers (`my-app-orders` rather than `orders`) to stay clear of whatever a user might have.

### stdio servers spawn local processes

`transport: "stdio"` is accepted with no additional gate:

```jsonc
{
  "name": "local-tools",
  "description": "Tools the caller spawns locally",
  "transport": "stdio",
  "command": "npx",
  "args": ["-y", "my-mcp-server"],
  "env": { "API_KEY": "..." }
}
```

Be clear-eyed about what this means: **an inline stdio server is arbitrary local process execution**, with arbitrary args and environment, available to anything holding the access key — independently of `allowDestructiveTools`, which gates the agent's own `run_command`. Treat the access key accordingly, and prefer an `http` server on loopback when you have the choice. See [Security model](/docs/automate-http-server/security).

### On chat completions

Inline MCP servers have no OpenAI equivalent, so they ride the `cephable` extension object:

```jsonc
{
  "model": "cephable-agent",
  "messages": [{ "role": "user", "content": "Look up order 4471" }],
  "cephable": { "mcpServers": [{ "name": "my-app", "description": "...", "transport": "http", "url": "..." }] }
}
```

---

## Caller-executed tools

Declare a tool by JSON Schema and execute it yourself. When the agent calls it, the run **parks** — it stays alive, holding its place in the agent loop — and the HTTP request returns the pending call. You execute it and post the result back to resume the same run.

This is the mechanism that makes stock OpenAI clients work, because it is the same shape as OpenAI tool-calling.

### The loop on `/v1/runs`

**1. Declare the tools and start the run:**

```jsonc
// POST /v1/runs
{
  "prompt": "Look up order 4471 and tell me whether it shipped.",
  "clientTools": [
    {
      "name": "lookup_order",
      "description": "Fetch an order by its id. Returns status, items and ship date.",
      "parameters": {
        "type": "object",
        "properties": { "id": { "type": "string", "description": "The order id" } },
        "required": ["id"]
      }
    }
  ],
  "include": { "trace": false, "events": false }
}
```

**2. The run parks and hands you the call** — `HTTP 200`, because nothing failed:

```jsonc
{
  "schemaVersion": 1,
  "requestId": "automate-run-9c2e…",
  "status": "awaiting_tool_results",
  "answer": "",
  "toolCalls": [{ "id": "client_tool_5f2a…", "name": "lookup_order", "arguments": { "id": "4471" } }],
  "resumeToken": "b41e7c0a-…",
  "durationMs": 4120,
  "steps": [ /* what it has done so far */ ],
  "usage": null
}
```

**3. Execute it and resume:**

```jsonc
// POST /v1/runs/b41e7c0a-…/tool-results
{
  "results": [{ "id": "client_tool_5f2a…", "result": "Order 4471: 2x widget, shipped 2026-09-02" }]
}
```

The response is the run's **next** outcome — which may be another `awaiting_tool_results` (an agent loop can need several rounds), or the finished record:

```jsonc
{
  "schemaVersion": 1,
  "status": "completed",
  "answer": "Order 4471 shipped on 2 September with two widgets.",
  "durationMs": 21480
}
```

Loop until `status` is no longer `awaiting_tool_results`.

### Tool definition fields

| Field | Type | Notes |
|---|---|---|
| `name` | `string` | **Required.** Letters, numbers, `_` and `-`; max 64 chars. Cannot start with `mcp__` (reserved). Must be unique in the run |
| `description` | `string` | **Required.** The model's only guidance on when to call it. Max 1024 chars |
| `parameters` | JSON Schema object | Optional. Passed to the agent as the tool's schema **verbatim** — there is no lossy Zod round-trip, so your `enum`s, `required` and descriptions reach the model intact. Omit for a no-argument tool |

At most **32** caller-executed tools per run.

### Result fields

| Field | Type | Notes |
|---|---|---|
| `id` | `string` | **Required.** The `id` from the `toolCalls` entry you are answering |
| `result` | any | What the model sees. A string passes through untouched; anything else is JSON-encoded |
| `error` | `string` | Report a failure instead. The agent sees a failed tool call and can adapt or explain, rather than the run dying |

Results are matched by `id`, not order, so you can answer several pending calls in one message. An `id` that matches nothing pending is ignored rather than failing the run — which makes a retried resume safe.

### Reporting a failure

Prefer `error` over inventing a result. The agent handles it the way it handles a built-in tool's failure:

```jsonc
{ "results": [{ "id": "client_tool_5f2a…", "error": "the orders database is unreachable" }] }
```

---

## Using it from LangChain and the OpenAI SDKs

`POST /v1/chat/completions` accepts OpenAI `tools` and answers with `finish_reason: "tool_calls"`, so `bind_tools` works with no Cephable-specific code. The resume token is folded into each `tool_call.id`, which OpenAI clients echo back verbatim — that is how the next request finds the parked run.

> **A complete working version of this** is the [python-langchain-tools sample](https://github.com/Cephable/Cephable-Automate-Agent-Samples/tree/main/samples/python-langchain-tools), which implements the same agent twice — once through LangChain and once against `/v1/runs` directly — so you can compare them side by side.

### Python

```python
import json
import os
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage

@tool
def lookup_order(id: str) -> str:
    """Fetch an order by its id. Returns status, items and ship date."""
    return f"Order {id}: 2x widget, shipped 2026-09-02"

model = ChatOpenAI(
    base_url="http://127.0.0.1:4317/v1",
    api_key=os.environ["CEPHABLE_AUTOMATE_KEY"],
    model="cephable-agent",
    timeout=930.0,   # agent runs are long
    max_retries=0,   # a retry hits 409 or starts a duplicate run
).bind_tools([lookup_order])

messages = [HumanMessage("Look up order 4471 and tell me whether it shipped.")]

while True:
    reply = model.invoke(messages)
    messages.append(reply)
    if not reply.tool_calls:
        print(reply.content)
        break
    # Cephable parked its run; execute the calls and send the whole conversation back.
    for call in reply.tool_calls:
        output = lookup_order.invoke(call["args"])
        messages.append(ToolMessage(content=str(output), tool_call_id=call["id"]))
```

### Node.js

```typescript
import OpenAI from 'openai';

const client = new OpenAI({
    baseURL: 'http://127.0.0.1:4317/v1',
    apiKey: process.env.CEPHABLE_AUTOMATE_KEY!,
    timeout: 930_000,
    maxRetries: 0,
});

const tools: OpenAI.ChatCompletionTool[] = [
    {
        type: 'function',
        function: {
            name: 'lookup_order',
            description: 'Fetch an order by its id. Returns status, items and ship date.',
            parameters: {
                type: 'object',
                properties: { id: { type: 'string' } },
                required: ['id'],
            },
        },
    },
];

const handlers: Record<string, (args: any) => Promise<string>> = {
    lookup_order: async ({ id }) => `Order ${id}: 2x widget, shipped 2026-09-02`,
};

const messages: OpenAI.ChatCompletionMessageParam[] = [
    { role: 'user', content: 'Look up order 4471 and tell me whether it shipped.' },
];

for (;;) {
    const completion = await client.chat.completions.create({ model: 'cephable-agent', messages, tools });
    const message = completion.choices[0].message;
    messages.push(message);

    if (!message.tool_calls?.length) {
        console.log(message.content);
        break;
    }

    for (const call of message.tool_calls) {
        const result = await handlers[call.function.name](JSON.parse(call.function.arguments));
        // tool_call_id carries Cephable's resume token — echo it back exactly.
        messages.push({ role: 'tool', tool_call_id: call.id, content: result });
    }
}
```

Two settings matter more than anything else: **`maxRetries: 0`** (a retry either hits `409` or starts a duplicate run) and **a very long timeout**.

### What the OpenAI path does not carry

`restrictToWorkspace`, `hitlAnswers`, `allowDestructiveTools`, `taskId`, and `selectedSkillIds`/`selectedMcpServerIds` have no OpenAI equivalent — use `/v1/runs` for those. `thinkingLevel` and `mcpServers` ride the `cephable` extension object.

---

## The parked run holds the inference slot

A parked run is mid-agent-loop, so it keeps the app's single inference slot. Three consequences:

1. **`/health` reports `busy: true` and `awaitingToolResults: true`** while a run waits on you. A client recovering from a crash can use that to tell "someone else's run is going" from "my own run is waiting on me".
2. **You have two minutes per round.** If no results arrive within that, the run is cancelled — otherwise a caller that crashed between receiving a call and answering it would lock the user's assistant out until the run's own `timeoutMs` (15 minutes by default, measured from run start).
3. **`timeoutMs` still bounds the whole task.** It is armed once at run start, not re-armed per round, so a runaway tool loop cannot hold the assistant indefinitely.

If you abandon a parked run, clear it rather than waiting for the timeout:

```bash
curl -sS http://127.0.0.1:4317/v1/automate/cancel \
  -H "Authorization: Bearer $CEPHABLE_AUTOMATE_KEY" \
  -H "Content-Type: application/json" -d '{ "force": true }'
```

`/v1/automate/cancel` and the resume route are the only routes not refused while a run is in flight.

---

## Which mechanism should you use?

**Reach for inline MCP servers when:**
- You already expose tools over MCP, or will use them from more than one client.
- You want the run to stay a single blocking request.
- Your tools are a coherent service with its own lifecycle.

**Reach for caller-executed tools when:**
- You are using LangChain, the OpenAI SDK, or an eval harness that already speaks tool-calling.
- Your "tools" are ordinary functions in your app and standing up an MCP server is overkill.
- The tool needs your process's live state — an open document, a UI selection, an authenticated session.

**A note on either:** the agent is a small on-device model. Keep tool counts low and descriptions concrete — a good `description` does more for reliability than any amount of prompt engineering, and 32 tools with vague descriptions will perform worse than 4 with sharp ones.

---

## Choosing not to trust your own inputs

Both mechanisms widen what a run can reach, and a run that reads a file, a web page, or a tool result can be steered by content in it. If your integration is unattended and its inputs are not fully trusted, pair custom tools with `restrictToWorkspace: true` and leave `allowDestructiveTools` off. See [Security model](/docs/automate-http-server/security).
