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 *
1414import { NextRequest , NextResponse } from 'next/server' ;
1515
1616const 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
68248export 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