Skip to content

Instantly share code, notes, and snippets.

@ben-vargas
Created August 23, 2025 02:14
Show Gist options
  • Select an option

  • Save ben-vargas/8f9c8c75e0b3d48ad5b5ddaabdb84219 to your computer and use it in GitHub Desktop.

Select an option

Save ben-vargas/8f9c8c75e0b3d48ad5b5ddaabdb84219 to your computer and use it in GitHub Desktop.

Tool Streaming Support for AI SDK Provider Claude Code

Issue Summary

GitHub Issue: #36 - Intermediate tool calls Requested by: @Evanfeenstra Date: August 22, 2025

The user is requesting support for intermediate tool calls in streamText and streamObject to enable building UIs that show tool calls and results as they happen, similar to other AI SDK providers.

Current State Analysis

What Currently Works

The provider successfully implements streaming for:

  • Text content (text-start, text-delta, text-end)
  • Response metadata and session information
  • Usage statistics and finish reasons
  • Warnings and error handling

What's Missing

Tool-related streaming events are not emitted, even though tools are being called behind the scenes. The user only sees:

  • start
  • start-step
  • text-start
  • text-delta
  • text-end

Missing events that should be emitted:

  • tool-input-start
  • tool-input-delta
  • tool-input-end
  • tool-call
  • tool-result

Technical Investigation

Claude Code SDK Capabilities

The @anthropic-ai/claude-code SDK (v1.0.81) provides real-time streaming of all messages including:

// When Claude decides to use a tool:
{
  type: 'assistant',
  message: {
    content: [
      {
        type: 'tool_use',
        id: 'toolu_xxx',
        name: 'ToolName',
        input: { /* tool arguments */ }
      }
    ]
  }
}

// When tool execution completes:
{
  type: 'user',
  message: {
    content: [
      {
        type: 'tool_result',
        tool_use_id: 'toolu_xxx',
        content: '/* tool output */',
        is_error: false
      }
    ]
  }
}

AI SDK v5 Requirements

The AI SDK expects these LanguageModelV2StreamPart types for tools:

type ToolStreamParts = 
  | { type: 'tool-input-start'; id: string; toolName: string; }
  | { type: 'tool-input-delta'; id: string; delta: string; }
  | { type: 'tool-input-end'; id: string; }
  | { type: 'tool-call'; toolCallId: string; toolName: string; input: string; }
  | { type: 'tool-result'; toolCallId: string; result: unknown; }

Critical Implementation Considerations

Provider-Executed Tools

IMPORTANT: Claude Code's tools (Bash, Read, Write, etc.) are executed by the Claude Code SDK itself, NOT by the AI SDK's tool runner. We MUST set providerExecuted: true on all tool-call events to prevent the AI SDK from attempting to re-execute these tools. This is a critical distinction from user-defined tools that would normally be executed by the AI SDK.

Implementation Plan

Phase 1: Core Tool Streaming Support

File: src/claude-code-language-model.ts

Step 1: Add Tool Detection Helper

// Add around line 190
private extractToolUses(content: any[]): Array<{id: string, name: string, input: any}> {
  return content
    .filter(c => c.type === 'tool_use')
    .map(c => ({ id: c.id, name: c.name, input: c.input }));
}

private extractToolResults(content: any[]): Array<{id: string, result: string, isError: boolean}> {
  return content
    .filter(c => c.type === 'tool_result')
    .map(c => ({ id: c.tool_use_id, result: c.content, isError: c.is_error }));
}

Step 2: Modify Stream Processing (lines 523-630)

for await (const message of response) {
  if (message.type === 'assistant') {
    // Extract text content
    const textContent = message.message.content
      .filter((c: any) => c.type === 'text')
      .map((c: any) => c.text)
      .join('');
    
    // Extract tool uses
    const toolUses = this.extractToolUses(message.message.content);
    
    // Emit tool streaming events
    for (const tool of toolUses) {
      const toolId = generateId();
      
      // Tool input start
      controller.enqueue({
        type: 'tool-input-start',
        id: toolId,
        toolName: tool.name,
      });
      
      // Tool input delta (send all at once since SDK doesn't stream incrementally)
      const inputString = JSON.stringify(tool.input);
      controller.enqueue({
        type: 'tool-input-delta',
        id: toolId,
        delta: inputString,
      });
      
      // Tool input end
      controller.enqueue({
        type: 'tool-input-end',
        id: toolId,
      });
      
      // Complete tool call
      controller.enqueue({
        type: 'tool-call',
        toolCallId: tool.id,
        toolName: tool.name,
        input: inputString,
        providerExecuted: true, // CRITICAL: Prevents AI SDK from re-executing Claude's internal tools
      });
    }
    
    // Handle text content (existing code)
    if (textContent) {
      // ... existing text streaming logic
    }
  } else if (message.type === 'user') {
    // Extract and emit tool results
    const toolResults = this.extractToolResults(message.message.content);
    
    for (const result of toolResults) {
      controller.enqueue({
        type: 'tool-result',
        toolCallId: result.id,
        result: result.result,
        providerMetadata: {
          'claude-code': {
            isError: result.isError
          }
        }
      });
    }
  }
  // ... rest of existing code
}

