Creating Tools
Step-by-step guide to building custom tools for Aleph
Overview
You can extend Aleph by creating custom tools. Three primary paths, ordered by integration depth:
| Method | Language | Registration | Best for |
|---|---|---|---|
| Rust built-in | Rust | Compile-time | Core capabilities, max performance |
| MCP server | Any (Node.js, Python, …) | Runtime | External integrations, polyglot teams |
| Markdown skill | Markdown + prompts | Runtime | Prompt-driven workflows, no code |
Every tool call funnels through the same chokepoint:
ScopedToolService (src/tools/scoped/). It merges per-channel
tool_permissions, the exec tier (Ask / Auto / Full), and the sandbox
command-policy floor on one path — when you write a new tool, the chokepoint
is what decides whether your tool needs to carry requires_confirmation
metadata or a sandbox dependency at registration.
Creating a Rust built-in tool
This is the deepest integration. Your tool is compiled into the Aleph binary with static dispatch and zero-cost abstractions.
Step 1 — Define the argument type
Create a struct for your tool's input parameters. Derive JsonSchema for
automatic schema generation — the field-level doc comments become the
parameter descriptions the LLM sees:
// src/builtin_tools/weather.rs
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Arguments for the weather tool.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WeatherArgs {
/// City name or coordinates (e.g., "Tokyo" or "35.6762,139.6503")
pub location: String,
/// Temperature unit: "celsius" or "fahrenheit" (default: celsius)
#[serde(default = "default_unit")]
pub unit: String,
/// Forecast days (1–7, default: 1)
#[serde(default = "default_days")]
pub days: u8,
}
fn default_unit() -> String { "celsius".to_string() }
fn default_days() -> u8 { 1 }The required array is auto-computed: only fields without
#[serde(default)] are required.
Step 2 — Define the output type
The output struct is serialized to JSON and returned to the LLM:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeatherOutput {
pub city: String,
pub temperature: f64,
pub unit: String,
pub condition: String,
pub forecast: Vec<ForecastDay>,
}Step 3 — Implement the AlephTool trait
use async_trait::async_trait;
use crate::error::Result;
use crate::tools::AlephTool;
#[derive(Clone)]
pub struct WeatherTool {
api_key: String,
client: reqwest::Client,
}
#[async_trait]
impl AlephTool for WeatherTool {
const NAME: &'static str = "weather";
const DESCRIPTION: &'static str =
"Get current weather and forecast for a location. \
Returns temperature, conditions, and multi-day forecast.";
type Args = WeatherArgs;
type Output = WeatherOutput;
async fn call(&self, args: Self::Args) -> Result<Self::Output> {
let response = self.client
.get("https://api.weather.example.com/v1/forecast")
.query(&[
("location", &args.location),
("unit", &args.unit),
("days", &args.days.to_string()),
("key", &self.api_key),
])
.send()
.await?
.json::<WeatherOutput>()
.await?;
Ok(response)
}
}Step 4 — Register in the builtin registry
Built-in tools are not registered through a hand-written builder — they are
assembled by the executor's builtin registry
(src/executor/builtin_registry/). Add your tool to
src/builtin_tools/mod.rs:
pub mod weather;
pub use weather::{WeatherTool, WeatherArgs, WeatherOutput};The registry creates instances and injects shared dependencies
(Arc<Sandbox>, Arc<ProfileManager>, Arc<McpManagerHandle>, …) into
the tools that need them. A tool constructed without a required dependency
returns a structured "sandbox not configured" / "manager not configured"
error and does not fall back to unscoped execution — the default is
safe, not permissive.
If your tool spawns subprocesses, it must hold an Arc<dyn Sandbox> and
dispatch through it (src/sandbox/mod.rs::Sandbox trait). Naked
Command::new is not allowed in src/builtin_tools/ for exec-class
tools.
Step 5 — Declare approval semantics
ScopedToolService reads ToolDefinitionMetadata, not tool names:
idempotent←LoopTool::is_idempotent()or an MCP server'sidempotentHint. Undeclared =false⇒ requires approval under theAsktier.requires_approval← an MCP server'sdestructiveHint, or membership inCONFIRMATION_REQUIRED_TOOLS(e.g.,vault_store,agent_delete,team_disband).
The way to change these semantics is to declare them. A name-glob whitelist adds no extra coverage on top of this.
Generated JSON Schema
When your tool is registered, Aleph auto-generates this tool definition for the LLM:
{
"type": "function",
"function": {
"name": "weather",
"description": "Get current weather and forecast for a location. Returns temperature, conditions, and multi-day forecast.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City name or coordinates (e.g., \"Tokyo\" or \"35.6762,139.6503\")"
},
"unit": {
"type": "string",
"description": "Temperature unit: \"celsius\" or \"fahrenheit\" (default: celsius)",
"default": "celsius"
},
"days": {
"type": "integer",
"description": "Forecast days (1–7, default: 1)",
"default": 1
}
},
"required": ["location"]
}
}
}Creating an MCP server tool
For languages other than Rust, or when you want to iterate without recompiling Aleph, MCP is the right path. The server runs as a separate process and communicates with Aleph over the Model Context Protocol.
Node.js example
Use the official @modelcontextprotocol/sdk:
// weather-server/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{ name: "weather-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler("tools/list", async () => ({
tools: [
{
name: "get_weather",
description: "Get current weather for a location",
inputSchema: {
type: "object",
properties: {
location: { type: "string", description: "City name or coordinates" },
unit: {
type: "string",
enum: ["celsius", "fahrenheit"],
default: "celsius",
},
},
required: ["location"],
},
annotations: {
readOnlyHint: true,
idempotentHint: true,
},
},
],
}));
server.setRequestHandler("tools/call", async (request) => {
if (request.params.name === "get_weather") {
const { location, unit } = request.params.arguments;
const response = await fetch(
`https://api.weather.example.com/v1?q=${location}&units=${unit}`
);
const data = await response.json();
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);Python example
Use the official mcp package:
# weather_server.py
import json
import httpx
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
app = Server("weather-server")
@app.list_tools()
async def list_tools():
return [
Tool(
name="get_weather",
description="Get current weather for a location",
inputSchema={
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "get_weather":
location = arguments["location"]
unit = arguments.get("unit", "celsius")
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://api.weather.example.com/v1",
params={"q": location, "units": unit},
)
data = resp.json()
return [TextContent(type="text", text=json.dumps(data, indent=2))]
raise ValueError(f"Unknown tool: {name}")
async def main():
async with stdio_server() as (read, write):
await app.run(read, write)
if __name__ == "__main__":
import asyncio
asyncio.run(main())Register with Aleph
Add the server to ~/.aleph/mcp_config.json:
{
"version": 1,
"servers": {
"weather": {
"id": "weather",
"name": "Weather Server",
"transport": "stdio",
"command": "npx",
"args": ["tsx", "/path/to/weather-server/index.ts"],
"requires_runtime": "node",
"auto_start": true,
"timeout_seconds": 30
}
}
}You must restart aleph-server for changes to take effect — the file is
not auto-reloaded and there is no MCP-management RPC surface.
MCP tool naming convention
Your server-declared tool name (get_weather) appears in Aleph's registry
as <server>__<tool> (weather__get_weather). McpHandler::qualified_name
is the single source of naming truth — it strips the manager's redundant
{server}: prefix, maps any character outside [A-Za-z0-9_-] to _, and
truncates to 64 characters (the alphabet OpenAI function calling /
Anthropic / Gemini all accept). Don't put colons or dots in your tool
names.
Annotations = metadata-driven gating
ToolAnnotations are the signal ScopedToolService actually reads to
decide idempotent / requires_approval:
| Annotation | Field |
|---|---|
readOnlyHint: true | concurrent_safe (auto-retry) |
idempotentHint: true | idempotent (tier treats as safe) |
destructiveHint: true | requires_approval (Ask tier still prompts) |
Not declaring them in your server response means the field is false —
which means "unknown ⇒ non-idempotent ⇒ mutating ⇒ Ask tier holds
fail-closed". That's by design; no name-glob override is allowed on top
of it.
Creating a Markdown skill
Markdown skills are the simplest way to extend Aleph — no code. Skills
define prompt-driven workflows in SKILL.md files.
Skill file structure
Create a SKILL.md file in one of the configured skill directories:
---
name: summarize_article
description: Summarize a web article into key points
---
# Summarize Article
Given the URL: {{url}}
Please:
1. Fetch the content from the URL
2. Identify the main topic and key arguments
3. Produce a concise summary with bullet points
4. Note any important data or statistics mentioned
Output the summary in markdown format.Skills are loaded at startup from configured directories and hot-reloaded on file change — no restart needed.
Tool design best practices
Naming conventions
- snake_case:
web_fetch,file_read,bash_exec. - Descriptive but concise:
memory_searchoversearch_memory_database. - MCP tools: write clean inner names (
get_weather); theserver__prefix is added byMcpHandler. - Do not put colons or dots in tool names — they map to
_and the name is truncated to 64 chars.
Description quality
The description is what the LLM uses to decide whether to call your tool. Be specific. State inputs and outputs.
Metadata, not name whitelists
If your tool is destructive, do not rely on a name match to force approval.
ScopedToolService reads ToolDefinitionMetadata:
- Built-in tools: register once in
CONFIRMATION_REQUIRED_TOOLS(insrc/tools/scoped/). - MCP servers: return
destructiveHint/requires_approvalintools/listannotations.
Error handling
Return Result<Self::Output> with Aleph's error types. Map HTTP / network
failures to ToolError::Transport or Timeout; map everything else to
ToolError::Execution so the model sees and recovers.
Sandbox contract
If your tool spawns subprocesses, accept an Arc<dyn Sandbox> and
dispatch through it (WorkspaceSandbox::execute). Naked Command::new is
not allowed in src/builtin_tools/. The sandbox + command-policy layer
(src/sandbox/command_policy/) handles working directory, capability
escalation, and the hardline command filter for you.
Testing custom tools
Unit tests
Test the AlephTool implementation directly:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_definition() {
let tool = WeatherTool::new("test_key".to_string());
let def = AlephTool::definition(&tool);
assert_eq!(def.name, "weather");
assert!(def.description.contains("weather"));
}
#[tokio::test]
async fn test_tool_call() {
let tool = WeatherTool::new("test_key".to_string());
let result = AlephTool::call(&tool, WeatherArgs {
location: "Tokyo".to_string(),
unit: "celsius".to_string(),
days: 1,
}).await;
assert!(result.is_ok());
}
}Integration tests
Integration tests exercise the full ScopedToolService pipeline: register
the tool against the shared LoopToolRegistry, construct a
ScopedToolService bound to that registry (with permissions and tier),
then execute(name, args). tests/sandbox_capability_approval.rs is the
reference example.
Summary
| What to build | Use this |
|---|---|
| High-performance core tool | Rust AlephTool trait |
| External API integration | MCP server (Node.js / Python) |
| Prompt-driven workflow | Markdown skill |
| Tool that needs hot-reload | MCP server or Markdown skill |
| Tool that needs type safety | Rust AlephTool trait |