all: support openai-compatible models

The support is rather minimal at this point:
Only hard-coded models, only -unsafe, only -skabandaddr="".

The "shared" LLM package is strongly Claude-flavored.

We can fix all of this and more over time, if we are inspired to.
(Maybe we'll switch to https://github.com/maruel/genai?)

The goal for now is to get the rough structure in place.
I've rebased and rebuilt this more times than I care to remember.
diff --git a/loop/agent.go b/loop/agent.go
index 3076385..960bf5a 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -17,10 +17,11 @@
 	"sync"
 	"time"
 
-	"sketch.dev/ant"
 	"sketch.dev/browser"
 	"sketch.dev/claudetool"
 	"sketch.dev/claudetool/bashkit"
+	"sketch.dev/llm"
+	"sketch.dev/llm/conversation"
 )
 
 const (
@@ -64,8 +65,8 @@
 	// Returns the current number of messages in the history
 	MessageCount() int
 
-	TotalUsage() ant.CumulativeUsage
-	OriginalBudget() ant.Budget
+	TotalUsage() conversation.CumulativeUsage
+	OriginalBudget() conversation.Budget
 
 	WorkingDir() string
 
@@ -150,7 +151,7 @@
 	Timestamp            time.Time  `json:"timestamp"`
 	ConversationID       string     `json:"conversation_id"`
 	ParentConversationID *string    `json:"parent_conversation_id,omitempty"`
-	Usage                *ant.Usage `json:"usage,omitempty"`
+	Usage                *llm.Usage `json:"usage,omitempty"`
 
 	// Message timing information
 	StartTime *time.Time     `json:"start_time,omitempty"`
@@ -164,7 +165,7 @@
 }
 
 // SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
