Skip to main content
Tools are functions that the LLM can call during agent runs. OpenClaw plugins can register custom tools to extend agent capabilities with domain-specific actions, integrations, and workflows.

Tool Structure

A tool has three core components:
  1. name - Unique identifier (e.g., "send_email", "create_ticket")
  2. description - Natural language description of what the tool does
  3. input - TypeBox schema defining expected parameters
  4. execute - Async function that performs the action
import type { AnyAgentTool } from "openclaw/plugin-sdk";
import { Type } from "@sinclair/typebox";

const myTool: AnyAgentTool = {
  name: "greet",
  description: "Generate a personalized greeting",
  input: Type.Object({
    name: Type.String({ description: "Name to greet" }),
    style: Type.Optional(
      Type.Union([Type.Literal("formal"), Type.Literal("casual")])
    )
  }),
  execute: async (args) => {
    const { name, style = "casual" } = args;
    const greeting =
      style === "formal"
        ? `Good day, ${name}. How may I assist you?`
        : `Hey ${name}! What's up?`;
    return {
      result: "success",
      details: { greeting }
    };
  }
};

Registering Tools

Register tools via the Plugin API:
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";

const plugin = {
  id: "greetings",
  register(api: OpenClawPluginApi) {
    api.registerTool(myTool);
  }
};

export default plugin;

Tool Options

optional
boolean
default:false
Mark the tool as optional, requiring explicit allowlisting in agent config.
api.registerTool(myTool, { optional: true });
Then allowlist in config.json:
{
  "agents": {
    "list": [
      { "id": "main", "tools": { "allow": ["greet"] } }
    ]
  }
}
name
string
Override the tool name (useful for tool factories).
api.registerTool(myTool, { name: "custom_name" });
names
string[]
Register multiple names for the same tool (for tool factories that generate multiple tools).
api.registerTool(toolFactory, { names: ["tool1", "tool2"] });

TypeBox Schemas

OpenClaw uses @sinclair/typebox for tool input schemas. TypeBox generates JSON Schema and provides TypeScript type inference.

Basic Types

import { Type } from "@sinclair/typebox";

// String
Type.String({ description: "A text value" })

// Number
Type.Number({ description: "A numeric value", minimum: 0, maximum: 100 })

// Integer
Type.Integer({ description: "An integer value" })

// Boolean
Type.Boolean({ description: "True or false" })

// Optional field
Type.Optional(Type.String({ description: "Optional text" }))

Arrays

// Array of strings
Type.Array(Type.String())

// Array of objects
Type.Array(
  Type.Object({
    id: Type.String(),
    value: Type.Number()
  })
)

// Array with constraints
Type.Array(Type.String(), { minItems: 1, maxItems: 10 })

Objects

Type.Object({
  name: Type.String({ description: "User name" }),
  age: Type.Number({ description: "User age" }),
  email: Type.Optional(Type.String({ description: "Email address" }))
})

Enums (String Literals)

Important: Do not use Type.Union with enum-like values. Use the stringEnum helper from the plugin SDK:
import { stringEnum } from "openclaw/plugin-sdk";

// Correct: Use stringEnum helper
const tool = {
  name: "set_mode",
  input: Type.Object({
    mode: stringEnum(["fast", "balanced", "quality"])
  }),
  execute: async (args) => {
    // args.mode is "fast" | "balanced" | "quality"
  }
};

// Also available: optionalStringEnum
const tool2 = {
  name: "configure",
  input: Type.Object({
    level: optionalStringEnum(["low", "medium", "high"])
  })
};
The stringEnum helper uses Type.Unsafe to generate a schema compatible with all LLM providers.

Nested Schemas

Type.Object({
  user: Type.Object({
    name: Type.String(),
    contacts: Type.Array(
      Type.Object({
        type: stringEnum(["email", "phone"]),
        value: Type.String()
      })
    )
  }),
  preferences: Type.Optional(
    Type.Object({
      theme: stringEnum(["light", "dark"]),
      notifications: Type.Boolean()
    })
  )
})

Tool Execution

The execute function receives validated parameters and returns a result:
execute: async (args) => {
  // args is typed based on the input schema
  const { name, style } = args;

  // Perform action (API call, database query, etc.)
  const result = await someOperation(name, style);

  // Return result
  return {
    result: "success",
    details: { greeting: result }
  };
}

Return Values

Tools should return an object with:
  • result - Status string ("success", "error", "pending", etc.)
  • details - Additional data (optional)
  • error - Error message (for failures)
// Success
return {
  result: "success",
  details: { message: "Operation completed" }
};

// Error
return {
  result: "error",
  error: "Failed to connect to API"
};

// With data
return {
  result: "success",
  details: {
    id: "123",
    status: "created",
    url: "https://example.com/resource/123"
  }
};

Async Operations

Tool execution can be async:
execute: async (args) => {
  // Wait for external API
  const response = await fetch("https://api.example.com/data", {
    method: "POST",
    body: JSON.stringify(args)
  });
  
  const data = await response.json();
  
  return {
    result: response.ok ? "success" : "error",
    details: data
  };
}

Error Handling

execute: async (args) => {
  try {
    const result = await riskyOperation(args);
    return { result: "success", details: result };
  } catch (err) {
    return {
      result: "error",
      error: err instanceof Error ? err.message : String(err)
    };
  }
}

Tool Context

Tools can access context via closures:
function createToolWithContext(api: OpenClawPluginApi): AnyAgentTool {
  return {
    name: "my_tool",
    description: "Tool with context",
    input: Type.Object({ message: Type.String() }),
    execute: async (args) => {
      // Access plugin API
      api.logger.info(`Tool called: ${args.message}`);
      
      // Access config
      const config = api.config;
      
      // Access runtime services
      const media = await api.runtime.media.loadWebMedia(args.url);
      
      return { result: "success" };
    }
  };
}

