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',
guards: [{ scope: 'orders:read' }], // the tool inherits exactly this check
exposeAsTool: { description: "List the current tenant's orders." },
})
export const createOrder = defineMutation({
name: 'orders.create', input: CreateInput, output: Order, target: { table: 'orders', op: 'insert' },
guards: [{ scope: 'orders:write' }],
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 })The guards are load-bearing, not incidental. "The agent can do nothing the
subject couldn't" is a claim about the descriptor's own access decision — the
synthesized tool runs the real handler under the calling subject and re-checks
the same guards:. Exposing a procedure whose decision is openAccess: gives
the model an unchecked endpoint, which is the right call for a public price
lookup and the wrong one for anything reading rows. (A descriptor with no
decision cannot reach this page: the app would not have booted.)
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.
confirm is enforced, not reported
A confirm tool is admitted only when its descriptor also declares
requiresApproval. Otherwise it is REFUSED — with the
fix in the reason — because the alternative is to mount it and hope the caller
asks, and the caller is a model.
export const refundOrder = defineMutation({
name: 'orders.refund',
// …
guards: [{ scope: 'orders:refund' }],
exposeAsTool: { description: 'Refund an order.' }, // confirm: true by default
requiresApproval: { approvers: [{ scope: 'orders:approve' }] },
})With that pair, the agent's call parks in your app's own approval queue and comes
back as a typed ApprovalRequired carrying an id; a human decides in your UI;
the identical call then succeeds exactly once. The human step is enforced by the
handler, so nothing depends on the agent harness honouring a flag.
The inventory reports both confirm and approvalBacked, because "why is this
tool not executable" is answered only by the pair. For an external MCP
server's tools approvalBacked is always false: their handler is behind an HTTP
boundary we do not own, so there is no point at which we could hold the call —
the human decision for an external write is the declaration-time one (allow is
required, includeWrites is opt-in).
The same toolset, to an EXTERNAL agent
appTools is the in-process form: your own agent loop, in your own handler. The
same descriptors — through the same admission decision (appToolDecision, which
appTools itself filters on) — can also be handed to an external MCP peer, so
Claude Code or Cursor calls your procedures directly:
// app.config.ts
export default {
agents: {
tools: { allow: ['orders.*'], includeWrites: true }, // the SAME AppToolPolicy
mcp: true, // off by default
},
}Five gates stand in front of it, the app credential the agent acts as is a
separate header from the operator's inspect token, and an unbacked confirm
tool is not mounted there at all (there is no human in that process). A
requiresApproval-backed one IS mounted: the human is not in the transport, they
are in your approval queue, so nothing there has to trust the client.
The full list, and what it does not defend against, is on the
MCP server page.
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.