Defining tools
Tools are functions the LLM can call during the agent loop. Each tool has a name, description, typed parameters, and an execute function.
AgentTool interface
Re-exported from @mariozechner/pi-agent-core:
interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> {
name: string
label: string
description: string
parameters: TParameters
execute: (
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>
) => Promise<AgentToolResult<TDetails>>
}The return type:
interface AgentToolResult<T = any> {
content: Array<{ type: "text"; text: string }>
details: T
}Parameters
Defined using TypeBox (@sinclair/typebox). The schema is used for LLM function calling and argument validation.
import { Type } from "@sinclair/typebox"
parameters: Type.Object({
expression: Type.String({ description: "Math expression to evaluate" }),
precision: Type.Optional(Type.Number({ description: "Decimal places" })),
})Stateless tools
Pure functions with no side effects beyond what they compute. Define directly as an AgentTool object.
import { Type } from "@sinclair/typebox"
import type { AgentTool } from "@electric-ax/agents-runtime"
const calculatorTool: AgentTool = {
name: "calculator",
label: "Calculator",
description: "Evaluate mathematical expressions.",
parameters: Type.Object({
expression: Type.String({ description: "The expression to evaluate" }),
}),
execute: async (_toolCallId, params) => {
const { expression } = params as { expression: string }
const result = evaluate(expression)
return {
content: [{ type: "text", text: String(result) }],
details: {},
}
},
}Stateful tools
Use a factory function that receives the HandlerContext. The state persists across wakes -- it is backed by the entity's durable stream. Reads go through ctx.db.collections and writes go through ctx.db.actions.
import { Type } from "@sinclair/typebox"
import type { AgentTool, HandlerContext } from "@electric-ax/agents-runtime"
function createMemoryStoreTool(ctx: HandlerContext): AgentTool {
return {
name: "memory_store",
label: "Memory Store",
description: "Persistent key-value store.",
parameters: Type.Object({
operation: Type.Union([
Type.Literal("get"),
Type.Literal("set"),
Type.Literal("delete"),
Type.Literal("list"),
]),
key: Type.Optional(Type.String()),
value: Type.Optional(Type.String()),
}),
execute: async (_, params) => {
const { operation, key, value } = params as {
operation: string
key?: string
value?: string
}
if (operation === "set") {
const existing = ctx.db.collections.kv?.get(key!)
if (existing) {
ctx.db.actions.kv_update({
key: key!,
updater: (draft) => {
draft.value = value!
},
})
} else {
ctx.db.actions.kv_insert({ row: { key: key!, value: value! } })
}
return {
content: [{ type: "text", text: `Stored "${key}"` }],
details: {},
}
}
if (operation === "get") {
const entry = ctx.db.collections.kv?.get(key!)
const text = entry ? entry.value : `No value found for "${key}"`
return { content: [{ type: "text", text }], details: {} }
}
if (operation === "delete") {
ctx.db.actions.kv_delete({ key: key! })
return {
content: [{ type: "text", text: `Deleted "${key}"` }],
details: {},
}
}
// list
const entries = ctx.db.collections.kv?.toArray ?? []
const text = entries.map((e) => `${e.key}: ${e.value}`).join("\n")
return {
content: [{ type: "text", text: text || "(empty)" }],
details: {},
}
},
}
}The entity state API:
| Operation | Write (via ctx.db.actions) | Read (via ctx.db.collections) |
|---|---|---|
| Insert | ctx.db.actions.<coll>_insert({ row: {...} }) | - |
| Update | ctx.db.actions.<coll>_update({ key, updater: (draft) => {...} }) | - |
| Delete | ctx.db.actions.<coll>_delete({ key }) | - |
| Get | - | ctx.db.collections.<coll>?.get(key) |
| List | - | ctx.db.collections.<coll>?.toArray |
Handler-scoped tools
Use a factory that receives the HandlerContext. These tools can spawn entities, observe streams, send messages, and use any other ctx primitive.
import { Type } from "@sinclair/typebox"
import type { AgentTool, HandlerContext } from "@electric-ax/agents-runtime"
function createDispatchTool(ctx: HandlerContext): AgentTool {
return {
name: "dispatch",
label: "Dispatch",
description: "Spawn a child agent and wait for its response.",
parameters: Type.Object({
type: Type.String({ description: "Entity type to spawn" }),
systemPrompt: Type.String({ description: "System prompt for the child" }),
task: Type.String({ description: "Task to send to the child" }),
}),
execute: async (_, params) => {
const { type, systemPrompt, task } = params as {
type: string
systemPrompt: string
task: string
}
const child = await ctx.spawn(
type,
`dispatch-${Date.now()}`,
{ systemPrompt },
{
initialMessage: task,
wake: { on: "runFinished", includeResponse: true },
}
)
return {
content: [{ type: "text", text: `Started ${child.entityUrl}; I will continue when it finishes.` }],
details: { childUrl: child.entityUrl },
}
},
}
}ctx.spawn returns an EntityHandle. Passing wake: { on: 'runFinished', includeResponse: true } means the parent will be woken later when the child's agent run completes, with the child's text response included in the wake payload. Tools should start child work and return; continuation happens in the later handler wake.
Wiring tools together
Tools are constructed in the handler and passed to useAgent. Include ctx.electricTools when your runtime host provides runtime-level tools that the LLM should be able to call:
registry.define("assistant", {
description: "An assistant with memory and delegation",
state: {
kv: { primaryKey: "key" },
},
async handler(ctx) {
const memoryTool = createMemoryStoreTool(ctx)
const dispatchTool = createDispatchTool(ctx)
ctx.useAgent({
systemPrompt: "You are a helpful assistant with persistent memory.",
model: "claude-sonnet-4-6",
tools: [...ctx.electricTools, memoryTool, dispatchTool, calculatorTool],
})
await ctx.agent.run()
},
})When you include ctx.electricTools, spread them before your custom tools so host-provided primitives keep their expected order.