# Custom Tools

> Build custom tool handlers executed on the tool executor service


Custom tools extend your agents' capabilities beyond the built-in data 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:

```typescript
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, struere, 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. |
| `templateOnly` | `boolean` | No | If true, tool is only available during system prompt template compilation and not exposed to the LLM at runtime. Defaults to `false`. |

The `parameters` field follows the JSON Schema specification:

```typescript
{
  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, struere, 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

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

| Parameter | Type | Description |
|-----------|------|-------------|
| `args` | `object` | The parsed arguments object matching the tool's `parameters` schema. Generated by the LLM based on conversation context. |
| `context` | `ExecutionContext` | Information about the calling actor (organization, actor ID, actor type). |
| `struere` | `StruereSDK` | SDK object providing access to all built-in tools (entity, whatsapp, calendar, airtable, email, payment, agent). |
| `fetch` | `SandboxedFetch` | Restricted `fetch` for external API calls. Only allows requests to approved domains. |

### 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:

```typescript
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:

```typescript
handler: async (args, context, struere, 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()
}
```

### struere (StruereSDK)

SDK object injected into every custom tool handler that provides access to all built-in tools via callback to Convex. Permissions are enforced — if the agent does not have permission for a built-in tool, the `struere.*` call will throw an error.

### 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.

## Calling Built-in Tools

The `struere` parameter lets you call any built-in tool directly from a custom tool handler. This is useful for combining external API calls with platform operations like creating entities or sending messages.

```typescript
handler: async (args, context, struere, fetch) => {
  const student = await struere.entity.create({
    type: "student",
    data: { name: args.name, phone: args.phone, classId: args.classId },
  })

  await struere.whatsapp.send({
    to: args.phone,
    text: `Welcome ${args.name}! You've been enrolled.`,
  })

  return { success: true, studentId: student.id }
}
```

### Available Namespaces

| Namespace | Methods |
|-----------|---------|
| `struere.entity` | `create`, `get`, `query`, `update`, `delete` |
| `struere.whatsapp` | `send`, `sendTemplate`, `sendInteractive`, `sendMedia`, `listTemplates`, `getConversation`, `getStatus` |
| `struere.calendar` | `list`, `create`, `update`, `delete`, `freeBusy` |
| `struere.airtable` | `listBases`, `listTables`, `listRecords`, `getRecord`, `createRecords`, `updateRecords`, `deleteRecords` |
| `struere.email` | `send` |
| `struere.payment` | `create`, `getStatus` |
| `struere.agent` | `chat` |
| `struere.web.fetch(args)` | Fetch a web page. Supports `url`, `targetSelector`, `removeSelector`, `tokenBudget`, `returnFormat` |
| `struere.web.search(args)` | Search the web. Supports `query`, `maxResults`, `site`, `gl`, `hl` |

All `struere.*` calls execute with the same actor context and permissions as the agent running the custom tool. If the agent lacks permission for a built-in tool, the call will throw an error.

### Example: Web Scraping Tool

Custom tools can use built-in tools via the `struere` SDK. This example fetches a web page as HTML and parses it:

```typescript
{
  name: 'my-scraper',
  description: 'Scrape a website and extract structured data',
  parameters: {
    type: 'object',
    properties: {
      url: { type: 'string', description: 'URL to scrape' },
    },
    required: ['url'],
  },
  handler: async (args, context, struere, fetch) => {
    const page = await struere.web.fetch({
      url: args.url,
      returnFormat: 'html',
    })
    const html = page?.data?.html || ''
    return { html: html.length, url: args.url }
  },
}
```

Note: When using `returnFormat: "html"`, the response has an `html` field instead of `content`.

## 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:

```typescript
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: { model: "openai/gpt-5-mini" },
  tools: [
    "entity.query",
    "send_email",
    "send_slack_notification",
  ],
})
```

### Template-Only Tools

Tools defined with `templateOnly: true` are executed during system prompt template compilation (e.g., `{{format_teacher_schedule()}}`) but are not exposed to the LLM as callable tools at runtime. This is useful for injecting dynamic data into system prompts without adding to the agent's runtime tool list.

```typescript
export default defineTools([
  {
    name: 'format_teacher_schedule',
    description: 'Query teachers and format availability into a readable schedule',
    templateOnly: true,
    parameters: {
      type: 'object',
      properties: {},
    },
    handler: async (args, context, struere) => {
      const result = await struere.entity.query({ type: 'teacher' })
      const teachers = Array.isArray(result) ? result : result?.results || []
      return teachers.map((t) => `${t.data?.name}: ${JSON.stringify(t.data?.availability)}`).join('\n')
    },
  },
])
```

Then reference the tool in your agent's system prompt:

```
You are a scheduling assistant. Here is the current teacher availability:

