Aleph
Tools & Extensions

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:

MethodLanguageRegistrationBest for
Rust built-inRustCompile-timeCore capabilities, max performance
MCP serverAny (Node.js, Python, …)RuntimeExternal integrations, polyglot teams
Markdown skillMarkdown + promptsRuntimePrompt-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:

  • idempotentLoopTool::is_idempotent() or an MCP server's idempotentHint. Undeclared = false ⇒ requires approval under the Ask tier.
  • requires_approval ← an MCP server's destructiveHint, or membership in CONFIRMATION_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:

AnnotationField
readOnlyHint: trueconcurrent_safe (auto-retry)
idempotentHint: trueidempotent (tier treats as safe)
destructiveHint: truerequires_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_search over search_memory_database.
  • MCP tools: write clean inner names (get_weather); the server__ prefix is added by McpHandler.
  • 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 (in src/tools/scoped/).
  • MCP servers: return destructiveHint / requires_approval in tools/list annotations.

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 buildUse this
High-performance core toolRust AlephTool trait
External API integrationMCP server (Node.js / Python)
Prompt-driven workflowMarkdown skill
Tool that needs hot-reloadMCP server or Markdown skill
Tool that needs type safetyRust AlephTool trait

On this page