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.
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 | 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.
// 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:
{ "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:
{
"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.
On chat completions
Inline MCP servers have no OpenAI equivalent, so they ride the cephable extension object:
{
"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:
// 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:
{
"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:
// 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:
{
"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 enums, 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:
{ "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, which implements the same agent twice — once through LangChain and once against
/v1/runsdirectly — so you can compare them side by side.
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
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:
/healthreportsbusy: trueandawaitingToolResults: truewhile 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".- 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). timeoutMsstill 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:
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.