Skip to main content

Agent tools

Agent tools are actions the AI can invoke during a run. A plugin registers a tool through api.registerTool(...); the runtime exposes it to the model, validates input against your schema, calls your execute() handler, and returns the result to the model.
Tools are registered from a plugin’s register function. If you have not built a plugin yet, start with Write your first plugin — this page goes deeper on the tool surface itself.

The tool shape

The object you pass to api.registerTool(...) is an AnyAgentTool. The fields that matter for a typical tool are:
FieldTypeNotes
namestringThe tool name the model sees. Use a namespaced, unique name.
descriptionstringThe model reads this to decide when to call the tool. Be specific.
parametersTypeBox schemaInput schema (Type.Object({ ... })).
execute(id, params) => Promise<result>Your handler. Returns a content result.
There is no ToolDefinition, no inputSchema, and no run() — those are not part of the WednesdayAI surface. The real type is AnyAgentTool, re-exported from openclaw/plugin-sdk.

Defining a tool

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

export default function register(api: OpenClawPluginApi): void {
  api.registerTool({
    name: "fetch_url",
    description: "Fetch the contents of a URL and return the text.",
    parameters: Type.Object({
      url: Type.String({ description: "The URL to fetch." }),
      timeoutMs: Type.Optional(
        Type.Number({ description: "Timeout in milliseconds. Default: 5000." }),
      ),
    }),
    async execute(_id, params) {
      const url = params.url as string;
      const timeoutMs = (params.timeoutMs as number | undefined) ?? 5000;
      const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
      if (!res.ok) {
        return {
          content: [{ type: "text", text: `HTTP ${res.status} from ${url}` }],
        };
      }
      const text = await res.text();
      return { content: [{ type: "text", text }] };
    },
  });
}

The execute signature

execute(id, params) receives:
  • id — the tool-call id assigned by the runtime. Most tools ignore it (_id).
  • params — the validated input object matching your parameters schema.

The return value

execute returns a content result. The common shape is a single text block:
return { content: [{ type: "text", text: "result here" }] };
The content array may include text and image blocks. For JSON payloads, use the jsonResult(...) helper from the SDK, which stringifies the payload into a text block and attaches structured details:
import { jsonResult } from "openclaw/plugin-sdk";

async execute(_id, params) {
  return jsonResult({ ok: true, query: params.query });
}

Reading parameters safely

Model-supplied params are untrusted. The SDK exposes typed readers that coerce and validate input and throw a clear error on bad values:
import { readStringParam, readNumberParam } from "openclaw/plugin-sdk";

async execute(_id, params) {
  const query = readStringParam(params, "query", { required: true });
  const limit = readNumberParam(params, "limit", { integer: true }) ?? 5;
  // ...
}
These readers also accept snake_case variants of camelCase keys, so timeoutMs resolves from either timeoutMs or timeout_ms.

Schema rules

These constraints apply to every tool parameters schema:
RuleWhy
No Type.UnionStructural unions confuse model tool use. Use separate fields or tools.
No anyOf / oneOf / allOfSame reason.
Use Type.Optional(...) not T | nullnull is not the WednesdayAI convention.
Use stringEnum / optionalStringEnum for enumsThese produce a Type.Unsafe enum the validators accept.
Never use format as a raw property nameSome validators reject it.
stringEnum and optionalStringEnum are exported from openclaw/plugin-sdk:
import { Type } from "@sinclair/typebox";
import { stringEnum } from "openclaw/plugin-sdk";

const parameters = Type.Object({
  mode: stringEnum(["fast", "thorough"], { description: "Search mode." }),
});

Optional tools (opt-in)

By default, a registered tool is available to all agents. If your tool makes outbound requests, uses a paid API, or has side effects, register it as optional so operators opt in explicitly:
api.registerTool(
  {
    name: "workflow_tool",
    description: "Run a local workflow.",
    parameters: Type.Object({ pipeline: Type.String() }),
    async execute(_id, params) {
      return { content: [{ type: "text", text: params.pipeline as string }] };
    },
  },
  { optional: true },
);
Optional tools are never auto-enabled. Users enable them in their config under tools.allow (global) or agents.list[].tools.allow (per-agent):
{
  tools: {
    allow: [
      "workflow_tool", // a specific tool name
      "my-plugin", // a plugin id — enables all tools from that plugin
      "group:plugins", // all plugin tools
    ],
  },
}
An allowlist that names only plugin tools is treated as plugin opt-in: core tools stay enabled unless you also list core tools or groups.

Owner-only tools

Set ownerOnly: true on the tool object to restrict execution to owner senders. The runtime wraps non-owner calls so they fail with a restriction error before execute runs.
api.registerTool({
  name: "danger_action",
  description: "Owner-only privileged action.",
  parameters: Type.Object({}),
  ownerOnly: true,
  async execute() {
    return { content: [{ type: "text", text: "done" }] };
  },
});

Tool factory context

The full set of fields available in a tool factory context (OpenClawPluginToolContext):
FieldTypeDescription
configOpenClawConfig | undefinedThe resolved gateway config
workspaceDirstring | undefinedWorkspace directory path for the current agent
agentDirstring | undefinedAgent-specific directory path
agentIdstring | undefinedThe agent id that is running
sessionKeystring | undefinedUnique session key (e.g. agent:id:telegram:dm:123)
sessionIdstring | undefinedEphemeral session UUID — regenerated on /new and /reset; use for per-conversation isolation
messageChannelstring | undefinedThe channel surface ("telegram", "discord", "whatsapp", etc.)
agentAccountIdstring | undefinedThe channel account id the message arrived on
requesterSenderIdstring | undefinedTrusted sender id from inbound context (runtime-provided, not tool args)
senderIsOwnerboolean | undefinedWhether the trusted sender is an owner
sandboxedboolean | undefinedWhether the gateway is running in sandbox mode

Tool factories

Instead of a static tool, you can pass a factory that receives per-invocation context (agent id, session key, workspace dir, sender identity) and returns a tool, an array of tools, or nothing:
api.registerTool((ctx) => {
  if (!ctx.agentId) return undefined;
  return {
    name: "scoped_tool",
    description: "A tool scoped to the current agent.",
    parameters: Type.Object({}),
    async execute() {
      return { content: [{ type: "text", text: `agent ${ctx.agentId}` }] };
    },
  };
});

Naming and conflicts

  • Tool names are not deduplicated at registration time. Two tools sharing a name will both register; runtime resolution order decides which is called. Use a unique, namespaced name (for example my-plugin_fetch_url).
  • A plugin tool name must not clash with a core tool name — conflicting tools are skipped.

Testing tools

Register the tool against a minimal fake api and call execute directly — no full gateway needed:
import { describe, expect, it } from "vitest";
import { Type } from "@sinclair/typebox";

it("returns text content", async () => {
  let captured: any;
  const api: any = { registerTool: (tool: any) => (captured = tool) };
  register(api);

  const result = await captured.execute("call-1", { url: "https://example.com" });
  expect(result.content[0].type).toBe("text");
});
pnpm --filter @wednesdayai/my-plugin test:fast

Best practices

  • Single responsibility — one tool per well-defined action.
  • Clear descriptions — the model chooses tools from the description; be specific about what and when.
  • Validate input — use the SDK param readers; never trust raw model output.
  • Return helpful errors — return a text result describing the problem rather than throwing for expected failures.
  • Mark side-effecting tools optional — give operators explicit control.

What’s next