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

Tested with @flue/runtime 1.0.0-beta.9. Beta APIs can change; check the current Flue documentation when upgrading.

Give the model one narrow capability

A tool lets a model read data or perform an action. Use defineTool(...) for application code, with a clear name and one bounded purpose. Flue validates model input before calling run, but validation is not authorization: the model may still invent valid-looking identifiers, URLs, or arguments.

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. Trusted code supplies the account, ticket, credential, API origin, and idempotency key. The model supplies only the comment body.

import { defineTool } from '@flue/runtime';
import * as v from 'valibot';

export type CommentScope = {
  apiOrigin: 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.apiOrigin,
      );

      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 };
    },
  });
}

Bind it to a finite workflow. This example accepts only a configured HTTPS origin, rather than letting a path, query, credentials, or model input redirect the service token.

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 configuredSupportUrl = process.env.SUPPORT_API_URL;

if (!apiToken || !supportToken || !configuredSupportUrl) {
  throw new Error('API_TOKEN, SUPPORT_TOKEN, and SUPPORT_API_URL are required');
}

const supportUrl = new URL(configuredSupportUrl);
if (
  supportUrl.protocol !== 'https:' ||
  supportUrl.username ||
  supportUrl.password ||
  supportUrl.pathname !== '/' ||
  supportUrl.search ||
  supportUrl.hash
) {
  throw new Error('SUPPORT_API_URL must be a bare HTTPS origin');
}
const supportApiOrigin = supportUrl.origin;

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({
      apiOrigin: supportApiOrigin,
      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(). The configured origin must itself come from trusted deployment configuration. HTTPS validation prevents accidental plaintext credentials, but it does not prove that an arbitrary host is trustworthy.

Do not accept accountId, ticketId, apiOrigin, or token as tool input. A schema would validate their shape while leaving the model in control of the authorization boundary.

Handle cancellation and retries

run receives an optional AbortSignal. Pass it to fetch, database clients, and provider SDKs when they support cancellation. Cancellation is cooperative: it cannot undo an effect that already reached the service.

The generated key above belongs to one workflow invocation and one intended comment. Reuse that same key when retrying that operation after an uncertain response; generate a new key for a genuinely new comment. Because every call to this tool instance shares the key, repeated calls in the same invocation should collapse to one logical effect when the support API implements idempotency. Do not reuse the key across unrelated workflow runs.

Without downstream idempotency or a reconciliation API, exactly-once execution cannot be guaranteed after an ambiguous failure. A thrown cancellation does not prove that 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. Active tool names must be unique; prefer names such as lookup_order_status or comment_on_ticket.

Use an Action when application code controls a reusable multi-step process, a skill for reusable instructions, and a sandbox for file and command access. Use MCP when another service hosts the tools, while keeping its credentials and connection settings in trusted code.

Sources

Next, continue to Part 4: Autonomous Agents to combine tools, skills, subagents, and sandboxes.