const plugin = {
  id: "my-plugin",
  register(api: OpenClawPluginApi) {
    api.registerTool(createToolWithContext(api));
  }
};

Tool Factories

Tool factories generate tools dynamically based on context:
import type { OpenClawPluginToolFactory } from "openclaw/plugin-sdk";

const toolFactory: OpenClawPluginToolFactory = (ctx) => {
  // ctx: { config, workspaceDir, agentId, sessionKey, messageChannel, ... }
  
  if (ctx.sandboxed) {
    // Don't provide tools in sandboxed environments
    return null;
  }
  
  return {
    name: "context_tool",
    description: "Tool that uses context",
    input: Type.Object({}),
    execute: async () => {
      return {
        result: "success",
        details: { agentId: ctx.agentId }
      };
    }
  };
};

api.registerTool(toolFactory);
Factories can return:
  • A single tool
  • An array of tools
  • null or undefined (to skip registration)

Example: HTTP Request Tool

import type { OpenClawPluginApi, AnyAgentTool } from "openclaw/plugin-sdk";
import { Type } from "@sinclair/typebox";
import { stringEnum } from "openclaw/plugin-sdk";

function createHttpTool(api: OpenClawPluginApi): AnyAgentTool {
  return {
    name: "http_request",
    description: "Make an HTTP request to an external API",
    input: Type.Object({
      url: Type.String({ description: "URL to request" }),
      method: stringEnum(["GET", "POST", "PUT", "DELETE"]),
      body: Type.Optional(Type.String({ description: "Request body (JSON string)" })),
      headers: Type.Optional(
        Type.Object({}, { additionalProperties: Type.String() })
      )
    }),
    execute: async (args) => {
      try {
        const response = await fetch(args.url, {
          method: args.method,
          headers: {
            "Content-Type": "application/json",
            ...args.headers
          },
          body: args.body
        });

        const text = await response.text();
        let data;
        try {
          data = JSON.parse(text);
        } catch {
          data = text;
        }

        return {
          result: response.ok ? "success" : "error",
          details: {
            status: response.status,
            statusText: response.statusText,
            data
          }
        };
      } catch (err) {
        api.logger.error(`HTTP request failed: ${err}`);
        return {
          result: "error",
          error: err instanceof Error ? err.message : String(err)
        };
      }
    }
  };
}

const plugin = {
  id: "http-tools",
  register(api: OpenClawPluginApi) {
    api.registerTool(createHttpTool(api), { optional: true });
  }
};

export default plugin;

Example: Database Query Tool

import type { OpenClawPluginApi, AnyAgentTool } from "openclaw/plugin-sdk";
import { Type } from "@sinclair/typebox";
import Database from "better-sqlite3";

function createDbQueryTool(api: OpenClawPluginApi): AnyAgentTool {
  const dbPath = api.resolvePath("./data.db");
  const db = new Database(dbPath);

  return {
    name: "db_query",
    description: "Execute a SQL query on the local database",
    input: Type.Object({
      query: Type.String({ description: "SQL query to execute" }),
      params: Type.Optional(
        Type.Array(Type.Union([Type.String(), Type.Number()]))
      )
    }),
    execute: async (args) => {
      try {
        const stmt = db.prepare(args.query);
        const rows = stmt.all(...(args.params ?? []));
        return {
          result: "success",
          details: { rows, count: rows.length }
        };
      } catch (err) {
        api.logger.error(`Query failed: ${err}`);
        return {
          result: "error",
          error: err instanceof Error ? err.message : String(err)
        };
      }
    }
  };
}

Tool Hooks

You can intercept tool calls with hooks:
api.registerHook("before_tool_call", async (event, ctx) => {
  api.logger.info(`Tool call: ${event.toolName}`);
  
  // Block dangerous operations
  if (event.toolName === "delete_file" && !event.params.confirmed) {
    return {
      block: true,
      blockReason: "Destructive operation requires confirmation"
    };
  }
});

api.registerHook("after_tool_call", async (event, ctx) => {
  if (event.error) {
    api.logger.error(`Tool ${event.toolName} failed: ${event.error}`);
  } else {
    api.logger.info(`Tool ${event.toolName} succeeded in ${event.durationMs}ms`);
  }
});
See Hooks for details.

Best Practices

1. Clear Descriptions

Write clear, actionable descriptions that help the LLM understand when to use the tool:
// Good
description: "Send an email to one or more recipients. Use this when the user asks to email someone or share information via email."

// Bad
description: "Email tool"

2. Validate Input

Use TypeBox constraints to validate input:
Type.String({ minLength: 1, maxLength: 500 })
Type.Number({ minimum: 0, maximum: 100 })
Type.Array(Type.String(), { minItems: 1 })

3. Handle Errors Gracefully

Always catch errors and return structured error responses:
try {
  const result = await operation();
  return { result: "success", details: result };
} catch (err) {
  return { result: "error", error: String(err) };
}

4. Use Optional Tools for Sensitive Operations

Mark destructive or sensitive tools as optional:
api.registerTool(deleteTool, { optional: true });

5. Log Tool Activity

Use the plugin logger for diagnostics:
execute: async (args) => {
  api.logger.info(`Executing ${tool.name} with args: ${JSON.stringify(args)}`);
  // ...
}

Next Steps

  • Hooks - Intercept tool execution
  • Channels - Build channel integrations
  • Examples - Real-world plugin examples