openclaw/docs/plugins/tool-plugins.md
RoboClaw f2e526882c
feat(gateway): let operators disable client file and image uploads (#158567)
Add a default-enabled, hot-applied Gateway switch for client file and image uploads, reflected in the Control UI. Enforce the policy at final input-write boundaries while preserving text, drafts, trusted internal/generated media, and authorized accepted request receipts. Retain existing skill support files by exact revision.

Closes #158556

Worked on by:
- @steipete

Work session: https://team.openclaw.ai/chat/roboclaw/dashboard/722174dd-3af0-4d0c-b6e9-d9fbe6101472

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
2026-09-27 18:40:36 -07:00

564 lines
20 KiB
Markdown

---
summary: "Build simple typed agent tools with defineToolPlugin and openclaw plugins init/build/validate"
title: "Tool plugins"
sidebarTitle: "Tool Plugins"
read_when:
- You want to build a simple OpenClaw plugin that only adds agent tools
- You want to use defineToolPlugin instead of hand-writing plugin manifest metadata
- You need to scaffold, generate, validate, test, or publish a tool-only plugin
---
`defineToolPlugin` builds a plugin that only adds agent-callable tools: no
channel, model provider, hook, service, or setup backend. It generates the
manifest metadata OpenClaw needs to discover tools without loading plugin
runtime code.
For provider, channel, hook, service, or mixed-capability plugins, start with
[Building plugins](/plugins/building-plugins), [Channel Plugins](/plugins/sdk-channel-plugins),
or [Provider Plugins](/plugins/sdk-provider-plugins) instead.
## Requirements
- Node 24.16+ or Node 26.1+.
- TypeScript ESM package output.
- `typebox` in `dependencies` (not just `devDependencies` - the generated
plugin imports it at runtime).
- `openclaw >=2026.5.17`, the first version that exports
`openclaw/plugin-sdk/tool-plugin`.
- A package root that ships `dist/`, `openclaw.plugin.json`, and
`package.json`.
## Quickstart
```bash
openclaw plugins init stock-quotes --name "Stock Quotes"
cd stock-quotes
npm install
npm run plugin:build
npm run plugin:validate
npm test
```
`plugins init` scaffolds:
| File | Purpose |
| ---------------------- | ----------------------------------------------------------------- |
| `src/index.ts` | `defineToolPlugin` entry with one `echo` tool |
| `src/index.test.ts` | Metadata test asserting the tool list |
| `tsconfig.json` | NodeNext TypeScript output to `dist/` |
| `vitest.config.ts` | Vitest config for `src/**/*.test.ts` |
| `package.json` | Scripts, runtime deps, `openclaw.extensions: ["./dist/index.js"]` |
| `openclaw.plugin.json` | Generated manifest metadata for the initial tool |
`npm run plugin:build` runs `npm run build` (tsc) then
`openclaw plugins build --entry ./dist/index.js`. `npm run plugin:validate`
rebuilds and runs `openclaw plugins validate --entry ./dist/index.js`.
Successful validation prints:
```text
Plugin stock-quotes is valid.
```
`openclaw plugins init <id>` options:
| Flag | Default | Effect |
| -------------------- | ------------------ | -------------------------------------- |
| `--directory <path>` | `<id>` | Output directory |
| `--name <name>` | Title-cased `<id>` | Display name |
| `--type <type>` | `tool` | Scaffold type: `tool` or `provider` |
| `--force` | off | Overwrite an existing output directory |
## Write a tool
`defineToolPlugin` takes plugin identity, an optional config schema, and a
static list of tools. Parameter and config types are inferred from the
TypeBox schemas.
```typescript
import { Type } from "typebox";
import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
export default defineToolPlugin({
id: "stock-quotes",
name: "Stock Quotes",
description: "Fetch stock quote snapshots.",
configSchema: Type.Object({
apiKey: Type.Optional(Type.String({ description: "Quote API key." })),
baseUrl: Type.Optional(Type.String({ description: "Quote API base URL." })),
}),
tools: (tool) => [
tool({
name: "stock_quote",
label: "Stock Quote",
description: "Fetch a stock quote snapshot.",
parameters: Type.Object({
symbol: Type.String({ description: "Ticker symbol, for example OPEN." }),
}),
outputSchema: Type.Object(
{
symbol: Type.String(),
configured: Type.Boolean(),
baseUrl: Type.String(),
},
{ additionalProperties: false },
),
async execute({ symbol }, config, context) {
context.signal?.throwIfAborted();
return {
symbol: symbol.toUpperCase(),
configured: Boolean(config.apiKey),
baseUrl: config.baseUrl ?? "https://api.example.com",
};
},
}),
],
});
```
Tool names are the stable API. Pick names that are unique, lowercase, and
specific enough to avoid collisions with core tools or other plugins.
## Optional and factory tools
Set `optional: true` when users should explicitly allowlist the tool before it
is sent to a model. `openclaw plugins build` writes the matching
`toolMetadata.<tool>.optional` manifest entry, so OpenClaw can see that the
tool is optional without loading plugin runtime code.
```typescript
tool({
name: "workflow_run",
description: "Run an external workflow.",
parameters: Type.Object({ goal: Type.String() }),
optional: true,
execute: ({ goal }) => ({ queued: true, goal }),
});
```
Use `factory` when a tool needs the runtime tool context before it can be
created - to opt out for a specific run, inspect sandbox state, or bind
runtime helpers. Metadata stays static even though the concrete tool is built
at runtime.
```typescript
tool({
name: "local_workflow",
description: "Run a local workflow outside sandboxed sessions.",
parameters: Type.Object({ goal: Type.String() }),
optional: true,
factory({ api, toolContext }) {
if (toolContext.sandboxed) {
return null;
}
return createLocalWorkflowTool(api);
},
});
```
Factories can use `toolContext.delivery?.send({ text, mediaUrl })` for outbound
messages in the active conversation. The host chooses the destination,
account, thread, and local-media policy; plugins cannot retarget this helper,
and retained copies stop working after the turn closes. The helper is unavailable
for channels whose delivery is owned by a Gateway transport.
A factory may return a core `AgentTool`, an array of them, or `null` or
`undefined` to opt out, as the example above does. When it returns a concrete
tool, that tool uses the core runtime signature
`execute(toolCallId, params, signal?, onUpdate?)` with the tool call ID first.
That is the opposite argument order from the declarative
`execute(params, config, context)` shown above, and it matches the
`api.registerTool` examples in [Building Plugins](/plugins/building-plugins).
Reading `params` from the first argument of a factory tool returns the tool
call ID string instead.
Concrete tools can provide `prepareArguments(args)` to normalize input before
schema validation. The native agent loop also honors
`executionMode: "sequential"` when tool calls must run one at a time. These
runtime properties, schemas, and display metadata come from the current factory
context whenever tools are assembled. Argument preparation and execution use the
same instance. Retained tools stop working when their owning plugin registry is
retired.
### Owner-authorized continuations
To participate when the exact parent resumes after an explicit `sessions_yield`,
register an `OpenClawPluginToolFactory<2>` descriptor through `api.registerTool`:
```typescript
api.registerTool(
{
contextVersion: 2,
create(context) {
if (context.senderIsOwner !== true) return null;
return createPrivilegedTool({ assertCurrent: context.assertInvocationCurrent });
},
},
{ name: "my_privileged_tool" },
);
```
The `OpenClawPluginToolContext<2>` type requires `assertInvocationCurrent`.
Carry it through awaited work and invoke it in the final synchronous write or
request guard, before effects—not only before starting work or after returning.
It checks the captured plugin lifetime and admitted run/worker authority; a
continuation also checks the original owner's live exact-parent binding. Standalone
HTTP/RPC calls use their authenticated request lifetime, while MCP tools retain
the existing authenticated grant or loopback-runtime lifetime.
Metadata-only catalog construction does not grant invocation authority. A retained
versioned tool without an admitted invocation fails when its guard is called.
Client-input tools may also receive `assertInputCommitAllowed`. Carry this
host-bound, synchronous policy callback to the storage owner's final admission
guard when persisting client-supplied bytes. Calling it checks the current upload
policy even for custom tool names the Gateway cannot classify. Do not call it for
text-only actions that do not upload bytes. It performs no database reads, so
it can run inside worker-backed write admission. It does not replace invocation
or mutation authority. Preserve it across awaited preparation, but do not apply
it to already accepted results or compensating cleanup. Agent-generated input
does not require this client-upload policy callback.
Legacy function and static-tool registrations remain supported with their existing
direct-turn context; this change introduces no removal date or shortened
compatibility window. They do **not** receive continued owner identity. Opt-in
alone grants nothing: management-only callers, unrelated sessions, and detached
cron runs still cannot acquire the owner's identity. `senderIsOwner` is an
availability check, never a substitute for the required final-effect guard.
Set `hideFromChannelProgress: true` on the concrete factory tool to keep its
transient activity out of channel progress drafts. Lifecycle events and the
final tool result still flow normally. OpenClaw preserves the current factory's
flag when normalizing its schema; omitted or `false` leaves normal progress
behavior in place. See [Progress drafts](/concepts/progress-drafts).
Factories still declare a fixed tool name up front. Use `definePluginEntry`
directly when the plugin computes tool names dynamically or combines tools
with hooks, services, providers, or commands.
## Return values
`defineToolPlugin` wraps plain return values into the OpenClaw tool-result
format:
- Return a string when the model should see that exact text.
- Return a JSON-compatible value when you want the model to see formatted JSON
and OpenClaw to keep the original value in `details`.
```typescript
tool({
name: "echo_text",
description: "Echo input text.",
parameters: Type.Object({
input: Type.String(),
}),
execute: ({ input }) => input,
});
```
```typescript
tool({
name: "echo_json",
description: "Echo input as structured JSON.",
parameters: Type.Object({
input: Type.String(),
}),
execute: ({ input }) => ({ input, length: input.length }),
});
```
Use a factory tool when you need a custom `AgentToolResult` or want to reuse an
existing `api.registerTool` implementation.
## Output contracts
Add `outputSchema` when a tool returns stable JSON-compatible data. It describes
the original value stored in `AgentToolResult.details`, not the formatted text
in `content`:
```typescript
tool({
name: "shipment_list",
description: "List shipments.",
parameters: Type.Object({
buyer: Type.Optional(Type.String()),
}),
outputSchema: Type.Array(
Type.Object(
{
id: Type.String(),
buyer: Type.String(),
paid: Type.Boolean(),
tons: Type.Number(),
},
{ additionalProperties: false },
),
),
execute: ({ buyer }) => listShipments(buyer),
});
```
[Code Mode](/tools/code-mode) and [Tool Search](/tools/tool-search) turn this
schema into a bounded TypeScript-style output hint. That lets a model call and
transform a known result in one program instead of spending another model turn
observing its shape.
OpenClaw compiles the schema before executing a catalog call, then validates the
final `details` value after tool hooks before returning it through the bridge.
An invalid schema cannot run the tool; a result mismatch fails the completed
call. Include every non-throwing result variant, including structured error
variants, or omit the schema when the result is not stable. Do not put secrets
or sensitive values in schema descriptions because trusted output metadata can
become model-visible.
Use `{ additionalProperties: false }` on object layers when you want a complete
compact output hint; open or truncated schemas remain available through
the callable catalog handle's `describe()` but are not advertised as complete
quick-index contracts.
Factory tools declare `outputSchema` on the concrete `AnyAgentTool` they
return. The static `tool({ factory })` declaration does not accept a separate
output schema because it could drift from the runtime tool.
OpenClaw also grades the call outcome from `details`, so `status`, `ok`,
`success`, `error`, `timedOut`, and `exitCode` are reserved names. A `status`
of `blocked`, `denied`, `invalid`, `cancelled`, or any other failure value
marks the call failed unless `ok` or `success` is explicitly `true`, even when
`execute` returned normally. Domain data that
uses one of those names belongs under a wrapper key, such as `{ card }`,
instead of at the top level of `details`.
For a tool-owned timeout, return `timedOut: true` and a positive integer
`timeoutMs` in `details`. If the agent provides no final reply, OpenClaw includes
that duration in the fallback warning without exposing raw error text. Return
`partial: true` with a nonempty `results` array when usable partial results are
available; the warning includes their count. These diagnostics do not turn an
incomplete operation into a successful call.
## Configuration
`configSchema` is optional. Omit it and OpenClaw applies a strict empty object
schema; the generated manifest still includes `configSchema`.
```typescript
export default defineToolPlugin({
id: "no-config-tools",
name: "No Config Tools",
description: "Adds tools that do not need configuration.",
tools: () => [],
});
```
With a `configSchema`, the second `execute` argument is typed from it:
```typescript
const configSchema = Type.Object({
apiKey: Type.String(),
});
export default defineToolPlugin({
id: "configured-tools",
name: "Configured Tools",
description: "Adds configured tools.",
configSchema,
tools: (tool) => [
tool({
name: "configured_ping",
description: "Check whether configuration is available.",
parameters: Type.Object({}),
execute: (_params, config) => ({ hasKey: config.apiKey.length > 0 }),
}),
],
});
```
OpenClaw reads plugin config from the plugin's entry in the Gateway config. Do
not hard-code secrets in source or docs examples; use config, environment
variables, or SecretRefs per the plugin's security model.
## Generated metadata
OpenClaw must read the plugin manifest before importing plugin runtime code.
`defineToolPlugin` exposes static metadata for this, and
`openclaw plugins build` writes it into the package. Rerun the generator after
changing plugin id, name, description, config schema, activation, or tool
names:
```bash
npm run build
openclaw plugins build --entry ./dist/index.js
```
Generated manifest for a one-tool plugin:
```json
{
"id": "stock-quotes",
"name": "Stock Quotes",
"description": "Fetch stock quote snapshots.",
"version": "0.1.0",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
},
"activation": {
"onStartup": true
},
"contracts": {
"tools": ["stock_quote"]
}
}
```
`contracts.tools` is the important discovery contract: it tells OpenClaw which
plugin owns each tool without loading every installed plugin's runtime. A
stale manifest means a tool can go missing from discovery, or a registration
error gets blamed on the wrong plugin.
## Package metadata
`openclaw plugins build` also aligns `package.json` to the selected runtime
entry:
```json
{
"type": "module",
"files": ["dist", "openclaw.plugin.json", "README.md"],
"dependencies": {
"typebox": "^1.1.38"
},
"peerDependencies": {
"openclaw": ">=2026.5.17"
},
"openclaw": {
"extensions": ["./dist/index.js"]
}
}
```
Ship built JavaScript (`./dist/index.js`), not a TypeScript source entry.
Source entries only work for workspace-local development.
## Validate in CI
`plugins build --check` fails without rewriting files when generated metadata
is stale:
```bash
npm run build
openclaw plugins build --entry ./dist/index.js --check
openclaw plugins validate --entry ./dist/index.js
npm test
```
OpenClaw SDK compatibility fields carry TypeScript `@deprecated` annotations,
which editors surface as migration warnings. To enforce them in CI, enable a
type-aware rule such as
[`@typescript-eslint/no-deprecated`](https://typescript-eslint.io/rules/no-deprecated/).
Oxlint is not type-aware, so it cannot enforce these annotations. The generated
`plugins init` scaffold therefore does not add a deprecation lint config.
`plugins validate` checks that:
- `openclaw.plugin.json` exists and passes the normal manifest loader.
- The current entry exports `defineToolPlugin` metadata.
- Generated manifest fields match the entry metadata.
- `contracts.tools` matches the declared tool names.
- `package.json` points `openclaw.extensions` at the selected runtime entry.
## Install and inspect locally
From a separate OpenClaw checkout or installed CLI, install the package path:
```bash
openclaw plugins install ./stock-quotes
openclaw plugins inspect stock-quotes --runtime
```
For a packaged smoke test, pack first and install the tarball:
```bash
npm pack
openclaw plugins install npm-pack:./openclaw-plugin-stock-quotes-0.1.0.tgz
openclaw plugins inspect stock-quotes --runtime --json
```
Installation applies to a running local Gateway automatically; start the Gateway
if it was stopped. Ask the agent to use the tool. If the tool is not visible, inspect the plugin runtime and the effective
tool catalog before changing code (see [Troubleshooting](#troubleshooting)).
After later source or manifest edits, use [plugin Reload](/cli/plugins#reload).
## Publish
Publish through ClawHub once the package is ready. `clawhub package publish`
takes a source: a local folder, a GitHub repo (`owner/repo[@ref]`), or a
tarball URL.
```bash
clawhub package publish ./stock-quotes --dry-run
clawhub package publish ./stock-quotes
```
Install with an explicit ClawHub locator:
```bash
openclaw plugins install clawhub:your-org/stock-quotes
```
Bare npm package specs install from npm, but ClawHub is the preferred
discovery and distribution surface for OpenClaw plugins. See [ClawHub publishing](/clawhub/publishing) for owner scope and
release review.
## Troubleshooting
### `plugin entry not found: ./dist/index.js`
The selected entry file does not exist. Run `npm run build`, then rerun
`openclaw plugins build --entry ./dist/index.js` or
`openclaw plugins validate --entry ./dist/index.js`.
### `plugin entry does not expose defineToolPlugin metadata`
The entry did not export a value created by `defineToolPlugin`. Confirm the
module's default export is the `defineToolPlugin(...)` result, or pass the
correct entry with `--entry`.
### `openclaw.plugin.json generated metadata is stale`
The manifest no longer matches the entry metadata. Run:
```bash
npm run build
openclaw plugins build --entry ./dist/index.js
```
Commit both `openclaw.plugin.json` and `package.json` changes.
### `package.json openclaw.extensions must include ./dist/index.js`
The package metadata points at a different runtime entry. Run
`openclaw plugins build --entry ./dist/index.js` so the generator aligns
package metadata with the entry you intend to ship.
### `Cannot find package 'typebox'`
The built plugin imports `typebox` at runtime. Keep it in `dependencies`,
reinstall, rebuild, and rerun validation.
### Tool does not appear after install
Check these in order:
1. `openclaw plugins inspect <plugin-id> --runtime`
2. `openclaw plugins validate --root <plugin-root> --entry ./dist/index.js`
3. `openclaw.plugin.json` has `contracts.tools` with the expected tool names.
4. `package.json` has `openclaw.extensions: ["./dist/index.js"]`.
5. Installation reported successful runtime application; after source edits or a repaired activation failure, run `openclaw plugins reload <plugin-id>`.
## See also
- [Building plugins](/plugins/building-plugins)
- [Plugin SDK overview](/plugins/sdk-overview)
- [Plugin entry points](/plugins/sdk-entrypoints)
- [Plugin SDK subpaths](/plugins/sdk-subpaths)
- [Plugin manifest](/plugins/manifest)
- [Plugins CLI](/cli/plugins)
- [ClawHub publishing](/clawhub/publishing)