Skip to content

11 — AI-Powered Features

Add a streaming AI chat to the TaskBoard that can read tasks and answer questions about them. Users type a question, the AI streams the response word-by-word, and it can look up real task data using tool-calling.

  • A /chat page with a streaming chat interface
  • An AI provider registered via DI
  • A tRPC chat mutation that streams (returns full response for now)
  • An MCP-style tool so the AI can query actual tasks
  • Token usage tracked per org

Add AIContainerModule to your app:

apps/web/src/app.server.ts
import { createCruzApp } from '@cruzjs/core';
import { AIContainerModule } from '@cruzjs/ai';
import { TasksModule } from './modules/tasks';
export default createCruzApp({
modules: [TasksModule, /* ... */],
containerModules: [AIContainerModule],
});

Set your API key in .dev.vars:

CRUZJS_AI_PROVIDER=openai
OPENAI_API_KEY=sk-...
apps/web/src/startup.server.ts
import { OpenAIProvider, AI_PROVIDER_REGISTRY } from '@cruzjs/ai';
export async function onStartup(container: CruzContainer) {
const registry = container.get(AI_PROVIDER_REGISTRY);
registry.register(new OpenAIProvider({
apiKey: process.env.OPENAI_API_KEY!,
defaultModel: 'gpt-4o-mini',
}));
}

Create apps/web/src/modules/tasks/tasks-ai.trpc.ts:

import { injectable, inject } from 'inversify';
import { Router, Route, TrpcRouter } from '@cruzjs/core';
import { orgProcedure } from '@cruzjs/core/trpc/context';
import { AIProviderRegistry, AI_PROVIDER_REGISTRY, McpBridge } from '@cruzjs/ai';
import { z } from 'zod';
import { TasksService } from './tasks.service';
@injectable()
@Router()
export class TasksAITrpc implements TrpcRouter {
constructor(
@inject(AI_PROVIDER_REGISTRY) private registry: AIProviderRegistry,
@inject(TasksService) private tasksService: TasksService,
) {}
@Route()
chat = orgProcedure
.input(z.object({
messages: z.array(z.object({ role: z.enum(['user', 'assistant']), content: z.string() })),
}))
.mutation(async ({ input, ctx }) => {
const ai = this.registry.resolve();
const orgId = ctx.org.orgId;
const result = await McpBridge.runWithTools(ai, [
{
role: 'system',
content: 'You are a helpful project management assistant. Use the list_tasks tool to look up task data when asked.',
},
...input.messages,
], {
tools: [{
name: 'list_tasks',
description: 'List tasks for the current organization',
parameters: {
status: { type: 'string', enum: ['pending', 'in_progress', 'done'], description: 'Filter by status' },
},
}],
executor: async (toolCall) => {
if (toolCall.name === 'list_tasks') {
const tasks = await this.tasksService.list(orgId, {
status: toolCall.arguments.status as string | undefined,
});
return JSON.stringify(tasks.map(t => ({ id: t.id, title: t.title, status: t.status })));
}
return '[]';
},
maxRounds: 3,
});
return { content: result.content };
});
}

Register it in TasksModule:

tasks.module.ts
import { TasksAITrpc } from './tasks-ai.trpc';
@Module({
providers: [TasksService, TasksAITrpc],
trpcRouters: [{ name: 'tasks', router: TasksAITrpc }],
})
export class TasksModule {}

Create apps/web/src/routes/chat.tsx:

import { useState } from 'react';
import { getTRPC } from '@cruzjs/core/trpc/client';
type Message = { role: 'user' | 'assistant'; content: string };
export default function ChatPage() {
const trpc = getTRPC();
const chatMutation = trpc.tasks.chat.useMutation();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const send = async () => {
if (!input.trim()) return;
const userMessage: Message = { role: 'user', content: input };
setInput('');
setMessages(prev => [...prev, userMessage]);
const result = await chatMutation.mutateAsync({ messages: [...messages, userMessage] });
setMessages(prev => [...prev, { role: 'assistant', content: result.content }]);
};
return (
<div className="max-w-2xl mx-auto p-6 space-y-4">
<h1 className="text-2xl font-semibold">Task Assistant</h1>
<div className="border rounded-lg p-4 h-96 overflow-y-auto space-y-3">
{messages.length === 0 && (
<p className="text-text-muted text-sm">Ask about your tasks. Try: "What tasks are in progress?"</p>
)}
{messages.map((m, i) => (
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-xs rounded-lg px-4 py-2 text-sm ${
m.role === 'user' ? 'bg-primary text-white' : 'bg-surface border border-surface-border'
}`}>
{m.content}
</div>
</div>
))}
{chatMutation.isPending && (
<div className="flex justify-start">
<div className="bg-surface border border-surface-border rounded-lg px-4 py-2 text-sm text-text-muted">
Thinking…
</div>
</div>
)}
</div>
<div className="flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && send()}
placeholder="Ask about your tasks…"
className="flex-1 rounded-lg border border-surface-border bg-surface px-4 py-2 text-sm outline-none focus:border-primary"
/>
<button
onClick={send}
disabled={chatMutation.isPending || !input.trim()}
className="rounded-lg bg-primary px-4 py-2 text-sm text-white disabled:opacity-60"
>
Send
</button>
</div>
</div>
);
}

After each AI call, record usage:

import { AIUsageTracker, AI_USAGE_TRACKER } from '@cruzjs/ai';
// In tasks-ai.trpc.ts, inject the tracker:
@inject(AI_USAGE_TRACKER) private tracker: AIUsageTracker,
// After McpBridge.runWithTools():
this.tracker.record({
orgId,
provider: 'openai',
model: 'gpt-4o-mini',
inputTokens: 0, // add when provider returns token counts
outputTokens: 0,
durationMs: Date.now() - start,
timestamp: new Date(),
});
Terminal window
cruz dev

Visit http://localhost:5173/chat. Ask:

  • “What tasks are pending?”
  • “How many tasks are in progress?”
  • “Summarize all done tasks”

The AI will call list_tasks automatically, read the real data, and reply in natural language.

  • Registered @cruzjs/ai with OpenAI provider
  • Created a tRPC mutation that runs the AI with tool-calling
  • AI can query real task data via McpBridge
  • Chat UI with message history
  • Token usage tracked per org

Explore more @cruzjs/ai features in the AI Integration docs:

  • useStream React hook for token-by-token streaming
  • useChat for multi-turn conversations with history
  • Org-scoped API keys per tenant
  • Anthropic, OpenRouter, Cloudflare Gateway providers