llm and everything: Update ToolResult to use []Content instead of string for multimodal support

This was a journey. The sketch-generated summary below is acceptable,
but I want to tell you about it in my voice too. The goal was to send
screenshots to Claude, so that it could... look at them. Currently
the take screenshot and read screenshot tools are different, and they'll
need to be renamed/prompt-engineered a bit, but that's all fine.

The miserable part was that we had to change the return value
of tool from string to Content[], and this crosses several layers:
 - llm.Tool
 - llm.Content
 - ant.Content & openai and gemini friends
 - AgentMessage [we left this alone]

Extra fun is that Claude's API for sending images has nested Content
fields, and empty string and missing needs to be distinguished for the
Text field (because lots of shell commands return the empty string!).

For the UI, I made us transform the results into a string, dropping
images. This would have been yet more churn for not much obvious
benefit. Plus, it was going to break skaband's compatibility, and ...
yet more work.

OpenAI and Gemini don't obviously support images in this same way,
so they just don't get the tools.

~~~~~~~~~~ Sketch said:

This architectural change transforms tool results from plain strings to []Content arrays, enabling multimodal interaction in the system. Key changes include:

- Core structural changes:
  - Modified ToolResult type from string to []Content across all packages
  - Added MediaType field to Content struct for MIME type support
  - Created TextContent and ImageContent helper functions
  - Updated all tool.Run implementations to return []Content

- Image handling:
  - Implemented base64 image support in Anthropic adapter
  - Added proper media type detection and content formatting
  - Created browser_read_image tool for displaying screenshots
  - Updated browser_screenshot to provide usable image paths

- Adapter improvements:
  - Updated all LLM adapters (ANT, OAI, GEM) to handle content arrays
  - Added specialized image content handling in the Anthropic adapter
  - Ensured proper JSON serialization/deserialization for all content types
  - Improved test coverage for content arrays

- UI enhancements:
  - Added omitempty tags to reduce JSON response size
  - Updated TypeScript types to handle array content
  - Made field naming consistent (tool_error vs is_error)
  - Preserved backward compatibility for existing consumers

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s1a2b3c4d5e6f7g8h
diff --git a/claudetool/bash.go b/claudetool/bash.go
index 4684d76..7dec267 100644
--- a/claudetool/bash.go
+++ b/claudetool/bash.go
@@ -102,22 +102,22 @@
 	}
 }
 
-func (b *BashTool) Run(ctx context.Context, m json.RawMessage) (string, error) {
+func (b *BashTool) Run(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	var req bashInput
 	if err := json.Unmarshal(m, &req); err != nil {
-		return "", fmt.Errorf("failed to unmarshal bash command input: %w", err)
+		return nil, fmt.Errorf("failed to unmarshal bash command input: %w", err)
 	}
 
 	// do a quick permissions check (NOT a security barrier)
 	err := bashkit.Check(req.Command)
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 
 	// Custom permission callback if set
 	if b.CheckPermission != nil {
 		if err := b.CheckPermission(req.Command); err != nil {
-			return "", err
+			return nil, err
 		}
 	}
 
@@ -125,23 +125,23 @@
 	if req.Background {
 		result, err := executeBackgroundBash(ctx, req)
 		if err != nil {
-			return "", err
+			return nil, err
 		}
 		// Marshal the result to JSON
 		// TODO: emit XML(-ish) instead?
 		output, err := json.Marshal(result)
 		if err != nil {
-			return "", fmt.Errorf("failed to marshal background result: %w", err)
+			return nil, fmt.Errorf("failed to marshal background result: %w", err)
 		}
-		return string(output), nil
+		return llm.TextContent(string(output)), nil
 	}
 
 	// For foreground commands, use executeBash
 	out, execErr := executeBash(ctx, req)
-	if execErr == nil {
-		return out, nil
+	if execErr != nil {
+		return nil, execErr
 	}
-	return "", execErr
+	return llm.TextContent(out), nil
 }
 
 const maxBashOutputLength = 131072
@@ -300,7 +300,7 @@
 }
 
 // BashRun is the legacy function for testing compatibility
-func BashRun(ctx context.Context, m json.RawMessage) (string, error) {
+func BashRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	// Use the default Bash tool which has no permission callback
 	return Bash.Run(ctx, m)
 }
diff --git a/claudetool/bash_test.go b/claudetool/bash_test.go
index 3865383..5091e84 100644
--- a/claudetool/bash_test.go
+++ b/claudetool/bash_test.go
@@ -22,8 +22,9 @@
 		}
 
 		expected := "Hello, world!\n"
-		if result != expected {
-			t.Errorf("Expected %q, got %q", expected, result)
+		resultStr := ContentToString(result)
+		if resultStr != expected {
+			t.Errorf("Expected %q, got %q", expected, resultStr)
 		}
 	})
 
