How to Define Custom Tools Using the `defineTool` API in the Copilot SDK

You register custom functionality by calling defineTool(name, config) from @github/copilot-sdk, which returns a Tool object containing a Zod-validated parameter schema and handler function that you pass to client.createSession().

The Copilot SDK enables hosts to expose custom capabilities to LLM-driven agents through typed tool definitions. By using the defineTool API, you can register functions with validated parameters that the model can invoke during a session. This pattern bridges host-side logic—such as database queries or encryption services—with Copilot's conversational runtime, as implemented in the github/copilot-sdk repository.

Understanding the Tool Interface

At the core of the system is the Tool<T> interface defined in nodejs/src/types.ts (lines 629-646). This interface represents a name-bound function that the LLM can call, complete with type-safe arguments and execution context.

export interface Tool<TArgs = unknown> {
    name: string;
    description?: string;
    parameters?: ZodSchema<TArgs> | Record<string, unknown>;
    handler?: ToolHandler<TArgs>;
    overridesBuiltInTool?: boolean;
    skipPermission?: boolean;
    defer?: "auto" | "never";
    metadata?: Record<string, unknown>;
}

The generic TArgs parameter ensures that the handler receives strongly-typed arguments parsed from the LLM's tool call. The interface also includes permission and deferral flags that control runtime behavior.

The defineTool Helper Function

Because TypeScript cannot infer argument types directly from a Zod schema without an explicit generic, the SDK provides the defineTool<T>() helper in nodejs/src/types.ts (lines 665-678). This function merges the supplied configuration with the tool name and returns a complete Tool<T> object.

export function defineTool<T = unknown>(
    name: string,
    config: {
        description?: string;
        parameters?: ZodSchema<T> | Record<string, unknown>;
        handler?: ToolHandler<T>;
        overridesBuiltInTool?: boolean;
        skipPermission?: boolean;
        defer?: "auto" | "never";
        metadata?: Record<string, unknown>;
    }
): Tool<T> {
    return { name, ...config };
}

Using this helper ensures type safety across the parameter schema and handler function while maintaining a clean, declarative API.

Session Registration and Lifecycle

Tools are registered when creating a session via client.createSession(). The SDK serializes each Tool into the RPC payload consumed by the Copilot runtime. According to the implementation in nodejs/test/e2e/tools.e2e.test.ts, you supply an array of tools to the tools option.

Two critical flags affect invocation behavior:

  • skipPermission – When set to true, the runtime invokes the tool without prompting the host for permission. The default value (false) triggers a permission request before execution.
  • defer – Set to "auto" (default) to allow lazy loading via the built-in tool search mechanism, or "never" to load immediately when the session starts.

Tool Invocation Flow

When the LLM decides to use a tool, the SDK executes a strict validation and dispatch pipeline:

  1. The model emits a tool_call in the chat message
  2. The SDK matches the call name against registered Tool objects
  3. Arguments are validated against the Zod schema provided in parameters
  4. The handler receives parsed arguments and an InvocationContext containing the session ID and request ID
  5. The handler's return value is wrapped into a tool message sent back to the model

If the handler throws an error, the SDK sanitizes it before transmission. As demonstrated in nodejs/test/e2e/tools.e2e.test.ts (lines 96-134), the model receives the string "unknown" rather than the actual error message or stack trace.

Practical Implementation Examples

Basic String Encryption Tool

This example from nodejs/test/e2e/tools.e2e.test.ts (lines 28-38) shows a minimal tool that transforms input using toUpperCase():

import { defineTool, approveAll } from "@github/copilot-sdk";
import { z } from "zod";

const encryptTool = defineTool("encrypt_string", {
  description: "Encrypts a string",
  parameters: z.object({ input: z.string().describe("String to encrypt") }),
  handler: ({ input }) => input.toUpperCase(),
});

const session = await client.createSession({
  onPermissionRequest: approveAll,
  tools: [encryptTool],
});

