Flue Zero to Expert, Part 3: Safe Tools
Give a Flue agent one narrow capability while trusted code keeps credentials, authorization, and destinations.
Series map
Part 1: Your First Flue Agent · Part 2: Typed Workflows · Part 3: Safe Tools · Part 4: Autonomous Agents · Part 5: Persistence and Durable Execution · Part 6: Production Routing and Deployment · Part 7: Operate Like an Expert
Part 3: Safe Tools
Give the model a narrow capability
A tool lets a model read data or perform an action.
Use defineTool(...) for application code. Give each tool a clear name and one bounded purpose. Flue validates model input before calling run.
Tool input is untrusted. The model may invent identifiers, URLs, or arguments. Validation checks shape. It does not grant access.
Keep credentials, tenant IDs, and destinations in trusted application code. Let the model choose only values inside that scope.
Build a secure side-effecting tool
This tool adds a comment to one support ticket. The authenticated route supplies the account, ticket, credential, and idempotency key. The model supplies only the comment body.
import { defineTool } from '@flue/runtime';
import * as v from 'valibot';
export type CommentScope = {
apiBaseUrl: string;
token: string;
accountId: string;
ticketId: string;
idempotencyKey: string;
};
export function createTicketCommentTool(scope: CommentScope) {
return defineTool({
name: 'comment_on_ticket',
description: 'Add one comment to the support ticket bound to this agent.',
input: v.object({
body: v.pipe(
v.string(),
v.minLength(1),
v.maxLength(2_000),
v.description('The comment text. Do not include credentials.'),
),
}),
output: v.object({
accepted: v.literal(true),
}),
async run({ input, signal }) {
const url = new URL(
`/v1/accounts/${encodeURIComponent(scope.accountId)}` +
`/tickets/${encodeURIComponent(scope.ticketId)}/comments`,
scope.apiBaseUrl,
);
const response = await fetch(url, {
method: 'POST',
headers: {
authorization: `Bearer ${scope.token}`,
'content-type': 'application/json',
'idempotency-key': scope.idempotencyKey,
},
body: JSON.stringify({ body: input.body }),
signal,
});
if (!response.ok) {
throw new Error(`Comment request failed with status ${response.status}`);
}
return { accepted: true };
},
});
}Now bind it to a finite workflow. This example uses one service token. That token may access every ticket, so it fits an internal single-tenant service.
import { randomUUID } from 'node:crypto';
import {
defineAgent,
defineWorkflow,
type WorkflowRouteHandler,
} from '@flue/runtime';
import * as v from 'valibot';
import { createTicketCommentTool } from '../tools/ticket-comment.ts';
const apiToken = process.env.API_TOKEN;
const supportToken = process.env.SUPPORT_TOKEN;
const apiBaseUrl = process.env.SUPPORT_API_URL;
if (!apiToken || !supportToken || !apiBaseUrl) {
throw new Error('API_TOKEN, SUPPORT_TOKEN, and SUPPORT_API_URL are required');
}
export const route: WorkflowRouteHandler = async (c, next) => {
if (c.req.header('authorization') !== `Bearer ${apiToken}`) {
return c.json({ error: 'Unauthorized' }, 401);
}
await next();
};
export default defineWorkflow({
agent: defineAgent(() => ({ model: 'anthropic/claude-haiku-4-5' })),
input: v.object({ accountId: v.string(), ticketId: v.string() }),
async run({ harness, input }) {
const tool = createTicketCommentTool({
apiBaseUrl,
token: supportToken,
accountId: input.accountId,
ticketId: input.ticketId,
idempotencyKey: randomUUID(),
});
const session = await harness.session();
return session.prompt('Add a concise progress update to this ticket.', {
tools: [tool],
});
},
});The authenticated caller selects the account and ticket. The model does not. A multi-user service must also verify that the caller may access both IDs before next().
Do not accept accountId, ticketId, apiBaseUrl, or token as tool input. A schema would validate their format, but the model would still control the authorization boundary.
The input schema limits empty and oversized comments. The output schema confirms the returned shape. Flue snapshots structured output as JSON-compatible data before sending it back to the model.
Handle cancellation and side effects
run receives an optional AbortSignal. Pass it to fetch, database clients, and provider SDKs when they support cancellation.
Cancellation is cooperative. It may stop an in-flight request, but it cannot undo an effect that already reached the service.
Side-effecting tools must tolerate retries and uncertain outcomes. Use an application-generated idempotency key when the destination supports one. The same key should produce one logical effect. Without downstream idempotency or a reconciliation API, exactly-once execution cannot be guaranteed after an ambiguous failure.
Do not assume a thrown cancellation means nothing happened. Check the destination before retrying destructive or costly operations.
Read-only tools are safer, but they still need authorization. Avoid generic tools that expose arbitrary URLs, SQL, provider methods, or credentials.
Keep tools local to their need
Put stable capabilities in an agent’s tools array. Supply a tool through session.prompt(...), session.skill(...), or session.task(...) when only one operation needs it.
Tool names must be unique among active tools. Prefer names such as lookup_order_status or comment_on_ticket.
Use an Action instead when application code controls a reusable multi-step process. Use a skill for reusable instructions. Use a sandbox for file and command access.
MCP is the next step when tools are hosted by another service. Its credentials and connection settings still belong in trusted code.
Sources
Next, continue to Part 4: Autonomous Agents to combine tools, skills, subagents, and sandboxes.