AI Agents
Pikku AI Agents let you define conversational AI assistants that use your Pikku functions as tools, maintain conversation memory, support multi-agent orchestration, and stream responses in real time β all with the same transport-agnostic architecture as the rest of Pikku.
What are AI Agents?β
An AI agent is a conversational interface backed by a large language model (LLM). When a user sends a message, the agent reasons about it, optionally calls Pikku functions as tools, and streams a response back through a channel.
Key features:
- Functions as Tools: Any wired Pikku function can be used as an agent tool β complete with schema validation and middleware
- Multi-Agent Orchestration: Agents can delegate to sub-agents, each with their own tools and memory
- Conversation Memory: Persistent thread-based message history with configurable storage backends
- Working Memory: Structured state that the agent maintains across turns (e.g., user preferences, task progress)
- Real-Time Streaming: Token-by-token streaming via Pikku channels with full middleware support
- Human-in-the-Loop: Tool approval flows that suspend execution until a user approves or denies a tool call
- Model Configuration: Centralized model aliases, per-agent overrides, and multi-provider support
- AI Middleware: Hooks to modify inputs, filter the output stream, and post-process results
Quick Exampleβ
Define an agent that manages todos:
import { pikkuAIAgent } from './.pikku/agent/pikku-agent-types.gen'
import { listTodos, createTodo } from './todos.functions'
export const todoAssistant = pikkuAIAgent({
name: 'todo-assistant',
description: 'A helpful assistant that manages todos',
instructions: 'You help users manage their todo lists.',
model: 'openai/gpt-4o-mini',
tools: [listTodos, createTodo],
memory: { storage: 'aiStorage', lastMessages: 10 },
maxSteps: 5,
})
The pikkuAIAgent function is generated by the Pikku CLI and provides full type safety for your agent definition.
Core Conceptsβ
Agent Definitionβ
Every agent is defined with pikkuAIAgent() and requires at minimum:
| Property | Type | Description |
|---|---|---|
name | string | Unique identifier for the agent |
description | string | Human-readable description (also shown to parent agents) |
instructions | string | string[] | System prompt that guides the agent's behavior |
model | string | Model identifier in provider/model format (e.g., openai/gpt-4o-mini) |
Optional properties:
| Property | Type | Default | Description |
|---|---|---|---|
tools | Function[] | β | Pikku functions the agent can call |
agents | Agent[] | β | Sub-agents this agent can delegate to |
memory | AIAgentMemoryConfig | β | Conversation and working memory config |
maxSteps | number | 10 | Maximum tool-call iterations per message |
toolChoice | 'auto' | 'required' | 'none' | 'auto' | How the model selects tools |
temperature | number | β | Model temperature override |
output | Schema | β | Structured output schema for the agent's response |
tags | string[] | β | Tags for filtering and organization |
middleware | Middleware[] | β | Pikku middleware applied to the agent |
channelMiddleware | ChannelMiddleware[] | β | Middleware for the streaming channel |
aiMiddleware | PikkuAIMiddlewareHooks[] | β | AI-specific middleware hooks |
permissions | PermissionGroup | β | Permission requirements for accessing the agent |
Toolsβ
Agents use your existing Pikku functions as tools. When you pass a function reference to the tools array, Pikku automatically:
- Extracts the function's input schema from its wired metadata
- Uses the function's description as the tool description for the LLM
- Executes the function through the standard Pikku function runner (with middleware, validation, etc.)
// These are regular Pikku functions
import { listTodos, createTodo, deleteTodo } from './todos.functions'
export const assistant = pikkuAIAgent({
name: 'assistant',
description: 'Task manager',
instructions: 'Help users manage tasks.',
model: 'openai/gpt-4o-mini',
tools: [listTodos, createTodo, deleteTodo],
})
Multi-Agent Orchestrationβ
Agents can delegate to sub-agents using the agents property. The parent agent sees each sub-agent as a tool it can call with a message and a session label:
export const todoAssistant = pikkuAIAgent({
name: 'todo-assistant',
description: 'Manages todo lists',
instructions: 'You help users manage their todo lists.',
model: 'openai/gpt-4o-mini',
tools: [listTodos, createTodo],
})
export const dailyPlanner = pikkuAIAgent({
name: 'daily-planner',
description: 'Plans your day and suggests tasks',
instructions: 'You help users plan their day.',
model: 'openai/gpt-4o-mini',
})
export const mainRouter = pikkuAIAgent({
name: 'main-router',
description: 'Routes requests to specialized agents',
instructions: 'You coordinate between agents.',
model: 'openai/gpt-4o-mini',
agents: [todoAssistant, dailyPlanner],
maxSteps: 5,
})
When the parent calls a sub-agent, it provides:
- message: The task or question to send
- session: A short label for thread continuity (reusing the same session continues the conversation)
Sub-agent calls are streamed through scoped channels, so the client receives events tagged with the sub-agent's name and session.
Conversation Memoryβ
Agents persist conversations in threads using an AIStorageService. Configure memory via the memory property:
export const agent = pikkuAIAgent({
name: 'my-agent',
// ...
memory: {
storage: 'aiStorage', // Name of the storage service in singletonServices
lastMessages: 10, // Number of recent messages to include in context
},
})
| Property | Type | Description |
|---|---|---|
storage | string | Key of the AIStorageService in your singleton services (defaults to 'aiStorage') |
lastMessages | number | How many recent messages to load from the thread (default: 20) |
workingMemory | Schema | Enable structured working memory with a JSON schema |
Working Memoryβ
Working memory lets agents maintain structured state across conversation turns β like user preferences, task lists, or accumulated context. When enabled, the agent receives its current working memory in the system prompt and can update it by outputting <working_memory> tags:
export const agent = pikkuAIAgent({
name: 'my-agent',
// ...
memory: {
storage: 'aiStorage',
lastMessages: 10,
workingMemory: WorkingMemorySchema, // A Zod/JSON schema
},
})
The agent outputs partial JSON updates. Setting a field to null deletes it. Updates are deep-merged with the existing state and validated against the schema.
Streamingβ
Agents stream responses through Pikku channels. The stream emits AIStreamEvent objects:
| Event Type | Description |
|---|---|
text-delta | Incremental text token from the model |
reasoning-delta | Reasoning/thinking token (for models that support it) |
tool-call | Agent is calling a tool (includes name and args) |
tool-result | Tool execution result |
agent-call | Parent agent is calling a sub-agent |
agent-result | Sub-agent has completed |
approval-request | A tool requires user approval before execution |
usage | Token usage statistics for the current step |
error | An error occurred during execution |
suspended | Execution suspended (e.g., missing RPC functions) |
done | Stream is complete |
Events from sub-agents include agent and session fields so clients can track which agent produced which output.
Tool Approval (Human-in-the-Loop)β
You can require user approval before certain tools execute. This is configured via the requiresToolApproval option when streaming:
await streamAIAgent(
'my-agent',
{ message, threadId, resourceId },
channel,
params,
undefined,
{ requiresToolApproval: 'explicit' }
)
| Mode | Behavior |
|---|---|
'explicit' | Only tools marked with requiresApproval: true in their function metadata need approval (default) |
'all' | Every tool call requires approval |
false | No approval required |
When a tool requires approval:
- The agent stream emits an
approval-requestevent with the tool name, args, and a reason - Execution suspends β the run state is saved with
status: 'suspended' - The client presents the approval request to the user
- The user approves or denies via
resumeAIAgent() - If approved, the tool executes and the agent continues; if denied, the agent receives a denial message and continues
// Resume after approval
await resumeAIAgent(
{ toolCallId: 'tc_123', approved: true },
channel,
params
)
Required Servicesβ
AI Agents require three services in your singleton services:
| Service | Interface | Purpose |
|---|---|---|
aiAgentRunner | AIAgentRunnerService | Executes the LLM (e.g., VercelAIAgentRunner from @pikku/ai-vercel) |
aiStorage | AIStorageService | Persists threads, messages, and working memory (e.g., PgAIStorageService from @pikku/pg) |
aiRunState | AIRunStateService | Tracks agent run status and pending approvals (e.g., PgAIStorageService also implements this) |
Setup Exampleβ
import { PgAIStorageService } from '@pikku/pg'
import { VercelAIAgentRunner } from '@pikku/ai-vercel'
import { createOpenAI } from '@ai-sdk/openai'
import { createAnthropic } from '@ai-sdk/anthropic'
import postgres from 'postgres'
const sql = postgres(process.env.DATABASE_URL!)
const pgAiStorage = new PgAIStorageService(sql)
await pgAiStorage.init()
const providers = {
openai: createOpenAI({
apiKey: process.env.OPENAI_API_KEY!,
}),
anthropic: createAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY!,
}),
}
const singletonServices = await createSingletonServices(config, {
aiStorage: pgAiStorage,
aiRunState: pgAiStorage,
aiAgentRunner: new VercelAIAgentRunner(providers),
})
The VercelAIAgentRunner from @pikku/ai-vercel uses the Vercel AI SDK under the hood, supporting any provider compatible with the AI SDK (OpenAI, Anthropic, Ollama, Google, etc.).
Model identifiers use provider/model format (e.g., openai/gpt-4o-mini, anthropic/claude-sonnet-4-20250514, ollama/llama3).
Model Configurationβ
You can configure model aliases and per-agent overrides centrally:
// In your config
const modelConfig = {
models: {
fast: 'openai/gpt-4o-mini',
smart: { model: 'anthropic/claude-sonnet-4-20250514', temperature: 0.7 },
},
agentDefaults: {
temperature: 0.5,
maxSteps: 8,
},
agentOverrides: {
'my-agent': {
model: 'anthropic/claude-sonnet-4-20250514',
temperature: 0.3,
},
},
}
With model aliases, your agent definitions stay clean:
export const agent = pikkuAIAgent({
name: 'my-agent',
instructions: '...',
model: 'fast', // Resolves to openai/gpt-4o-mini
// ...
})
Resolution order for model settings: agent override > model alias > agent defaults > agent definition.
AI Middlewareβ
AI middleware hooks let you intercept and transform at three points in the agent execution:
const myMiddleware: PikkuAIMiddlewareHooks = {
// Before the LLM call β modify messages or instructions
modifyInput: async (services, { messages, instructions }) => {
return { messages, instructions: instructions + '\nBe concise.' }
},
// During streaming β filter or transform individual events
modifyOutputStream: async (services, { event, allEvents, state }) => {
if (event.type === 'text-delta') {
return event // Pass through
}
return null // Filter out
},
// After completion β modify the final text and messages
modifyOutput: async (services, { text, messages, usage }) => {
return { text: text.trim(), messages }
},
}
export const agent = pikkuAIAgent({
name: 'my-agent',
// ...
aiMiddleware: [myMiddleware],
})
Visualizing Agents in the Consoleβ
The Pikku Console provides a built-in chat interface for your AI agents, displays agent metadata, and lets you test agents interactively. See Console Features for details.
Next Stepsβ
- Channels: Learn about the streaming transport used by agents
- Functions: Understand how Pikku functions work as agent tools
- Middleware: Apply middleware to agent tool calls