Aleph
Tools & Extensions

Tool System

Architecture, traits, and design of Aleph's extensible tool framework

Overview

Aleph's tool system is a type-safe, extensible framework that gives the agent the ability to act on the world — executing shell commands, reading files, searching the web, controlling a browser, and more. Every tool is defined as a Rust trait implementation with automatic JSON Schema generation, giving compile-time correctness and runtime flexibility.

Three principles:

  1. Type safety first — Tool arguments and outputs are strongly-typed Rust structs. The JSON Schema sent to the LLM is auto-generated from these types via schemars, eliminating schema drift.
  2. One chokepoint — Every tool call funnels through ScopedToolService (src/tools/scoped/), which merges the per-channel tool-permission layer with the exec tier and the sandbox command-policy floor.
  3. Capability-gated surfaces — Capability-bearing builtin tools (MCP resource / prompt readers, the OAuth login helper) are only present in the registry while at least one connected server actually advertises that capability.

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                          Harness                                  │
│                                                                  │
│           Arc<dyn ToolService>  (per-request override)           │
└──────────────────────────────┬──────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                  ScopedToolService  (src/tools/scoped/)           │
│                                                                  │
│  • tool_permissions merge (global → agent → channel,               │
│    most-restrictive wins)                                         │
│  • exec tier (Ask / Auto / Full)                                 │
│  • sandbox::command_policy floor                                 │
│  • action-aware approval gate (HITL seam)                        │
└──────────────────────────────┬──────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                      LoopToolRegistry                              │
│                                                                  │
│  Builtin handlers · MCP handlers (McpHandler per server) ·         │
│  Capability-gated builtin tools (mcp_read_resource,               │
│  mcp_list_resources, mcp_list_resource_templates, mcp_get_prompt, │
│  mcp_list_prompts, mcp_login)                                     │
└─────────────────────────────────────────────────────────────────┘

Source locations:

  • Production chokepoint: src/tools/scoped/ (dispatch.rs, builder.rs, traits.rs, ledger.rs)
  • Tool registry: src/tools/registry/, src/tools/handlers/
  • Builtin tools: src/builtin_tools/
  • MCP client / manager: src/mcp/
  • Browser runtime + per-action tools: src/browser/, src/builtin_tools/browser_tools/

The production chokepoint — ToolService

Harness consumers depend on Arc<dyn ToolService> exclusively:

pub trait ToolService: Send + Sync + 'static {
    async fn execute(&self, name: &str, input: Value) -> Result<ToolOutput, ToolError>;
    async fn list(&self) -> Vec<ToolDefinition>;
    async fn describe(&self, name: &str) -> Option<ToolDefinition>;
    fn dispatcher_schema(&self) -> Arc<[crate::dispatcher::ToolDefinition]>;
}

The production impl is ScopedToolService (src/tools/scoped/). The Gateway builds one per request via gateway::execution_engine::tool_service_builder::build_request_tool_service and supplies it as FlowRequest::tool_service, so each turn sees an allow-listed view over the shared LoopToolRegistry.

ScopedToolService carries the HITL seams natively:

  • with_confirmation(confirm_tools, requester) — gates requires_confirmation tools through ApprovalRequester (wired at boot to ChannelApprovalBridgeAdapter).
  • with_turn_context(TurnContext) — scopes the TURN_CONTEXT task-local for every tool call so HITL tools (ask_user, sandbox escalations, channel approval) can route back to the originating channel.

Tool authors implement AlephTool (typed) or LoopTool (untyped via RegistryToolAdapter). The harness fallback AgentHarnessRunner.tool_service is NullToolService (src/tools/null.rs) — production never reaches it because Gateway always supplies the per-request override; a NotFound from that service signals an upstream wiring regression.

The pre-ScopedToolService Phase 2 decorator chain (facade.rs / dispatch.rs / registry.rs / middleware/ / handlers/, ~2,700 lines) was deleted in 2026-05-20; it was unreachable because every Gateway request overrode it.

The AlephTool trait

