Skip to content
MANTYX.IO

Local tools

A local tool is defined and executed in the SDK’s process. When the model calls it, MANTYX pauses the agent loop, emits a local_tool_call event over SSE, and waits for the SDK to POST a tool-result back via HTTP.

This is how you give the agent access to anything that requires running code in your environment — the local filesystem, an internal HTTP service, a native library, secrets that can’t leave your machine.

The wire protocol exposes three client-resolved tool kinds. They share a transport — local_tool_call event in, tool-result POST out — but differ by which SDK-side helper builds them and which kind discriminator MANTYX echoes on the event:

kind Helper Use it when
local defineLocalTool / LocalTool / define_local_tool Generic in-process function — filesystem, native library, internal HTTP.
a2a_local defineLocalA2A / LocalA2A / define_local_a2a An A2A peer only your process can reach.
mcp_local defineLocalMcp / LocalMcp / define_local_mcp A whole MCP server only your process can reach.

This page covers kind: "local". The two specialised helpers are documented on their own pages.

import { defineLocalTool } from "@mantyx/sdk";
import { z } from "zod";
const tool = defineLocalTool({
name: "read_file",
description: "Read a UTF-8 file from the local filesystem.",
parameters: z.object({ path: z.string() }),
execute: async ({ path }) => {
const fs = await import("node:fs/promises");
return fs.readFile(path, "utf8");
},
});
from pydantic import BaseModel
from mantyx import define_local_tool
class ReadFileArgs(BaseModel):
path: str
tool = define_local_tool(
name="read_file",
description="Read a UTF-8 file from the local filesystem.",
parameters=ReadFileArgs,
execute=lambda args: open(args.path).read(),
)
type readFileArgs struct {
Path string `json:"path" jsonschema:"description=Path to the file to read"`
}
tool := mantyx.LocalTool(mantyx.LocalToolSpec{
Name: "read_file",
Description: "Read a UTF-8 file from the local filesystem.",
Parameters: &readFileArgs{},
Execute: func(ctx context.Context, args readFileArgs) (string, error) {
b, err := os.ReadFile(args.Path)
return string(b), err
},
})

The tool name must match ^[a-zA-Z0-9_]{1,64}$. The SDK validates this client-side; the server enforces it as well.

The SDK converts your local schema definition (Zod / Pydantic / tagged Go struct) into a JSON Schema that the server feeds to LLM providers. Unsupported features (effects, transforms, intersections) degrade to a permissive "object" rather than failing the request.

For best results, keep schemas to the JSON-Schema-friendly intersection: string, number, boolean, array, nested object, plus optional / nullable / default. Add a description to each field — the model uses it to decide when to call the tool.

The handler must return a string that the SDK forwards as the wire-level tool result. For structured outputs, JSON-serialize before returning:

execute: async () => JSON.stringify({ ok: true, count: 42 });

In Go, Execute also accepts a typed second return value the SDK serialises for you — the same Go type can drive both your handler’s return shape and the JSON the model sees:

type Result struct {
Labels map[int]string `json:"labels"`
}
Execute: func(ctx context.Context, args ResolveIDsArgs) (*Result, error) {
out, err := lookup(ctx, args.IDs)
if err != nil { return nil, err }
return &Result{Labels: out}, nil
}

string and json.RawMessage returns are forwarded verbatim; any other type is json.Marshaled by the SDK before dispatch.

A thrown error (or a non-nil error in Go) is forwarded to the model as a tool-error response. You typically don’t need to catch and re-throw; the SDK wraps the message into the right wire shape automatically.

A local tool can hand files back to the model alongside its textual result. Return the richer result shape — LocalToolResult (TypeScript), ToolResult (Python / Go) — carrying a files array of { filename, mimeType, data }, where data is base64-encoded bytes with no data: URL prefix. The SDK posts them with the tool-result, and MANTYX surfaces them to the model on the next turn as native file parts (Anthropic / Gemini / Bedrock inside the tool_result; OpenAI as a synthetic follow-up user turn) — the same pipeline used by server-resolved tools that return files.

import { defineLocalTool } from "@mantyx/sdk";
import { z } from "zod";
defineLocalTool({
name: "render_chart",
parameters: z.object({ kind: z.string() }),
execute: async ({ kind }) => {
const png = await renderChart(kind); // Buffer
return {
result: `Rendered a ${kind} chart.`,
files: [
{ filename: "chart.png", mimeType: "image/png", data: png.toString("base64") },
],
};
},
});
import base64
from mantyx import define_local_tool, ToolResult, ToolResultFile
def render_chart(args):
png = render(args.kind) # bytes
return ToolResult(
result=f"Rendered a {args.kind} chart.",
files=[
ToolResultFile(
filename="chart.png",
mime_type="image/png",
data=base64.b64encode(png).decode(),
),
],
)
define_local_tool(name="render_chart", parameters=ChartArgs, execute=render_chart)
mantyx.LocalTool(mantyx.LocalToolSpec{
Name: "render_chart",
Parameters: &chartArgs{},
Execute: func(ctx context.Context, args chartArgs) (mantyx.ToolResult, error) {
png, err := renderChart(args.Kind)
if err != nil {
return mantyx.ToolResult{}, err
}
return mantyx.ToolResult{
Result: fmt.Sprintf("Rendered a %s chart.", args.Kind),
Files: []mantyx.ToolResultFile{
{Filename: "chart.png", MimeType: "image/png", Data: base64.StdEncoding.EncodeToString(png)},
},
}, nil
},
})

result is optional — leave it empty when the files are the whole payload. Files are ignored on the error path: a thrown error (or non-nil Go error) is surfaced as a tool-error response with no attachments. Limits are enforced server-side: up to 20 files per result, mimeType must be an allowed attachment type, and the combined decoded size is capped (currently 5 MB). For larger artifacts, upload them out of band and reference a URL in result instead. In Go, returning ToolResult also skips outputSchema inference — the {result, files} envelope is transport, not a model-facing output contract.

