defineToolPlugin builds a plugin that only adds agent-callable tools: no
channel, model provider, hook, service, or setup backend. It generates the
manifest metadata Apus needs to discover tools without loading plugin
runtime code.
For provider, channel, hook, service, or mixed-capability plugins, start with Building plugins, Channel Plugins, or Provider Plugins instead.
Requirements
- Node 24.22.1+ or Node 26.12.1+.
- TypeScript ESM package output.
typeboxindependencies(not justdevDependencies- the generated plugin imports it at runtime).apus >=2026.5.17, the first version that exportsapus/plugin-sdk/tool-plugin.- A package root that ships
dist/,apus.plugin.json, andpackage.json.
Quickstart
apus 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, apus.extensions: ["./dist/index.js"] |
apus.plugin.json |
Generated manifest metadata for the initial tool |
npm run plugin:build runs npm run build (tsc) then
apus plugins build --entry ./dist/index.js. npm run plugin:validate
rebuilds and runs apus plugins validate --entry ./dist/index.js.
Successful validation prints:
Plugin stock-quotes is valid.
apus 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.
import { Type } from "typebox";
import { defineToolPlugin } from "apus/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. apus plugins build writes the matching
toolMetadata.<tool>.optional manifest entry, so Apus can see that the
tool is optional without loading plugin runtime code.
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.
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.
For an admitted session turn, the optional
memoryAudience?: MemoryAudience field carries the same host-resolved memory
audience used by the selected memory provider. It is either
{ kind: "owner-private", agentId } or
{ kind: "conversation", agentId, sessionKey, sessionId }. The field is absent
when the host cannot prove an audience or when no session turn exists. Plugins
must consume this prepared value and must not infer memory authority from
senderIsOwner, session-key patterns, or session lookups.
Tools that retain or use memoryAudience across awaited work must call both
currency guards immediately before releasing data or performing an effect:
toolContext.assertInvocationCurrent();
toolContext.assertMemoryAudienceCurrent?.();
The optional assertMemoryAudienceCurrent?(): void guard fails after any session
in the captured lineage changes incarnation or lifecycle.
assertInvocationCurrent separately checks the admitted tool invocation and
plugin lifetime. Use a version 2 tool context when the tool depends on these live
authority checks.
The optional assertOperatorScope(scope) guard checks a scope such as
operator.admin against the operator authority already admitted for this run,
including the current invocation and plugin lifetime. It is absent when the run
has no admitted operator authority, and when the turn's input came from another
session or from the system, such as a subagent's completion notice: that turn
still runs under the operator who started the work, but it acts on that input
rather than on the operator's request. A tool that requires a scope must reject when
the guard is absent and call it again immediately before each effect after awaited
work. senderIsOwner does not grant operator scopes. Scheduled or autonomous
turns do not gain admin rights merely because an admin created them.
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.
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.
Set async: false on a concrete tool the model uses to wait, as
sessions_yield does. On GPT-6 Astra requests that use
async function calls, Apus marks other direct
function tools async but keeps this one synchronous, so the response pauses at
the call and the next request delivers earlier async results first. Other
providers, Code Mode, and calls through the Tool Search catalog are unchanged;
declare catalogMode: "direct-only" to keep the tool directly visible. Omitting
the property keeps the default behavior.
Owner-authorized continuations
To participate when the exact parent resumes after an explicit sessions_yield,
register an ApusPluginToolFactory<2> descriptor through api.registerTool:
api.registerTool(
{
contextVersion: 2,
create(context) {
if (context.senderIsOwner !== true) return null;
return createPrivilegedTool({ assertCurrent: context.assertInvocationCurrent });
},
},
{ name: "my_privileged_tool" },
);
The ApusPluginToolContext<2> type requires assertInvocationCurrent and
may provide memoryAudience with assertMemoryAudienceCurrent.
Carry assertInvocationCurrent 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.
V2 factories may receive workspaceCommands when the current attempt provides a
subscribed tool catalog and a selected local workspace. Its execute operation
accepts command, optional timeoutSeconds, background, and awaitResults;
control accepts action: "poll" | "kill", sessionId, optional timeoutMs,
and optional expectedInstanceId. Save the instance identifier from the original
process receipt and supply it when controlling that exact launch. The process
owner rejects a missing or replacement instance instead of polling or stopping it;
the identifier does not grant permission.
Both take { parentToolCallId, signal? } as their second argument. Use the actual
enclosing tool call ID while that callback is active. The host also carries the
parent cancellation signal when a plugin omits the optional signal.
These operations use the existing core command and process owners, including permissions, approvals, activity receipts, and cancellation. They cannot override the execution host, working directory, or environment. Execution on a remote or different workspace is rejected rather than attributed to local source. A missing capability is unsupported; do not substitute a direct subprocess. Retained callbacks fail after invocation or plugin retirement. Background process lifetime belongs to the existing process supervisor after launch; a completed plugin callback does not itself stop a background process.
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. Apus preserves the current factory's
flag when normalizing its schema; omitted or false leaves normal progress
behavior in place. See 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 Apus 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 Apus to keep the original value in
details.
tool({
name: "echo_text",
description: "Echo input text.",
parameters: Type.Object({
input: Type.String(),
}),
execute: ({ input }) => input,
});
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:
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 and 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.
Apus 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.
Apus 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, Apus 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.
Deliver the reply directly
By default the model reads every tool result and writes the user-visible reply itself. A tool that already produces the finished reply, such as a report or a confirmation text, can hand it to Apus instead and end the turn without that extra model step.
Declare the capability on the tool and return the reply in
details.sourceReply:
api.registerTool({
name: "order_status",
description: "Report the status of one order.",
parameters: Type.Object({ orderId: Type.String() }),
canDeliverSourceReply: true,
catalogMode: "direct-only",
async execute(_toolCallId, params) {
const report = await buildOrderStatusReport(params.orderId);
return {
content: [{ type: "text", text: report.text }],
details: {
ok: true,
sourceReply: {
text: report.text,
mediaUrls: report.pdfPath ? [report.pdfPath] : [],
},
},
};
},
});
Apus delivers the reply to the conversation the turn came from and ends the
turn, so no further model turn restates the result. Other tool calls from the
same model step still run to completion and are recorded. After a successful
send, delivery records the reply as the assistant turn in the session
transcript, with the same session checks as any other delivered reply. A reply
needs text, mediaUrl, or mediaUrls; attachments ride along with them.
Error results, results without a deliverable reply, and sourceReply.final: false are ignored, and the model continues as usual with content.
Apus reads the reply after tool hooks and result middleware run, so
middleware can rewrite or withdraw it. Only the tool author can grant the
capability: canDeliverSourceReply lives on the tool definition, never in a
result. A call made from inside a Code Mode program returns to that program and
is never delivered as a reply. On the Codex harness, only calls the model makes
directly in the direct-only catalog can deliver, so declare
catalogMode: "direct-only" as in the example; otherwise the model restates the
result as usual.
Configuration
configSchema is optional. Omit it and Apus applies a strict empty object
schema; the generated manifest still includes configSchema.
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:
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 }),
}),
],
});
Apus 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
Apus must read the plugin manifest before importing plugin runtime code.
defineToolPlugin exposes static metadata for this, and
apus plugins build writes it into the package. Rerun the generator after
changing plugin id, name, description, config schema, activation, or tool
names:
npm run build
apus plugins build --entry ./dist/index.js
Generated manifest for a one-tool plugin:
{
"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 Apus 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
apus plugins build also aligns package.json to the selected runtime
entry:
{
"type": "module",
"files": ["dist", "apus.plugin.json", "README.md"],
"dependencies": {
"typebox": "^1.1.38"
},
"peerDependencies": {
"apus": ">=2026.5.17"
},
"apus": {
"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:
npm run build
apus plugins build --entry ./dist/index.js --check
apus plugins validate --entry ./dist/index.js
npm test
Apus 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.
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:
apus.plugin.jsonexists and passes the normal manifest loader.- The current entry exports
defineToolPluginmetadata. - Generated manifest fields match the entry metadata.
contracts.toolsmatches the declared tool names.package.jsonpointsapus.extensionsat the selected runtime entry.
Install and inspect locally
From a separate Apus checkout or installed CLI, install the package path:
apus plugins install ./stock-quotes
apus plugins inspect stock-quotes --runtime
For a packaged smoke test, pack first and install the tarball:
npm pack
apus plugins install npm-pack:./apus-plugin-stock-quotes-0.1.0.tgz
apus 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). After later source or manifest edits, use plugin 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.
clawhub package publish ./stock-quotes --dry-run
clawhub package publish ./stock-quotes
Install with an explicit ClawHub locator:
apus plugins install clawhub:your-org/stock-quotes
Bare npm package specs install from npm, but ClawHub is the preferred discovery and distribution surface for Apus plugins. See 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
apus plugins build --entry ./dist/index.js or
apus 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.
apus.plugin.json generated metadata is stale
The manifest no longer matches the entry metadata. Run:
npm run build
apus plugins build --entry ./dist/index.js
Commit both apus.plugin.json and package.json changes.
package.json apus.extensions must include ./dist/index.js
The package metadata points at a different runtime entry. Run
apus 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:
apus plugins inspect <plugin-id> --runtimeapus plugins validate --root <plugin-root> --entry ./dist/index.jsapus.plugin.jsonhascontracts.toolswith the expected tool names.package.jsonhasapus.extensions: ["./dist/index.js"].- Installation reported successful runtime application; after source edits or a repaired activation failure, run
apus plugins reload <plugin-id>.