Node tool plugins
Node tool plugins are out-of-process ToolPlugin implementations executed by a
Node.js tool host. They are a good fit when you want to author tools in
JavaScript/TypeScript, reuse npm libraries, or ship a self-contained JS tool
package without adding Python runtime code.
This page is the canonical authoring guide for node_tool plugins.
Runtime references (source of truth):
- Loader / plugin source:
core/python/agent_app/plugin_sources/node_tool.py - Python proxy plugin:
core/python/agent_core/tool_hosts/node_tool_plugin.py - Wire protocol:
docs/reference/tool-host-protocol.md
Reference implementations:
- Small object export:
plugins/js-echo-tool - Class export:
plugins/js-class-tool - Multi-tool package:
plugins/js-multi-tools - Filesystem tools with streaming toggle:
plugins/js-fs-tools - Template:
plugins/template-js-multi-tools
See also:
What Node tools are for
Use a Node tool plugin when:
- your tool logic is already in JavaScript/TypeScript
- you want to use npm packages directly
- you want to ship tools as a Node package with
package.json - you are comfortable with a subprocess boundary
Prefer an in-process Python tool when:
- you need the newest payload-first Python tool API directly
- you need custom/freeform raw-text payload handling today
- you want simpler debugging without subprocess/protocol layers
Important current limitation:
- the in-process Python tool API is payload-first
- the v1 Node tool-host protocol still uses object-shaped
arguments
So Node tools are best suited to classic object/function-style tool calls right now.
Development workflow
Recommended workflow:
- start from
plugins/template-js-multi-tools - implement one small tool first
- test the JS module directly
- test it through the Python plugin manager /
AgentCore - try it from a real app config using
node_tool
Step 0: package layout
Typical layout:
my-js-tools/
package.json
src/
index.ts
EchoTool.ts
dist/
index.js
Directory-based node_tool packages are discovered from package.json#agent.tools.
Minimal example:
{
"name": "my-js-tools",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outdir=dist"
},
"agent": {
"kinds": ["tools"],
"tools": [
{ "id": "echo", "entry": "dist/index.js", "export": "echoTool" }
]
}
}
The agent.tools entries are what the loader reads for directory-based tools.
Step 1: implement one small tool
Minimal object-export example:
export type ToolHostEmit = (event: { type: string; payload?: unknown }) => void;
type ToolState = {
config: Record<string, unknown>;
calls?: number;
};
export const echoTool = {
name: "my_js_echo_tool",
version: "0.1.0",
init(config: Record<string, unknown>): ToolState {
return { config, calls: 0 };
},
getToolSchemas(_state: ToolState) {
return [
{
type: "function",
function: {
name: "echo",
description: "Echo back the provided value.",
parameters: {
type: "object",
properties: {
value: { type: "string" }
},
required: ["value"]
}
}
}
];
},
async executeTool(
toolName: string,
args: Record<string, unknown>,
state: ToolState,
emit: ToolHostEmit
) {
if (toolName !== "echo") {
return { success: false, error: `Unknown tool: ${toolName}` };
}
state.calls = (state.calls ?? 0) + 1;
emit({ type: "part", payload: { message: "working..." } });
return {
success: true,
result: {
value: String(args?.value ?? ""),
calls: state.calls
}
};
},
formatToolResult(result: any) {
if (!result?.success) {
return `Error: ${String(result?.error ?? "unknown")}`;
}
return `Echo: ${String(result?.result?.value ?? "")}`;
},
toDisplayFormat(text: string, result: any) {
const single = result?.success ? `echo ${String(result?.result?.value ?? "")}` : "echo (error)";
return { type: "text", content: text, single_line: single };
}
} as const;
export default echoTool;
Class exports are also supported:
- plain object export
- class export instantiated with
new - factory function returning an object
See plugins/js-class-tool for the class pattern.
Step 2: build it
Typical command:
npm install
npm run build
If dist/ is not checked in, the application plugin manager can install
dependencies and run the build in its cache for directory-based tools.
Relevant policy knobs:
{
"plugin_policy": {
"node_install_deps": true,
"node_build": true,
"node_allow_install_scripts": false
}
}
Step 3: load it in app config
Supported config forms:
{
"plugins": [
{ "node_tool": { "path": "./plugins/my-js-tools" } }
]
}
{
"plugins": [
{ "node_tool": { "git": "https://github.com/acme/my-js-tools.git", "ref": "v0.1.0" } }
]
}
{
"plugins": [
{ "node_tool": { "file": "./tools/my_tool.js", "id": "my_tool" } }
]
}
String shortcuts:
{
"plugins": [
"node:./plugins/my-js-tools",
"node+git:https://github.com/acme/my-js-tools.git#v0.1.0"
]
}
Runtime contract
The Python side treats a Node tool package as a proxy ToolPlugin. The host
protocol methods are described in more detail in
docs/reference/tool-host-protocol.md.
Required methods
The v1 host expects these methods:
init(config) -> stategetToolSchemas(state) -> schema[]executeTool(toolName, args, state, emit) -> result
The current v1 host does not expose Python-side prepare(...) /
prepareAsync(...) hooks yet. Node-hosted tools should therefore keep schema
discovery inside init(...) / getToolSchemas(...) for now, or wait for a
future host-protocol extension if they need an explicit long-running
preparation phase.
The host protocol method names are snake_case on the wire, but the JS tool surface uses camelCase in practice.
Optional methods
Common optional methods:
getConfigSchema()getUiElements()getTags(config, models)requiredTags()formatToolResult(result, state)formatToolCallPreview(toolName, args, state)toDisplayFormat(text, result, state)
State round-tripping
Node tools do not keep durable state in the Python process. Instead, the host
round-trips state through RPC calls.
Example:
type ToolState = {
config: Record<string, unknown>;
calls?: number;
};
init(config: Record<string, unknown>): ToolState {
return { config, calls: 0 };
}
If you mutate state inside executeTool(...), the updated state is sent back to
Python and reused on the next call.
Streaming
Streaming uses the emit(...) callback:
emit({ type: "part", payload: { message: "starting" } });
emit({ type: "part", payload: { message: "working" } });
On the Python side, these become partial tool events.
Important rule:
stdoutis reserved for NDJSON protocol messages- write logs/debug output to
stderr, notstdout
Tool schemas
Today, Node-hosted tools should expose classic object/function-style schemas.
Example:
getToolSchemas() {
return [
{
type: "function",
function: {
name: "read_file",
description: "Read a file from disk.",
parameters: {
type: "object",
properties: {
file_path: { type: "string" }
},
required: ["file_path"]
}
}
}
];
}
Do not assume the Node host currently supports the full in-process Python payload-first/custom-freeform tool contract.
Exports and discovery
Directory-based tools
For a package directory, the loader reads package.json#agent.tools.
Example:
{
"agent": {
"tools": [
{ "id": "echo", "entry": "dist/index.js", "export": "echoTool" },
{ "id": "reverse", "entry": "dist/index.js", "export": "reverseTool" }
]
}
}
Notes:
entryis relative to the package rootexportis optional; without it, the default export is used- one package can expose multiple tools
Single-file tools
Single-file specs skip package.json#agent.tools and point directly to a file:
{
"plugins": [
{
"node_tool": {
"file": "./plugins/js-single-file-tool/UpperTool.ts",
"id": "js_single_file_tool"
}
}
]
}
Supported export shapes
The host supports:
- plain object export
- class export
- factory function returning an object
See:
plugins/js-echo-toolplugins/js-class-tool
Testing Node tools
Recommended layers:
- JS/TS unit tests for pure logic
- build test (
npm run build) - Python-side loader/integration tests
- full app config test
Quick local loop
npm install
npm run build
pytest core/python/tests/test_node_tool_plugins.py -q
Useful references:
core/python/tests/test_node_tool_plugins.pycore/python/tests/fixtures/node_tools/
What to test
At minimum:
- schema discovery
- tool execution result shape
- streaming partials
- state round-tripping
- directory vs single-file loading
- class/default/named exports if relevant
If your tool uses config restrictions such as allowed_paths, add tests for the
failure path too.
Common pitfalls
- Do not print logs to
stdout; usestderr. - Keep returned state JSON-serializable.
- Keep tool results JSON-serializable.
format_tool_resultshould usually return a string. It may return an explicit provider-native envelope withtype: "provider_native_tool_result"when the target provider supports that format. Arbitrary objects are not treated as structured model-facing content.- Prefer small, copyable schemas and result objects.
- Remember that host protocol methods are still object-arguments-based.
- If you need raw-text/freeform payloads today, prefer a Python tool.
Reference examples
plugins/template-js-multi-toolsplugins/js-echo-toolplugins/js-class-toolplugins/js-fs-toolsdocs/reference/tool-host-protocol.md