Skip to content
DocsToolsCustom Tools

Custom Tools

Build custom tool handlers executed on the tool executor service

Custom tools extend your agents' capabilities beyond the built-in entity, event, and agent tools. Tool handlers are defined in your project and executed on the tool executor service with a restricted fetch allowlist.

Defining Custom Tools

Create a tools/index.ts file in your project root and use defineTools to register your custom tools:

import { defineTools } from 'struere'

export default defineTools([
  {
    name: "send_email",
    description: "Send an email to a recipient",
    parameters: {
      type: "object",
      properties: {
        to: { type: "string", description: "Recipient email address" },
        subject: { type: "string", description: "Email subject line" },
        body: { type: "string", description: "Email body content" },
      },
      required: ["to", "subject", "body"],
    },
    handler: async (args, context, fetch) => {
      const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${process.env.SENDGRID_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          personalizations: [{ to: [{ email: args.to }] }],
          from: { email: "noreply@example.com" },
          subject: args.subject,
          content: [{ type: "text/plain", value: args.body }],
        }),
      })
      return { success: response.ok }
    },
  },
])

Tool Definition Schema

Each custom tool requires the following fields:

Field Type Required Description
name string Yes Unique tool name. Used to reference the tool in agent definitions.
description string Yes Human-readable description. Passed to the LLM to help it understand when to use the tool.
parameters object Yes JSON Schema defining the tool's input parameters.
handler function Yes Async function that executes the tool logic.

The parameters field follows the JSON Schema specification:

{
  name: "create_stripe_customer",
  description: "Create a new customer in Stripe",
  parameters: {
    type: "object",
    properties: {
      email: { type: "string", description: "Customer email" },
      name: { type: "string", description: "Customer name" },
    },
    required: ["email", "name"],
  },
  handler: async (args, context, fetch) => {
    const response = await fetch("https://api.stripe.com/v1/customers", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.STRIPE_SECRET_KEY}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: `email=${encodeURIComponent(args.email)}&name=${encodeURIComponent(args.name)}&metadata[orgId]=${context.organizationId}`,
    })
    const customer = await response.json()
    return { customerId: customer.id, email: customer.email }
  },
}

Handler Function Signature

handler: (args: object, context: ExecutionContext, fetch: SandboxedFetch) => Promise<any>

args

The parsed arguments object matching the tool's parameters schema. The LLM generates these based on the conversation context.

context (ExecutionContext)

Provides information about the calling actor:

interface ExecutionContext {
  organizationId: string
  actorId: string
  actorType: "user" | "agent" | "system"
}
Field Description
organizationId The Convex organization ID of the caller
actorId The ID of the user or agent making the call
actorType Whether the caller is a "user", "agent", or "system"

Use the context to scope your tool's behavior to the current organization and actor:

handler: async (args, context, fetch) => {
  const response = await fetch("https://api.stripe.com/v1/customers", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.STRIPE_SECRET_KEY}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: `email=${args.email}&metadata[orgId]=${context.organizationId}`,
  })
  return await response.json()
}

fetch (SandboxedFetch)

A restricted version of the standard fetch API that only allows requests to approved domains. Attempts to call domains outside the allowlist will throw an error.

Sandboxed Fetch Allowlist

The following domains are permitted for outbound requests from custom tool handlers:

Domain Typical Use
api.openai.com OpenAI API calls
api.anthropic.com Anthropic API calls
api.stripe.com Payment processing
api.sendgrid.com Email delivery
api.twilio.com SMS and voice
hooks.slack.com Slack webhook notifications
discord.com Discord webhook notifications
api.github.com GitHub API integration

Any fetch call to a domain not on this list will be rejected with an error.

Execution Environment

Custom tool handlers execute on the tool executor service at tool-executor.struere.dev. The execution flow is:

Agent LLM decides to use custom tool
    |
    v
Convex backend receives tool call
    |
    v
POST to tool-executor.struere.dev/execute
    |
    v
Handler code executes in sandbox
    |
    v
Result returned to agent LLM loop

The tool executor also provides a validation endpoint at POST /validate that checks handler code syntax before deployment.

Using Custom Tools in Agents

Reference custom tools by name in your agent definitions alongside built-in tools:

import { defineAgent } from 'struere'

export default defineAgent({
  name: "Notification Agent",
  slug: "notifications",
  version: "0.1.0",
  systemPrompt: "You send notifications to users via email and Slack.",
  model: { provider: "anthropic", name: "claude-sonnet-4" },
  tools: [
    "entity.query",
    "event.emit",
    "send_email",
    "send_slack_notification",
  ],
})

Complete Example

A tools/index.ts with multiple custom tools:

import { defineTools } from 'struere'

export default defineTools([
  {
    name: "send_email",
    description: "Send an email to a recipient",
    parameters: {
      type: "object",
      properties: {
        to: { type: "string", description: "Recipient email address" },
        subject: { type: "string", description: "Email subject line" },
        body: { type: "string", description: "Email body content" },
      },
      required: ["to", "subject", "body"],
    },
    handler: async (args, context, fetch) => {
      const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${process.env.SENDGRID_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          personalizations: [{ to: [{ email: args.to }] }],
          from: { email: "noreply@example.com" },
          subject: args.subject,
          content: [{ type: "text/plain", value: args.body }],
        }),
      })
      return { success: response.ok }
    },
  },
  {
    name: "send_slack_notification",
    description: "Post a message to a Slack channel via webhook",
    parameters: {
      type: "object",
      properties: {
        message: { type: "string", description: "The message text to post" },
        channel: { type: "string", description: "Slack channel name" },
      },
      required: ["message"],
    },
    handler: async (args, context, fetch) => {
      const webhookUrl = process.env.SLACK_WEBHOOK_URL
      const response = await fetch(webhookUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          text: args.message,
          channel: args.channel,
        }),
      })
      return { success: response.ok }
    },
  },
  {
    name: "create_stripe_customer",
    description: "Create a new customer in Stripe",
    parameters: {
      type: "object",
      properties: {
        email: { type: "string", description: "Customer email" },
        name: { type: "string", description: "Customer name" },
      },
      required: ["email", "name"],
    },
    handler: async (args, context, fetch) => {
      const response = await fetch("https://api.stripe.com/v1/customers", {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${process.env.STRIPE_SECRET_KEY}`,
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: `email=${encodeURIComponent(args.email)}&name=${encodeURIComponent(args.name)}&metadata[orgId]=${context.organizationId}`,
      })
      const customer = await response.json()
      return { customerId: customer.id, email: customer.email }
    },
  },
])