Functional tools with DI
Register a tool that needs constructor-injected dependencies without a class — provideAgentTool(factory, inject) resolves it through Nest's container and returns { spec, handler }, auto-discovered at boot exactly like an @AiTool class.
Reach for this when a tool needs a DI-resolved dependency — a repository, an HTTP client, another
service — but you'd rather not stand up a whole @AiTool class for it, or you're generating tools
from data (e.g. one per configured integration). A functional tool is the same { spec, handler } shape a class-based tool compiles down to; provideAgentTool is how you register one as a Nest
provider so AiToolDiscoveryService picks it up at boot.
The contract
From functional-tool.ts:
/** A tool expressed as data + handler, not an `@AiTool` class. */
interface FunctionalTool {
spec: ToolSpec;
handler: ToolHandler;
}
// Two forms — a static tool, or a factory with `inject` for DI-resolved dependencies:
function provideAgentTool(tool: FunctionalTool): Provider;
function provideAgentTool(
factory: (...deps: never[]) => FunctionalTool,
inject?: FactoryProvider['inject'],
): Provider;A ToolSpec is the same shape a @AiTool({ ... }) decorator captures — { name, kind, description, inputSchema, roles?, ability? } — and a ToolHandler is the same { execute(input, ctx) } contract
a decorated class implements.
A tool with an injected dependency
// src/tools/count-open-orders.tool.ts
import { provideAgentTool, type AiToolCtx, type FunctionalTool } from '@dudousxd/nestjs-agent';
import { z } from 'zod';
import { OrdersService } from '../orders/orders.service.js';
function countOpenOrdersTool(orders: OrdersService): FunctionalTool {
return {
spec: {
name: 'countOpenOrders',
kind: 'read',
description: 'How many orders are currently open for a customer.',
inputSchema: z.object({ customerId: z.string() }),
},
handler: {
async execute(input: { customerId: string }, ctx: AiToolCtx) {
// Scope to the caller's tenant from ctx.actor, never from tool input.
return { count: await orders.countOpen(input.customerId, ctx.actor.tenantRef) };
},
},
};
}
export const CountOpenOrdersToolProvider = provideAgentTool(countOpenOrdersTool, [OrdersService]);Drop the provider in any module's providers — no separate tool registry to keep in sync:
// src/orders/orders.module.ts
import { Module } from '@nestjs/common';
import { CountOpenOrdersToolProvider } from '../tools/count-open-orders.tool.js';
import { OrdersService } from './orders.service.js';
@Module({
providers: [OrdersService, CountOpenOrdersToolProvider],
})
export class OrdersModule {}provideAgentTool mints a fresh, unique provide token per call, so the same factory can be used more
than once (e.g. one tool per configured integration) without collisions.
A static tool with no dependencies
For a tool with nothing to inject, register it directly on AgentModule.forRoot({ tools: [...] })
instead of a per-module provider:
const echoTool: FunctionalTool = {
spec: {
name: 'echo',
kind: 'read',
description: 'Echoes the input back — a smoke-test tool.',
inputSchema: z.object({ text: z.string() }),
},
handler: {
async execute(input: { text: string }) {
return { text: input.text };
},
},
};
AgentModule.forRoot({
model: myModelProvider,
tools: [echoTool],
// ...
});Functional tool vs. @AiTool class
Both compile to the exact same { spec, handler } shape and are discovered the same way at boot —
neither is more "real" than the other. Reach for @AiTool when a tool is naturally a class (it fits
alongside your other providers, benefits from method-level readability). Reach for
provideAgentTool when the tool is more natural as data — generated per config, wrapping a single
function, or needing DI without the ceremony of a class. See Tools for
the @AiTool decorator surface, read vs action, and the full AiToolCtx shape — everything
there about kind, roles, and ability applies to ToolSpec here unchanged.
Related
- Tools — the
@AiTooldecorator surface,readvsaction, andAiToolCtx - Custom
ModelProvider— the other hand-rolled SPI, for the model side instead of the tool side
Inline → durable
Durable is the production default — dispatchedSteps routes every model/tool call as a checkpointed, worker-served step the moment durable is true. This recipe covers getting to durable: true, and the dispatchedSteps: false escape hatch for a simple in-process case.
Billing export
Inject AGENT_GOVERNANCE_QUERIES and build your own CSV/JSON spend export off the same read-model the standalone dashboard and Telescope tab consume — the interface and the diagnostics channel are both public.