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 rpc.agent.stream('my-agent', {
message,
threadId,
resourceId
}, { 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
rpc.agent.resume() - If approved, the tool executes and the agent continues; if denied, the agent receives a denial message and continues
// Resume after approval
await rpc.agent.resume('run_abc', {
toolCallId: 'tc_123',
approved: true
})
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],
})
Credential Integrationβ
When an agent calls a tool that belongs to an addon requiring a credential, Pikku automatically checks whether the current user has that credential connected. If not, the agent run suspends with a ToolCredentialRequired error and the stream emits a credential-request event:
{
"type": "credential-request",
"credentialName": "google-sheets",
"credentialType": "oauth2",
"connectUrl": "/auth/oauth2/google-sheets/authorize"
}
The client can then redirect the user to connect the credential. Once connected, resume the agent:
await rpc.agent.resume(runId, { toolCallId, approved: true })
This is fully automatic β you don't write credential-checking logic in your tools or agent definitions.
Voice Supportβ
The @pikku/ai-voice package provides AI middleware for speech-to-text and text-to-speech, turning any agent into a voice assistant.
npm install @pikku/ai-voice
import { voiceInput, voiceOutput } from '@pikku/ai-voice'
export const voiceAgent = pikkuAIAgent({
name: 'voice-assistant',
description: 'A voice-enabled assistant',
instructions: 'You help users via voice.',
model: 'fast',
tools: [listTodos],
aiMiddleware: [
voiceInput({ language: 'en' }), // Transcribes audio attachments to text
voiceOutput({ voice: 'alloy' }), // Synthesizes response text to audio
],
})
voiceInput(options?) β modifies incoming messages, transcribing any audio attachments using an STTService from your singleton services.
voiceOutput(options?) β intercepts the output stream, synthesizing text into audio-delta events using a TTSService from your singleton services.
| Option | Type | Description |
|---|---|---|
language | string | Hint for speech-to-text transcription |
voice | string | Voice name for text-to-speech |
format | string | Audio format (default: pcm16) |
Requires stt (STTService) and/or tts (TTSService) in your singleton services.
React UI (@pikku/assistant-ui)β
The @pikku/assistant-ui package provides React components and hooks for building agent chat interfaces, built on top of assistant-ui.
npm install @pikku/assistant-ui @assistant-ui/react
Drop-in Chat Componentβ
import { PikkuAgentChat } from '@pikku/assistant-ui'
function App() {
return (
<PikkuAgentChat
api="/api/rpc"
agentName="my-agent"
threadId="thread-123"
resourceId="user-456"
emptyMessage="Ask me anything!"
dark={true}
/>
)
}
Custom UI with Hooksβ
For full control, use the runtime hook directly:
import { usePikkuAgentRuntime, PikkuApprovalContext } from '@pikku/assistant-ui'
import { AssistantRuntimeProvider, Thread } from '@assistant-ui/react'
function CustomChat() {
const runtime = usePikkuAgentRuntime({
api: '/api/rpc',
agentName: 'my-agent',
threadId: 'thread-123',
resourceId: 'user-456',
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
Tool Approval UIβ
The approval context tracks pending tool approvals and credential requests:
import { usePikkuApproval } from '@pikku/assistant-ui'
function ApprovalPanel() {
const { pendingApprovals, handleApproval } = usePikkuApproval()
return pendingApprovals.map((approval) => (
<div key={approval.toolCallId}>
<p>Agent wants to call: {approval.toolName}</p>
{approval.type === 'credential-request' ? (
<a href={approval.connectUrl}>Connect {approval.credentialName}</a>
) : (
<>
<button onClick={() => handleApproval(approval.toolCallId, true)}>Approve</button>
<button onClick={() => handleApproval(approval.toolCallId, false)}>Deny</button>
</>
)}
</div>
))
}
Runtime Optionsβ
| Option | Type | Description |
|---|---|---|
api | string | RPC endpoint URL |
agentName | string | Agent to connect to |
threadId | string | Conversation thread ID |
resourceId | string | Resource scope (e.g., user ID) |
credentials | RequestCredentials | Fetch credentials mode |
headers | Record<string, string> | Extra headers for RPC calls |
model | string | Model override |
temperature | number | Temperature override |
initialMessages | any[] | Pre-populate the chat |
onFinish | () => void | Called when a response completes |
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
- Credentials: Per-user credentials and OAuth2 for agent tools
- Functions: Understand how Pikku functions work as agent tools
- Middleware: Apply middleware to agent tool calls