const reply = await session.sendAndWait({
  prompt: "Use encrypt_string to encrypt this string: Hello",
});
console.log(reply?.data.content);   // → "HELLO"

Handling Permission Requests

For tools requiring explicit approval, implement onPermissionRequest to control access. This pattern from nodejs/test/e2e/tools.e2e.test.ts (lines 80-96) captures requests and approves single invocations:

const permissionRequests: PermissionRequest[] = [];

const session = await client.createSession({
  tools: [
    defineTool("encrypt_string", {
      description: "Encrypts a string",
      parameters: z.object({ input: z.string() }),
      handler: ({ input }) => input.toUpperCase(),
    }),
  ],
  onPermissionRequest: (req) => {
    permissionRequests.push(req);
    return { kind: "approve-once" };   // approve this single invocation
  },
});

Complex Data Types and Return Values

Tool handlers can return sophisticated data structures that the model consumes as JSON. This example from nodejs/test/e2e/tools.e2e.test.ts (lines 37-62) demonstrates returning database query results:

defineTool("db_query", {
  description: "Performs a database query",
  parameters: z.object({
    query: z.object({
      table: z.string(),
      ids: z.array(z.number()),
      sortAscending: z.boolean(),
    }),
  }),
  handler: ({ query }, invocation) => {
    // Simulated DB rows
    return [
      { countryId: 19, cityName: "Passos", population: 135460 },
      { countryId: 12, cityName: "San Lorenzo", population: 204356 },
    ];
  },
});

Error Handling and Sanitization

When tools encounter failures, the SDK prevents error details from reaching the LLM. As shown in nodejs/test/e2e/tools.e2e.test.ts (lines 96-134), thrown errors are replaced with "unknown":

defineTool("get_user_location", {
  description: "Gets the user's location",
  handler: () => {
    throw new Error("Melbourne");   // internal error
  },
});

The assistant receives only the sanitized "unknown" value, ensuring internal implementation details remain concealed.

Key Source Files

The complete implementation spans these critical paths in the github/copilot-sdk repository:

Summary

  • Use defineTool to create type-safe tool definitions with Zod schemas that validate LLM-provided arguments at runtime
  • Register tools by passing them to the tools array in client.createSession(), available via the export in nodejs/src/index.ts
  • Control permissions via the onPermissionRequest callback or set skipPermission: true for trusted internal operations
  • Return structured data from handlers—the SDK automatically serializes complex objects for the model
  • Expect sanitization—errors thrown in handlers are caught and replaced with "unknown" to prevent information leakage

Frequently Asked Questions

What is the defineTool API in the Copilot SDK?

The defineTool API is a TypeScript helper function exported from @github/copilot-sdk (defined in nodejs/src/types.ts, lines 665-678) that constructs a Tool object by merging a tool name with configuration options including a Zod schema, handler function, and permission flags. It enables type-safe registration of custom functions that LLM agents can invoke during sessions.

How do I handle permissions for custom tools?

By default, the Copilot runtime requests permission before invoking any tool where skipPermission is not set to true. You provide an onPermissionRequest callback when calling client.createSession() to approve or deny individual invocations, or return { kind: "approve-once" } to authorize single executions without persistent approval.

Can I return complex objects from tool handlers?

Yes, tool handlers can return any JSON-serializable data structure. The SDK automatically serializes the return value into a tool message sent back to the model. As shown in the test suite at nodejs/test/e2e/tools.e2e.test.ts (lines 37-62), you can return arrays of objects with nested properties, and the model will receive the complete structured data.

What happens when a tool handler throws an error?

When a handler throws, the SDK catches the error and sanitizes it before sending to the model. According to the error handling tests in nodejs/test/e2e/tools.e2e.test.ts (lines 96-134), the assistant receives the string "unknown" rather than the actual error message or stack trace, preventing sensitive internal details from leaking to the LLM.

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 →