{{format_teacher_schedule()}}
```

The tool runs at prompt compilation time and its output is inlined into the system prompt. The LLM never sees `format_teacher_schedule` as a callable tool.

## Complete Example

A `tools/index.ts` with multiple custom tools:

```typescript
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, struere, 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, struere, 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, struere, 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 }
    },
  },
])
```

## Best Practices

### Use Custom Tools to Reduce Agent Tool Bloat

Instead of giving an agent many built-in tools and relying on the LLM to orchestrate multi-step workflows, create a single custom tool that composes built-in operations via the `struere` SDK. This keeps the agent's tool list small and focused, which improves LLM decision-making.

**Instead of this** (agent has 5+ tools, LLM must orchestrate):

```typescript
tools: ["entity.create", "whatsapp.send", "entity.query"]
```

**Do this** (agent has 1 purpose-built tool):

```typescript
tools: ["enroll_student"]
```

```typescript
defineTools([{
  name: "enroll_student",
  description: "Enroll a student in a class and send welcome message",
  parameters: {
    type: "object",
    properties: {
      name: { type: "string", description: "Student name" },
      phone: { type: "string", description: "Phone number with country code" },
      classId: { type: "string", description: "Class entity ID" },
    },
    required: ["name", "phone", "classId"],
  },
  handler: async (args, context, struere, fetch) => {
    const student = await struere.entity.create({
      type: "student",
      data: { name: args.name, phone: args.phone, classId: args.classId },
    })

    await struere.whatsapp.send({
      to: args.phone,
      text: `Welcome ${args.name}! You've been enrolled.`,
    })

    return { success: true, studentId: student.id }
  },
}])
```

This pattern has several advantages:

- **Fewer tools = better LLM decisions.** LLMs perform worse as the number of available tools grows. A single `enroll_student` tool is unambiguous. Aim for fewer than 5 tools per agent. Agents perform significantly worse as tool count grows — accuracy drops noticeably beyond 5 tools.
- **Guaranteed execution order.** The multi-step workflow always runs in the correct sequence — no risk of the LLM skipping a step or calling tools out of order.
- **Atomic operations.** If any step fails, the handler throws immediately. No partial state from the LLM stopping mid-workflow.
- **Simpler prompts.** The system prompt doesn't need to explain how to orchestrate multiple tools together.

### Consolidate Related Tools with an Action Parameter

When you have several tools that operate on the same resource, merge them into a single tool with an `action` parameter. This reduces tool count without losing functionality.

**Before** (3 tools):

```typescript
defineTools({
  schedule_session: { ... },
  reschedule_session: { ... },
  cancel_session: { ... }
})
```

**After** (1 tool):

```typescript
defineTools({
  manage_session: {
    description: "Schedule, reschedule, or cancel a session",
    params: {
      action: { type: "string", enum: ["schedule", "reschedule", "cancel"], description: "The action to perform" },
      sessionId: { type: "string", description: "Session ID (required for reschedule and cancel)" },
      date: { type: "string", description: "New date (required for schedule and reschedule)" },
      teacherId: { type: "string", description: "Teacher ID (required for schedule)" }
    },
    handler: async (args, context, struere) => {
      switch (args.action) {
        case "schedule":
          return await struere.entity.create({ type: "session", data: { date: args.date, teacherId: args.teacherId, status: "pending" } })
        case "reschedule":
          return await struere.entity.update({ id: args.sessionId, data: { date: args.date } })
        case "cancel":
          return await struere.entity.update({ id: args.sessionId, data: { status: "cancelled" } })
      }
    }
  }
})
```

This pattern turns 3 tools into 1, keeping the agent's tool list short and the LLM's job simple.

### Use Template-Only Tools for Dynamic System Prompt Data

When you need to inject dynamic data (e.g., schedules, configurations, entity lists) into an agent's system prompt, use `templateOnly: true` instead of adding a runtime tool. This keeps the agent's callable tool list focused on actions while still providing rich context at prompt compilation time.