@@ -37,8 +38,9 @@
 		}
 
 		expected := "foobar"
-		if result != expected {
-			t.Errorf("Expected %q, got %q", expected, result)
+		resultStr := ContentToString(result)
+		if resultStr != expected {
+			t.Errorf("Expected %q, got %q", expected, resultStr)
 		}
 	})
 
@@ -62,8 +64,9 @@
 		}
 
 		expected := "Completed\n"
-		if result != expected {
-			t.Errorf("Expected %q, got %q", expected, result)
+		resultStr := ContentToString(result)
+		if resultStr != expected {
+			t.Errorf("Expected %q, got %q", expected, resultStr)
 		}
 	})
 
@@ -228,7 +231,8 @@
 
 		// Parse the returned JSON
 		var bgResult BackgroundResult
-		if err := json.Unmarshal([]byte(result), &bgResult); err != nil {
+		resultStr := ContentToString(result)
+		if err := json.Unmarshal([]byte(resultStr), &bgResult); err != nil {
 			t.Fatalf("Failed to unmarshal background result: %v", err)
 		}
 
@@ -285,7 +289,8 @@
 
 		// Parse the returned JSON
 		var bgResult BackgroundResult
-		if err := json.Unmarshal([]byte(result), &bgResult); err != nil {
+		resultStr := ContentToString(result)
+		if err := json.Unmarshal([]byte(resultStr), &bgResult); err != nil {
 			t.Fatalf("Failed to unmarshal background result: %v", err)
 		}
 
@@ -342,7 +347,8 @@
 
 		// Parse the returned JSON
 		var bgResult BackgroundResult
-		if err := json.Unmarshal([]byte(result), &bgResult); err != nil {
+		resultStr := ContentToString(result)
+		if err := json.Unmarshal([]byte(resultStr), &bgResult); err != nil {
 			t.Fatalf("Failed to unmarshal background result: %v", err)
 		}
 
diff --git a/claudetool/browse/browse.go b/claudetool/browse/browse.go
index 52248b8..60e66ac 100644
--- a/claudetool/browse/browse.go
+++ b/claudetool/browse/browse.go
@@ -3,11 +3,14 @@
 
 import (
 	"context"
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"log"
+	"net/http"
 	"os"
 	"path/filepath"
+	"strings"
 	"sync"
 	"time"
 
@@ -143,15 +146,15 @@
 	}
 }
 
-func (b *BrowseTools) navigateRun(ctx context.Context, m json.RawMessage) (string, error) {
+func (b *BrowseTools) navigateRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	var input navigateInput
 	if err := json.Unmarshal(m, &input); err != nil {
-		return errorResponse(fmt.Errorf("invalid input: %w", err)), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("invalid input: %w", err))), nil
 	}
 
 	browserCtx, err := b.GetBrowserContext()
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	err = chromedp.Run(browserCtx,
@@ -159,10 +162,10 @@
 		chromedp.WaitReady("body"),
 	)
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
-	return successResponse(), nil
+	return llm.TextContent(successResponse()), nil
 }
 
 // ClickTool definition
@@ -194,15 +197,15 @@
 	}
 }
 
-func (b *BrowseTools) clickRun(ctx context.Context, m json.RawMessage) (string, error) {
+func (b *BrowseTools) clickRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	var input clickInput
 	if err := json.Unmarshal(m, &input); err != nil {
-		return errorResponse(fmt.Errorf("invalid input: %w", err)), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("invalid input: %w", err))), nil
 	}
 
 	browserCtx, err := b.GetBrowserContext()
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	actions := []chromedp.Action{
@@ -217,10 +220,10 @@
 
 	err = chromedp.Run(browserCtx, actions...)
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
-	return successResponse(), nil
+	return llm.TextContent(successResponse()), nil
 }
 
 // TypeTool definition
@@ -257,15 +260,15 @@
 	}
 }
 
-func (b *BrowseTools) typeRun(ctx context.Context, m json.RawMessage) (string, error) {
+func (b *BrowseTools) typeRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	var input typeInput
 	if err := json.Unmarshal(m, &input); err != nil {
-		return errorResponse(fmt.Errorf("invalid input: %w", err)), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("invalid input: %w", err))), nil
 	}
 
 	browserCtx, err := b.GetBrowserContext()
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	actions := []chromedp.Action{
@@ -281,10 +284,10 @@
 
 	err = chromedp.Run(browserCtx, actions...)
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
-	return successResponse(), nil
+	return llm.TextContent(successResponse()), nil
 }
 
 // WaitForTool definition
@@ -316,10 +319,10 @@
 	}
 }
 
