diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9adff1a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,18 @@
+# Browser behavior
+# mock = keep using deterministic local replies
+# api = call /api/chat, which uses server-side provider credentials
+VITE_HAGGLY_CHAT_MODE=mock
+
+# Server model provider for /api/chat
+# Choose one: openai or anthropic
+AI_PROVIDER=
+AI_MAX_TOKENS=900
+
+# OpenAI Responses API
+OPENAI_API_KEY=
+OPENAI_MODEL=
+
+# Anthropic Messages API
+ANTHROPIC_API_KEY=
+ANTHROPIC_MODEL=
+ANTHROPIC_VERSION=2023-06-01
diff --git a/api/chat.js b/api/chat.js
new file mode 100644
index 0000000..707c057
--- /dev/null
+++ b/api/chat.js
@@ -0,0 +1,43 @@
+import { handleChatRequest } from '../src/lib/ai/chatHandler.js'
+
+async function readJsonBody(request) {
+ if (request.body && typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
+ return request.body
+ }
+
+ if (typeof request.body === 'string') {
+ return JSON.parse(request.body)
+ }
+
+ if (Buffer.isBuffer(request.body)) {
+ return JSON.parse(request.body.toString('utf8'))
+ }
+
+ const chunks = []
+ for await (const chunk of request) {
+ chunks.push(chunk)
+ }
+
+ if (!chunks.length) {
+ return {}
+ }
+
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
+}
+
+export default async function handler(request, response) {
+ try {
+ const body = request.method === 'POST' ? await readJsonBody(request) : undefined
+ const result = await handleChatRequest({
+ method: request.method,
+ body,
+ })
+
+ response.status(result.status).json(result.body)
+ } catch (_error) {
+ response.status(400).json({
+ code: 'invalid_json',
+ error: 'Request body must be valid JSON.',
+ })
+ }
+}
diff --git a/src/features/chat/ChatPanel.jsx b/src/features/chat/ChatPanel.jsx
index d146095..15d4e16 100644
--- a/src/features/chat/ChatPanel.jsx
+++ b/src/features/chat/ChatPanel.jsx
@@ -1,5 +1,6 @@
import { useState } from 'react'
import Surface from '../../components/ui/Surface'
+import { isApiChatEnabled, requestAssistantReply } from '../../lib/ai/chatClient'
import { createInitialMessages, createMockAssistantReply } from './mockAssistant'
import ChatComposer from './ChatComposer'
import ChatMessage from './ChatMessage'
@@ -11,6 +12,7 @@ function ChatPanel({ conversation, mode, onConversationChange, onCopy }) {
const [conversationStatus, setConversationStatus] = useState(() => conversation?.status ?? 'active')
const [conversationCreatedAt, setConversationCreatedAt] = useState(() => conversation?.createdAt ?? Date.now())
const [isReplying, setIsReplying] = useState(false)
+ const isApiMode = isApiChatEnabled()
const publishMessages = (nextMessages, status = conversationStatus) => {
const nextId = conversation?.id ?? conversationId
@@ -32,7 +34,7 @@ function ChatPanel({ conversation, mode, onConversationChange, onCopy }) {
})
}
- const handleSend = (content) => {
+ const handleSend = async (content) => {
const userMessage = {
id: `user-${Date.now()}`,
role: 'user',
@@ -44,13 +46,38 @@ function ChatPanel({ conversation, mode, onConversationChange, onCopy }) {
publishMessages(messagesWithUser)
setIsReplying(true)
- setTimeout(() => {
+ if (!isApiMode) {
+ setTimeout(() => {
+ publishMessages([
+ ...messagesWithUser,
+ createMockAssistantReply({ mode: activeMode, message: content }),
+ ])
+ setIsReplying(false)
+ }, 450)
+ return
+ }
+
+ try {
+ const assistantReply = await requestAssistantReply({
+ mode: activeMode,
+ message: content,
+ messages: messagesWithUser,
+ })
+
+ publishMessages([...messagesWithUser, assistantReply])
+ } catch (error) {
publishMessages([
...messagesWithUser,
- createMockAssistantReply({ mode: activeMode, message: content }),
+ {
+ id: `assistant-error-${Date.now()}`,
+ role: 'assistant',
+ content: `I could not reach the Haggly model endpoint. ${error.message}`,
+ createdAt: Date.now(),
+ },
])
+ } finally {
setIsReplying(false)
- }, 450)
+ }
}
const handleStatusChange = (event) => {
@@ -62,7 +89,9 @@ function ChatPanel({ conversation, mode, onConversationChange, onCopy }) {
Negotiation chat
-
Mock Haggly agent for now. No API key needed.
+
+ {isApiMode ? 'API-backed Haggly agent.' : 'Mock Haggly agent for now. No API key needed.'}
+
diff --git a/src/lib/ai/chatClient.js b/src/lib/ai/chatClient.js
new file mode 100644
index 0000000..61ab594
--- /dev/null
+++ b/src/lib/ai/chatClient.js
@@ -0,0 +1,33 @@
+export function isApiChatEnabled(env = import.meta.env) {
+ return env?.VITE_HAGGLY_CHAT_MODE === 'api'
+}
+
+async function readResponseJson(response) {
+ try {
+ return await response.json()
+ } catch (_error) {
+ return {}
+ }
+}
+
+export async function requestAssistantReply({ mode, message, messages, fetchImpl = fetch }) {
+ const response = await fetchImpl('/api/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ mode, message, messages }),
+ })
+ const data = await readResponseJson(response)
+
+ if (!response.ok) {
+ throw new Error(data.error ?? 'Haggly API request failed.')
+ }
+
+ return {
+ id: `assistant-${Date.now()}`,
+ role: 'assistant',
+ content: data.message,
+ provider: data.provider,
+ playbookIds: data.playbookIds,
+ createdAt: Date.now(),
+ }
+}
diff --git a/src/lib/ai/chatClient.test.js b/src/lib/ai/chatClient.test.js
new file mode 100644
index 0000000..1e6fcfc
--- /dev/null
+++ b/src/lib/ai/chatClient.test.js
@@ -0,0 +1,37 @@
+import { describe, expect, it, vi } from 'vitest'
+import { isApiChatEnabled, requestAssistantReply } from './chatClient'
+
+describe('chatClient', () => {
+ it('uses mock mode unless API mode is explicitly enabled', () => {
+ expect(isApiChatEnabled({})).toBe(false)
+ expect(isApiChatEnabled({ VITE_HAGGLY_CHAT_MODE: 'api' })).toBe(true)
+ })
+
+ it('calls the local chat API and returns an assistant message', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ message: 'API reply', provider: 'openai' }),
+ })
+
+ await expect(
+ requestAssistantReply({
+ mode: 'seller',
+ message: 'They offered $80.',
+ messages: [{ role: 'user', content: 'They offered $80.' }],
+ fetchImpl,
+ }),
+ ).resolves.toMatchObject({
+ role: 'assistant',
+ content: 'API reply',
+ provider: 'openai',
+ })
+
+ expect(fetchImpl).toHaveBeenCalledWith(
+ '/api/chat',
+ expect.objectContaining({
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ })
+})
diff --git a/src/lib/ai/chatHandler.js b/src/lib/ai/chatHandler.js
new file mode 100644
index 0000000..0eba686
--- /dev/null
+++ b/src/lib/ai/chatHandler.js
@@ -0,0 +1,83 @@
+import { createChatPrompt } from './chatPrompt'
+import { createModelClient, MissingModelConfigError, ModelRequestError } from './modelClient'
+
+function getLatestMessage(body) {
+ return typeof body?.message === 'string' ? body.message.trim() : ''
+}
+
+function getErrorResponse(error) {
+ if (error instanceof MissingModelConfigError) {
+ return {
+ status: 500,
+ body: {
+ code: error.code,
+ error: error.message,
+ },
+ }
+ }
+
+ if (error instanceof ModelRequestError) {
+ return {
+ status: 502,
+ body: {
+ code: error.code,
+ error: error.message,
+ provider: error.provider,
+ },
+ }
+ }
+
+ return {
+ status: 500,
+ body: {
+ code: 'chat_api_error',
+ error: 'Haggly could not create a response.',
+ },
+ }
+}
+
+export async function handleChatRequest({
+ method,
+ body,
+ env = process.env,
+ fetchImpl = fetch,
+} = {}) {
+ if (method !== 'POST') {
+ return {
+ status: 405,
+ body: { error: 'Method not allowed' },
+ }
+ }
+
+ const message = getLatestMessage(body)
+ if (!message) {
+ return {
+ status: 400,
+ body: {
+ code: 'missing_message',
+ error: 'Message is required.',
+ },
+ }
+ }
+
+ try {
+ const prompt = createChatPrompt({
+ mode: body?.mode,
+ message,
+ messages: body?.messages,
+ })
+ const client = createModelClient({ env, fetchImpl })
+ const assistantMessage = await client.createReply(prompt)
+
+ return {
+ status: 200,
+ body: {
+ message: assistantMessage,
+ provider: client.provider,
+ playbookIds: prompt.playbookIds,
+ },
+ }
+ } catch (error) {
+ return getErrorResponse(error)
+ }
+}
diff --git a/src/lib/ai/chatHandler.test.js b/src/lib/ai/chatHandler.test.js
new file mode 100644
index 0000000..1a9741a
--- /dev/null
+++ b/src/lib/ai/chatHandler.test.js
@@ -0,0 +1,58 @@
+import { describe, expect, it, vi } from 'vitest'
+import { handleChatRequest } from './chatHandler'
+
+describe('handleChatRequest', () => {
+ it('rejects non-POST requests', async () => {
+ await expect(handleChatRequest({ method: 'GET' })).resolves.toMatchObject({
+ status: 405,
+ body: { error: 'Method not allowed' },
+ })
+ })
+
+ it('returns a clear developer error when model config is missing', async () => {
+ await expect(
+ handleChatRequest({
+ method: 'POST',
+ body: { mode: 'seller', message: 'They offered $80.' },
+ env: {},
+ fetchImpl: vi.fn(),
+ }),
+ ).resolves.toMatchObject({
+ status: 500,
+ body: {
+ code: 'missing_model_config',
+ },
+ })
+ })
+
+ it('returns an assistant message from the configured model provider', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ output_text: 'Current read: hold your price.' }),
+ })
+
+ await expect(
+ handleChatRequest({
+ method: 'POST',
+ body: {
+ mode: 'seller',
+ message: "I'm selling a chair for $200 and they offered $100.",
+ messages: [{ role: 'user', content: "I'm selling a chair." }],
+ },
+ env: {
+ AI_PROVIDER: 'openai',
+ OPENAI_API_KEY: 'openai-secret',
+ OPENAI_MODEL: 'test-openai-model',
+ },
+ fetchImpl,
+ }),
+ ).resolves.toMatchObject({
+ status: 200,
+ body: {
+ message: 'Current read: hold your price.',
+ provider: 'openai',
+ playbookIds: ['core-negotiation-agent', 'seller-marketplace'],
+ },
+ })
+ })
+})
diff --git a/src/lib/ai/chatPrompt.js b/src/lib/ai/chatPrompt.js
new file mode 100644
index 0000000..f820c83
--- /dev/null
+++ b/src/lib/ai/chatPrompt.js
@@ -0,0 +1,80 @@
+import { buildNegotiationContext } from '../../features/negotiation/negotiationContext'
+import { assembleHagglyPrompt } from '../prompts/promptAssembler'
+
+function extractPriceAfterLabel(text, labels) {
+ for (const label of labels) {
+ const pattern = new RegExp(`${label}\\D{0,40}\\$?([0-9][0-9,.]*)`, 'i')
+ const match = text.match(pattern)
+
+ if (match) {
+ return Number(match[1].replace(/,/g, ''))
+ }
+ }
+
+ return null
+}
+
+function extractPrices(text) {
+ const prices = [...text.matchAll(/\$([0-9][0-9,.]*)/g)].map((match) =>
+ Number(match[1].replace(/,/g, '')),
+ )
+
+ return {
+ askingPrice:
+ extractPriceAfterLabel(text, ['asking', 'listed', 'price', 'selling', 'buying']) ??
+ prices[0] ??
+ null,
+ theirOffer:
+ extractPriceAfterLabel(text, ['offer', 'offered', 'they said', 'want to offer']) ??
+ prices[1] ??
+ null,
+ minimumPrice:
+ extractPriceAfterLabel(text, ['minimum', 'lowest', 'bottom', 'would take']) ?? null,
+ }
+}
+
+function extractItem(text) {
+ const itemPatterns = [
+ /(?:selling|buying|negotiating|looking at)\s+(?:a|an|the)?\s*([^,.]+?)(?:\s+for|\s+listed|\s+asking|,|\.|$)/i,
+ /(?:item is|it's|it is)\s+(?:a|an|the)?\s*([^,.]+?)(?:,|\.|$)/i,
+ ]
+
+ for (const pattern of itemPatterns) {
+ const match = text.match(pattern)
+
+ if (match?.[1]) {
+ return match[1].trim()
+ }
+ }
+
+ return 'item'
+}
+
+function normalizeConversation(messages) {
+ if (!Array.isArray(messages)) {
+ return []
+ }
+
+ return messages
+ .filter((message) => ['assistant', 'user'].includes(message.role) && message.content)
+ .slice(-8)
+ .map((message) => ({
+ role: message.role,
+ content: String(message.content),
+ }))
+}
+
+export function createChatPrompt({ mode = 'seller', message = '', messages = [] } = {}) {
+ const prices = extractPrices(message)
+ const context = buildNegotiationContext({
+ mode,
+ item: extractItem(message),
+ ...prices,
+ })
+
+ return assembleHagglyPrompt({
+ context,
+ latestMessage: message,
+ conversation: normalizeConversation(messages),
+ })
+}
diff --git a/src/lib/ai/chatPrompt.test.js b/src/lib/ai/chatPrompt.test.js
new file mode 100644
index 0000000..f30409c
--- /dev/null
+++ b/src/lib/ai/chatPrompt.test.js
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { createChatPrompt } from './chatPrompt'
+
+describe('createChatPrompt', () => {
+ it('builds structured prompt parts from the latest seller message', () => {
+ const prompt = createChatPrompt({
+ mode: 'seller',
+ message:
+ "I'm selling a gaming chair for $200. They offered $80. I would take $160 if they pick up tonight.",
+ messages: [
+ { role: 'assistant', content: 'What is your minimum?' },
+ { role: 'user', content: 'I would take $160.' },
+ ],
+ })
+
+ expect(prompt.playbookIds).toEqual(['core-negotiation-agent', 'seller-marketplace'])
+ expect(prompt.system).toContain('Do not behave like a script generator')
+ expect(prompt.user).toContain('"mode": "seller"')
+ expect(prompt.user).toContain('"offerType": "lowball"')
+ expect(prompt.user).toContain('"minimumPrice": 160')
+ expect(prompt.user).toContain('pick up tonight')
+ })
+})
diff --git a/src/lib/ai/modelClient.js b/src/lib/ai/modelClient.js
new file mode 100644
index 0000000..3d96065
--- /dev/null
+++ b/src/lib/ai/modelClient.js
@@ -0,0 +1,182 @@
+export class MissingModelConfigError extends Error {
+ constructor(message) {
+ super(message)
+ this.name = 'MissingModelConfigError'
+ this.code = 'missing_model_config'
+ }
+}
+
+export class ModelRequestError extends Error {
+ constructor(message, { provider, status } = {}) {
+ super(message)
+ this.name = 'ModelRequestError'
+ this.code = 'model_request_failed'
+ this.provider = provider
+ this.status = status
+ }
+}
+
+function requireEnv(env, key, helpText) {
+ const value = env[key]?.trim()
+
+ if (!value) {
+ throw new MissingModelConfigError(`${key} is required. ${helpText}`)
+ }
+
+ return value
+}
+
+function getMaxTokens(env) {
+ const value = Number(env.AI_MAX_TOKENS ?? 900)
+ return Number.isFinite(value) && value > 0 ? Math.round(value) : 900
+}
+
+async function readJson(response) {
+ try {
+ return await response.json()
+ } catch (_error) {
+ return null
+ }
+}
+
+function getProviderError(data) {
+ return data?.error?.message ?? data?.message ?? 'Unknown provider error'
+}
+
+function extractOpenAIText(data) {
+ if (typeof data?.output_text === 'string' && data.output_text.trim()) {
+ return data.output_text.trim()
+ }
+
+ const text = data?.output
+ ?.flatMap((item) => item.content ?? [])
+ .map((content) => content.text)
+ .filter(Boolean)
+ .join('\n')
+ .trim()
+
+ return text || null
+}
+
+function extractAnthropicText(data) {
+ const text = data?.content
+ ?.filter((content) => content.type === 'text')
+ .map((content) => content.text)
+ .join('\n')
+ .trim()
+
+ return text || null
+}
+
+function createOpenAIClient({ env, fetchImpl }) {
+ const apiKey = requireEnv(
+ env,
+ 'OPENAI_API_KEY',
+ 'Set OPENAI_API_KEY on the server or Vercel project.',
+ )
+ const model = requireEnv(env, 'OPENAI_MODEL', 'Set OPENAI_MODEL to the model you want Haggly to use.')
+
+ return {
+ provider: 'openai',
+ async createReply(prompt) {
+ const response = await fetchImpl('https://api.openai.com/v1/responses', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ model,
+ instructions: prompt.system,
+ input: prompt.user,
+ max_output_tokens: getMaxTokens(env),
+ }),
+ })
+ const data = await readJson(response)
+
+ if (!response.ok) {
+ throw new ModelRequestError(`OpenAI request failed: ${getProviderError(data)}`, {
+ provider: 'openai',
+ status: response.status,
+ })
+ }
+
+ const text = extractOpenAIText(data)
+ if (!text) {
+ throw new ModelRequestError('OpenAI response did not include text output.', {
+ provider: 'openai',
+ status: response.status,
+ })
+ }
+
+ return text
+ },
+ }
+}
+
+function createAnthropicClient({ env, fetchImpl }) {
+ const apiKey = requireEnv(
+ env,
+ 'ANTHROPIC_API_KEY',
+ 'Set ANTHROPIC_API_KEY on the server or Vercel project.',
+ )
+ const model = requireEnv(
+ env,
+ 'ANTHROPIC_MODEL',
+ 'Set ANTHROPIC_MODEL to the model you want Haggly to use.',
+ )
+
+ return {
+ provider: 'anthropic',
+ async createReply(prompt) {
+ const response = await fetchImpl('https://api.anthropic.com/v1/messages', {
+ method: 'POST',
+ headers: {
+ 'x-api-key': apiKey,
+ 'anthropic-version': env.ANTHROPIC_VERSION ?? '2023-06-01',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ model,
+ max_tokens: getMaxTokens(env),
+ system: prompt.system,
+ messages: [{ role: 'user', content: prompt.user }],
+ }),
+ })
+ const data = await readJson(response)
+
+ if (!response.ok) {
+ throw new ModelRequestError(`Anthropic request failed: ${getProviderError(data)}`, {
+ provider: 'anthropic',
+ status: response.status,
+ })
+ }
+
+ const text = extractAnthropicText(data)
+ if (!text) {
+ throw new ModelRequestError('Anthropic response did not include text output.', {
+ provider: 'anthropic',
+ status: response.status,
+ })
+ }
+
+ return text
+ },
+ }
+}
+
+export function createModelClient({ env = process.env, fetchImpl = fetch } = {}) {
+ const provider = env.AI_PROVIDER?.trim().toLowerCase()
+
+ if (provider === 'openai') {
+ return createOpenAIClient({ env, fetchImpl })
+ }
+
+ if (provider === 'anthropic') {
+ return createAnthropicClient({ env, fetchImpl })
+ }
+
+ throw new MissingModelConfigError(
+ 'Set AI_PROVIDER to "openai" or "anthropic", then set the matching API key and model env vars.',
+ )
+}
diff --git a/src/lib/ai/modelClient.test.js b/src/lib/ai/modelClient.test.js
new file mode 100644
index 0000000..ee9058f
--- /dev/null
+++ b/src/lib/ai/modelClient.test.js
@@ -0,0 +1,91 @@
+import { describe, expect, it, vi } from 'vitest'
+import { createModelClient, MissingModelConfigError, ModelRequestError } from './modelClient'
+
+describe('createModelClient', () => {
+ it('throws a clear configuration error when no provider is configured', () => {
+ expect(() => createModelClient({ env: {}, fetchImpl: vi.fn() })).toThrow(
+ MissingModelConfigError,
+ )
+ })
+
+ it('calls the OpenAI Responses API with server-side credentials', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ output_text: 'OpenAI reply' }),
+ })
+
+ const client = createModelClient({
+ env: {
+ AI_PROVIDER: 'openai',
+ OPENAI_API_KEY: 'openai-secret',
+ OPENAI_MODEL: 'test-openai-model',
+ },
+ fetchImpl,
+ })
+
+ await expect(client.createReply({ system: 'system prompt', user: 'user prompt' })).resolves.toBe(
+ 'OpenAI reply',
+ )
+
+ const [url, options] = fetchImpl.mock.calls[0]
+ expect(url).toBe('https://api.openai.com/v1/responses')
+ expect(options.headers.Authorization).toBe('Bearer openai-secret')
+ expect(JSON.parse(options.body)).toMatchObject({
+ model: 'test-openai-model',
+ instructions: 'system prompt',
+ input: 'user prompt',
+ })
+ })
+
+ it('calls the Anthropic Messages API with server-side credentials', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ content: [{ type: 'text', text: 'Anthropic reply' }] }),
+ })
+
+ const client = createModelClient({
+ env: {
+ AI_PROVIDER: 'anthropic',
+ ANTHROPIC_API_KEY: 'anthropic-secret',
+ ANTHROPIC_MODEL: 'test-anthropic-model',
+ },
+ fetchImpl,
+ })
+
+ await expect(client.createReply({ system: 'system prompt', user: 'user prompt' })).resolves.toBe(
+ 'Anthropic reply',
+ )
+
+ const [url, options] = fetchImpl.mock.calls[0]
+ expect(url).toBe('https://api.anthropic.com/v1/messages')
+ expect(options.headers['x-api-key']).toBe('anthropic-secret')
+ expect(options.headers['anthropic-version']).toBe('2023-06-01')
+ expect(JSON.parse(options.body)).toMatchObject({
+ model: 'test-anthropic-model',
+ max_tokens: 900,
+ system: 'system prompt',
+ messages: [{ role: 'user', content: 'user prompt' }],
+ })
+ })
+
+ it('wraps provider failures with a model request error', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: async () => ({ error: { message: 'bad key' } }),
+ })
+
+ const client = createModelClient({
+ env: {
+ AI_PROVIDER: 'openai',
+ OPENAI_API_KEY: 'openai-secret',
+ OPENAI_MODEL: 'test-openai-model',
+ },
+ fetchImpl,
+ })
+
+ await expect(client.createReply({ system: 'system', user: 'user' })).rejects.toThrow(
+ ModelRequestError,
+ )
+ })
+})
diff --git a/vercel.json b/vercel.json
index f07b71a..9e66d6b 100644
--- a/vercel.json
+++ b/vercel.json
@@ -2,6 +2,11 @@
"buildCommand": "npm run build",
"outputDirectory": "dist",
"framework": "vite",
+ "functions": {
+ "api/chat.js": {
+ "includeFiles": "prompts/playbooks/**"
+ }
+ },
"rewrites": [
{ "source": "/(.*)", "destination": "/" }
]