BUILD YOUR FIRST AI AGENT
Build a document Q&A agent from scratch. You'll add a search tool, conversation memory, safety guardrails, a chat UI, and a test — all in about 15 minutes.
[STEP 1 — SETUP]
Create a new project. The ai-saas template includes everything you need — the agent plugin is already on.
npx create-fabrk-app my-agent --template ai-saas
cd my-agent
pnpm installCheck that agents are enabled in your Vite config. Without this, the agent endpoints don't exist:
import { defineConfig } from 'vite'
import fabrk from 'fabrk'
export default defineConfig({
plugins: [
fabrk({
agents: true, // scans agents/ for agent definitions
dashboard: true, // exposes /__ai dev dashboard
}),
],
})Here's the layout you're building toward:
my-agent/
├── agents/
│ └── qa/
│ └── agent.ts ← agent definition (Step 3)
├── tools/
│ └── search-docs.ts ← tool definition (Step 2)
├── src/
│ └── app/
│ └── page.tsx ← chat UI (Step 7)
└── vite.config.tsagents/ folder. Every subfolder with an agent.ts becomes a live endpoint at /api/agents/[name]. The file you're creating in Step 3 — agents/qa/agent.ts — becomes POST /api/agents/qa automatically.[STEP 2 — DEFINE A TOOL]
You're building the search tool your agent will call when it needs information. Give defineTool a name, a description the LLM uses to decide when to call it, a JSON schema for the input, and a handler that runs when it does. The handler returns a result using textResult.
import { defineTool, textResult } from 'fabrk'
// Simulated knowledge base — replace with your real data source
const knowledgeBase = [
{
id: 'kb-1',
title: 'Getting Started',
content: 'Install FABRK with: npx create-fabrk-app my-app --template ai-saas',
source: '/getting-started',
},
{
id: 'kb-2',
title: 'Agent Routing',
content: 'Agents live in agents/<name>/agent.ts and are served at /api/agents/<name>',
source: '/agents#routing',
},
{
id: 'kb-3',
title: 'Memory',
content: 'Enable memory on defineAgent with memory: true to persist conversation history',
source: '/agents#memory',
},
]
export default defineTool({
name: 'search_docs',
description: 'Search the documentation knowledge base for relevant articles. Use this whenever the user asks a question.',
schema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query — use keywords from the user question',
},
},
required: ['query'],
},
async handler(input) {
const query = (input.query as string).toLowerCase()
const results = knowledgeBase
.filter(
(doc) =>
doc.title.toLowerCase().includes(query) ||
doc.content.toLowerCase().includes(query)
)
.slice(0, 3)
if (results.length === 0) {
return textResult('No relevant documentation found for that query.')
}
const formatted = results
.map((r) => `[SOURCE: ${r.source}]\n${r.title}\n${r.content}`)
.join('\n\n')
return textResult(formatted)
},
})tools/ folder for you. The filename — without the extension — is the name you use in defineAgent. No imports needed. Drop a file in tools/, reference it by name, done.[STEP 3 — DEFINE THE AGENT]
Now you're defining the agent itself — choosing a model, writing the system prompt, pointing at your tool, and setting a spend limit. The tools array uses the same filename you created in Step 2. No import paths needed.
import { defineAgent } from 'fabrk'
export default defineAgent({
model: 'claude-sonnet-4-5-20250514',
systemPrompt: `You are a helpful documentation assistant.
When answering questions, always call search_docs first to find relevant content.
Cite the source URL at the end of your answer like this: [SOURCE: /path].
Be concise — 2-3 sentences maximum per answer.`,
tools: ['search_docs'],
stream: true,
auth: 'none',
budget: {
daily: 5.00, // $5/day maximum spend
alertThreshold: 0.8, // warn at 80%
},
})Your agent is working right now. Start the dev server and try it:
pnpm dev
# Agent is now live at POST /api/agents/qa
# Dev dashboard at http://localhost:5173/__aicurl -X POST http://localhost:5173/api/agents/qa \
-H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"How do I install FABRK?"}]}'[STEP 4 — WIRE THE ROUTE]
You don't write a route handler — fabrk generates it from your agent definition. This step shows you exactly what the endpoint expects and what it sends back. That context is helpful when something isn't behaving the way you expect.
Your agent endpoint accepts a JSON body like this:
POST /api/agents/qa
Content-Type: application/json
{
"messages": [
{ "role": "user", "content": "How do I install FABRK?" }
]
}With stream: true (the default), the response streams back to the browser as a series of JSON events — one per line:
data: {"type":"text-delta","content":"Based on "}
data: {"type":"text-delta","content":"the docs..."}
data: {"type":"tool-call","name":"search_docs","input":{"query":"install"},"iteration":1}
data: {"type":"tool-result","name":"search_docs","output":"Install FABRK with...","durationMs":12}
data: {"type":"text-delta","content":"Install FABRK with: npx create-fabrk-app"}
data: {"type":"usage","promptTokens":312,"completionTokens":48,"cost":0.00021}
data: {"type":"done"}stream: false on the agent definition to get a plain JSON response instead. Good for server-to-server calls or background jobs where you want the full answer at once before doing anything with it.[STEP 5 — ADD MEMORY]
Right now, every message you send starts fresh. The agent forgets everything the moment the response finishes. This step fixes that — so users can ask "what about routing?" after asking about installation, without repeating themselves.
You're adding two things: memory: true on the agent, and one line in your app entry point that sets up the store.
import { defineAgent } from 'fabrk'
export default defineAgent({
model: 'claude-sonnet-4-5-20250514',
systemPrompt: `You are a helpful documentation assistant.
When answering questions, always call search_docs first to find relevant content.
Cite the source URL at the end of your answer like this: [SOURCE: /path].
Be concise — 2-3 sentences maximum per answer.`,
tools: ['search_docs'],
stream: true,
auth: 'none',
memory: true, // ← enable conversation memory
budget: {
daily: 5.00,
alertThreshold: 0.8,
},
})import { setMemoryStore, InMemoryMemoryStore } from 'fabrk'
// Wire the store once at startup — all agents share it
setMemoryStore(new InMemoryMemoryStore())With memory on, fabrk creates a thread for each session and loads the history into every new request automatically. Your client passes back a threadId to pick up where it left off:
POST /api/agents/qa
{
"messages": [{ "role": "user", "content": "What about routing?" }],
"threadId": "thread_abc123"
}InMemoryMemoryStore clears when the server restarts — that's fine for development. When you deploy, swap it for a database- backed store. The MemoryStore interface needs four methods: createThread, getThread, appendMessage, and getMessages. Implement those four and pass your instance to setMemoryStore.[STEP 6 — ADD GUARDRAILS]
Guardrails are functions that inspect content before it goes in or comes out. Input guardrails run before the LLM sees the message. Output guardrails run before the response reaches the browser. This step adds two: one that blocks enormous inputs, and one that scrubs personal data from responses.
maxLength rejects inputs over 2,000 characters before the LLM even sees them. piiRedactor strips emails, phone numbers, and SSNs from every response before it leaves the server.
import { defineAgent, maxLength, piiRedactor } from 'fabrk'
export default defineAgent({
model: 'claude-sonnet-4-5-20250514',
systemPrompt: `You are a helpful documentation assistant.
When answering questions, always call search_docs first to find relevant content.
Cite the source URL at the end of your answer like this: [SOURCE: /path].
Be concise — 2-3 sentences maximum per answer.`,
tools: ['search_docs'],
stream: true,
auth: 'none',
memory: true,
budget: {
daily: 5.00,
alertThreshold: 0.8,
},
// Block inputs over 2,000 characters — prevents prompt injection via long payloads
inputGuardrails: [maxLength(2000)],
// Redact PII from all responses before they leave the server
outputGuardrails: [piiRedactor()],
})Return pass: false to block the request and send a 400. Return a replacement to swap the content and keep going — that's how piiRedactor works. You can write your own guardrail as a plain function:
import type { Guardrail } from 'fabrk'
// Block questions about competitors
const noCompetitorMentions: Guardrail = (content) => {
const competitors = ['langchain', 'mastra', 'vercel ai']
const lower = content.toLowerCase()
for (const c of competitors) {
if (lower.includes(c)) {
return { pass: false, reason: 'Competitor mention blocked' }
}
}
return { pass: true }
}[STEP 7 — CONNECT THE FRONTEND]
Now you're building the chat page. The useAgent hook connects to your agent, reads the stream, and gives you messages, tool calls, and loading state — ready to render. You don't handle the streaming logic yourself.
'use client'
import { useState } from 'react'
import { useAgent } from 'fabrk'
import { cn } from '@fabrk/core'
import { mode } from '@fabrk/design-system'
export default function ChatPage() {
const { send, stop, messages, isStreaming, toolCalls, error } = useAgent('qa')
const [input, setInput] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!input.trim() || isStreaming) return
const text = input
setInput('')
await send(text)
}
return (
<div className="max-w-2xl mx-auto p-6 space-y-4">
<h1 className={cn('text-xl font-bold uppercase', mode.font)}>
{'>'} DOC Q&A
</h1>
{/* Tool call activity */}
{toolCalls.length > 0 && (
<div className={cn('border border-border bg-muted p-3 text-xs', mode.radius, mode.font)}>
{toolCalls.map((tc, i) => (
<div key={i} className="text-muted-foreground">
[{tc.output ? 'DONE' : 'RUNNING'}] {tc.name}
{tc.durationMs !== undefined && ` (${tc.durationMs}ms)`}
</div>
))}
</div>
)}
{/* Message thread */}
<div className="space-y-3 min-h-[200px]">
{messages.map((msg, i) => (
<div
key={i}
className={cn(
'p-3 text-sm border border-border',
mode.radius,
msg.role === 'user'
? 'bg-primary/10 text-foreground ml-8'
: 'bg-card text-foreground'
)}
>
<div className={cn('text-xs text-muted-foreground mb-1 uppercase', mode.font)}>
[{msg.role === 'user' ? 'YOU' : 'AGENT'}]
</div>
{typeof msg.content === 'string' ? msg.content : '[multipart content]'}
</div>
))}
{isStreaming && messages[messages.length - 1]?.role !== 'assistant' && (
<div className={cn('p-3 text-sm border border-border bg-card', mode.radius)}>
<div className={cn('text-xs text-muted-foreground mb-1 uppercase', mode.font)}>
[AGENT]
</div>
<span className="animate-pulse">...</span>
</div>
)}
</div>
{error && (
<p className="text-xs text-destructive">[ERROR] {error}</p>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask a question..."
className={cn(
'flex-1 px-3 py-2 text-sm bg-card border border-border text-foreground',
'placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary',
mode.radius, mode.font
)}
/>
{isStreaming ? (
<button
type="button"
onClick={stop}
className={cn('px-4 py-2 text-xs bg-destructive text-destructive-foreground', mode.radius, mode.font)}
>
{'>'} STOP
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className={cn('px-4 py-2 text-xs bg-primary text-primary-foreground disabled:opacity-50', mode.radius, mode.font)}
>
{'>'} SEND
</button>
)}
</form>
</div>
)
}text-delta— adds a token chunk to the current assistant message as it arrivestool-call— adds an entry totoolCallsso you can show "[RUNNING] search_docs"tool-result— updates that entry with the output and how long it tookusage— tracks token counts and cost so farerror— sets the error string so you can display it
[STEP 8 — TEST IT]
You're writing a test that proves your agent actually searches before it answers.createTestAgent runs the real agent loop with a fake LLM standing in for the provider — no API calls, no cost, same result every time.
The test checks that when you ask "How do I install FABRK?", the agent calls search_docs first. That's how you verify your system prompt instruction ("always call search_docs first") is actually working.
import { describe, it, expect } from 'vitest'
import {
mockLLM,
createTestAgent,
calledTool,
respondedWith,
} from 'fabrk'
import searchDocsTool from '../../tools/search-docs'
describe('qa agent', () => {
it('calls search_docs when asked a question', async () => {
const mock = mockLLM()
// When the LLM sees "install", it should call search_docs
mock
.onMessage('install')
.callTool('search_docs', { query: 'install FABRK' })
// After the tool runs, the LLM responds with the answer
mock
.onToolCall('search_docs')
.returnResult('Install FABRK with: npx create-fabrk-app')
mock.setDefault('Install FABRK with: npx create-fabrk-app [SOURCE: /getting-started]')
const agent = createTestAgent({
tools: [searchDocsTool],
systemPrompt: 'Always call search_docs before answering.',
mock,
})
const result = await agent.send('How do I install FABRK?')
// Verify the tool was called
expect(calledTool(result, 'search_docs')).toBe(true)
// Verify the response contains the answer
expect(respondedWith(result, 'Install FABRK')).toBe(true)
})
it('does not call tools for simple greetings', async () => {
const mock = mockLLM()
mock.setDefault('Hello! How can I help you today?')
const agent = createTestAgent({
tools: [searchDocsTool],
mock,
})
const result = await agent.send('Hello')
expect(calledTool(result, 'search_docs')).toBe(false)
expect(respondedWith(result, 'Hello')).toBe(true)
})
})pnpm test agents/qa/agent.test.tsmockLLM()— fake LLM you control with chainable matchersmock.onMessage(pattern).callTool(name, input)— when the message matches, the mock emits a tool callmock.onToolCall(name).returnResult(text)— what the tool gives back to the agentcreateTestAgent(opts)— runs your real agent loop against the mockcalledTool(result, name)— true if the named tool ran during this conversationrespondedWith(result, text)— true if the final response contains the given text
[WHAT'S NEXT]
Your agent searches docs, remembers conversations, guards against bad input, renders in a chat UI, and has tests to back it up. Here's where to go next.
Connect agents into multi-step sequences using defineWorkflow. Add branches, parallel steps, and pause-for-approval before continuing.
Use defineSupervisor and agentAsTool to have one agent hand off subtasks to another — no extra network requests involved.
Point your agent at any MCP server — GitHub, Slack, a database — using connectMCPServer. Your agent calls their tools the same way it calls yours.
Save agent state so a long-running job can pick up where it left off after a failure or redeploy. Use InMemoryCheckpointStore to start, or plug in your own.
defineAgent, all built-in guardrail factories, evals and datasets, semantic memory, computer-use tools, and voice (TTS/STT/realtime).