-func (b *BrowseTools) waitForRun(ctx context.Context, m json.RawMessage) (string, error) {
+func (b *BrowseTools) waitForRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	var input waitForInput
 	if err := json.Unmarshal(m, &input); err != nil {
-		return errorResponse(fmt.Errorf("invalid input: %w", err)), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("invalid input: %w", err))), nil
 	}
 
 	timeout := 30000 // default timeout 30 seconds
@@ -329,7 +332,7 @@
 
 	browserCtx, err := b.GetBrowserContext()
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	timeoutCtx, cancel := context.WithTimeout(browserCtx, time.Duration(timeout)*time.Millisecond)
@@ -337,10 +340,10 @@
 
 	err = chromedp.Run(timeoutCtx, chromedp.WaitReady(input.Selector))
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
-	return successResponse(), nil
+	return llm.TextContent(successResponse()), nil
 }
 
 // GetTextTool definition
@@ -371,15 +374,15 @@
 	}
 }
 
-func (b *BrowseTools) getTextRun(ctx context.Context, m json.RawMessage) (string, error) {
+func (b *BrowseTools) getTextRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	var input getTextInput
 	if err := json.Unmarshal(m, &input); err != nil {
-		return errorResponse(fmt.Errorf("invalid input: %w", err)), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("invalid input: %w", err))), nil
 	}
 
 	browserCtx, err := b.GetBrowserContext()
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	var text string
@@ -388,16 +391,16 @@
 		chromedp.Text(input.Selector, &text),
 	)
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	output := getTextOutput{Text: text}
 	result, err := json.Marshal(output)
 	if err != nil {
-		return errorResponse(fmt.Errorf("failed to marshal response: %w", err)), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("failed to marshal response: %w", err))), nil
 	}
 
-	return string(result), nil
+	return llm.TextContent(string(result)), nil
 }
 
 // EvalTool definition
@@ -428,30 +431,30 @@
 	}
 }
 
-func (b *BrowseTools) evalRun(ctx context.Context, m json.RawMessage) (string, error) {
+func (b *BrowseTools) evalRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	var input evalInput
 	if err := json.Unmarshal(m, &input); err != nil {
-		return errorResponse(fmt.Errorf("invalid input: %w", err)), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("invalid input: %w", err))), nil
 	}
 
 	browserCtx, err := b.GetBrowserContext()
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	var result any
 	err = chromedp.Run(browserCtx, chromedp.Evaluate(input.Expression, &result))
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	output := evalOutput{Result: result}
 	response, err := json.Marshal(output)
 	if err != nil {
-		return errorResponse(fmt.Errorf("failed to marshal response: %w", err)), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("failed to marshal response: %w", err))), nil
 	}
 
-	return string(response), nil
+	return llm.TextContent(string(response)), nil
 }
 
 // ScreenshotTool definition
@@ -487,15 +490,15 @@
 	}
 }
 
-func (b *BrowseTools) screenshotRun(ctx context.Context, m json.RawMessage) (string, error) {
+func (b *BrowseTools) screenshotRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	var input screenshotInput
 	if err := json.Unmarshal(m, &input); err != nil {
-		return errorResponse(fmt.Errorf("invalid input: %w", err)), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("invalid input: %w", err))), nil
 	}
 
 	browserCtx, err := b.GetBrowserContext()
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	var buf []byte
@@ -514,23 +517,26 @@
 
 	err = chromedp.Run(browserCtx, actions...)
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	// Save the screenshot and get its ID
 	id := b.SaveScreenshot(buf)
 	if id == "" {
-		return errorResponse(fmt.Errorf("failed to save screenshot")), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("failed to save screenshot"))), nil
 	}
 
-	// Return the ID in the response
-	output := screenshotOutput{ID: id}
-	response, err := json.Marshal(output)
-	if err != nil {
-		return errorResponse(fmt.Errorf("failed to marshal response: %w", err)), nil
-	}
+	// Get the full path to the screenshot
+	screenshotPath := GetScreenshotPath(id)
 
-	return string(response), nil
+	// Return the ID and instructions on how to view the screenshot
+	result := fmt.Sprintf(`{
+  "id": "%s",
+  "path": "%s",
+  "message": "Screenshot saved. To view this screenshot in the conversation, use the read_image tool with the path provided."
+}`, id, screenshotPath)
+
+	return llm.TextContent(result), nil
 }
 
 // ScrollIntoViewTool definition
@@ -557,15 +563,15 @@
 	}
 }
 
-func (b *BrowseTools) scrollIntoViewRun(ctx context.Context, m json.RawMessage) (string, error) {
+func (b *BrowseTools) scrollIntoViewRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	var input scrollIntoViewInput
 	if err := json.Unmarshal(m, &input); err != nil {
-		return errorResponse(fmt.Errorf("invalid input: %w", err)), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("invalid input: %w", err))), nil
 	}
 
 	browserCtx, err := b.GetBrowserContext()
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	script := fmt.Sprintf(`
@@ -583,28 +589,35 @@
 		chromedp.Evaluate(script, &result),
 	)
 	if err != nil {
-		return errorResponse(err), nil
+		return llm.TextContent(errorResponse(err)), nil
 	}
 
 	if !result {
-		return errorResponse(fmt.Errorf("element not found: %s", input.Selector)), nil
+		return llm.TextContent(errorResponse(fmt.Errorf("element not found: %s", input.Selector))), nil
 	}
 
-	return successResponse(), nil
+	return llm.TextContent(successResponse()), nil
 }
 
-// GetAllTools returns all browser tools
-func (b *BrowseTools) GetAllTools() []*llm.Tool {
-	return []*llm.Tool{
+// GetTools returns browser tools, optionally filtering out screenshot-related tools
+func (b *BrowseTools) GetTools(includeScreenshotTools bool) []*llm.Tool {
+	tools := []*llm.Tool{
 		b.NewNavigateTool(),
 		b.NewClickTool(),
 		b.NewTypeTool(),
 		b.NewWaitForTool(),
 		b.NewGetTextTool(),
 		b.NewEvalTool(),
-		b.NewScreenshotTool(),
 		b.NewScrollIntoViewTool(),
 	}
+
+	// Add screenshot-related tools if supported
+	if includeScreenshotTools {
+		tools = append(tools, b.NewScreenshotTool())
+		tools = append(tools, b.NewReadImageTool())
+	}
+
+	return tools
 }
 
 // SaveScreenshot saves a screenshot to disk and returns its ID
@@ -631,3 +644,67 @@
 func GetScreenshotPath(id string) string {
 	return filepath.Join(ScreenshotDir, id+".png")
 }
+
+// ReadImageTool definition
+type readImageInput struct {
+	Path string `json:"path"`
+}
+
+// NewReadImageTool creates a tool for reading images and returning them as base64 encoded data
+func (b *BrowseTools) NewReadImageTool() *llm.Tool {
+	return &llm.Tool{
+		Name:        "browser_read_image",
+		Description: "Read an image file (such as a screenshot) and encode it for sending to the LLM",
+		InputSchema: json.RawMessage(`{
+			"type": "object",
+			"properties": {
+				"path": {
+					"type": "string",
+					"description": "Path to the image file to read"
+				}
+			},
+			"required": ["path"]
+		}`),
+		Run: b.readImageRun,
+	}
+}
+
+func (b *BrowseTools) readImageRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+	var input readImageInput
+	if err := json.Unmarshal(m, &input); err != nil {
+		return llm.TextContent(errorResponse(fmt.Errorf("invalid input: %w", err))), nil
+	}
+
+	// Check if the path exists
+	if _, err := os.Stat(input.Path); os.IsNotExist(err) {
+		return llm.TextContent(errorResponse(fmt.Errorf("image file not found: %s", input.Path))), nil
+	}
+
+	// Read the file
+	imageData, err := os.ReadFile(input.Path)
+	if err != nil {
+		return llm.TextContent(errorResponse(fmt.Errorf("failed to read image file: %w", err))), nil
+	}
+
+	// Detect the image type
+	imageType := http.DetectContentType(imageData)
+	if !strings.HasPrefix(imageType, "image/") {
+		return llm.TextContent(errorResponse(fmt.Errorf("file is not an image: %s", imageType))), nil
+	}
+
+	// Encode the image as base64
+	base64Data := base64.StdEncoding.EncodeToString(imageData)
+
+	// Create a Content object that includes both text and the image
+	return []llm.Content{
+		{
+			Type: llm.ContentTypeText,
+			Text: fmt.Sprintf("Image from %s (type: %s)", input.Path, imageType),
+		},
+		{
+			Type:      llm.ContentTypeText, // Will be mapped to image in content array
+			MediaType: imageType,
+			Data:      base64Data,
+		},
+	}, nil
+}
diff --git a/claudetool/browse/browse_test.go b/claudetool/browse/browse_test.go
index b3168da..f1360d8 100644
--- a/claudetool/browse/browse_test.go
+++ b/claudetool/browse/browse_test.go
@@ -3,7 +3,9 @@
 import (
 	"context"
 	"encoding/json"
+	"fmt"
 	"os"
+	"path/filepath"
 	"slices"
 	"strings"
 	"testing"
@@ -63,24 +65,32 @@
 	}
 }
 
-func TestGetAllTools(t *testing.T) {
+func TestGetTools(t *testing.T) {
 	// Create browser tools instance
 	tools := NewBrowseTools(context.Background())
 
-	// Get all tools
-	allTools := tools.GetAllTools()
-
-	// We should have 8 tools
-	if len(allTools) != 8 {
-		t.Errorf("expected 8 tools, got %d", len(allTools))
-	}
-
-	// Check that each tool has the expected name prefix
-	for _, tool := range allTools {
-		if !strings.HasPrefix(tool.Name, "browser_") {
-			t.Errorf("tool name %q does not have prefix 'browser_'", tool.Name)
+	// Test with screenshot tools included
+	t.Run("with screenshots", func(t *testing.T) {
+		toolsWithScreenshots := tools.GetTools(true)
+		if len(toolsWithScreenshots) != 9 {
+			t.Errorf("expected 9 tools with screenshots, got %d", len(toolsWithScreenshots))
 		}
-	}
+
+		// Check tool naming convention
+		for _, tool := range toolsWithScreenshots {
+			if !strings.HasPrefix(tool.Name, "browser_") {
+				t.Errorf("tool name %q does not have prefix 'browser_'", tool.Name)
+			}
+		}
+	})
+
+	// Test without screenshot tools
+	t.Run("without screenshots", func(t *testing.T) {
+		noScreenshotTools := tools.GetTools(false)
+		if len(noScreenshotTools) != 7 {
+			t.Errorf("expected 7 tools without screenshots, got %d", len(noScreenshotTools))
+		}
+	})
 }
 
 // TestBrowserInitialization verifies that the browser can start correctly
@@ -169,7 +179,8 @@
 		Error  string `json:"error,omitempty"`
 	}
 
-	if err := json.Unmarshal([]byte(result), &response); err != nil {
+	resultText := result[0].Text
+	if err := json.Unmarshal([]byte(resultText), &response); err != nil {
 		t.Fatalf("Error unmarshaling response: %v", err)
 	}
 
@@ -239,3 +250,57 @@
 	// Clean up the test file
 	os.Remove(filePath)
 }
+
+func TestReadImageTool(t *testing.T) {
+	// Create a test BrowseTools instance
+	ctx := context.Background()
+	browseTools := NewBrowseTools(ctx)
+
+	// Create a test image
+	testDir := t.TempDir()
+	testImagePath := filepath.Join(testDir, "test_image.png")
+
+	// Create a small 1x1 black PNG image
+	smallPng := []byte{
+		0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
+		0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
+		0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0x60, 0x00, 0x00, 0x00,
+		0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
+		0x42, 0x60, 0x82,
+	}
+
+	// Write the test image
+	err := os.WriteFile(testImagePath, smallPng, 0o644)
+	if err != nil {
+		t.Fatalf("Failed to create test image: %v", err)
+	}
+
+	// Create the tool
+	readImageTool := browseTools.NewReadImageTool()
+
+	// Prepare input
+	input := fmt.Sprintf(`{"path": "%s"}`, testImagePath)
+
+	// Run the tool
+	result, err := readImageTool.Run(ctx, json.RawMessage(input))
+	if err != nil {
+		t.Fatalf("Read image tool failed: %v", err)
+	}
+
+	// In the updated code, result is already a []llm.Content
+	contents := result
+
+	// Check that we got at least two content objects
+	if len(contents) < 2 {
+		t.Fatalf("Expected at least 2 content objects, got %d", len(contents))
+	}
+
+	// Check that the second content has image data
+	if contents[1].MediaType == "" {
+		t.Errorf("Expected MediaType in second content")
+	}
+
+	if contents[1].Data == "" {
+		t.Errorf("Expected Data in second content")
+	}
+}
diff --git a/claudetool/browse/register.go b/claudetool/browse/register.go
index a540c8f..183bf14 100644
--- a/claudetool/browse/register.go
+++ b/claudetool/browse/register.go
@@ -10,7 +10,7 @@
 // RegisterBrowserTools initializes the browser tools and returns all the tools
 // ready to be added to an agent. It also returns a cleanup function that should
 // be called when done to properly close the browser.
-func RegisterBrowserTools(ctx context.Context) ([]*llm.Tool, func()) {
+func RegisterBrowserTools(ctx context.Context, supportsScreenshots bool) ([]*llm.Tool, func()) {
 	browserTools := NewBrowseTools(ctx)
 
 	// Initialize the browser
@@ -18,8 +18,7 @@
 		log.Printf("Warning: Failed to initialize browser: %v", err)
 	}
 
-	// Return all tools and a cleanup function
-	return browserTools.GetAllTools(), func() {
+	return browserTools.GetTools(supportsScreenshots), func() {
 		browserTools.Close()
 	}
 }
diff --git a/claudetool/codereview/codereview_test.go b/claudetool/codereview/codereview_test.go
index aa7b8f1..4872168 100644
--- a/claudetool/codereview/codereview_test.go
+++ b/claudetool/codereview/codereview_test.go
@@ -246,7 +246,11 @@
 	}
 
 	// Normalize paths in the result
-	normalized := normalizePaths(result, dir)
+	resultStr := ""
+	if len(result) > 0 {
+		resultStr = result[0].Text
+	}
+	normalized := normalizePaths(resultStr, dir)
 	return normalized, nil
 }
 
diff --git a/claudetool/codereview/differential.go b/claudetool/codereview/differential.go
index f358594..37728d4 100644
--- a/claudetool/codereview/differential.go
+++ b/claudetool/codereview/differential.go
@@ -37,27 +37,27 @@
 	return spec
 }
 
-func (r *CodeReviewer) Run(ctx context.Context, m json.RawMessage) (string, error) {
+func (r *CodeReviewer) Run(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	// NOTE: If you add or modify error messages here, update the corresponding UI parsing in:
 	// webui/src/web-components/sketch-tool-card.ts (SketchToolCardCodeReview.getStatusIcon)
 	if err := r.RequireNormalGitState(ctx); err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for normal git state", "err", err)
-		return "", err
+		return nil, err
 	}
 	if err := r.RequireNoUncommittedChanges(ctx); err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for uncommitted changes", "err", err)
-		return "", err
+		return nil, err
 	}
 
 	// Check that the current commit is not the initial commit
 	currentCommit, err := r.CurrentCommit(ctx)
 	if err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to get current commit", "err", err)
-		return "", err
+		return nil, err
 	}
 	if r.IsInitialCommit(currentCommit) {
 		slog.DebugContext(ctx, "CodeReviewer.Run: current commit is initial commit, nothing to review")
-		return "", fmt.Errorf("no new commits have been added, nothing to review")
+		return nil, fmt.Errorf("no new commits have been added, nothing to review")
 	}
 
 	// No matter what failures happen from here out, we will declare this to have been reviewed.
@@ -67,7 +67,7 @@
 	changedFiles, err := r.changedFiles(ctx, r.initialCommit, currentCommit)
 	if err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to get changed files", "err", err)
-		return "", err
+		return nil, err
 	}
 
 	// Prepare to analyze before/after for the impacted files.
@@ -79,7 +79,7 @@
 	if err != nil {
 		// TODO: log and skip to stuff that doesn't require packages
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to get packages for files", "err", err)
-		return "", err
+		return nil, err
 	}
 	allPkgList := slices.Collect(maps.Keys(allPkgs))
 
@@ -101,7 +101,7 @@
 	testMsg, err := r.checkTests(ctx, allPkgList)
 	if err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to check tests", "err", err)
-		return "", err
+		return nil, err
 	}
 	if testMsg != "" {
 		errorMessages = append(errorMessages, testMsg)
@@ -110,7 +110,7 @@
 	goplsMsg, err := r.checkGopls(ctx, changedFiles) // includes vet checks
 	if err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to check gopls", "err", err)
-		return "", err
+		return nil, err
 	}
 	if goplsMsg != "" {
 		errorMessages = append(errorMessages, goplsMsg)
@@ -143,7 +143,7 @@
 	if buf.Len() == 0 {
 		buf.WriteString("OK")
 	}
-	return buf.String(), nil
+	return llm.TextContent(buf.String()), nil
 }
 
 func (r *CodeReviewer) initializeInitialCommitWorktree(ctx context.Context) error {
diff --git a/claudetool/edit.go b/claudetool/edit.go
index 50084b7..b539cd6 100644
--- a/claudetool/edit.go
+++ b/claudetool/edit.go
@@ -67,55 +67,75 @@
 }
 
 // EditRun is the implementation of the edit tool
-func EditRun(ctx context.Context, input json.RawMessage) (string, error) {
+func EditRun(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
 	var editRequest editInput
 	if err := json.Unmarshal(input, &editRequest); err != nil {
-		return "", fmt.Errorf("failed to parse edit input: %v", err)
+		return nil, fmt.Errorf("failed to parse edit input: %v", err)
 	}
 
 	// Validate the command
 	cmd := editCommand(editRequest.Command)
 	if !isValidCommand(cmd) {
-		return "", fmt.Errorf("unrecognized command %s. The allowed commands are: view, create, str_replace, insert, undo_edit", cmd)
+		return nil, fmt.Errorf("unrecognized command %s. The allowed commands are: view, create, str_replace, insert, undo_edit", cmd)
 	}
 
 	path := editRequest.Path
 
 	// Validate the path
 	if err := validatePath(cmd, path); err != nil {
-		return "", err
+		return nil, err
 	}
 
 	// Execute the appropriate command
 	switch cmd {
 	case viewCommand:
-		return handleView(ctx, path, editRequest.ViewRange)
+		result, err := handleView(ctx, path, editRequest.ViewRange)
+		if err != nil {
+			return nil, err
+		}
+		return llm.TextContent(result), nil
 	case createCommand:
 		if editRequest.FileText == nil {
-			return "", fmt.Errorf("parameter file_text is required for command: create")
+			return nil, fmt.Errorf("parameter file_text is required for command: create")
 		}
-		return handleCreate(path, *editRequest.FileText)
+		result, err := handleCreate(path, *editRequest.FileText)
+		if err != nil {
+			return nil, err
+		}
+		return llm.TextContent(result), nil
 	case strReplaceCommand:
 		if editRequest.OldStr == nil {
-			return "", fmt.Errorf("parameter old_str is required for command: str_replace")
+			return nil, fmt.Errorf("parameter old_str is required for command: str_replace")
 		}
 		newStr := ""
 		if editRequest.NewStr != nil {
 			newStr = *editRequest.NewStr
 		}
-		return handleStrReplace(path, *editRequest.OldStr, newStr)
+		result, err := handleStrReplace(path, *editRequest.OldStr, newStr)
+		if err != nil {
+			return nil, err
+		}
+		return llm.TextContent(result), nil
 	case insertCommand:
 		if editRequest.InsertLine == nil {
-			return "", fmt.Errorf("parameter insert_line is required for command: insert")
+			return nil, fmt.Errorf("parameter insert_line is required for command: insert")
 		}
 		if editRequest.NewStr == nil {
-			return "", fmt.Errorf("parameter new_str is required for command: insert")
+			return nil, fmt.Errorf("parameter new_str is required for command: insert")
 		}
-		return handleInsert(path, *editRequest.InsertLine, *editRequest.NewStr)
+		result, err := handleInsert(path, *editRequest.InsertLine, *editRequest.NewStr)
+		if err != nil {
+			return nil, err
+		}
+		return llm.TextContent(result), nil
 	case undoEditCommand:
-		return handleUndoEdit(path)
+		result, err := handleUndoEdit(path)
+		if err != nil {
+			return nil, err
+		}
+		return llm.TextContent(result), nil
 	default:
-		return "", fmt.Errorf("command %s is not implemented", cmd)
+		return nil, fmt.Errorf("command %s is not implemented", cmd)
 	}
 }
 
diff --git a/claudetool/edit_test.go b/claudetool/edit_test.go
index fe3d66c..ab687fa 100644
--- a/claudetool/edit_test.go
+++ b/claudetool/edit_test.go
@@ -50,7 +50,7 @@
 		t.Fatalf("Tool execution failed: %v", err)
 	}
 
-	return result
+	return ContentToString(result)
 }
 
 // TestEditToolView tests the view command functionality
diff --git a/claudetool/keyword.go b/claudetool/keyword.go
index 048a236..27d9888 100644
--- a/claudetool/keyword.go
+++ b/claudetool/keyword.go
@@ -83,10 +83,10 @@
 	return strings.TrimSpace(string(out)), nil
 }
 
-func keywordRun(ctx context.Context, m json.RawMessage) (string, error) {
+func keywordRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	var input keywordInput
 	if err := json.Unmarshal(m, &input); err != nil {
-		return "", err
+		return nil, err
 	}
 	wd := WorkingDir(ctx)
 	root, err := FindRepoRoot(wd)
@@ -100,7 +100,7 @@
 	for _, term := range input.SearchTerms {
 		out, err := ripgrep(ctx, wd, []string{term})
 		if err != nil {
-			return "", err
+			return nil, err
 		}
 		if len(out) > 64*1024 {
 			slog.InfoContext(ctx, "keyword search result too large", "term", term, "bytes", len(out))
@@ -115,7 +115,7 @@
 		var err error
 		out, err = ripgrep(ctx, wd, keep)
 		if err != nil {
-			return "", err
+			return nil, err
 		}
 		if len(out) < 128*1024 {
 			break
@@ -139,10 +139,10 @@
 
 	resp, err := convo.SendMessage(initialMessage)
 	if err != nil {
-		return "", fmt.Errorf("failed to send relevance filtering message: %w", err)
+		return nil, fmt.Errorf("failed to send relevance filtering message: %w", err)
 	}
 	if len(resp.Content) != 1 {
-		return "", fmt.Errorf("unexpected number of messages in relevance filtering response: %d", len(resp.Content))
+		return nil, fmt.Errorf("unexpected number of messages in relevance filtering response: %d", len(resp.Content))
 	}
 
 	filtered := resp.Content[0].Text
@@ -155,7 +155,7 @@
 		"filtered", filtered,
 	)
 
-	return resp.Content[0].Text, nil
+	return llm.TextContent(resp.Content[0].Text), nil
 }
 
 func ripgrep(ctx context.Context, wd string, terms []string) (string, error) {
diff --git a/claudetool/patch.go b/claudetool/patch.go
index 419e966..24a22ef 100644
--- a/claudetool/patch.go
+++ b/claudetool/patch.go
@@ -94,18 +94,18 @@
 }
 
 // PatchRun is the entry point for the user_patch tool.
-func PatchRun(ctx context.Context, m json.RawMessage) (string, error) {
+func PatchRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
 	var input patchInput
 	if err := json.Unmarshal(m, &input); err != nil {
-		return "", fmt.Errorf("failed to unmarshal user_patch input: %w", err)
+		return nil, fmt.Errorf("failed to unmarshal user_patch input: %w", err)
 	}
 
 	// Validate the input
 	if !filepath.IsAbs(input.Path) {
-		return "", fmt.Errorf("path %q is not absolute", input.Path)
+		return nil, fmt.Errorf("path %q is not absolute", input.Path)
 	}
 	if len(input.Patches) == 0 {
-		return "", fmt.Errorf("no patches provided")
+		return nil, fmt.Errorf("no patches provided")
 	}
 	// TODO: check whether the file is autogenerated, and if so, require a "force" flag to modify it.
 
@@ -118,11 +118,11 @@
 			switch patch.Operation {
 			case "prepend_bof", "append_eof", "overwrite":
 			default:
-				return "", fmt.Errorf("file %q does not exist", input.Path)
+				return nil, fmt.Errorf("file %q does not exist", input.Path)
 			}
 		}
 	case err != nil:
-		return "", fmt.Errorf("failed to read file %q: %w", input.Path, err)
+		return nil, fmt.Errorf("failed to read file %q: %w", input.Path, err)
 	}
 
 	likelyGoFile := strings.HasSuffix(input.Path, ".go")
@@ -151,7 +151,7 @@
 			buf.Replace(0, len(orig), patch.NewText)
 		case "replace":
 			if patch.OldText == "" {
-				return "", fmt.Errorf("patch %d: oldText cannot be empty for %s operation", i, patch.Operation)
+				return nil, fmt.Errorf("patch %d: oldText cannot be empty for %s operation", i, patch.Operation)
 			}
 
 			// Attempt to apply the patch.
@@ -214,7 +214,7 @@
 			patchErr = errors.Join(patchErr, fmt.Errorf("old text not found:\n%s", patch.OldText))
 			continue
 		default:
-			return "", fmt.Errorf("unrecognized operation %q", patch.Operation)
+			return nil, fmt.Errorf("unrecognized operation %q", patch.Operation)
 		}
 	}
 
@@ -224,18 +224,18 @@
 			"patches": input.Patches,
 			"errors":  patchErr,
 		})
-		return "", patchErr
+		return nil, patchErr
 	}
 
 	patched, err := buf.Bytes()
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 	if err := os.MkdirAll(filepath.Dir(input.Path), 0o700); err != nil {
-		return "", fmt.Errorf("failed to create directory %q: %w", filepath.Dir(input.Path), err)
+		return nil, fmt.Errorf("failed to create directory %q: %w", filepath.Dir(input.Path), err)
 	}
 	if err := os.WriteFile(input.Path, patched, 0o600); err != nil {
-		return "", fmt.Errorf("failed to write patched contents to file %q: %w", input.Path, err)
+		return nil, fmt.Errorf("failed to write patched contents to file %q: %w", input.Path, err)
 	}
 
 	response := new(strings.Builder)
@@ -244,7 +244,7 @@
 	if parsed {
 		parseErr := parseGo(patched)
 		if parseErr != nil {
-			return "", fmt.Errorf("after applying all patches, the file no longer parses:\n%w", parseErr)
+			return nil, fmt.Errorf("after applying all patches, the file no longer parses:\n%w", parseErr)
 		}
 	}
 
@@ -253,7 +253,7 @@
 	}
 
 	// TODO: maybe report the patch result to the model, i.e. some/all of the new code after the patches and formatting.
-	return response.String(), nil
+	return llm.TextContent(response.String()), nil
 }
 
 func parseGo(buf []byte) error {
diff --git a/claudetool/think.go b/claudetool/think.go
index 69aac3c..9611150 100644
--- a/claudetool/think.go
+++ b/claudetool/think.go
@@ -34,6 +34,6 @@
 `
 )
 
-func thinkRun(ctx context.Context, m json.RawMessage) (string, error) {
-	return "recorded", nil
+func thinkRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+	return llm.TextContent("recorded"), nil
 }
diff --git a/claudetool/util.go b/claudetool/util.go
new file mode 100644
index 0000000..88136e4
--- /dev/null
+++ b/claudetool/util.go
@@ -0,0 +1,13 @@
+package claudetool
+
+import (
+	"sketch.dev/llm"
+)
+
+// ContentToString extracts text from []llm.Content if available
+func ContentToString(content []llm.Content) string {
+	if len(content) == 0 {
+		return ""
+	}
+	return content[0].Text
+}