Summary
Tools (e.g., LeadCaptureTool) are defined and passed to AI providers, but tool-use responses from the providers are never detected or executed. The providers currently only yield text deltas — when the AI decides to call a tool, the tool_use / function_call response blocks are silently ignored. This means lead capture (a headline feature) does not actually work.
This issue covers wiring up the full tool-calling loop: detect tool-use in provider responses → execute the tool → feed the result back to the provider → stream the final text response to the client.
Current Behavior
- Server passes
tools to the provider via ProviderChatParams.tools (packages/server/src/handler.ts:101)
- Each provider converts tool definitions to the provider-specific format:
- Claude:
toClaudeTools() in packages/core/src/providers/base.ts:35-45
- OpenAI:
toOpenAITools() in packages/core/src/providers/base.ts:47-60
- Gemini:
toGeminiTools() in packages/core/src/providers/base.ts:68-86
- Provider sends tools to the API — but streaming only yields
text_delta content:
- Claude (
packages/core/src/providers/claude.ts:33-37): only handles content_block_delta with text_delta type
- OpenAI (
packages/core/src/providers/openai.ts:36-41): only reads delta.content
- Gemini (
packages/core/src/providers/gemini.ts:43-47): only reads chunk.text()
- When the AI wants to call a tool (e.g.,
capture_lead), the tool-use block is silently skipped — no tool is executed, no result sent back
ChatTool.execute() (packages/core/src/tools/base.ts:8) is never called from anywhere in the codebase
Expected Behavior
- Provider detects a tool-use response from the AI
- Server looks up the tool by name from the configured
tools array
- Server calls
tool.execute(input) with the parsed arguments
- Tool result is sent back to the provider as a tool-result message
- Provider continues generating text based on the tool result
- Final text is streamed to the widget
- Widget receives optional metadata (e.g.,
leadCaptured: true) via the SSE stream
Implementation Guide
Phase 1: Update Provider Interface
File: packages/core/src/providers/base.ts
The AIProvider.chat() method currently returns AsyncGenerator<string> — it can only yield text. Change the yield type to support both text and tool-use events:
// New yield type for the chat generator
export interface ChatYield {
type: 'text' | 'tool_use';
text?: string;
toolUse?: {
id: string;
name: string;
input: Record<string, unknown>;
};
}
export interface AIProvider {
name: string;
chat(params: ProviderChatParams): AsyncGenerator<ChatYield>;
chatSync(params: ProviderChatParams): Promise<string>;
}
Phase 2: Update Each Provider to Detect Tool Use
Claude (packages/core/src/providers/claude.ts)
Claude's streaming API emits content_block_start with type: 'tool_use' followed by input_json_delta events. The current code at line 33-37 only handles text_delta:
// Current (lines 33-37) — only handles text
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
yield event.delta.text;
}
}
Update to also handle tool_use blocks:
- Track
content_block_start events where content_block.type === 'tool_use' to capture the tool id and name
- Accumulate
input_json_delta chunks to build the full tool input
- On
content_block_stop, yield a ChatYield with type: 'tool_use'
OpenAI (packages/core/src/providers/openai.ts)
OpenAI's streaming chunks include delta.tool_calls when the model wants to call a function. The current code at line 36-41 only reads delta.content:
// Current (lines 36-41) — only handles content
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) yield delta;
}
Update to also detect chunk.choices[0]?.delta?.tool_calls and accumulate the function name + arguments across chunks.
Gemini (packages/core/src/providers/gemini.ts)
Gemini returns functionCall parts in chunk.candidates[0].content.parts. The current code at line 43-47 only reads chunk.text():
Update to check for part.functionCall in the response parts.
Phase 3: Tool Execution Loop in Server Handler
File: packages/server/src/handler.ts
The handler at lines 96-110 currently streams provider output directly to the client. Add a tool execution loop:
// Pseudocode for the updated handler loop
const toolMap = new Map(config.tools?.map(t => [t.name, t]) ?? []);
let continueLoop = true;
while (continueLoop) {
continueLoop = false;
for await (const chunk of provider.chat({ messages, systemPrompt, tools: toolDefs })) {
if (chunk.type === 'text' && chunk.text) {
fullResponse += chunk.text;
yield JSON.stringify({ content: chunk.text });
}
if (chunk.type === 'tool_use' && chunk.toolUse) {
const tool = toolMap.get(chunk.toolUse.name);
if (tool) {
const result = await tool.execute(chunk.toolUse.input);
// Add tool-use + tool-result messages to conversation
// This lets the provider see what happened
messages.push(/* assistant tool_use message */);
messages.push(/* tool result message */);
// Signal tool execution to widget
if (chunk.toolUse.name === 'capture_lead' && result.success) {
yield JSON.stringify({ leadCaptured: true, leadData: result.data });
}
continueLoop = true; // Re-call provider with tool result
}
}
}
}
Key considerations:
- Add a max tool-call iterations guard (e.g., 5) to prevent infinite loops
- The conversation messages array needs to include tool-use and tool-result messages in the format each provider expects
ChatMessage type in packages/core/src/types.ts may need a toolUse or toolResult field
Phase 4: Wire Up Webhook Dispatch
File: packages/server/src/handler.ts
The WebhookDispatcher is instantiated at line 18 but webhooks.dispatch() is never called. After a tool executes successfully, dispatch the event:
if (chunk.toolUse.name === 'capture_lead' && result.success) {
webhooks?.dispatch('lead:captured', { lead: result.data, conversationId: req.conversationId });
}
Phase 5: Update Widget to Handle Tool Events
File: packages/widget/src/widget.ts
The widget's handleSend() method at line 253-284 already checks for chunk.leadCaptured implicitly via the WidgetChatChunk type (packages/widget/src/api/types.ts:17), but doesn't act on it. Add handling:
if (chunk.leadCaptured) {
this.config.onLeadCaptured?.(chunk.leadData);
this.emit('leadCaptured', chunk.leadData);
}
Add onLeadCaptured to WidgetConfig interface.
Files to Modify
| File |
Change |
packages/core/src/providers/base.ts |
New ChatYield type, update AIProvider interface |
packages/core/src/providers/claude.ts |
Handle tool_use content blocks in streaming |
packages/core/src/providers/openai.ts |
Handle tool_calls in streaming deltas |
packages/core/src/providers/gemini.ts |
Handle functionCall parts in streaming |
packages/core/src/types.ts |
Extend ChatMessage for tool-use/tool-result roles |
packages/server/src/handler.ts |
Add tool execution loop, webhook dispatch |
packages/widget/src/api/types.ts |
Add leadData to WidgetChatChunk |
packages/widget/src/widget.ts |
Handle leadCaptured event, add config callback |
Tests to Add
- Unit: Each provider correctly yields
ChatYield with type: 'tool_use' when mock API returns tool-use response
- Unit: Handler executes tool and re-calls provider with result
- Unit: Handler respects max iterations guard
- Unit: Widget emits
leadCaptured event when chunk contains leadCaptured: true
- Integration: Full flow — user message → provider requests tool → tool executes → provider responds with text
Acceptance Criteria
Summary
Tools (e.g.,
LeadCaptureTool) are defined and passed to AI providers, but tool-use responses from the providers are never detected or executed. The providers currently only yield text deltas — when the AI decides to call a tool, thetool_use/function_callresponse blocks are silently ignored. This means lead capture (a headline feature) does not actually work.This issue covers wiring up the full tool-calling loop: detect tool-use in provider responses → execute the tool → feed the result back to the provider → stream the final text response to the client.
Current Behavior
toolsto the provider viaProviderChatParams.tools(packages/server/src/handler.ts:101)toClaudeTools()inpackages/core/src/providers/base.ts:35-45toOpenAITools()inpackages/core/src/providers/base.ts:47-60toGeminiTools()inpackages/core/src/providers/base.ts:68-86text_deltacontent:packages/core/src/providers/claude.ts:33-37): only handlescontent_block_deltawithtext_deltatypepackages/core/src/providers/openai.ts:36-41): only readsdelta.contentpackages/core/src/providers/gemini.ts:43-47): only readschunk.text()capture_lead), the tool-use block is silently skipped — no tool is executed, no result sent backChatTool.execute()(packages/core/src/tools/base.ts:8) is never called from anywhere in the codebaseExpected Behavior
toolsarraytool.execute(input)with the parsed argumentsleadCaptured: true) via the SSE streamImplementation Guide
Phase 1: Update Provider Interface
File:
packages/core/src/providers/base.tsThe
AIProvider.chat()method currently returnsAsyncGenerator<string>— it can only yield text. Change the yield type to support both text and tool-use events:Phase 2: Update Each Provider to Detect Tool Use
Claude (
packages/core/src/providers/claude.ts)Claude's streaming API emits
content_block_startwithtype: 'tool_use'followed byinput_json_deltaevents. The current code at line 33-37 only handlestext_delta:Update to also handle tool_use blocks:
content_block_startevents wherecontent_block.type === 'tool_use'to capture the toolidandnameinput_json_deltachunks to build the full tool inputcontent_block_stop, yield aChatYieldwithtype: 'tool_use'OpenAI (
packages/core/src/providers/openai.ts)OpenAI's streaming chunks include
delta.tool_callswhen the model wants to call a function. The current code at line 36-41 only readsdelta.content:Update to also detect
chunk.choices[0]?.delta?.tool_callsand accumulate the function name + arguments across chunks.Gemini (
packages/core/src/providers/gemini.ts)Gemini returns
functionCallparts inchunk.candidates[0].content.parts. The current code at line 43-47 only readschunk.text():Update to check for
part.functionCallin the response parts.Phase 3: Tool Execution Loop in Server Handler
File:
packages/server/src/handler.tsThe handler at lines 96-110 currently streams provider output directly to the client. Add a tool execution loop:
Key considerations:
ChatMessagetype inpackages/core/src/types.tsmay need atoolUseortoolResultfieldPhase 4: Wire Up Webhook Dispatch
File:
packages/server/src/handler.tsThe
WebhookDispatcheris instantiated at line 18 butwebhooks.dispatch()is never called. After a tool executes successfully, dispatch the event:Phase 5: Update Widget to Handle Tool Events
File:
packages/widget/src/widget.tsThe widget's
handleSend()method at line 253-284 already checks forchunk.leadCapturedimplicitly via theWidgetChatChunktype (packages/widget/src/api/types.ts:17), but doesn't act on it. Add handling:Add
onLeadCapturedtoWidgetConfiginterface.Files to Modify
packages/core/src/providers/base.tsChatYieldtype, updateAIProviderinterfacepackages/core/src/providers/claude.tstool_usecontent blocks in streamingpackages/core/src/providers/openai.tstool_callsin streaming deltaspackages/core/src/providers/gemini.tsfunctionCallparts in streamingpackages/core/src/types.tsChatMessagefor tool-use/tool-result rolespackages/server/src/handler.tspackages/widget/src/api/types.tsleadDatatoWidgetChatChunkpackages/widget/src/widget.tsleadCapturedevent, add config callbackTests to Add
ChatYieldwithtype: 'tool_use'when mock API returns tool-use responseleadCapturedevent when chunk containsleadCaptured: trueAcceptance Criteria
capture_lead, the tool actually executes and the callback firesleadCapturedevent with lead data