Phase 2: Testing & Validation

Test Script 1: Basic Tool Streaming

// examples/streaming-with-tools.ts
import { streamText } from 'ai';
import { claudeCode } from '../dist/index.js';

async function main() {
  const result = streamText({
    model: claudeCode('sonnet'),
    prompt: 'What files are in the current directory? List them with their sizes.',
  });

  for await (const part of result.fullStream) {
    switch(part.type) {
      case 'tool-input-start':
        console.log(`🔧 Starting tool: ${part.toolName}`);
        break;
      case 'tool-input-delta':
        console.log(`   Input: ${part.delta}`);
        break;
      case 'tool-call':
        console.log(`✅ Tool called: ${part.toolName} (${part.toolCallId})`);
        break;
      case 'tool-result':
        console.log(`📊 Tool result for ${part.toolCallId}:`, part.result?.substring(0, 100));
        break;
      case 'text-delta':
        process.stdout.write(part.delta);
        break;
    }
  }
}

Test Script 2: Multiple Tool Calls

// examples/streaming-multiple-tools.ts
async function testMultipleTools() {
  const result = streamText({
    model: claudeCode('sonnet'),
    prompt: 'Check the current directory, then read the package.json file, and summarize what this project does.',
  });

  const toolCalls = [];
  const toolResults = [];

  for await (const part of result.fullStream) {
    if (part.type === 'tool-call') {
      toolCalls.push({ name: part.toolName, id: part.toolCallId });
    }
    if (part.type === 'tool-result') {
      toolResults.push({ id: part.toolCallId });
    }
  }

  console.log('\nTool calls made:', toolCalls);
  console.log('Tool results received:', toolResults);
}

Phase 3: Edge Cases & Error Handling

  1. Handle tool errors: When is_error: true in tool_result
  2. Handle missing tool results: Timeout or interrupted streams
  3. Handle malformed tool inputs: Invalid JSON in tool use
  4. Concurrent tool calls: Multiple tools called in same message
  5. Prevent double execution: MUST set providerExecuted: true on all tool-call events to prevent AI SDK from attempting to re-execute Claude Code's internal tools (Bash, Read, Write, etc.)

Phase 4: Documentation

Update README.md with:

## Tool Streaming Support

The provider now supports streaming of intermediate tool calls, enabling real-time UI updates:

```typescript
const result = streamText({
  model: claudeCode('sonnet'),
  prompt: 'Analyze the current directory structure',
});

for await (const part of result.fullStream) {
  switch(part.type) {
    case 'tool-input-start':
      // Tool call initiated
      break;
    case 'tool-input-delta':
      // Tool input being streamed
      break;
    case 'tool-call':
      // Complete tool call with ID and input
      break;
    case 'tool-result':
      // Tool execution result
      break;
    // ... handle other stream parts
  }
}

Implementation Timeline

  1. Day 1: Implement core tool detection and streaming logic
  2. Day 2: Add comprehensive test coverage
  3. Day 3: Handle edge cases and error scenarios
  4. Day 4: Update documentation and examples
  5. Day 5: Final testing and PR submission

Backwards Compatibility

This implementation is fully backwards compatible:

  • Existing code continues to work unchanged
  • New tool streaming events are additive only
  • No breaking changes to existing APIs
  • Users can opt-in to tool streaming by using fullStream

Benefits

  1. Enhanced UI/UX: Applications can show tool calls as they happen
  2. Better Debugging: Developers can see exactly what tools are being called
  3. Feature Parity: Matches capabilities of other AI SDK providers
  4. Progressive Enhancement: Works with existing code, enhances when needed

Risks & Mitigations

Risk Mitigation
Performance impact from additional events Minimal - events are only emitted when tools are used
Breaking existing streams Additive changes only, no modifications to existing events
Incomplete tool results Add timeout handling and error states
Large tool inputs/outputs Consider chunking for very large payloads
AI SDK re-executing provider tools Set providerExecuted: true on all tool-call events
User confusion about tool source Document that these are Claude Code's internal tools

Testing Checklist

  • Single tool call streams correctly
  • Multiple sequential tool calls work
  • Concurrent tool calls handled properly
  • Tool errors are propagated correctly
  • Stream interruption handled gracefully
  • Large tool inputs/outputs work
  • Backwards compatibility maintained
  • Examples run successfully
  • Documentation is clear and complete

Next Steps

  1. Review this plan with maintainers
  2. Create feature branch feature/tool-streaming
  3. Implement changes incrementally with tests
  4. Open PR with comprehensive description
  5. Address feedback and iterate

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment