Tools
`*.tool.tsx` files — Schema-validated tool definitions agents can call, with side effects, retries, and tenant scoping.
A tool is a function the agent can call. It has a name, a description, a Schema-typed input + output, and a handler. The framework's discovery picks up every *.tool.tsx file; the agent runtime composes them into the model's tool list.
Tools are the bridge between the LLM ("I want to look up the user's plan") and your data ("here's the row from users").
Defining a tool
// apps/api/tools/searchDocs.tool.tsx
import { defineTool } from '@voltro/ai'
import { Schema } from 'effect'
export const searchDocs = defineTool({
name: 'search-docs',
description: 'Search Voltro documentation. Returns up to 5 results.',
input: Schema.Struct({ query: Schema.String }),
output: Schema.Array(Schema.Struct({
title: Schema.String,
snippet: Schema.String,
href: Schema.String,
})),
})
export default async ({ query }, ctx) => {
const rows = await ctx.store.select('docs')
.where('body', 'fts', query)
.limit(5)
.all()
// Project to the declared `output` shape. When `output` is set, the
// framework decodes the handler's return value through it before
// handing the result to the model — returning raw `docs` rows here
// would fail that decode.
return rows.map((r) => ({
title: r.title,
snippet: r.body.slice(0, 160),
href: `/docs/${r.id}`,
}))
}The export shape is the same as agents: a defineTool({...}) config + a default-exported async handler.
How the agent uses it
Tools are wired on the agent's executor (*.agent.server.tsx) — they import server services, so they stay out of the browser:
// apps/api/agents/support.agent.server.tsx
import { defineAgentExecutor } from '@voltro/ai'
import { support } from './support.agent'
import { searchDocs } from '../tools/searchDocs.tool'
export default defineAgentExecutor(support, {
tools: { searchDocs },
})At call time, the framework:
- Translates each tool into the provider's native tool format (Anthropic's
toolsshape). - Includes them in the chat-completion request.
- When the model returns a tool-call message, looks up the matching tool, validates the input against the Schema, runs the handler, validates the output, and feeds the result back to the model.
- Loops until the model returns a final text response (or hits
maxTurns).
You don't write the tool-call loop. The framework does.
Description prompt engineering
The model's only signal for "when should I call this tool" is the description. Be specific:
// BAD
description: 'Search docs.'
// GOOD
description: `Search the Voltro framework documentation. Use this when the
user asks how to use a feature, how to debug something, or what an API
does. Returns up to 5 results ranked by relevance.`Include:
- When to call — the situations this tool handles
- What it returns — shape + ranking hints
- What it doesn't do — sets boundaries against over-calling
For tools that should ONLY be called once per turn, say so in the description. The model usually listens.
Tool input validation
Schema validates the model's tool-call arguments before your handler sees them. Invalid inputs → the framework feeds an error back to the model + asks it to retry:
input: Schema.Struct({
query: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(200)),
limit: Schema.Number.pipe(Schema.between(1, 20)).pipe(Schema.optional),
})The model sees a structured "your input was invalid for these reasons" message + reformulates. The user never sees the failure — it's a model-internal retry.
Tools with side effects
Tools that write (createTicket, bookMeeting, sendEmail) get the same ctx as mutations:
export const createTicket = defineTool({
name: 'create-ticket',
description: 'Open a support ticket for the user.',
input: Schema.Struct({
subject: Schema.String,
body: Schema.String,
}),
output: Schema.Struct({ id: Schema.String }),
})
export default async (input, ctx) => {
if (ctx.subject.type !== 'user') {
throw new Error('Tool only available for signed-in users.')
}
const t = await ctx.store.insert('tickets', {
...input,
userId: ctx.subject.id,
tenantId: ctx.subject.tenantId,
})
return { id: t.id }
}The tool's subject is the calling agent's subject — i.e. the user who invoked the agent. Tools cannot impersonate other users.
Tenant scoping
Tools inherit ctx.subject.tenantId from the agent's caller. Reads via ctx.store auto-scope to that tenant; writes need assertOwnTenant. Same rules as mutations.
The model cannot pass a different tenantId to escalate — even if it tries (a system_prompt injection attempt), the framework's tenant scope is enforced at the data layer, not the tool layer.
Effect tool bodies reach app services
A tool body written as an inline execute(input) => Effect may yield* any Effect service the caller's runtime provides — JiraService, EffectStore, HttpClient, your own Context.Tag layers. The loop primitives (streamText, runAssistant, generateObjectWithTools) capture the ambient runtime (Effect.runtime) and thread it into the tool-execution context, so a tool run inside a workflow step or action that already has the service in scope can call it directly:
import { defineTool } from '@voltro/ai'
import { JiraService } from '@voltro/plugin-atlassian'
import { Effect, Schema } from 'effect'
export const getIssueComments = defineTool({
name: 'getIssueComments',
description: 'Fetch all comments of a Jira issue.',
input: Schema.Struct({ jiraKey: Schema.String }),
output: Schema.Array(Schema.Struct({ author: Schema.NullOr(Schema.String), body: Schema.String })),
execute: ({ jiraKey }) =>
Effect.gen(function* () {
const jira = yield* JiraService // ← provided by the caller's runtime
const raw = yield* jira.getComments(jiraKey)
return raw.map((c) => ({ author: c.author?.displayName ?? null, body: c.body }))
}).pipe(Effect.catchAll(() => Effect.succeed([]))), // degrade → empty, never abort the loop
})Catch the service's failures inside the body (the body's error channel is never): an uncaught failure surfaces to the model as a tool error. Tools that close over their data instead (a pure execute over a pre-fetched payload, or a (input, ctx) handler writing via ctx.store) don't need the runtime at all — both forms work side by side.
Tools that call other tools
A tool can use another tool's handler internally:
import searchDocsHandler from '../tools/searchDocs.tool'
export default async (input, ctx) => {
const docs = await searchDocsHandler({ query: input.query }, ctx)
// …
}Avoid calling streamText / generateText from inside a tool body to spawn a nested agent — it's an easy way to build an accidental runaway loop (the model calls the tool, the tool runs another model that calls the tool again). The framework doesn't stop you, but for chained work, model the chain as a workflow and start it through its generated RPC boundary instead.
Tools as a wedge for testability
A tool's input + output are typed + Schema-validated. That makes them trivial to unit-test:
import tool from './searchDocs.tool'
import { makeTestContext, mockStore } from '@voltro/testing'
test('searchDocs finds relevant docs', async () => {
const ctx = makeTestContext({
store: mockStore({ docs: [{ id: 'foo', title: 'Foo', body: 'bar baz' }] }),
})
const out = await tool({ query: 'bar' }, ctx)
expect(out).toEqual([{ title: 'Foo', snippet: 'bar baz', href: '/docs/foo' }])
})makeTestContext / mockStore come from @voltro/testing — a separate package you add as a devDependency per app (pnpm --filter @my-app/api add -D @voltro/testing vitest); see Testing → Unit testing.
For tests that exercise the model loop (not just the tool body), use mockAi / useMockAi from @voltro/ai/test to install a deterministic provider — see Providers.
No agent involved, no model call. Just the tool's logic. This is the right unit boundary — agents are integration tests; tools are unit tests.
App-as-an-agent — expose existing procedures as tools
You don't have to hand-wrap every endpoint as a *.tool.tsx. A query /
mutation / action descriptor already IS a safe LLM-tool spec — typed input,
RBAC-scoped, validated, audited — so annotate it exposeAsTool and synthesize
the toolset with appTools. The synthesized tool runs the REAL handler under
the calling subject: the agent can do nothing the subject couldn't (no new
authorization path), by construction.
// Opt a descriptor in (a description is REQUIRED — the model needs it):
export const listOrders = defineQuery({
name: 'orders.list', input: ListInput, output: Schema.Array(Order), source: 'orders',
exposeAsTool: { description: "List the current tenant's orders." },
})
export const createOrder = defineMutation({
name: 'orders.create', input: CreateInput, output: Order, target: { table: 'orders', op: 'insert' },
exposeAsTool: { description: 'Create an order.', confirm: true }, // writes confirm by default
})// Server-side: synthesize + run. `entries` = { descriptor, invoke } bound to
// the request ctx (the serve layer provides them).
import { appTools, generateObjectWithTools } from '@voltro/ai'
const tools = appTools(entries, { allow: ['orders.*'], includeWrites: true })
const { object } = yield* generateObjectWithTools({ prompt, tools, schema: Result })Safety defaults (don't override blindly): reads are included, writes are
opt-in (includeWrites: true) and confirm by default. Put destructive
tags on deny. exposeAsTool: true alone does NOT expose — a tool with no
description is unusable; always use the object form. The annotations also
surface in the capability manifest, so a coding agent discovers what's
tool-exposable.
The end-user-facing counterpart is <AppAgent> (from @voltro/web) — a
"do it for me" chat whose ceiling is the logged-in subject's own permissions.
See Schema-driven UI → Reactive components.
Anti-patterns
- Tools that take freeform JSON. The model writes JSON poorly. Use Schema everywhere; let the framework reject bad inputs.
- Tools without descriptions. The model has nothing to go on — it'll either over-call (every turn) or never call.
- Long-running tools. Tool calls block the agent's turn. For anything > 5s, queue a workflow and return a handle the agent can poll.
- Tools that throw on common errors. A throw stops the agent. Return a typed error variant so the model can recover.