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.
Three flavours of “local”
Section titled “Three flavours of “local””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.
Defining a local tool
Section titled “Defining a local tool”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 BaseModelfrom 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 },})Naming rules
Section titled “Naming rules”The tool name must match ^[a-zA-Z0-9_]{1,64}$. The SDK validates this client-side; the server enforces it as well.
Parameter schemas
Section titled “Parameter schemas”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.
Returning a result
Section titled “Returning a result”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.
Returning files
Section titled “Returning files”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 base64from 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.
Declaring an outputSchema
Section titled “Declaring an outputSchema”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).
Long-running tools
Section titled “Long-running tools”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,})Read-only (parallel) tools
Section titled “Read-only (parallel) tools”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.
Timeouts
Section titled “Timeouts”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.
How dispatch works
Section titled “How dispatch works”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:
kindomitted or"local"→ look upnamein the generic registry, validateargsagainst the schema, run the handler.kind: "a2a_local"→ look upnamein the A2A registry, take the cached Agent Card resolved from the suppliedagentCardUrl, dispatch theargs.messageover JSON-RPCmessage/send, and post the reply text back.kind: "mcp_local"→ look up the server in the MCP registry bymcpServer, take the live MCP session opened from the suppliedurl/command, strip the<server>_prefix frommcpToolName, dispatch viatools/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.
