Defining and Registering Custom Tools for LLM Agents Using the ToolDef Interface

Flue provides a ToolDef interface that lets you register deterministic functions—complete with JSON Schema parameters and async execution logic—that LLM agents can invoke during conversations by passing them to init() for global availability or to individual prompt, skill, or task calls for scoped use.

The withastro/flue SDK enables you to extend LLM agent capabilities beyond built-in operations by implementing the ToolDef interface. This pattern allows you to expose any deterministic JavaScript or TypeScript function as a callable tool that the model can invoke during reasoning. Understanding how to define, validate, and register these tools is essential for building specialized agent workflows.

Understanding the ToolDef Interface Structure

The contract for custom tools is defined in packages/sdk/src/types.ts (line 57). To create a valid tool, you must provide four properties:

  • name – A unique string identifier that cannot collide with built-in tools like task or shell.
  • description – Natural language guidance that helps the LLM decide when to invoke the tool.
  • parameters – A JSON Schema object describing expected arguments. Flue provides a Type helper from @flue/sdk/client to build these schemas fluently.
  • execute – An async function receiving parsed arguments and an optional AbortSignal, returning a string result. Thrown errors surface to the model as tool errors.

Tool Validation and Lifecycle

Before execution, Flue validates custom tools in the Session.createCustomTools method located in packages/sdk/src/session.ts (line 13). This validation ensures that no custom tool name conflicts with BUILTIN_TOOL_NAMES and that all names are unique within the custom tool set.

Upon validation, each ToolDef converts into an internal AgentTool object. This wrapper structures the return value as { content: [{ type: 'text', text: <result> }], details: { customTool: <tool-name> } } and handles AbortSignal propagation for cancellation support.

Registration Patterns

Flue supports two distinct strategies for defining and registering custom tools depending on your scope requirements.

Global Registration via init()

To make tools available for every session interaction, pass them to the init function defined in packages/sdk/src/harness.ts. This registers them globally across all subsequent calls:

import { init, Type, type ToolDef } from '@flue/sdk/client';

const calculator: ToolDef = {
  name: 'calculator',
  description: 'Perform arithmetic and return the numeric result.',
  parameters: Type.Object({
    expression: Type.String({ description: 'Math expression like "2 + 3"' }),
  }),
  async execute(args) {
    const result = Function(`"use strict"; return (${args.expression})`)();
    return String(result);
  },
};

const harness = await init({
  sandbox,
  model: 'anthropic/claude-sonnet-4-6',
  tools: [calculator], // Available to all subsequent calls
});

Per-Call Registration

For scoped tool access, pass the tools array to individual methods like prompt, skill, or task. This limits tool availability to that specific invocation:

const { text } = await session.prompt(
  'Calculate 7 * 6 using the calculator tool.',
  { tools: [calculator] }, // Only available for this specific call
);

Execution Flow and Error Handling

When an LLM decides to invoke a tool, the following sequence occurs:

  1. The model emits a tool-call payload containing the tool name and arguments.
  2. Flue matches the name against registered AgentTool instances.
  3. The wrapped execute function runs with parsed JSON arguments.
  4. The string result returns to the LLM as a tool result message.
  5. If execution throws, Flue surfaces the error as a tool-error message visible to the model.

Built-in tools (such as task and shell) always register first, followed by custom tools in the order provided.

Complete Working Example

The following example from examples/hello-world/.flue/agents/with-tools.ts demonstrates the full lifecycle—from definition to delegation.

Defining the tool (lines 24-36):

import { Type, type ToolDef } from '@flue/sdk/client';

const calculator: ToolDef = {
  name: 'calculator',
  description: 'Perform arithmetic. Returns the numeric result as a string.',
  parameters: Type.Object({
    expression: Type.String({
      description: 'A math expression like "2 + 3"',
    }),
  }),
  async execute(args) {
    const expr = args.expression as string;
    const result = Function(`"use strict"; return (${expr})`)();
    return String(result);
  },
};

Per-call usage (lines 38-42):

const { text } = await session.prompt(
  'Use the calculator tool to compute 7 * 6. Tell me the result.',
  { tools: [calculator] },
);
console.log(text); // Output includes "42"

Using tools in delegated tasks (lines 45-57):

await session.shell('mkdir -p /tmp/task-workspace');
await session.shell(
  'echo "You are a math helper. Always respond with just the numeric answer." > /tmp/task-workspace/AGENTS.md',
);

const taskResponse = await session.prompt(
  'Use the task tool with cwd /tmp/task-workspace to ask: "What is 100 + 23?"',
);
console.log(taskResponse.text); // → "123"

Summary

  • The ToolDef interface in packages/sdk/src/types.ts requires name, description, parameters (JSON Schema), and an async execute function.
  • Flue validates custom tools in Session.createCustomTools to prevent naming collisions with built-ins and ensure uniqueness.
  • Register tools globally via init({ tools }) in packages/sdk/src/harness.ts or per-call via the tools option on specific methods.
  • The execution wrapper formats results as structured content objects and respects AbortSignal for cancellation.
  • Custom tools coexist with built-in tools, extending agent capabilities without replacing core functionality.

Frequently Asked Questions

Can custom tools override Flue's built-in tools like task or shell?

No. The validation logic in packages/sdk/src/session.ts explicitly checks custom tool names against BUILTIN_TOOL_NAMES. If a collision is detected, Flue throws a validation error before the session begins, ensuring built-in tools remain accessible.

How does Flue handle errors thrown inside a tool's execute function?

When the execute function throws an error, Flue catches it and formats the result as a tool-error message that is visible to the LLM. This allows the model to potentially retry with different parameters or report the failure to the user, maintaining conversational continuity.

What is the difference between the ToolDef interface and the internal AgentTool?

ToolDef is the public SDK interface you implement when defining custom functionality. AgentTool is Flue's internal representation created by Session.createCustomTools that wraps your execute method, handles JSON argument parsing, manages AbortSignal propagation, and formats the return value according to the expected conversation protocol.

Can I register tools after initializing a session?

No. Tools must be registered either during the init call for global availability or at the time of specific method invocations (prompt, skill, task). There is no dynamic registration API for modifying active tool sets mid-session.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →