Skip to content

Commit 5a26722

Browse files
feat: Claude Tool Use for Coperniq queries 🔧
- Add TOOLS array with get_work_orders, get_contacts, get_projects - Implement agentic loop (up to 5 iterations) for tool execution - Execute tools against Coperniq API and return results to Claude - Update system prompt to instruct Claude to use tools - Chat now has DIRECT ACCESS to Coperniq Instance 388 data Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 682ec05 commit 5a26722

1 file changed

Lines changed: 296 additions & 46 deletions

File tree

  • chat_ui/frontend/src/app/api/chat

chat_ui/frontend/src/app/api/chat/route.ts

Lines changed: 296 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/**
2-
* Chat API Route - Claude Integration
2+
* Chat API Route - Claude Integration with Tool Use
33
*
4-
* Handles chat messages via Anthropic Claude API.
5-
* Pattern from: solarappraisal-ai/src/app/api/chat/route.ts
4+
* Handles chat messages via Anthropic Claude API WITH TOOLS.
5+
* Claude can query Coperniq work orders, contacts, and assets.
66
*
77
* NO OpenAI - Uses Anthropic Claude only
88
*
@@ -14,6 +14,7 @@
1414
import { NextRequest, NextResponse } from 'next/server';
1515

1616
const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
17+
const COPERNIQ_API_URL = 'https://api.coperniq.io/v1';
1718

1819
// Available Claude models (NO OpenAI)
1920
// Source: https://platform.claude.com/docs/en/about-claude/models/overview
@@ -45,30 +46,210 @@ interface ChatRequest {
4546
sessionId?: string;
4647
}
4748

48-
// MEP Domain Expert System Prompt
49-
const SYSTEM_PROMPT = `You are an AI assistant for MEP (Mechanical, Electrical, Plumbing) contractors.
50-
You help with:
51-
- Work order management and scheduling
52-
- HVAC system troubleshooting and diagnostics
53-
- Electrical panel inspections and load calculations
54-
- Plumbing service calls and backflow testing
55-
- Solar installation and commissioning
56-
- Fire protection system maintenance
49+
// MEP Domain Expert System Prompt with Tool Instructions
50+
const SYSTEM_PROMPT = `You are an AI assistant for Kipper Energy Solutions, a MEP (Mechanical, Electrical, Plumbing) contractor.
5751
58-
You have access to Coperniq, the contractor's operating system.
59-
Be helpful, concise, and professional. When discussing technical topics, use industry-standard terminology.
52+
## Your Capabilities
53+
You have DIRECT ACCESS to Coperniq (the contractor's operating system) through tools:
54+
- get_work_orders: Query all work orders, filter by status or trade
55+
- get_contacts: Look up customer information
56+
- get_projects: View ongoing projects
57+
- create_work_order: Schedule new service calls
6058
61-
Key certifications you understand:
59+
## When Users Ask About Data
60+
ALWAYS use your tools to get REAL data. Examples:
61+
- "How many work orders today?" → Use get_work_orders tool
62+
- "Show me HVAC jobs" → Use get_work_orders with trade filter
63+
- "Who is our customer?" → Use get_contacts tool
64+
65+
## Trade Expertise
66+
You support: HVAC, Plumbing, Electrical, Solar, Low Voltage, Fire & Safety, Roofing
67+
68+
## Key Certifications You Understand
6269
- HVAC: EPA 608, NATE, ACCA
6370
- Electrical: NEC codes, OSHA 30
6471
- Plumbing: UPC codes, Backflow certifications
6572
- Solar: NABCEP PV, NEC 690/705
66-
- Fire: NFPA 72, sprinkler inspection`;
73+
- Fire: NFPA 72, sprinkler inspection
74+
75+
Be helpful, concise, and professional. When discussing technical topics, use industry-standard terminology.`;
76+
77+
// Claude Tool Definitions for Coperniq
78+
const TOOLS = [
79+
{
80+
name: 'get_work_orders',
81+
description: 'Get work orders from Coperniq. Can filter by status (pending, scheduled, in_progress, completed) or trade (HVAC, Plumbing, Electrical, Solar, Fire Protection).',
82+
input_schema: {
83+
type: 'object',
84+
properties: {
85+
status: {
86+
type: 'string',
87+
enum: ['all', 'pending', 'scheduled', 'in_progress', 'completed'],
88+
description: 'Filter by status. Use "all" for all work orders.',
89+
},
90+
trade: {
91+
type: 'string',
92+
enum: ['all', 'HVAC', 'Plumbing', 'Electrical', 'Solar', 'Fire Protection', 'Low Voltage', 'Roofing'],
93+
description: 'Filter by trade. Use "all" for all trades.',
94+
},
95+
limit: {
96+
type: 'number',
97+
description: 'Maximum number of results to return. Default 10.',
98+
},
99+
},
100+
required: [],
101+
},
102+
},
103+
{
104+
name: 'get_contacts',
105+
description: 'Get customer contacts from Coperniq.',
106+
input_schema: {
107+
type: 'object',
108+
properties: {
109+
search: {
110+
type: 'string',
111+
description: 'Search by customer name or company.',
112+
},
113+
limit: {
114+
type: 'number',
115+
description: 'Maximum number of results. Default 10.',
116+
},
117+
},
118+
required: [],
119+
},
120+
},
121+
{
122+
name: 'get_projects',
123+
description: 'Get ongoing projects from Coperniq.',
124+
input_schema: {
125+
type: 'object',
126+
properties: {
127+
status: {
128+
type: 'string',
129+
enum: ['all', 'active', 'completed', 'on_hold'],
130+
description: 'Filter by project status.',
131+
},
132+
limit: {
133+
type: 'number',
134+
description: 'Maximum number of results. Default 10.',
135+
},
136+
},
137+
required: [],
138+
},
139+
},
140+
];
141+
142+
// Execute tools against Coperniq API
143+
async function executeTool(toolName: string, toolInput: Record<string, unknown>, apiKey: string): Promise<string> {
144+
try {
145+
switch (toolName) {
146+
case 'get_work_orders': {
147+
const [requestsRes, projectsRes] = await Promise.all([
148+
fetch(`${COPERNIQ_API_URL}/requests`, {
149+
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
150+
}),
151+
fetch(`${COPERNIQ_API_URL}/projects`, {
152+
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
153+
}),
154+
]);
155+
156+
const requests = requestsRes.ok ? await requestsRes.json() : [];
157+
const projects = projectsRes.ok ? await projectsRes.json() : [];
158+
159+
// Combine and transform
160+
let workOrders = [
161+
...(Array.isArray(requests) ? requests : requests.data || []).map((r: Record<string, unknown>) => ({
162+
id: `req-${r.id}`,
163+
title: r.title || 'Untitled',
164+
status: r.status,
165+
trade: r.trade || 'General',
166+
customer: (r.client as Record<string, unknown>)?.name || (r.primaryContact as Record<string, unknown>)?.name || 'Unassigned',
167+
type: 'request',
168+
})),
169+
...(Array.isArray(projects) ? projects : projects.data || []).map((p: Record<string, unknown>) => ({
170+
id: `proj-${p.id}`,
171+
title: p.title || 'Untitled',
172+
status: p.status,
173+
stage: p.stage,
174+
trade: p.trade || 'General',
175+
customer: (p.client as Record<string, unknown>)?.name || (p.primaryContact as Record<string, unknown>)?.name || 'Unassigned',
176+
type: 'project',
177+
})),
178+
];
179+
180+
// Apply filters
181+
const { status, trade, limit = 10 } = toolInput;
182+
if (status && status !== 'all') {
183+
workOrders = workOrders.filter((wo) => wo.status?.toLowerCase().includes(String(status).toLowerCase()));
184+
}
185+
if (trade && trade !== 'all') {
186+
workOrders = workOrders.filter((wo) => wo.trade?.toLowerCase() === String(trade).toLowerCase());
187+
}
188+
189+
workOrders = workOrders.slice(0, Number(limit));
190+
191+
return JSON.stringify({
192+
total: workOrders.length,
193+
work_orders: workOrders,
194+
});
195+
}
196+
197+
case 'get_contacts': {
198+
const response = await fetch(`${COPERNIQ_API_URL}/contacts`, {
199+
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
200+
});
201+
const contacts = response.ok ? await response.json() : [];
202+
let results = Array.isArray(contacts) ? contacts : contacts.data || [];
203+
204+
const { search, limit = 10 } = toolInput;
205+
if (search) {
206+
const searchLower = String(search).toLowerCase();
207+
results = results.filter((c: Record<string, unknown>) =>
208+
String(c.name || '').toLowerCase().includes(searchLower) ||
209+
String(c.companyName || '').toLowerCase().includes(searchLower)
210+
);
211+
}
212+
213+
return JSON.stringify({
214+
total: results.slice(0, Number(limit)).length,
215+
contacts: results.slice(0, Number(limit)),
216+
});
217+
}
218+
219+
case 'get_projects': {
220+
const response = await fetch(`${COPERNIQ_API_URL}/projects`, {
221+
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
222+
});
223+
const projects = response.ok ? await response.json() : [];
224+
let results = Array.isArray(projects) ? projects : projects.data || [];
225+
226+
const { status, limit = 10 } = toolInput;
227+
if (status && status !== 'all') {
228+
results = results.filter((p: Record<string, unknown>) =>
229+
String(p.status || '').toLowerCase().includes(String(status).toLowerCase())
230+
);
231+
}
232+
233+
return JSON.stringify({
234+
total: results.slice(0, Number(limit)).length,
235+
projects: results.slice(0, Number(limit)),
236+
});
237+
}
238+
239+
default:
240+
return JSON.stringify({ error: `Unknown tool: ${toolName}` });
241+
}
242+
} catch (error) {
243+
console.error(`Tool ${toolName} error:`, error);
244+
return JSON.stringify({ error: `Failed to execute ${toolName}`, details: String(error) });
245+
}
246+
}
67247

68248
export async function POST(request: NextRequest) {
69-
const apiKey = process.env.ANTHROPIC_API_KEY;
249+
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
250+
const coperniqApiKey = process.env.COPERNIQ_API_KEY;
70251

71-
if (!apiKey) {
252+
if (!anthropicApiKey) {
72253
console.error('ANTHROPIC_API_KEY not configured');
73254
return NextResponse.json(
74255
{ error: 'API key not configured' },
@@ -78,7 +259,7 @@ export async function POST(request: NextRequest) {
78259

79260
try {
80261
const body: ChatRequest = await request.json();
81-
const { messages, model: modelAlias = DEFAULT_MODEL, stream = false } = body;
262+
const { messages, model: modelAlias = DEFAULT_MODEL } = body;
82263

83264
if (!messages || !Array.isArray(messages) || messages.length === 0) {
84265
return NextResponse.json(
@@ -92,45 +273,114 @@ export async function POST(request: NextRequest) {
92273

93274
console.log(`Chat request using model: ${modelAlias} -> ${modelId}`);
94275

95-
// Transform messages for Claude API format
96-
const claudeMessages = messages.map((msg) => ({
276+
// Build conversation with any existing messages
277+
let conversationMessages: Array<{ role: string; content: unknown }> = messages.map((msg) => ({
97278
role: msg.role,
98279
content: msg.content,
99280
}));
100281

101-
const response = await fetch(ANTHROPIC_API_URL, {
102-
method: 'POST',
103-
headers: {
104-
'Content-Type': 'application/json',
105-
'x-api-key': apiKey,
106-
'anthropic-version': '2023-06-01',
107-
},
108-
body: JSON.stringify({
109-
model: modelId,
110-
max_tokens: 4096,
111-
system: SYSTEM_PROMPT,
112-
messages: claudeMessages,
113-
}),
114-
});
282+
// Agentic loop: Keep calling Claude until we get a final text response
283+
let finalResponse = '';
284+
let loopCount = 0;
285+
const MAX_LOOPS = 5;
115286

116-
if (!response.ok) {
117-
const errorText = await response.text();
118-
console.error('Claude API error:', response.status, errorText);
119-
throw new Error(`Claude API error: ${response.status}`);
120-
}
287+
while (loopCount < MAX_LOOPS) {
288+
loopCount++;
289+
290+
const response = await fetch(ANTHROPIC_API_URL, {
291+
method: 'POST',
292+
headers: {
293+
'Content-Type': 'application/json',
294+
'x-api-key': anthropicApiKey,
295+
'anthropic-version': '2023-06-01',
296+
},
297+
body: JSON.stringify({
298+
model: modelId,
299+
max_tokens: 4096,
300+
system: SYSTEM_PROMPT,
301+
tools: coperniqApiKey ? TOOLS : [], // Only include tools if Coperniq is configured
302+
messages: conversationMessages,
303+
}),
304+
});
305+
306+
if (!response.ok) {
307+
const errorText = await response.text();
308+
console.error('Claude API error:', response.status, errorText);
309+
throw new Error(`Claude API error: ${response.status}`);
310+
}
311+
312+
const data = await response.json();
313+
314+
// Check stop_reason to determine next action
315+
if (data.stop_reason === 'end_turn') {
316+
// Claude finished - extract text
317+
const textBlock = data.content?.find((block: { type: string }) => block.type === 'text');
318+
finalResponse = textBlock?.text || 'I apologize, but I could not generate a response.';
319+
break;
320+
}
321+
322+
if (data.stop_reason === 'tool_use') {
323+
// Claude wants to use a tool - execute it and continue
324+
const toolUseBlocks = data.content?.filter((block: { type: string }) => block.type === 'tool_use') || [];
121325

122-
const data = await response.json();
326+
if (toolUseBlocks.length === 0) {
327+
// No tool blocks found, extract any text
328+
const textBlock = data.content?.find((block: { type: string }) => block.type === 'text');
329+
finalResponse = textBlock?.text || 'I apologize, but I could not generate a response.';
330+
break;
331+
}
332+
333+
// Add assistant's response to conversation
334+
conversationMessages.push({
335+
role: 'assistant',
336+
content: data.content,
337+
});
338+
339+
// Execute each tool and collect results
340+
const toolResults: Array<{ type: string; tool_use_id: string; content: string }> = [];
341+
342+
for (const toolBlock of toolUseBlocks) {
343+
console.log(`Executing tool: ${toolBlock.name}`, toolBlock.input);
344+
345+
const result = await executeTool(
346+
toolBlock.name,
347+
toolBlock.input || {},
348+
coperniqApiKey || ''
349+
);
123350

124-
// Extract the assistant's response
125-
const assistantMessage = data.content?.[0]?.text || 'I apologize, but I could not generate a response.';
351+
toolResults.push({
352+
type: 'tool_result',
353+
tool_use_id: toolBlock.id,
354+
content: result,
355+
});
356+
}
357+
358+
// Add tool results to conversation
359+
conversationMessages.push({
360+
role: 'user',
361+
content: toolResults,
362+
});
363+
364+
console.log(`Tool loop ${loopCount}: Executed ${toolResults.length} tools, continuing...`);
365+
} else {
366+
// Unknown stop_reason or max_tokens - extract what we have
367+
const textBlock = data.content?.find((block: { type: string }) => block.type === 'text');
368+
finalResponse = textBlock?.text || 'I apologize, but I ran into a limit processing your request.';
369+
break;
370+
}
371+
}
372+
373+
if (loopCount >= MAX_LOOPS && !finalResponse) {
374+
finalResponse = 'I apologize, but I reached the maximum number of operations. Please try a simpler request.';
375+
}
126376

127377
return NextResponse.json({
128378
message: {
129379
role: 'assistant',
130-
content: assistantMessage,
380+
content: finalResponse,
131381
},
132-
usage: data.usage,
133-
model: data.model,
382+
toolsUsed: loopCount > 1,
383+
model: modelId,
134384
});
135385
} catch (error) {
136386
console.error('Chat API error:', error);

0 commit comments

Comments
 (0)