-func (m *AgentMessage) SetConvo(convo *ant.Convo) {
+func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
 	if convo == nil {
 		m.ConversationID = ""
 		m.ParentConversationID = nil
@@ -262,16 +263,16 @@
 
 // ConvoInterface defines the interface for conversation interactions
 type ConvoInterface interface {
-	CumulativeUsage() ant.CumulativeUsage
-	ResetBudget(ant.Budget)
+	CumulativeUsage() conversation.CumulativeUsage
+	ResetBudget(conversation.Budget)
 	OverBudget() error
-	SendMessage(message ant.Message) (*ant.MessageResponse, error)
-	SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
+	SendMessage(message llm.Message) (*llm.Response, error)
+	SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
 	GetID() string
-	ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
-	ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error)
+	ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
+	ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
 	CancelToolUse(toolUseID string, cause error) error
-	SubConvoWithHistory() *ant.Convo
+	SubConvoWithHistory() *conversation.Convo
 }
 
 type Agent struct {
@@ -287,7 +288,7 @@
 	outsideHTTP       string        // base address of the outside webserver (only when under docker)
 	ready             chan struct{} // closed when the agent is initialized (only when under docker)
 	startedAt         time.Time
-	originalBudget    ant.Budget
+	originalBudget    conversation.Budget
 	title             string
 	branchName        string
 	codereview        *claudetool.CodeReviewer
@@ -531,7 +532,7 @@
 }
 
 // OnToolCall implements ant.Listener and tracks the start of a tool call.
-func (a *Agent) OnToolCall(ctx context.Context, convo *ant.Convo, id string, toolName string, toolInput json.RawMessage, content ant.Content) {
+func (a *Agent) OnToolCall(ctx context.Context, convo *conversation.Convo, id string, toolName string, toolInput json.RawMessage, content llm.Content) {
 	// Track the tool call
 	a.mu.Lock()
 	a.outstandingToolCalls[id] = toolName
@@ -539,7 +540,7 @@
 }
 
 // OnToolResult implements ant.Listener.
-func (a *Agent) OnToolResult(ctx context.Context, convo *ant.Convo, toolID string, toolName string, toolInput json.RawMessage, content ant.Content, result *string, err error) {
+func (a *Agent) OnToolResult(ctx context.Context, convo *conversation.Convo, toolID string, toolName string, toolInput json.RawMessage, content llm.Content, result *string, err error) {
 	// Remove the tool call from outstanding calls
 	a.mu.Lock()
 	delete(a.outstandingToolCalls, toolID)
@@ -553,13 +554,13 @@
 		ToolName:   toolName,
 		ToolInput:  string(toolInput),
 		ToolCallId: content.ToolUseID,
-		StartTime:  content.StartTime,
-		EndTime:    content.EndTime,
+		StartTime:  content.ToolUseStartTime,
+		EndTime:    content.ToolUseEndTime,
 	}
 
 	// Calculate the elapsed time if both start and end times are set
-	if content.StartTime != nil && content.EndTime != nil {
-		elapsed := content.EndTime.Sub(*content.StartTime)
+	if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
+		elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
 		m.Elapsed = &elapsed
 	}
 
@@ -568,18 +569,18 @@
 }
 
 // OnRequest implements ant.Listener.
-func (a *Agent) OnRequest(ctx context.Context, convo *ant.Convo, id string, msg *ant.Message) {
+func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
 	a.mu.Lock()
 	defer a.mu.Unlock()
 	a.outstandingLLMCalls[id] = struct{}{}
 	// We already get tool results from the above. We send user messages to the outbox in the agent loop.
 }
 
-// OnResponse implements ant.Listener. Responses contain messages from the LLM
+// OnResponse implements conversation.Listener. Responses contain messages from the LLM
 // that need to be displayed (as well as tool calls that we send along when
 // they're done). (It would be reasonable to also mention tool calls when they're
 // started, but we don't do that yet.)
-func (a *Agent) OnResponse(ctx context.Context, convo *ant.Convo, id string, resp *ant.MessageResponse) {
+func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
 	// Remove the LLM call from outstanding calls
 	a.mu.Lock()
 	delete(a.outstandingLLMCalls, id)
@@ -597,7 +598,7 @@
 	}
 
 	endOfTurn := false
-	if resp.StopReason != ant.StopReasonToolUse && convo.Parent == nil {
+	if resp.StopReason != llm.StopReasonToolUse && convo.Parent == nil {
 		endOfTurn = true
 	}
 	m := AgentMessage{
@@ -610,10 +611,10 @@
 	}
 
 	// Extract any tool calls from the response
-	if resp.StopReason == ant.StopReasonToolUse {
+	if resp.StopReason == llm.StopReasonToolUse {
 		var toolCalls []ToolCall
 		for _, part := range resp.Content {
-			if part.Type == ant.ContentTypeToolUse {
+			if part.Type == llm.ContentTypeToolUse {
 				toolCalls = append(toolCalls, ToolCall{
 					Name:       part.ToolName,
 					Input:      string(part.ToolInput),
@@ -653,17 +654,15 @@
 	return slices.Clone(a.history[start:end])
 }
 
-func (a *Agent) OriginalBudget() ant.Budget {
+func (a *Agent) OriginalBudget() conversation.Budget {
 	return a.originalBudget
 }
 
 // AgentConfig contains configuration for creating a new Agent.
 type AgentConfig struct {
 	Context          context.Context
-	AntURL           string
-	APIKey           string
-	HTTPC            *http.Client
-	Budget           ant.Budget
+	Service          llm.Service
+	Budget           conversation.Budget
 	GitUsername      string
 	GitEmail         string
 	SessionID        string
@@ -778,15 +777,9 @@
 // initConvo initializes the conversation.
 // It must not be called until all agent fields are initialized,
 // particularly workingDir and git.
-func (a *Agent) initConvo() *ant.Convo {
+func (a *Agent) initConvo() *conversation.Convo {
 	ctx := a.config.Context
-	convo := ant.NewConvo(ctx, a.config.APIKey)
-	if a.config.HTTPC != nil {
-		convo.HTTPC = a.config.HTTPC
-	}
-	if a.config.AntURL != "" {
-		convo.URL = a.config.AntURL
-	}
+	convo := conversation.New(ctx, a.config.Service)
 	convo.PromptCaching = true
 	convo.Budget = a.config.Budget
 
@@ -832,7 +825,7 @@
 	// Register all tools with the conversation
 	// When adding, removing, or modifying tools here, double-check that the termui tool display
 	// template in termui/termui.go has pretty-printing support for all tools.
-	convo.Tools = []*ant.Tool{
+	convo.Tools = []*llm.Tool{
 		bashTool, claudetool.Keyword,
 		claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
 		a.codereview.Tool(),
@@ -863,8 +856,8 @@
 	return false
 }
 
-func (a *Agent) titleTool() *ant.Tool {
-	title := &ant.Tool{
+func (a *Agent) titleTool() *llm.Tool {
+	title := &llm.Tool{
 		Name:        "title",
 		Description: `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`,
 		InputSchema: json.RawMessage(`{
@@ -990,20 +983,20 @@
 	}
 }
 
-func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]ant.Content, error) {
-	var m []ant.Content
+func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
+	var m []llm.Content
 	if block {
 		select {
 		case <-ctx.Done():
 			return m, ctx.Err()
 		case msg := <-a.inbox:
-			m = append(m, ant.StringContent(msg))
+			m = append(m, llm.StringContent(msg))
 		}
 	}
 	for {
 		select {
 		case msg := <-a.inbox:
-			m = append(m, ant.StringContent(msg))
+			m = append(m, llm.StringContent(msg))
 		default:
 			return m, nil
 		}
@@ -1052,7 +1045,7 @@
 		}
 
 		// If the model is not requesting to use a tool, we're done
-		if resp.StopReason != ant.StopReasonToolUse {
+		if resp.StopReason != llm.StopReasonToolUse {
 			a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
 			break
 		}
@@ -1078,7 +1071,7 @@
 }
 
 // processUserMessage waits for user messages and sends them to the model
-func (a *Agent) processUserMessage(ctx context.Context) (*ant.MessageResponse, error) {
+func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
 	// Wait for at least one message from the user
 	msgs, err := a.GatherMessages(ctx, true)
 	if err != nil { // e.g. the context was canceled while blocking in GatherMessages
@@ -1086,8 +1079,8 @@
 		return nil, err
 	}
 
-	userMessage := ant.Message{
-		Role:    ant.MessageRoleUser,
+	userMessage := llm.Message{
+		Role:    llm.MessageRoleUser,
 		Content: msgs,
 	}
 
@@ -1109,8 +1102,8 @@
 }
 
 // handleToolExecution processes a tool use request from the model
-func (a *Agent) handleToolExecution(ctx context.Context, resp *ant.MessageResponse) (bool, *ant.MessageResponse) {
-	var results []ant.Content
+func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
+	var results []llm.Content
 	cancelled := false
 
 	// Transition to checking for cancellation state
@@ -1200,7 +1193,7 @@
 }
 
 // continueTurnWithToolResults continues the conversation with tool results
-func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []ant.Content, autoqualityMessages []string, cancelled bool) (bool, *ant.MessageResponse) {
+func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
 	// Get any messages the user sent while tools were executing
 	a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
 	msgs, err := a.GatherMessages(ctx, false)
@@ -1211,19 +1204,19 @@
 
 	// Inject any auto-generated messages from quality checks
 	for _, msg := range autoqualityMessages {
-		msgs = append(msgs, ant.StringContent(msg))
+		msgs = append(msgs, llm.StringContent(msg))
 	}
 
 	// Handle cancellation by appending a message about it
 	if cancelled {
-		msgs = append(msgs, ant.StringContent(cancelToolUseMessage))
+		msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
 		// EndOfTurn is false here so that the client of this agent keeps processing
 		// further messages; the conversation is not over.
 		a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
 	} else if err := a.convo.OverBudget(); err != nil {
 		// Handle budget issues by appending a message about it
 		budgetMsg := "We've exceeded our budget. Please ask the user to confirm before continuing by ending the turn."
-		msgs = append(msgs, ant.StringContent(budgetMsg))
+		msgs = append(msgs, llm.StringContent(budgetMsg))
 		a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
 	}
 
@@ -1232,8 +1225,8 @@
 
 	// Send the combined message to continue the conversation
 	a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
-	resp, err := a.convo.SendMessage(ant.Message{
-		Role:    ant.MessageRoleUser,
+	resp, err := a.convo.SendMessage(llm.Message{
+		Role:    llm.MessageRoleUser,
 		Content: results,
 	})
 	if err != nil {
@@ -1264,11 +1257,11 @@
 	return nil
 }
 
-func collectTextContent(msg *ant.MessageResponse) string {
+func collectTextContent(msg *llm.Response) string {
 	// Collect all text content
 	var allText strings.Builder
 	for _, content := range msg.Content {
-		if content.Type == ant.ContentTypeText && content.Text != "" {
+		if content.Type == llm.ContentTypeText && content.Text != "" {
 			if allText.Len() > 0 {
 				allText.WriteString("\n\n")
 			}
@@ -1278,7 +1271,7 @@
 	return allText.String()
 }
 
-func (a *Agent) TotalUsage() ant.CumulativeUsage {
+func (a *Agent) TotalUsage() conversation.CumulativeUsage {
 	a.mu.Lock()
 	defer a.mu.Unlock()
 	return a.convo.CumulativeUsage()
@@ -1604,7 +1597,7 @@
 
 	Reply with ONLY the reprompt text.
 	`
-	userMessage := ant.UserStringMessage(msg)
+	userMessage := llm.UserStringMessage(msg)
 	// By doing this in a subconversation, the agent doesn't call tools (because
 	// there aren't any), and there's not a concurrency risk with on-going other
 	// outstanding conversations.
diff --git a/loop/agent_test.go b/loop/agent_test.go
index 0924b39..56708e3 100644
--- a/loop/agent_test.go
+++ b/loop/agent_test.go
@@ -11,8 +11,10 @@
 	"testing"
 	"time"
 
-	"sketch.dev/ant"
 	"sketch.dev/httprr"
+	"sketch.dev/llm"
+	"sketch.dev/llm/ant"
+	"sketch.dev/llm/conversation"
 )
 
 // TestAgentLoop tests that the Agent loop functionality works correctly.
@@ -58,7 +60,7 @@
 	if err := os.Chdir("/"); err != nil {
 		t.Fatal(err)
 	}
-	budget := ant.Budget{MaxResponses: 100}
+	budget := conversation.Budget{MaxResponses: 100}
 	wd, err := os.Getwd()
 	if err != nil {
 		t.Fatal(err)
@@ -66,9 +68,11 @@
 
 	apiKey := cmp.Or(os.Getenv("OUTER_SKETCH_ANTHROPIC_API_KEY"), os.Getenv("ANTHROPIC_API_KEY"))
 	cfg := AgentConfig{
-		Context:      ctx,
-		APIKey:       apiKey,
-		HTTPC:        client,
+		Context: ctx,
+		Service: &ant.Service{
+			APIKey: apiKey,
+			HTTPC:  client,
+		},
 		Budget:       budget,
 		GitUsername:  "Test Agent",
 		GitEmail:     "totallyhuman@sketch.dev",
@@ -206,7 +210,7 @@
 func TestAgentProcessTurnWithNilResponse(t *testing.T) {
 	// Create a mock conversation that will return nil and error
 	mockConvo := &MockConvoInterface{
-		sendMessageFunc: func(message ant.Message) (*ant.MessageResponse, error) {
+		sendMessageFunc: func(message llm.Message) (*llm.Response, error) {
 			return nil, fmt.Errorf("test error: simulating nil response")
 		},
 	}
@@ -250,40 +254,40 @@
 
 // MockConvoInterface implements the ConvoInterface for testing
 type MockConvoInterface struct {
-	sendMessageFunc              func(message ant.Message) (*ant.MessageResponse, error)
-	sendUserTextMessageFunc      func(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
-	toolResultContentsFunc       func(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
-	toolResultCancelContentsFunc func(resp *ant.MessageResponse) ([]ant.Content, error)
+	sendMessageFunc              func(message llm.Message) (*llm.Response, error)
+	sendUserTextMessageFunc      func(s string, otherContents ...llm.Content) (*llm.Response, error)
+	toolResultContentsFunc       func(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
+	toolResultCancelContentsFunc func(resp *llm.Response) ([]llm.Content, error)
 	cancelToolUseFunc            func(toolUseID string, cause error) error
-	cumulativeUsageFunc          func() ant.CumulativeUsage
-	resetBudgetFunc              func(ant.Budget)
+	cumulativeUsageFunc          func() conversation.CumulativeUsage
+	resetBudgetFunc              func(conversation.Budget)
 	overBudgetFunc               func() error
 	getIDFunc                    func() string
-	subConvoWithHistoryFunc      func() *ant.Convo
+	subConvoWithHistoryFunc      func() *conversation.Convo
 }
 
-func (m *MockConvoInterface) SendMessage(message ant.Message) (*ant.MessageResponse, error) {
+func (m *MockConvoInterface) SendMessage(message llm.Message) (*llm.Response, error) {
 	if m.sendMessageFunc != nil {
 		return m.sendMessageFunc(message)
 	}
 	return nil, nil
 }
 
-func (m *MockConvoInterface) SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error) {
+func (m *MockConvoInterface) SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error) {
 	if m.sendUserTextMessageFunc != nil {
 		return m.sendUserTextMessageFunc(s, otherContents...)
 	}
 	return nil, nil
 }
 
-func (m *MockConvoInterface) ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error) {
+func (m *MockConvoInterface) ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error) {
 	if m.toolResultContentsFunc != nil {
 		return m.toolResultContentsFunc(ctx, resp)
 	}
 	return nil, nil
 }
 
-func (m *MockConvoInterface) ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error) {
+func (m *MockConvoInterface) ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error) {
 	if m.toolResultCancelContentsFunc != nil {
 		return m.toolResultCancelContentsFunc(resp)
 	}
@@ -297,14 +301,14 @@
 	return nil
 }
 
-func (m *MockConvoInterface) CumulativeUsage() ant.CumulativeUsage {
+func (m *MockConvoInterface) CumulativeUsage() conversation.CumulativeUsage {
 	if m.cumulativeUsageFunc != nil {
 		return m.cumulativeUsageFunc()
 	}
-	return ant.CumulativeUsage{}
+	return conversation.CumulativeUsage{}
 }
 
-func (m *MockConvoInterface) ResetBudget(budget ant.Budget) {
+func (m *MockConvoInterface) ResetBudget(budget conversation.Budget) {
 	if m.resetBudgetFunc != nil {
 		m.resetBudgetFunc(budget)
 	}
@@ -324,7 +328,7 @@
 	return "mock-convo-id"
 }
 
-func (m *MockConvoInterface) SubConvoWithHistory() *ant.Convo {
+func (m *MockConvoInterface) SubConvoWithHistory() *conversation.Convo {
 	if m.subConvoWithHistoryFunc != nil {
 		return m.subConvoWithHistoryFunc()
 	}
@@ -337,7 +341,7 @@
 func TestAgentProcessTurnWithNilResponseNilError(t *testing.T) {
 	// Create a mock conversation that will return nil response and nil error
 	mockConvo := &MockConvoInterface{
-		sendMessageFunc: func(message ant.Message) (*ant.MessageResponse, error) {
+		sendMessageFunc: func(message llm.Message) (*llm.Response, error) {
 			return nil, nil // This is unusual but now handled gracefully
 		},
 	}
@@ -464,48 +468,48 @@
 
 // mockConvoInterface is a mock implementation of ConvoInterface for testing
 type mockConvoInterface struct {
-	SendMessageFunc        func(message ant.Message) (*ant.MessageResponse, error)
-	ToolResultContentsFunc func(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
+	SendMessageFunc        func(message llm.Message) (*llm.Response, error)
+	ToolResultContentsFunc func(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
 }
 
 func (c *mockConvoInterface) GetID() string {
 	return "mockConvoInterface-id"
 }
 
-func (c *mockConvoInterface) SubConvoWithHistory() *ant.Convo {
+func (c *mockConvoInterface) SubConvoWithHistory() *conversation.Convo {
 	return nil
 }
 
-func (m *mockConvoInterface) CumulativeUsage() ant.CumulativeUsage {
-	return ant.CumulativeUsage{}
+func (m *mockConvoInterface) CumulativeUsage() conversation.CumulativeUsage {
+	return conversation.CumulativeUsage{}
 }
 
-func (m *mockConvoInterface) ResetBudget(ant.Budget) {}
+func (m *mockConvoInterface) ResetBudget(conversation.Budget) {}
 
 func (m *mockConvoInterface) OverBudget() error {
 	return nil
 }
 
-func (m *mockConvoInterface) SendMessage(message ant.Message) (*ant.MessageResponse, error) {
+func (m *mockConvoInterface) SendMessage(message llm.Message) (*llm.Response, error) {
 	if m.SendMessageFunc != nil {
 		return m.SendMessageFunc(message)
 	}
-	return &ant.MessageResponse{StopReason: ant.StopReasonEndTurn}, nil
+	return &llm.Response{StopReason: llm.StopReasonEndTurn}, nil
 }
 
-func (m *mockConvoInterface) SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error) {
-	return m.SendMessage(ant.UserStringMessage(s))
+func (m *mockConvoInterface) SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error) {
+	return m.SendMessage(llm.UserStringMessage(s))
 }
 
-func (m *mockConvoInterface) ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error) {
+func (m *mockConvoInterface) ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error) {
 	if m.ToolResultContentsFunc != nil {
 		return m.ToolResultContentsFunc(ctx, resp)
 	}
-	return []ant.Content{}, nil
+	return []llm.Content{}, nil
 }
 
-func (m *mockConvoInterface) ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error) {
-	return []ant.Content{ant.StringContent("Tool use cancelled")}, nil
+func (m *mockConvoInterface) ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error) {
+	return []llm.Content{llm.StringContent("Tool use cancelled")}, nil
 }
 
 func (m *mockConvoInterface) CancelToolUse(toolUseID string, cause error) error {
@@ -542,11 +546,11 @@
 	agent.inbox <- "Test message"
 
 	// Setup the mock to simulate a model response with end of turn
-	mockConvo.SendMessageFunc = func(message ant.Message) (*ant.MessageResponse, error) {
-		return &ant.MessageResponse{
-			StopReason: ant.StopReasonEndTurn,
-			Content: []ant.Content{
-				ant.StringContent("This is a test response"),
+	mockConvo.SendMessageFunc = func(message llm.Message) (*llm.Response, error) {
+		return &llm.Response{
+			StopReason: llm.StopReasonEndTurn,
+			Content: []llm.Content{
+				llm.StringContent("This is a test response"),
 			},
 		}, nil
 	}
@@ -615,29 +619,29 @@
 
 	// First response requests a tool
 	firstResponseDone := false
-	mockConvo.SendMessageFunc = func(message ant.Message) (*ant.MessageResponse, error) {
+	mockConvo.SendMessageFunc = func(message llm.Message) (*llm.Response, error) {
 		if !firstResponseDone {
 			firstResponseDone = true
-			return &ant.MessageResponse{
-				StopReason: ant.StopReasonToolUse,
-				Content: []ant.Content{
-					ant.StringContent("I'll use a tool"),
-					{Type: ant.ContentTypeToolUse, ToolName: "test_tool", ToolInput: []byte("{}"), ID: "test_id"},
+			return &llm.Response{
+				StopReason: llm.StopReasonToolUse,
+				Content: []llm.Content{
+					llm.StringContent("I'll use a tool"),
+					{Type: llm.ContentTypeToolUse, ToolName: "test_tool", ToolInput: []byte("{}"), ID: "test_id"},
 				},
 			}, nil
 		}
 		// Second response ends the turn
-		return &ant.MessageResponse{
-			StopReason: ant.StopReasonEndTurn,
-			Content: []ant.Content{
-				ant.StringContent("Finished using the tool"),
+		return &llm.Response{
+			StopReason: llm.StopReasonEndTurn,
+			Content: []llm.Content{
+				llm.StringContent("Finished using the tool"),
 			},
 		}, nil
 	}
 
 	// Tool result content handler
-	mockConvo.ToolResultContentsFunc = func(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error) {
-		return []ant.Content{ant.StringContent("Tool executed successfully")}, nil
+	mockConvo.ToolResultContentsFunc = func(ctx context.Context, resp *llm.Response) ([]llm.Content, error) {
+		return []llm.Content{llm.StringContent("Tool executed successfully")}, nil
 	}
 
 	// Track state transitions
diff --git a/loop/donetool.go b/loop/donetool.go
index e4b0542..63604d8 100644
--- a/loop/donetool.go
+++ b/loop/donetool.go
@@ -5,8 +5,8 @@
 	"encoding/json"
 	"fmt"
 
-	"sketch.dev/ant"
 	"sketch.dev/claudetool"
+	"sketch.dev/llm"
 )
 
 // makeDoneTool creates a tool that provides a checklist to the agent. There
@@ -14,8 +14,8 @@
 // not as reliable as it could be. Historically, we've found that Claude ignores
 // the tool results here, so we don't tell the tool to say "hey, really check this"
 // at the moment, though we've tried.
-func makeDoneTool(codereview *claudetool.CodeReviewer, gitUsername, gitEmail string) *ant.Tool {
-	return &ant.Tool{
+func makeDoneTool(codereview *claudetool.CodeReviewer, gitUsername, gitEmail string) *llm.Tool {
+	return &llm.Tool{
 		Name:        "done",
 		Description: `Use this tool when you have achieved the user's goal. The parameters form a checklist which you should evaluate.`,
 		InputSchema: json.RawMessage(doneChecklistJSONSchema(gitUsername, gitEmail)),
diff --git a/loop/mocks.go b/loop/mocks.go
index 7e05070..811ab2c 100644
--- a/loop/mocks.go
+++ b/loop/mocks.go
@@ -6,10 +6,11 @@
 	"sync"
 	"testing"
 
-	"sketch.dev/ant"
+	"sketch.dev/llm"
+	"sketch.dev/llm/conversation"
 )
 
-// MockConvo is a custom mock for ant.Convo interface
+// MockConvo is a custom mock for conversation.Convo interface
 type MockConvo struct {
 	mu sync.Mutex
 	t  *testing.T
@@ -21,23 +22,23 @@
 }
 
 type mockCall struct {
-	args   []interface{}
-	result []interface{}
+	args   []any
+	result []any
 }
 
 type mockExpectation struct {
 	until  chan any
-	args   []interface{}
-	result []interface{}
+	args   []any
+	result []any
 }
 
 // Return sets up return values for an expectation
-func (e *mockExpectation) Return(values ...interface{}) {
+func (e *mockExpectation) Return(values ...any) {
 	e.result = values
 }
 
 // Return sets up return values for an expectation
-func (e *mockExpectation) BlockAndReturn(until chan any, values ...interface{}) {
+func (e *mockExpectation) BlockAndReturn(until chan any, values ...any) {
 	e.until = until
 	e.result = values
 }
@@ -53,7 +54,7 @@
 }
 
 // ExpectCall sets up an expectation for a method call
-func (m *MockConvo) ExpectCall(method string, args ...interface{}) *mockExpectation {
+func (m *MockConvo) ExpectCall(method string, args ...any) *mockExpectation {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	expectation := &mockExpectation{args: args}
@@ -65,7 +66,7 @@
 }
 
 // findMatchingExpectation finds a matching expectation for a method call
-func (m *MockConvo) findMatchingExpectation(method string, args ...interface{}) (*mockExpectation, bool) {
+func (m *MockConvo) findMatchingExpectation(method string, args ...any) (*mockExpectation, bool) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	expectations, ok := m.expectations[method]
@@ -87,7 +88,7 @@
 }
 
 // matchArgs checks if call arguments match expectation arguments
-func matchArgs(expected, actual []interface{}) bool {
+func matchArgs(expected, actual []any) bool {
 	if len(expected) != len(actual) {
 		return false
 	}
@@ -107,7 +108,7 @@
 }
 
 // recordCall records a method call
-func (m *MockConvo) recordCall(method string, args ...interface{}) {
+func (m *MockConvo) recordCall(method string, args ...any) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	if _, ok := m.calls[method]; !ok {
@@ -116,7 +117,7 @@
 	m.calls[method] = append(m.calls[method], &mockCall{args: args})
 }
 
-func (m *MockConvo) SendMessage(message ant.Message) (*ant.MessageResponse, error) {
+func (m *MockConvo) SendMessage(message llm.Message) (*llm.Response, error) {
 	m.recordCall("SendMessage", message)
 	exp, ok := m.findMatchingExpectation("SendMessage", message)
 	if !ok {
@@ -129,10 +130,10 @@
 	if err, ok := exp.result[1].(error); ok {
 		retErr = err
 	}
-	return exp.result[0].(*ant.MessageResponse), retErr
+	return exp.result[0].(*llm.Response), retErr
 }
 
-func (m *MockConvo) SendUserTextMessage(message string, otherContents ...ant.Content) (*ant.MessageResponse, error) {
+func (m *MockConvo) SendUserTextMessage(message string, otherContents ...llm.Content) (*llm.Response, error) {
 	m.recordCall("SendUserTextMessage", message, otherContents)
 	exp, ok := m.findMatchingExpectation("SendUserTextMessage", message, otherContents)
 	if !ok {
@@ -145,10 +146,10 @@
 	if err, ok := exp.result[1].(error); ok {
 		retErr = err
 	}
-	return exp.result[0].(*ant.MessageResponse), retErr
+	return exp.result[0].(*llm.Response), retErr
 }
 
-func (m *MockConvo) ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error) {
+func (m *MockConvo) ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error) {
 	m.recordCall("ToolResultContents", resp)
 	exp, ok := m.findMatchingExpectation("ToolResultContents", resp)
 	if !ok {
@@ -162,10 +163,10 @@
 		retErr = err
 	}
 
-	return exp.result[0].([]ant.Content), retErr
+	return exp.result[0].([]llm.Content), retErr
 }
 
-func (m *MockConvo) ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error) {
+func (m *MockConvo) ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error) {
 	m.recordCall("ToolResultCancelContents", resp)
 	exp, ok := m.findMatchingExpectation("ToolResultCancelContents", resp)
 	if !ok {
@@ -179,12 +180,12 @@
 		retErr = err
 	}
 
-	return exp.result[0].([]ant.Content), retErr
+	return exp.result[0].([]llm.Content), retErr
 }
 
-func (m *MockConvo) CumulativeUsage() ant.CumulativeUsage {
+func (m *MockConvo) CumulativeUsage() conversation.CumulativeUsage {
 	m.recordCall("CumulativeUsage")
-	return ant.CumulativeUsage{}
+	return conversation.CumulativeUsage{}
 }
 
 func (m *MockConvo) OverBudget() error {
@@ -197,12 +198,12 @@
 	return "mock-conversation-id"
 }
 
-func (m *MockConvo) SubConvoWithHistory() *ant.Convo {
+func (m *MockConvo) SubConvoWithHistory() *conversation.Convo {
 	m.recordCall("SubConvoWithHistory")
 	return nil
 }
 
-func (m *MockConvo) ResetBudget(_ ant.Budget) {
+func (m *MockConvo) ResetBudget(_ conversation.Budget) {
 	m.recordCall("ResetBudget")
 }
 
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 4a415c8..f7a3979 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -23,7 +23,7 @@
 	"sketch.dev/loop/server/gzhandler"
 
 	"github.com/creack/pty"
-	"sketch.dev/ant"
+	"sketch.dev/llm/conversation"
 	"sketch.dev/loop"
 	"sketch.dev/webui"
 )
@@ -50,29 +50,29 @@
 }
 
 type State struct {
-	MessageCount         int                  `json:"message_count"`
-	TotalUsage           *ant.CumulativeUsage `json:"total_usage,omitempty"`
-	InitialCommit        string               `json:"initial_commit"`
-	Title                string               `json:"title"`
-	BranchName           string               `json:"branch_name,omitempty"`
-	Hostname             string               `json:"hostname"`    // deprecated
-	WorkingDir           string               `json:"working_dir"` // deprecated
-	OS                   string               `json:"os"`          // deprecated
-	GitOrigin            string               `json:"git_origin,omitempty"`
-	OutstandingLLMCalls  int                  `json:"outstanding_llm_calls"`
-	OutstandingToolCalls []string             `json:"outstanding_tool_calls"`
-	SessionID            string               `json:"session_id"`
-	SSHAvailable         bool                 `json:"ssh_available"`
-	SSHError             string               `json:"ssh_error,omitempty"`
-	InContainer          bool                 `json:"in_container"`
-	FirstMessageIndex    int                  `json:"first_message_index"`
-	AgentState           string               `json:"agent_state,omitempty"`
-	OutsideHostname      string               `json:"outside_hostname,omitempty"`
-	InsideHostname       string               `json:"inside_hostname,omitempty"`
-	OutsideOS            string               `json:"outside_os,omitempty"`
-	InsideOS             string               `json:"inside_os,omitempty"`
-	OutsideWorkingDir    string               `json:"outside_working_dir,omitempty"`
-	InsideWorkingDir     string               `json:"inside_working_dir,omitempty"`
+	MessageCount         int                           `json:"message_count"`
+	TotalUsage           *conversation.CumulativeUsage `json:"total_usage,omitempty"`
+	InitialCommit        string                        `json:"initial_commit"`
+	Title                string                        `json:"title"`
+	BranchName           string                        `json:"branch_name,omitempty"`
+	Hostname             string                        `json:"hostname"`    // deprecated
+	WorkingDir           string                        `json:"working_dir"` // deprecated
+	OS                   string                        `json:"os"`          // deprecated
+	GitOrigin            string                        `json:"git_origin,omitempty"`
+	OutstandingLLMCalls  int                           `json:"outstanding_llm_calls"`
+	OutstandingToolCalls []string                      `json:"outstanding_tool_calls"`
+	SessionID            string                        `json:"session_id"`
+	SSHAvailable         bool                          `json:"ssh_available"`
+	SSHError             string                        `json:"ssh_error,omitempty"`
+	InContainer          bool                          `json:"in_container"`
+	FirstMessageIndex    int                           `json:"first_message_index"`
+	AgentState           string                        `json:"agent_state,omitempty"`
+	OutsideHostname      string                        `json:"outside_hostname,omitempty"`
+	InsideHostname       string                        `json:"inside_hostname,omitempty"`
+	OutsideOS            string                        `json:"outside_os,omitempty"`
+	InsideOS             string                        `json:"inside_os,omitempty"`
+	OutsideWorkingDir    string                        `json:"outside_working_dir,omitempty"`
+	InsideWorkingDir     string                        `json:"inside_working_dir,omitempty"`
 }
 
 type InitRequest struct {
@@ -298,12 +298,12 @@
 
 		// Create a combined structure with all information
 		downloadData := struct {
-			Messages     []loop.AgentMessage `json:"messages"`
-			MessageCount int                 `json:"message_count"`
-			TotalUsage   ant.CumulativeUsage `json:"total_usage"`
-			Hostname     string              `json:"hostname"`
-			WorkingDir   string              `json:"working_dir"`
-			DownloadTime string              `json:"download_time"`
+			Messages     []loop.AgentMessage          `json:"messages"`
+			MessageCount int                          `json:"message_count"`
+			TotalUsage   conversation.CumulativeUsage `json:"total_usage"`
+			Hostname     string                       `json:"hostname"`
+			WorkingDir   string                       `json:"working_dir"`
+			DownloadTime string                       `json:"download_time"`
 		}{
 			Messages:     messages,
 			MessageCount: messageCount,