Most AI features in SaaS products are shallow: a text box that calls an API and displays the result. Real AI integration means the model has context about your user, can take actions in your system, and produces outputs that persist. Here's how to build that.
The Context Problem
An AI feature without user context is a generic chatbot. The difference between ChatGPT and a useful AI assistant in your product is the context you provide:
// Bad -- no context
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
messages: [{ role: 'user', content: userMessage }],
})
// Good -- rich context
const user = await db.user.findUnique({
where: { id: session.userId },
include: { subscription: true, recentActivity: { take: 10 } },
})
const systemPrompt = [
`You are an AI assistant for ${user.name}'s account.`,
`Their current plan: ${user.subscription.plan}`,
`Recent activity: ${JSON.stringify(user.recentActivity)}`,
`Today: ${new Date().toISOString()}`,
].join(' ')
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
system: systemPrompt,
messages: [{ role: 'user', content: userMessage }],
})
Streaming Responses to the UI
Users abandon AI features that make them wait for the full response:
// app/api/ai/chat/route.ts
import Anthropic from '@anthropic-ai/sdk'
export async function POST(req: Request) {
const { messages, userId } = await req.json()
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const response = anthropic.messages.stream({
model: 'claude-sonnet-4-6',
max_tokens: 2048,
system: await buildSystemPrompt(userId),
messages,
})
for await (const event of response) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
controller.enqueue(encoder.encode(event.delta.text))
}
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
})
}
// hooks/useChat.ts
function useChat() {
const [response, setResponse] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
async function send(messages: Message[]) {
setIsStreaming(true)
setResponse('')
const res = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
})
const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
setResponse(prev => prev + decoder.decode(value))
}
setIsStreaming(false)
}
return { response, isStreaming, send }
}
Tool Use: Actions in Your System
Give the AI the ability to read and write data:
const tools = [
{
name: 'get_user_orders',
description: 'Retrieve the user recent orders',
input_schema: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Number of orders to retrieve (default 5)' }
}
}
},
{
name: 'create_support_ticket',
description: 'Create a support ticket for the user',
input_schema: {
type: 'object',
properties: {
subject: { type: 'string' },
description: { type: 'string' },
priority: { type: 'string', enum: ['low', 'medium', 'high'] }
},
required: ['subject', 'description']
}
}
]
async function handleToolCall(toolName: string, input: unknown, userId: string) {
switch (toolName) {
case 'get_user_orders':
return db.order.findMany({ where: { userId }, take: (input as any).limit ?? 5 })
case 'create_support_ticket':
return db.ticket.create({ data: { ...input as any, userId } })
}
}
Persisting Conversations
// Save each turn to the database
await db.chatMessage.createMany({
data: [
{ conversationId, role: 'user', content: userMessage },
{ conversationId, role: 'assistant', content: aiResponse },
]
})
// Load history for subsequent turns
const history = await db.chatMessage.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
take: 20, // last 20 messages
})
Cost Control
Track token usage per user and enforce limits:
const response = await anthropic.messages.create({ /* ... */ })
await db.aiUsage.create({
data: {
userId,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
model: response.model,
cost: calculateCost(response.usage, response.model),
}
})
// Before each request, check monthly usage
const monthlyUsage = await getMonthlyTokens(userId)
if (monthlyUsage > FREE_TIER_LIMIT && !user.subscription) {
throw new Error('Token limit reached — upgrade to continue')
}
The AI SaaS Starter at whoffagents.com ships with Claude and OpenAI routes pre-configured, streaming chat hooks, conversation persistence, and token usage tracking. $99 one-time.
Top comments (0)