You can attach a JSON Schema for the tool’s structured return value alongside parameters. MANTYX forwards it to providers that support per-tool response schemas (Gemini’s responseJsonSchema on the FunctionDeclaration); other engines surface it through the description and rely on host-side validation. Either way the model uses it to plan follow-up calls more reliably.

The schema must be a JSON object root — non-object roots are dropped server-side because providers reject them in this position.

defineLocalTool({
name: "send_email",
parameters: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
outputSchema: z.object({ id: z.string() }), // Zod is auto-converted
execute: async (args) => JSON.stringify({ id: await sendEmail(args) }),
});
class SendEmailArgs(BaseModel):
to: str
subject: str
body: str
class SendEmailResult(BaseModel):
id: str
define_local_tool(
name="send_email",
parameters=SendEmailArgs,
output_schema=SendEmailResult, # Pydantic model or JSON Schema dict
execute=lambda args: json.dumps({"id": send_email(args)}),
)

In Go the OutputSchema is inferred from Execute’s return type by default — same reflection path Parameters already uses for the input. A typed struct return (or pointer-to-struct) yields a JSON Schema you don’t have to write twice; string and json.RawMessage returns skip inference because they’re opaque text payloads:

type SendEmailResult struct {
ID string `json:"id" jsonschema:"Provider-side message id"`
}
mantyx.LocalTool(mantyx.LocalToolSpec{
Name: "send_email",
Parameters: &SendEmailArgs{},
Execute: func(ctx context.Context, args SendEmailArgs) (*SendEmailResult, error) {
// Returned struct's JSON Schema is auto-shipped as `outputSchema`.
return &SendEmailResult{ID: "msg_1"}, nil
},
})

To override the inferred schema (or to attach one when Execute returns a string / json.RawMessage), set OutputSchema explicitly — it accepts the same shapes as Parameters (map[string]any, json.RawMessage, or a struct / pointer-to-struct).

Set longRunning: true (TypeScript / Python: long_running=True; Go: LongRunning: true) when a single call to your tool may return a pending / status response and you do the polling yourself. MANTYX appends a stable hint to the model-facing description:

NOTE: This is a long-running operation. Do not call this tool again if it has already returned an intermediate or pending status.

…so every provider treats the tool the same way. Without the hint, models routinely fire repeat calls and waste turns. The flag is purely declarative — MANTYX does not change scheduling, increase the per-call timeout, or otherwise alter the tool’s lifecycle.

defineLocalTool({
name: "kick_off_export",
parameters: z.object({ dataset: z.string() }),
outputSchema: z.object({ jobId: z.string(), status: z.enum(["pending", "done"]) }),
longRunning: true,
execute: async ({ dataset }) => JSON.stringify(await enqueueExport(dataset)),
});
define_local_tool(
name="kick_off_export",
parameters=KickOffExportArgs,
output_schema=KickOffExportResult,
long_running=True,
execute=enqueue_export,
)
mantyx.LocalTool(mantyx.LocalToolSpec{
Name: "kick_off_export",
Parameters: &KickOffExportArgs{},
LongRunning: true,
Execute: enqueueExport,
})

Set readOnly: true (TypeScript / Python: read_only=True; Go: ReadOnly: true) on a tool that has no side effects and whose result doesn’t depend on other tools. When the model emits several read-only tool calls in the same turn, MANTYX publishes those local_tool_call events concurrently and resolves them together, instead of one-at-a-time in model-emit order. Mutating tools (the default, false) stay strictly sequential.

defineLocalTool({
name: "read_file",
parameters: z.object({ path: z.string() }),
readOnly: true,
execute: async ({ path }) => (await import("node:fs/promises")).readFile(path, "utf8"),
});
define_local_tool(
name="read_file",
parameters=ReadFileArgs,
read_only=True,
execute=lambda args: open(args.path).read(),
)
mantyx.LocalTool(mantyx.LocalToolSpec{
Name: "read_file",
ReadOnly: true,
Execute: readFile,
})

The SDK already dispatches each local_tool_call independently (every event carries its own toolUseId, and you POST one tool-result per id in any order), so no extra wiring is needed on your side — flipping readOnly on parallel-safe reads simply lets MANTYX fan them out. The field is additive: omit it to keep the original sequential behavior. See the protocol reference §4.1.1.

The server enforces a tool-result timeout (default 60s) for each local_tool_call. If the SDK doesn’t POST a result in time, the run terminates with result.subtype = "error_local_tool_timeout".

For longer-running work, persist the result somewhere durable and have the tool body return a “queued” / pending message immediately; on a follow-up turn, return the actual result via a different tool that reads from the durable store. Pair the kick-off tool with longRunning: true so the model doesn’t double-fire while the job is still in flight.

Each SDK keeps three small registries keyed by tool name — one for generic local handlers, one for local A2A peers, one for local MCP servers. On a local_tool_call SSE event the SDK switches on the kind field in the payload:

  • kind omitted or "local" → look up name in the generic registry, validate args against the schema, run the handler.
  • kind: "a2a_local" → look up name in the A2A registry, take the cached Agent Card resolved from the supplied agentCardUrl, dispatch the args.message over JSON-RPC message/send, and post the reply text back.
  • kind: "mcp_local" → look up the server in the MCP registry by mcpServer, take the live MCP session opened from the supplied url / command, strip the <server>_ prefix from mcpToolName, dispatch via tools/call, and post the flattened text content back.

You don’t normally see this dispatch in user code — runAgent / streamAgent / session.send does it for you. It’s only relevant when you’re implementing a third-party SDK against the Wire protocol.