AlephTool is the primary interface for typed tools. Static-dispatch, with the JSON Schema auto-derived from Args:

pub trait AlephTool: Clone + Send + Sync + 'static {
    const NAME: &'static str;
    const DESCRIPTION: &'static str;
    type Args: Serialize + DeserializeOwned + JsonSchema + Send;
    type Output: Serialize + Send;

    async fn call(&self, args: Self::Args) -> Result<Self::Output>;

    async fn call_json(&self, args: Value) -> Result<Value> { /* blanket */ }
    fn definition(&self) -> ToolDefinition { /* blanket, schemars */ }
}

LoopTool is the untyped, JSON-Value shape used by McpHandler and the capability-gated builtin tools. RegistryToolAdapter bridges the two.

Schema generation via schemars

Tool argument types derive JsonSchema, which generates the JSON Schema sent to the LLM. Field-level documentation comments become parameter descriptions:

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SearchArgs {
    /// Search query (natural language)
    pub query: String,
    /// Maximum results to return (default: 10)
    #[serde(default)]
    pub max_results: Option<u32>,
}

The required array is auto-computed: only fields without #[serde(default)] are required.

Tool categories

SourceDispatchExamples
Built-in (src/builtin_tools/)Static (AlephTool)bash_exec, code_exec, file_ops, web_fetch, search, memory_*, browser_*
MCP (external process)Dynamic (LoopTool via McpHandler)<server>__<tool>, capability-gated mcp_read_resource / mcp_list_resources / mcp_list_resource_templates / mcp_get_prompt / mcp_list_prompts / mcp_login
Browser per-action (src/builtin_tools/browser_tools/)Staticbrowser_open, browser_click, browser_navigate, browser_snapshot, browser_screenshot, browser_evaluate, browser_fill_form, …

A capability-gated builtin appears in the registry only while at least one connected MCP server advertises that capability — the model is never offered a tool that every call would reject. The bridge reconciles every server's capability set on every manager event (src/mcp/tool_bridge.rs).

Built-in tool registration

Builtin tools are registered by the executor's builtin registry (src/executor/builtin_registry/), not by a hand-written builder. Each tool holds the shared dependencies it needs (Arc<Sandbox>, Arc<ProfileManager>, Arc<McpManagerHandle>, …) and the registry owns wiring them up at boot.

A tool constructed without a required dependency returns a structured "sandbox not configured" / "manager not configured" error rather than falling back to unscoped execution — the default is safe, not permissive.

Per-channel tool permissions

Limiting what an agent may do (as opposed to who may connect) is the job of the per-channel tool-permission layer, ScopedToolService. It merges a ToolPermissionsConfig across three tiers — global → agent → channel, most-restrictive wins — and does not read IdentityContext. This is orthogonal to connection trust.

Three compositions happen at the same chokepoint, in this order:

  1. [policies.tool_permissions] — per-tool allow / ask / deny, exact name or glob, most-restrictive-wins across the three tiers. An explicit entry beats the tier.
  2. The exec tier (Ask / Auto / Full) — consults ToolFacts {idempotent, requires_approval}, never the tool's name. Default is fail-closed: a tool Aleph has not heard of is non-idempotent, hence Ask under any tier.
  3. [sandbox.command_policy] — the hardline command floor, which no tier and no permission entry can lower.

Deny hides the tool from the model and refuses the call; Ask routes to the approval gate.

Tool result

#[derive(Debug, Serialize, Deserialize)]
pub struct ToolResult {
    pub success: bool,
    pub output: Value,
    pub error: Option<String>,
    pub duration_ms: u64,
}

Integration with the agent loop

When the LLM responds with a tool call:

  1. Resolve the call against the per-request ScopedToolService.
  2. Filter through the merged tool_permissions (allow / ask / deny).
  3. Consult the exec tier for tools nobody named (Ask / Auto / Full).
  4. Gate confirm-gated tools through the approval requester (HITL).
  5. Execute — exec-class tools route through WorkspaceSandbox.
  6. Record the decision to the session event log; signed tools also append to the per-agent signed ledger.

Next steps

On this page