shelley: add subagent tool for parallel task delegation

Philip Zeyliger and Shelley created

Prompt: we're continuing work in this worktree (don't switch) on subagents. I think things are working. Can you tell me what the system prmopt is for subagents? Is it the same as the main one? how does that end up working?

Prompt: 1. Fetch and rebase on origin/main. Notably, there's a UI change to indicate which conversations are working vs. not, and that might need to be plumbed into subagent tasks in the convo list. 2. Let's write a system prompt for subagents. It should be short, but include some of the basic details. It's important to understand that only the last message gets sent to the parent agent, though it can of course reference files and so on.

Prompt: fetch, rebase on origin/main, and squash into one commit

Adds a subagent tool that allows the main agent to spawn independent
conversations for parallel task delegation. Subagents:
- Have their own conversation with full tool access (except nested subagents)
- Run with a minimal system prompt focused on task completion
- Return their final message to the parent agent
- Support both synchronous (wait) and async (fire-and-forget) modes

Key changes:
- New subagent tool in claudetool/subagent.go
- SubagentRunner in server/subagent.go handles conversation lifecycle
- Database schema adds parent_conversation_id for tracking hierarchy
- Minimal subagent system prompt (vs full 17KB main prompt)
- UI shows subagents nested under parent conversations
- Working indicator (pulsing dot) for subagent conversations

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

claudetool/subagent.go                    | 179 +++++++++
claudetool/subagent_test.go               | 154 +++++++
claudetool/toolset.go                     |  18 
cmd/go2ts.go                              |  17 
db/db.go                                  |  92 ++++
db/generated/conversations.sql.go         | 141 ++++++
db/generated/models.go                    |  15 
db/query/conversations.sql                |  22 
db/schema/009-add-parent-conversation.sql |   5 
loop/predictable.go                       |  47 ++
server/convo.go                           |  46 ++
server/handlers.go                        |   3 
server/server.go                          |   8 
server/subagent.go                        | 187 +++++++++
server/subagent_system_prompt.txt         |  13 
server/system_prompt.go                   |  44 ++
test/server_test.go                       | 131 ++++++
ui/src/App.tsx                            |  35 +
ui/src/components/ChatInterface.tsx       |   2 
ui/src/components/ConversationDrawer.tsx  | 482 +++++++++++++++++-------
ui/src/components/Message.tsx             |  19 
ui/src/components/SubagentTool.tsx        | 143 +++++++
ui/src/generated-types.ts                 |  80 ++--
ui/src/services/api.ts                    |   8 
24 files changed, 1,660 insertions(+), 231 deletions(-)

Detailed changes

claudetool/subagent.go 🔗

@@ -0,0 +1,179 @@
+package claudetool
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+
+	"shelley.exe.dev/llm"
+)
+
+// SubagentRunner is the interface for running a subagent conversation.
+// This is implemented by the server package to avoid import cycles.
+type SubagentRunner interface {
+	// RunSubagent runs a subagent conversation and returns the last response.
+	// If wait is false, it starts processing in background and returns immediately.
+	// timeout is the maximum time to wait for a response.
+	RunSubagent(ctx context.Context, conversationID, prompt string, wait bool, timeout time.Duration) (string, error)
+}
+
+// SubagentDB is the database interface for subagent operations.
+// This is implemented by the db package.
+type SubagentDB interface {
+	// GetOrCreateSubagentConversation retrieves or creates a subagent conversation.
+	// Returns the conversation ID and the actual slug used (may differ from requested
+	// slug if a numeric suffix was added for uniqueness).
+	GetOrCreateSubagentConversation(ctx context.Context, slug, parentID, cwd string) (conversationID, actualSlug string, err error)
+}
+
+// SubagentTool provides the ability to spawn and interact with subagent conversations.
+type SubagentTool struct {
+	DB                   SubagentDB
+	ParentConversationID string
+	WorkingDir           *MutableWorkingDir
+	Runner               SubagentRunner
+}
+
+const (
+	subagentName        = "subagent"
+	subagentDescription = `Spawn or interact with a subagent conversation.
+
+Subagents are independent conversations that can work on subtasks in parallel.
+Use subagents for:
+- Long-running tasks that you want to delegate
+- Token-intensive tasks that produce lots of output, little of which is needed
+- Parallel exploration of different approaches
+- Breaking down complex problems into independent pieces
+
+Each subagent has its own slug identifier within this conversation.
+You can send messages to existing subagents by using the same slug.
+The tool returns the subagent's last response, or a status if the timeout is reached.
+`
+	subagentInputSchema = `
+{
+  "type": "object",
+  "required": ["slug", "prompt"],
+  "properties": {
+    "slug": {
+      "type": "string",
+      "description": "A short identifier for this subagent (e.g., 'research-api', 'test-runner')"
+    },
+    "prompt": {
+      "type": "string",
+      "description": "The message to send to the subagent"
+    },
+    "timeout_seconds": {
+      "type": "integer",
+      "description": "How long to wait for a response (default: 60, max: 300)"
+    },
+    "wait": {
+      "type": "boolean",
+      "description": "Whether to wait for completion (default: true). If false, returns immediately."
+    }
+  }
+}
+`
+)
+
+type subagentInput struct {
+	Slug           string `json:"slug"`
+	Prompt         string `json:"prompt"`
+	TimeoutSeconds int    `json:"timeout_seconds,omitempty"`
+	Wait           *bool  `json:"wait,omitempty"`
+}
+
+// Tool returns an llm.Tool for the subagent functionality.
+func (s *SubagentTool) Tool() *llm.Tool {
+	return &llm.Tool{
+		Name:        subagentName,
+		Description: strings.TrimSpace(subagentDescription),
+		InputSchema: llm.MustSchema(subagentInputSchema),
+		Run:         s.Run,
+	}
+}
+
+func (s *SubagentTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var req subagentInput
+	if err := json.Unmarshal(m, &req); err != nil {
+		return llm.ErrorfToolOut("failed to parse subagent input: %w", err)
+	}
+
+	// Validate slug
+	if req.Slug == "" {
+		return llm.ErrorfToolOut("slug is required")
+	}
+	req.Slug = sanitizeSlug(req.Slug)
+	if req.Slug == "" {
+		return llm.ErrorfToolOut("slug must contain alphanumeric characters")
+	}
+
+	if req.Prompt == "" {
+		return llm.ErrorfToolOut("prompt is required")
+	}
+
+	// Set defaults
+	timeout := 60 * time.Second
+	if req.TimeoutSeconds > 0 {
+		if req.TimeoutSeconds > 300 {
+			req.TimeoutSeconds = 300
+		}
+		timeout = time.Duration(req.TimeoutSeconds) * time.Second
+	}
+
+	wait := true
+	if req.Wait != nil {
+		wait = *req.Wait
+	}
+
+	// Get or create the subagent conversation
+	conversationID, actualSlug, err := s.DB.GetOrCreateSubagentConversation(ctx, req.Slug, s.ParentConversationID, s.WorkingDir.Get())
+	if err != nil {
+		return llm.ErrorfToolOut("failed to get/create subagent conversation: %w", err)
+	}
+
+	// Use the runner to execute the subagent
+	response, err := s.Runner.RunSubagent(ctx, conversationID, req.Prompt, wait, timeout)
+	if err != nil {
+		return llm.ErrorfToolOut("subagent error: %w", err)
+	}
+
+	// Include actual slug in response if it differs from requested
+	slugNote := ""
+	if actualSlug != req.Slug {
+		slugNote = fmt.Sprintf(" (Note: slug was changed to '%s' for uniqueness. Use '%s' for future messages to this subagent.)", actualSlug, actualSlug)
+	}
+
+	return llm.ToolOut{
+		LLMContent: llm.TextContent(fmt.Sprintf("Subagent '%s' response:%s\n%s", actualSlug, slugNote, response)),
+		Display: SubagentDisplayData{
+			Slug:           actualSlug,
+			ConversationID: conversationID,
+		},
+	}
+}
+
+// SubagentDisplayData is the display data sent to the UI for subagent tool results.
+type SubagentDisplayData struct {
+	Slug           string `json:"slug"`
+	ConversationID string `json:"conversation_id"`
+}
+
+func sanitizeSlug(slug string) string {
+	// Lowercase, keep alphanumeric and hyphens
+	var result strings.Builder
+	for _, r := range strings.ToLower(slug) {
+		if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
+			result.WriteRune(r)
+		} else if r == ' ' || r == '_' {
+			result.WriteRune('-')
+		}
+	}
+	// Remove consecutive hyphens and trim
+	s := result.String()
+	for strings.Contains(s, "--") {
+		s = strings.ReplaceAll(s, "--", "-")
+	}
+	return strings.Trim(s, "-")
+}

claudetool/subagent_test.go 🔗

@@ -0,0 +1,154 @@
+package claudetool
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+	"time"
+)
+
+// mockSubagentDB implements SubagentDB for testing.
+type mockSubagentDB struct {
+	conversations map[string]string // slug -> conversationID
+}
+
+func newMockSubagentDB() *mockSubagentDB {
+	return &mockSubagentDB{
+		conversations: make(map[string]string),
+	}
+}
+
+func (m *mockSubagentDB) GetOrCreateSubagentConversation(ctx context.Context, slug, parentID, cwd string) (string, string, error) {
+	key := parentID + ":" + slug
+	if id, ok := m.conversations[key]; ok {
+		return id, slug, nil
+	}
+	id := "subagent-" + slug
+	m.conversations[key] = id
+	return id, slug, nil
+}
+
+// mockSubagentRunner implements SubagentRunner for testing.
+type mockSubagentRunner struct {
+	response string
+	err      error
+}
+
+func (m *mockSubagentRunner) RunSubagent(ctx context.Context, conversationID, prompt string, wait bool, timeout time.Duration) (string, error) {
+	if m.err != nil {
+		return "", m.err
+	}
+	return m.response, nil
+}
+
+func TestSubagentTool_SanitizeSlug(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected string
+	}{
+		{"test-slug", "test-slug"},
+		{"Test Slug", "test-slug"},
+		{"test_slug", "test-slug"},
+		{"test--slug", "test-slug"},
+		{"-test-slug-", "test-slug"},
+		{"test@slug!", "testslug"},
+		{"123-abc", "123-abc"},
+		{"", ""},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			result := sanitizeSlug(tt.input)
+			if result != tt.expected {
+				t.Errorf("sanitizeSlug(%q) = %q, want %q", tt.input, result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestSubagentTool_Run(t *testing.T) {
+	wd := NewMutableWorkingDir("/tmp")
+	db := newMockSubagentDB()
+	runner := &mockSubagentRunner{response: "Task completed successfully"}
+
+	tool := &SubagentTool{
+		DB:                   db,
+		ParentConversationID: "parent-123",
+		WorkingDir:           wd,
+		Runner:               runner,
+	}
+
+	input := subagentInput{
+		Slug:   "test-task",
+		Prompt: "Do something useful",
+	}
+	inputJSON, _ := json.Marshal(input)
+
+	result := tool.Run(context.Background(), inputJSON)
+	if result.Error != nil {
+		t.Fatalf("unexpected error: %v", result.Error)
+	}
+
+	if len(result.LLMContent) == 0 {
+		t.Fatal("expected LLM content")
+	}
+
+	if result.LLMContent[0].Text == "" {
+		t.Error("expected non-empty response text")
+	}
+
+	// Check display data
+	if result.Display == nil {
+		t.Error("expected display data")
+	}
+	displayData, ok := result.Display.(SubagentDisplayData)
+	if !ok {
+		t.Error("display data should be SubagentDisplayData")
+	}
+	if displayData.Slug != "test-task" {
+		t.Errorf("expected slug 'test-task', got %q", displayData.Slug)
+	}
+}
+
+func TestSubagentTool_Validation(t *testing.T) {
+	wd := NewMutableWorkingDir("/tmp")
+	db := newMockSubagentDB()
+	runner := &mockSubagentRunner{response: "OK"}
+
+	tool := &SubagentTool{
+		DB:                   db,
+		ParentConversationID: "parent-123",
+		WorkingDir:           wd,
+		Runner:               runner,
+	}
+
+	// Test empty slug
+	t.Run("empty slug", func(t *testing.T) {
+		input := subagentInput{Slug: "", Prompt: "test"}
+		inputJSON, _ := json.Marshal(input)
+		result := tool.Run(context.Background(), inputJSON)
+		if result.Error == nil {
+			t.Error("expected error for empty slug")
+		}
+	})
+
+	// Test empty prompt
+	t.Run("empty prompt", func(t *testing.T) {
+		input := subagentInput{Slug: "test", Prompt: ""}
+		inputJSON, _ := json.Marshal(input)
+		result := tool.Run(context.Background(), inputJSON)
+		if result.Error == nil {
+			t.Error("expected error for empty prompt")
+		}
+	})
+
+	// Test invalid slug (only special chars)
+	t.Run("invalid slug", func(t *testing.T) {
+		input := subagentInput{Slug: "@#$%", Prompt: "test"}
+		inputJSON, _ := json.Marshal(input)
+		result := tool.Run(context.Background(), inputJSON)
+		if result.Error == nil {
+			t.Error("expected error for invalid slug")
+		}
+	})
+}

claudetool/toolset.go 🔗

@@ -50,6 +50,13 @@ type ToolSetConfig struct {
 	// OnWorkingDirChange is called when the working directory changes.
 	// This can be used to persist the change to a database.
 	OnWorkingDirChange func(newDir string)
+	// SubagentRunner is the runner for subagent conversations.
+	// If set, the subagent tool will be available.
+	SubagentRunner SubagentRunner
+	// SubagentDB is the database for subagent conversations.
+	SubagentDB SubagentDB
+	// ParentConversationID is the ID of the parent conversation (for subagent tool).
+	ParentConversationID string
 }
 
 // ToolSet holds a set of tools for a single conversation.
@@ -120,6 +127,17 @@ func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet {
 		changeDirTool.Tool(),
 	}
 
+	// Add subagent tool if configured
+	if cfg.SubagentRunner != nil && cfg.SubagentDB != nil && cfg.ParentConversationID != "" {
+		subagentTool := &SubagentTool{
+			DB:                   cfg.SubagentDB,
+			ParentConversationID: cfg.ParentConversationID,
+			WorkingDir:           wd,
+			Runner:               cfg.SubagentRunner,
+		}
+		tools = append(tools, subagentTool.Tool())
+	}
+
 	var cleanup func()
 	if cfg.EnableBrowser {
 		// Get max image dimension from the LLM service

cmd/go2ts.go 🔗

@@ -94,14 +94,15 @@ type conversationStateForTS struct {
 }
 
 type conversationWithStateForTS struct {
-	ConversationID string  `json:"conversation_id"`
-	Slug           *string `json:"slug"`
-	UserInitiated  bool    `json:"user_initiated"`
-	CreatedAt      string  `json:"created_at"`
-	UpdatedAt      string  `json:"updated_at"`
-	Cwd            *string `json:"cwd"`
-	Archived       bool    `json:"archived"`
-	Working        bool    `json:"working"`
+	ConversationID       string  `json:"conversation_id"`
+	Slug                 *string `json:"slug"`
+	UserInitiated        bool    `json:"user_initiated"`
+	CreatedAt            string  `json:"created_at"`
+	UpdatedAt            string  `json:"updated_at"`
+	Cwd                  *string `json:"cwd"`
+	Archived             bool    `json:"archived"`
+	ParentConversationID *string `json:"parent_conversation_id"`
+	Working              bool    `json:"working"`
 }
 
 type streamResponseForTS struct {

db/db.go 🔗

@@ -597,3 +597,95 @@ func (db *DB) DeleteConversation(ctx context.Context, conversationID string) err
 		return q.DeleteConversation(ctx, conversationID)
 	})
 }
+
+// CreateSubagentConversation creates a new subagent conversation with a parent
+func (db *DB) CreateSubagentConversation(ctx context.Context, slug, parentID string, cwd *string) (*generated.Conversation, error) {
+	conversationID, err := generateConversationID()
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate conversation ID: %w", err)
+	}
+	var conversation generated.Conversation
+	err = db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+		q := generated.New(tx.Conn())
+		conversation, err = q.CreateSubagentConversation(ctx, generated.CreateSubagentConversationParams{
+			ConversationID:       conversationID,
+			Slug:                 &slug,
+			Cwd:                  cwd,
+			ParentConversationID: &parentID,
+		})
+		return err
+	})
+	return &conversation, err
+}
+
+// GetSubagents retrieves all subagent conversations for a parent conversation
+func (db *DB) GetSubagents(ctx context.Context, parentID string) ([]generated.Conversation, error) {
+	var conversations []generated.Conversation
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		conversations, err = q.GetSubagents(ctx, &parentID)
+		return err
+	})
+	return conversations, err
+}
+
+// GetConversationBySlugAndParent retrieves a subagent conversation by slug and parent ID
+func (db *DB) GetConversationBySlugAndParent(ctx context.Context, slug, parentID string) (*generated.Conversation, error) {
+	var conversation generated.Conversation
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		conversation, err = q.GetConversationBySlugAndParent(ctx, generated.GetConversationBySlugAndParentParams{
+			Slug:                 &slug,
+			ParentConversationID: &parentID,
+		})
+		return err
+	})
+	if err == sql.ErrNoRows {
+		return nil, nil // Not found, return nil without error
+	}
+	return &conversation, err
+}
+
+// SubagentDBAdapter adapts *DB to the claudetool.SubagentDB interface.
+type SubagentDBAdapter struct {
+	DB *DB
+}
+
+// GetOrCreateSubagentConversation implements claudetool.SubagentDB.
+// Returns the conversation ID and the actual slug used (may differ if a suffix was added).
+func (a *SubagentDBAdapter) GetOrCreateSubagentConversation(ctx context.Context, slug, parentID, cwd string) (string, string, error) {
+	// Try to find existing with exact slug
+	existing, err := a.DB.GetConversationBySlugAndParent(ctx, slug, parentID)
+	if err != nil {
+		return "", "", err
+	}
+	if existing != nil {
+		return existing.ConversationID, *existing.Slug, nil
+	}
+
+	// Try to create new, handling unique constraint violations by appending numbers
+	baseSlug := slug
+	actualSlug := slug
+	for attempt := 0; attempt < 100; attempt++ {
+		conv, err := a.DB.CreateSubagentConversation(ctx, actualSlug, parentID, &cwd)
+		if err == nil {
+			return conv.ConversationID, actualSlug, nil
+		}
+
+		// Check if this is a unique constraint violation
+		errLower := strings.ToLower(err.Error())
+		if strings.Contains(errLower, "unique constraint") ||
+			strings.Contains(errLower, "duplicate") {
+			// Try with a numeric suffix
+			actualSlug = fmt.Sprintf("%s-%d", baseSlug, attempt+1)
+			continue
+		}
+
+		// Some other error occurred
+		return "", "", err
+	}
+
+	return "", "", fmt.Errorf("failed to create unique subagent slug after 100 attempts")
+}

db/generated/conversations.sql.go 🔗

@@ -13,7 +13,7 @@ const archiveConversation = `-- name: ArchiveConversation :one
 UPDATE conversations
 SET archived = TRUE, updated_at = CURRENT_TIMESTAMP
 WHERE conversation_id = ?
-RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
 `
 
 func (q *Queries) ArchiveConversation(ctx context.Context, conversationID string) (Conversation, error) {
@@ -27,6 +27,7 @@ func (q *Queries) ArchiveConversation(ctx context.Context, conversationID string
 		&i.UpdatedAt,
 		&i.Cwd,
 		&i.Archived,
+		&i.ParentConversationID,
 	)
 	return i, err
 }
@@ -43,7 +44,7 @@ func (q *Queries) CountArchivedConversations(ctx context.Context) (int64, error)
 }
 
 const countConversations = `-- name: CountConversations :one
-SELECT COUNT(*) FROM conversations WHERE archived = FALSE
+SELECT COUNT(*) FROM conversations WHERE archived = FALSE AND parent_conversation_id IS NULL
 `
 
 func (q *Queries) CountConversations(ctx context.Context) (int64, error) {
@@ -56,7 +57,7 @@ func (q *Queries) CountConversations(ctx context.Context) (int64, error) {
 const createConversation = `-- name: CreateConversation :one
 INSERT INTO conversations (conversation_id, slug, user_initiated, cwd)
 VALUES (?, ?, ?, ?)
-RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
 `
 
 type CreateConversationParams struct {
@@ -82,6 +83,41 @@ func (q *Queries) CreateConversation(ctx context.Context, arg CreateConversation
 		&i.UpdatedAt,
 		&i.Cwd,
 		&i.Archived,
+		&i.ParentConversationID,
+	)
+	return i, err
+}
+
+const createSubagentConversation = `-- name: CreateSubagentConversation :one
+INSERT INTO conversations (conversation_id, slug, user_initiated, cwd, parent_conversation_id)
+VALUES (?, ?, FALSE, ?, ?)
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
+`
+
+type CreateSubagentConversationParams struct {
+	ConversationID       string  `json:"conversation_id"`
+	Slug                 *string `json:"slug"`
+	Cwd                  *string `json:"cwd"`
+	ParentConversationID *string `json:"parent_conversation_id"`
+}
+
+func (q *Queries) CreateSubagentConversation(ctx context.Context, arg CreateSubagentConversationParams) (Conversation, error) {
+	row := q.db.QueryRowContext(ctx, createSubagentConversation,
+		arg.ConversationID,
+		arg.Slug,
+		arg.Cwd,
+		arg.ParentConversationID,
+	)
+	var i Conversation
+	err := row.Scan(
+		&i.ConversationID,
+		&i.Slug,
+		&i.UserInitiated,
+		&i.CreatedAt,
+		&i.UpdatedAt,
+		&i.Cwd,
+		&i.Archived,
+		&i.ParentConversationID,
 	)
 	return i, err
 }
@@ -97,7 +133,7 @@ func (q *Queries) DeleteConversation(ctx context.Context, conversationID string)
 }
 
 const getConversation = `-- name: GetConversation :one
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
 WHERE conversation_id = ?
 `
 
@@ -112,12 +148,13 @@ func (q *Queries) GetConversation(ctx context.Context, conversationID string) (C
 		&i.UpdatedAt,
 		&i.Cwd,
 		&i.Archived,
+		&i.ParentConversationID,
 	)
 	return i, err
 }
 
 const getConversationBySlug = `-- name: GetConversationBySlug :one
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
 WHERE slug = ?
 `
 
@@ -132,12 +169,77 @@ func (q *Queries) GetConversationBySlug(ctx context.Context, slug *string) (Conv
 		&i.UpdatedAt,
 		&i.Cwd,
 		&i.Archived,
+		&i.ParentConversationID,
 	)
 	return i, err
 }
 
+const getConversationBySlugAndParent = `-- name: GetConversationBySlugAndParent :one
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
+WHERE slug = ? AND parent_conversation_id = ?
+`
+
+type GetConversationBySlugAndParentParams struct {
+	Slug                 *string `json:"slug"`
+	ParentConversationID *string `json:"parent_conversation_id"`
+}
+
+func (q *Queries) GetConversationBySlugAndParent(ctx context.Context, arg GetConversationBySlugAndParentParams) (Conversation, error) {
+	row := q.db.QueryRowContext(ctx, getConversationBySlugAndParent, arg.Slug, arg.ParentConversationID)
+	var i Conversation
+	err := row.Scan(
+		&i.ConversationID,
+		&i.Slug,
+		&i.UserInitiated,
+		&i.CreatedAt,
+		&i.UpdatedAt,
+		&i.Cwd,
+		&i.Archived,
+		&i.ParentConversationID,
+	)
+	return i, err
+}
+
+const getSubagents = `-- name: GetSubagents :many
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
+WHERE parent_conversation_id = ?
+ORDER BY created_at ASC
+`
+
+func (q *Queries) GetSubagents(ctx context.Context, parentConversationID *string) ([]Conversation, error) {
+	rows, err := q.db.QueryContext(ctx, getSubagents, parentConversationID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []Conversation{}
+	for rows.Next() {
+		var i Conversation
+		if err := rows.Scan(
+			&i.ConversationID,
+			&i.Slug,
+			&i.UserInitiated,
+			&i.CreatedAt,
+			&i.UpdatedAt,
+			&i.Cwd,
+			&i.Archived,
+			&i.ParentConversationID,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
 const listArchivedConversations = `-- name: ListArchivedConversations :many
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
 WHERE archived = TRUE
 ORDER BY updated_at DESC
 LIMIT ? OFFSET ?
@@ -165,6 +267,7 @@ func (q *Queries) ListArchivedConversations(ctx context.Context, arg ListArchive
 			&i.UpdatedAt,
 			&i.Cwd,
 			&i.Archived,
+			&i.ParentConversationID,
 		); err != nil {
 			return nil, err
 		}
@@ -180,8 +283,8 @@ func (q *Queries) ListArchivedConversations(ctx context.Context, arg ListArchive
 }
 
 const listConversations = `-- name: ListConversations :many
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
-WHERE archived = FALSE
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
+WHERE archived = FALSE AND parent_conversation_id IS NULL
 ORDER BY updated_at DESC
 LIMIT ? OFFSET ?
 `
@@ -208,6 +311,7 @@ func (q *Queries) ListConversations(ctx context.Context, arg ListConversationsPa
 			&i.UpdatedAt,
 			&i.Cwd,
 			&i.Archived,
+			&i.ParentConversationID,
 		); err != nil {
 			return nil, err
 		}
@@ -223,7 +327,7 @@ func (q *Queries) ListConversations(ctx context.Context, arg ListConversationsPa
 }
 
 const searchArchivedConversations = `-- name: SearchArchivedConversations :many
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
 WHERE slug LIKE '%' || ? || '%' AND archived = TRUE
 ORDER BY updated_at DESC
 LIMIT ? OFFSET ?
@@ -252,6 +356,7 @@ func (q *Queries) SearchArchivedConversations(ctx context.Context, arg SearchArc
 			&i.UpdatedAt,
 			&i.Cwd,
 			&i.Archived,
+			&i.ParentConversationID,
 		); err != nil {
 			return nil, err
 		}
@@ -267,8 +372,8 @@ func (q *Queries) SearchArchivedConversations(ctx context.Context, arg SearchArc
 }
 
 const searchConversations = `-- name: SearchConversations :many
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
-WHERE slug LIKE '%' || ? || '%' AND archived = FALSE
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
+WHERE slug LIKE '%' || ? || '%' AND archived = FALSE AND parent_conversation_id IS NULL
 ORDER BY updated_at DESC
 LIMIT ? OFFSET ?
 `
@@ -296,6 +401,7 @@ func (q *Queries) SearchConversations(ctx context.Context, arg SearchConversatio
 			&i.UpdatedAt,
 			&i.Cwd,
 			&i.Archived,
+			&i.ParentConversationID,
 		); err != nil {
 			return nil, err
 		}
@@ -311,7 +417,7 @@ func (q *Queries) SearchConversations(ctx context.Context, arg SearchConversatio
 }
 
 const searchConversationsWithMessages = `-- name: SearchConversationsWithMessages :many
-SELECT DISTINCT c.conversation_id, c.slug, c.user_initiated, c.created_at, c.updated_at, c.cwd, c.archived FROM conversations c
+SELECT DISTINCT c.conversation_id, c.slug, c.user_initiated, c.created_at, c.updated_at, c.cwd, c.archived, c.parent_conversation_id FROM conversations c
 LEFT JOIN messages m ON c.conversation_id = m.conversation_id AND m.type IN ('user', 'agent')
 WHERE c.archived = FALSE
   AND (
@@ -332,6 +438,7 @@ type SearchConversationsWithMessagesParams struct {
 }
 
 // Search conversations by slug OR message content (user messages and agent responses, not system prompts)
+// Includes both top-level conversations and subagent conversations
 func (q *Queries) SearchConversationsWithMessages(ctx context.Context, arg SearchConversationsWithMessagesParams) ([]Conversation, error) {
 	rows, err := q.db.QueryContext(ctx, searchConversationsWithMessages,
 		arg.Column1,
@@ -355,6 +462,7 @@ func (q *Queries) SearchConversationsWithMessages(ctx context.Context, arg Searc
 			&i.UpdatedAt,
 			&i.Cwd,
 			&i.Archived,
+			&i.ParentConversationID,
 		); err != nil {
 			return nil, err
 		}
@@ -373,7 +481,7 @@ const unarchiveConversation = `-- name: UnarchiveConversation :one
 UPDATE conversations
 SET archived = FALSE, updated_at = CURRENT_TIMESTAMP
 WHERE conversation_id = ?
-RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
 `
 
 func (q *Queries) UnarchiveConversation(ctx context.Context, conversationID string) (Conversation, error) {
@@ -387,6 +495,7 @@ func (q *Queries) UnarchiveConversation(ctx context.Context, conversationID stri
 		&i.UpdatedAt,
 		&i.Cwd,
 		&i.Archived,
+		&i.ParentConversationID,
 	)
 	return i, err
 }
@@ -395,7 +504,7 @@ const updateConversationCwd = `-- name: UpdateConversationCwd :one
 UPDATE conversations
 SET cwd = ?, updated_at = CURRENT_TIMESTAMP
 WHERE conversation_id = ?
-RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
 `
 
 type UpdateConversationCwdParams struct {
@@ -414,6 +523,7 @@ func (q *Queries) UpdateConversationCwd(ctx context.Context, arg UpdateConversat
 		&i.UpdatedAt,
 		&i.Cwd,
 		&i.Archived,
+		&i.ParentConversationID,
 	)
 	return i, err
 }
@@ -422,7 +532,7 @@ const updateConversationSlug = `-- name: UpdateConversationSlug :one
 UPDATE conversations
 SET slug = ?, updated_at = CURRENT_TIMESTAMP
 WHERE conversation_id = ?
-RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
 `
 
 type UpdateConversationSlugParams struct {
@@ -441,6 +551,7 @@ func (q *Queries) UpdateConversationSlug(ctx context.Context, arg UpdateConversa
 		&i.UpdatedAt,
 		&i.Cwd,
 		&i.Archived,
+		&i.ParentConversationID,
 	)
 	return i, err
 }

db/generated/models.go 🔗

@@ -9,13 +9,14 @@ import (
 )
 
 type Conversation struct {
-	ConversationID string    `json:"conversation_id"`
-	Slug           *string   `json:"slug"`
-	UserInitiated  bool      `json:"user_initiated"`
-	CreatedAt      time.Time `json:"created_at"`
-	UpdatedAt      time.Time `json:"updated_at"`
-	Cwd            *string   `json:"cwd"`
-	Archived       bool      `json:"archived"`
+	ConversationID       string    `json:"conversation_id"`
+	Slug                 *string   `json:"slug"`
+	UserInitiated        bool      `json:"user_initiated"`
+	CreatedAt            time.Time `json:"created_at"`
+	UpdatedAt            time.Time `json:"updated_at"`
+	Cwd                  *string   `json:"cwd"`
+	Archived             bool      `json:"archived"`
+	ParentConversationID *string   `json:"parent_conversation_id"`
 }
 
 type Message struct {

db/query/conversations.sql 🔗

@@ -13,7 +13,7 @@ WHERE slug = ?;
 
 -- name: ListConversations :many
 SELECT * FROM conversations
-WHERE archived = FALSE
+WHERE archived = FALSE AND parent_conversation_id IS NULL
 ORDER BY updated_at DESC
 LIMIT ? OFFSET ?;
 
@@ -25,12 +25,13 @@ LIMIT ? OFFSET ?;
 
 -- name: SearchConversations :many
 SELECT * FROM conversations
-WHERE slug LIKE '%' || ? || '%' AND archived = FALSE
+WHERE slug LIKE '%' || ? || '%' AND archived = FALSE AND parent_conversation_id IS NULL
 ORDER BY updated_at DESC
 LIMIT ? OFFSET ?;
 
 -- name: SearchConversationsWithMessages :many
 -- Search conversations by slug OR message content (user messages and agent responses, not system prompts)
+-- Includes both top-level conversations and subagent conversations
 SELECT DISTINCT c.* FROM conversations c
 LEFT JOIN messages m ON c.conversation_id = m.conversation_id AND m.type IN ('user', 'agent')
 WHERE c.archived = FALSE
@@ -64,7 +65,7 @@ DELETE FROM conversations
 WHERE conversation_id = ?;
 
 -- name: CountConversations :one
-SELECT COUNT(*) FROM conversations WHERE archived = FALSE;
+SELECT COUNT(*) FROM conversations WHERE archived = FALSE AND parent_conversation_id IS NULL;
 
 -- name: CountArchivedConversations :one
 SELECT COUNT(*) FROM conversations WHERE archived = TRUE;
@@ -86,3 +87,18 @@ UPDATE conversations
 SET cwd = ?, updated_at = CURRENT_TIMESTAMP
 WHERE conversation_id = ?
 RETURNING *;
+
+
+-- name: CreateSubagentConversation :one
+INSERT INTO conversations (conversation_id, slug, user_initiated, cwd, parent_conversation_id)
+VALUES (?, ?, FALSE, ?, ?)
+RETURNING *;
+
+-- name: GetSubagents :many
+SELECT * FROM conversations
+WHERE parent_conversation_id = ?
+ORDER BY created_at ASC;
+
+-- name: GetConversationBySlugAndParent :one
+SELECT * FROM conversations
+WHERE slug = ? AND parent_conversation_id = ?;

db/schema/009-add-parent-conversation.sql 🔗

@@ -0,0 +1,5 @@
+-- Add parent_conversation_id column for subagent conversations
+ALTER TABLE conversations ADD COLUMN parent_conversation_id TEXT REFERENCES conversations(conversation_id);
+
+-- Index for efficient parent-child lookups
+CREATE INDEX idx_conversations_parent_id ON conversations(parent_conversation_id) WHERE parent_conversation_id IS NOT NULL;

loop/predictable.go 🔗

@@ -21,6 +21,7 @@ import (
 //   - "echo: <text>" - echoes the text back
 //   - "bash: <command>" - triggers bash tool with command
 //   - "think: <thoughts>" - triggers think tool
+//   - "subagent: <slug> <prompt>" - triggers subagent tool
 //   - "delay: <seconds>" - delays response by specified seconds
 //   - See Do() method for complete list of supported patterns
 type PredictableService struct {
@@ -164,6 +165,17 @@ func (s *PredictableService) Do(ctx context.Context, req *llm.Request) (*llm.Res
 			return s.makeScreenshotToolResponse(selector, inputTokens), nil
 		}
 
+		if strings.HasPrefix(inputText, "subagent: ") {
+			// Format: "subagent: <slug> <prompt>"
+			parts := strings.SplitN(strings.TrimPrefix(inputText, "subagent: "), " ", 2)
+			slug := parts[0]
+			prompt := "do the task"
+			if len(parts) > 1 {
+				prompt = parts[1]
+			}
+			return s.makeSubagentToolResponse(slug, prompt, inputTokens), nil
+		}
+
 		if strings.HasPrefix(inputText, "delay: ") {
 			delayStr := strings.TrimPrefix(inputText, "delay: ")
 			delaySeconds, err := strconv.ParseFloat(delayStr, 64)
@@ -520,6 +532,41 @@ func (s *PredictableService) makeScreenshotToolResponse(selector string, inputTo
 	}
 }
 
+func (s *PredictableService) makeSubagentToolResponse(slug, prompt string, inputTokens uint64) *llm.Response {
+	toolInputData := map[string]any{
+		"slug":   slug,
+		"prompt": prompt,
+	}
+	toolInputBytes, _ := json.Marshal(toolInputData)
+	toolInput := json.RawMessage(toolInputBytes)
+	responseText := fmt.Sprintf("Delegating to subagent '%s'...", slug)
+	outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
+	if outputTokens == 0 {
+		outputTokens = 1
+	}
+	return &llm.Response{
+		ID:    fmt.Sprintf("pred-subagent-%d", time.Now().UnixNano()),
+		Type:  "message",
+		Role:  llm.MessageRoleAssistant,
+		Model: "predictable-v1",
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: responseText},
+			{
+				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+				Type:      llm.ContentTypeToolUse,
+				ToolName:  "subagent",
+				ToolInput: toolInput,
+			},
+		},
+		StopReason: llm.StopReasonToolUse,
+		Usage: llm.Usage{
+			InputTokens:  inputTokens,
+			OutputTokens: outputTokens,
+			CostUSD:      0.0,
+		},
+	}
+}
+
 // makeToolSmorgasbordResponse creates a response that uses all available tool types
 func (s *PredictableService) makeToolSmorgasbordResponse(inputTokens uint64) *llm.Response {
 	baseNano := time.Now().UnixNano()

server/convo.go 🔗

@@ -124,8 +124,19 @@ func (cm *ConversationManager) Hydrate(ctx context.Context) error {
 		return fmt.Errorf("failed to get conversation history: %w", err)
 	}
 
-	if conversation.UserInitiated && !hasSystemMessage(messages) {
-		systemMsg, err := cm.createSystemPrompt(ctx)
+	// Generate system prompt if missing:
+	// - For user-initiated conversations: full system prompt
+	// - For subagent conversations (has parent): minimal subagent prompt
+	if !hasSystemMessage(messages) {
+		var systemMsg *generated.Message
+		var err error
+		if conversation.ParentConversationID != nil {
+			// Subagent conversation - use minimal prompt
+			systemMsg, err = cm.createSubagentSystemPrompt(ctx)
+		} else if conversation.UserInitiated {
+			// User-initiated conversation - use full prompt
+			systemMsg, err = cm.createSystemPrompt(ctx)
+		}
 		if err != nil {
 			return err
 		}
@@ -253,6 +264,36 @@ func (cm *ConversationManager) createSystemPrompt(ctx context.Context) (*generat
 	return created, nil
 }
 
+func (cm *ConversationManager) createSubagentSystemPrompt(ctx context.Context) (*generated.Message, error) {
+	systemPrompt, err := GenerateSubagentSystemPrompt(cm.cwd)
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate subagent system prompt: %w", err)
+	}
+
+	if systemPrompt == "" {
+		cm.logger.Info("Skipping empty subagent system prompt generation")
+		return nil, nil
+	}
+
+	systemMessage := llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: systemPrompt}},
+	}
+
+	created, err := cm.db.CreateMessage(ctx, db.CreateMessageParams{
+		ConversationID: cm.conversationID,
+		Type:           db.MessageTypeSystem,
+		LLMData:        systemMessage,
+		UsageData:      llm.Usage{},
+	})
+	if err != nil {
+		return nil, fmt.Errorf("failed to store subagent system prompt: %w", err)
+	}
+
+	cm.logger.Info("Stored subagent system prompt", "length", len(systemPrompt))
+	return created, nil
+}
+
 func (cm *ConversationManager) partitionMessages(messages []generated.Message) ([]llm.Message, []llm.SystemContent) {
 	var history []llm.Message
 	var system []llm.SystemContent
@@ -321,6 +362,7 @@ func (cm *ConversationManager) ensureLoop(service llm.Service, modelID string) e
 	// Create tools for this conversation with the conversation's working directory
 	toolSetConfig.WorkingDir = cwd
 	toolSetConfig.ModelID = modelID
+	toolSetConfig.ParentConversationID = conversationID // For subagent tool
 	toolSetConfig.OnWorkingDirChange = func(newDir string) {
 		// Persist working directory change to database
 		if err := db.UpdateConversationCwd(context.Background(), conversationID, newDir); err != nil {

server/handlers.go 🔗

@@ -552,6 +552,9 @@ func (s *Server) conversationMux() *http.ServeMux {
 	mux.HandleFunc("POST /{id}/rename", func(w http.ResponseWriter, r *http.Request) {
 		s.handleRenameConversation(w, r, r.PathValue("id"))
 	})
+	mux.HandleFunc("GET /{id}/subagents", func(w http.ResponseWriter, r *http.Request) {
+		s.handleGetSubagents(w, r, r.PathValue("id"))
+	})
 	return mux
 }
 

server/server.go 🔗

@@ -221,7 +221,7 @@ type Server struct {
 
 // NewServer creates a new server instance
 func NewServer(database *db.DB, llmManager LLMProvider, toolSetConfig claudetool.ToolSetConfig, logger *slog.Logger, predictableOnly bool, terminalURL, defaultModel, requireHeader string, links []Link) *Server {
-	return &Server{
+	s := &Server{
 		db:                  database,
 		llmManager:          llmManager,
 		toolSetConfig:       toolSetConfig,
@@ -233,6 +233,12 @@ func NewServer(database *db.DB, llmManager LLMProvider, toolSetConfig claudetool
 		requireHeader:       requireHeader,
 		links:               links,
 	}
+
+	// Set up subagent support
+	s.toolSetConfig.SubagentRunner = NewSubagentRunner(s)
+	s.toolSetConfig.SubagentDB = &db.SubagentDBAdapter{DB: database}
+
+	return s
 }
 
 // RegisterRoutes registers HTTP routes on the given mux

server/subagent.go 🔗

@@ -0,0 +1,187 @@
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/llm"
+)
+
+// SubagentRunner implements claudetool.SubagentRunner.
+type SubagentRunner struct {
+	server *Server
+}
+
+// NewSubagentRunner creates a new SubagentRunner.
+func NewSubagentRunner(s *Server) *SubagentRunner {
+	return &SubagentRunner{server: s}
+}
+
+// RunSubagent implements claudetool.SubagentRunner.
+func (r *SubagentRunner) RunSubagent(ctx context.Context, conversationID, prompt string, wait bool, timeout time.Duration) (string, error) {
+	s := r.server
+
+	// Get or create conversation manager for the subagent
+	manager, err := s.getOrCreateConversationManager(ctx, conversationID)
+	if err != nil {
+		return "", fmt.Errorf("failed to get conversation manager: %w", err)
+	}
+
+	// Get the model ID from the server's default
+	// In predictable-only mode, use "predictable" as the model
+	modelID := s.defaultModel
+	if modelID == "" && s.predictableOnly {
+		modelID = "predictable"
+	}
+
+	// Get LLM service
+	llmService, err := s.llmManager.GetService(modelID)
+	if err != nil {
+		return "", fmt.Errorf("failed to get LLM service: %w", err)
+	}
+
+	// Create user message
+	userMessage := llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: prompt}},
+	}
+
+	// Accept the user message (this starts processing)
+	_, err = manager.AcceptUserMessage(ctx, llmService, modelID, userMessage)
+	if err != nil {
+		return "", fmt.Errorf("failed to accept user message: %w", err)
+	}
+
+	if !wait {
+		return fmt.Sprintf("Subagent started processing. Conversation ID: %s", conversationID), nil
+	}
+
+	// Wait for the agent to finish (or timeout)
+	return r.waitForResponse(ctx, conversationID, timeout)
+}
+
+func (r *SubagentRunner) waitForResponse(ctx context.Context, conversationID string, timeout time.Duration) (string, error) {
+	s := r.server
+
+	deadline := time.Now().Add(timeout)
+	pollInterval := 500 * time.Millisecond
+
+	for {
+		select {
+		case <-ctx.Done():
+			return "", ctx.Err()
+		default:
+		}
+
+		if time.Now().After(deadline) {
+			return "Subagent is still working (timeout reached). Send another message to check status.", nil
+		}
+
+		// Check if agent is still working
+		working, err := r.isAgentWorking(ctx, conversationID)
+		if err != nil {
+			return "", fmt.Errorf("failed to check agent status: %w", err)
+		}
+
+		if !working {
+			// Agent is done, get the last message
+			return r.getLastAssistantResponse(ctx, conversationID)
+		}
+
+		// Wait before polling again
+		select {
+		case <-ctx.Done():
+			return "", ctx.Err()
+		case <-time.After(pollInterval):
+		}
+
+		// Don't hog the conversation manager mutex
+		s.mu.Lock()
+		if mgr, ok := s.activeConversations[conversationID]; ok {
+			mgr.Touch()
+		}
+		s.mu.Unlock()
+	}
+}
+
+func (r *SubagentRunner) isAgentWorking(ctx context.Context, conversationID string) (bool, error) {
+	s := r.server
+
+	// Get the conversation manager - it tracks the working state
+	s.mu.Lock()
+	mgr, ok := s.activeConversations[conversationID]
+	s.mu.Unlock()
+
+	if !ok {
+		// No active manager means the agent is not working
+		return false, nil
+	}
+
+	return mgr.IsAgentWorking(), nil
+}
+
+func (r *SubagentRunner) getLastAssistantResponse(ctx context.Context, conversationID string) (string, error) {
+	s := r.server
+
+	// Get the latest message
+	msg, err := s.db.GetLatestMessage(ctx, conversationID)
+	if err != nil {
+		return "", fmt.Errorf("failed to get latest message: %w", err)
+	}
+
+	// Extract text content
+	if msg.LlmData == nil {
+		return "", nil
+	}
+
+	var llmMsg llm.Message
+	if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+		return "", fmt.Errorf("failed to parse message: %w", err)
+	}
+
+	var texts []string
+	for _, content := range llmMsg.Content {
+		if content.Type == llm.ContentTypeText && content.Text != "" {
+			texts = append(texts, content.Text)
+		}
+	}
+
+	return strings.Join(texts, "\n"), nil
+}
+
+// createSubagentToolSetConfig creates a ToolSetConfig for subagent conversations.
+// Subagent conversations don't have nested subagents to avoid complexity.
+func (s *Server) createSubagentToolSetConfig(conversationID string) claudetool.ToolSetConfig {
+	return claudetool.ToolSetConfig{
+		LLMProvider:      s.llmManager,
+		EnableJITInstall: true,
+		EnableBrowser:    true, // Subagents can use browser tools
+		// No SubagentRunner/DB - subagents can't spawn nested subagents
+	}
+}
+
+// Ensure SubagentRunner implements claudetool.SubagentRunner.
+var _ claudetool.SubagentRunner = (*SubagentRunner)(nil)
+
+// handleGetSubagents returns the list of subagents for a conversation.
+func (s *Server) handleGetSubagents(w http.ResponseWriter, r *http.Request, conversationID string) {
+	if r.Method != "GET" {
+		http.Error(w, "Method not allowed", 405)
+		return
+	}
+
+	subagents, err := s.db.GetSubagents(r.Context(), conversationID)
+	if err != nil {
+		s.logger.Error("Failed to get subagents", "conversationID", conversationID, "error", err)
+		http.Error(w, "Failed to get subagents", 500)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(subagents)
+}

server/subagent_system_prompt.txt 🔗

@@ -0,0 +1,13 @@
+You are a subagent of Shelley, a coding agent. You have been delegated a specific task by the parent agent.
+
+Key constraints:
+- Complete your assigned task thoroughly
+- Your final message will be returned to the parent agent as the result
+- Write important findings to files if the parent may need them later
+- Be concise in your final response - summarize what you did and the outcome
+- If you encounter blocking issues, explain them clearly so the parent can help
+
+Working directory: {{.WorkingDirectory}}
+{{if .GitInfo}}
+Git repository root: {{.GitInfo.Root}}
+{{end}}

server/system_prompt.go 🔗

@@ -13,6 +13,9 @@ import (
 //go:embed system_prompt.txt
 var systemPromptTemplate string
 
+//go:embed subagent_system_prompt.txt
+var subagentSystemPromptTemplate string
+
 // SystemPromptData contains all the data needed to render the system prompt template
 type SystemPromptData struct {
 	WorkingDirectory string
@@ -287,3 +290,44 @@ func isSudoAvailable() bool {
 	_, err := cmd.CombinedOutput()
 	return err == nil
 }
+
+// SubagentSystemPromptData contains data for subagent system prompts (minimal subset)
+type SubagentSystemPromptData struct {
+	WorkingDirectory string
+	GitInfo          *GitInfo
+}
+
+// GenerateSubagentSystemPrompt generates a minimal system prompt for subagent conversations.
+func GenerateSubagentSystemPrompt(workingDir string) (string, error) {
+	wd := workingDir
+	if wd == "" {
+		var err error
+		wd, err = os.Getwd()
+		if err != nil {
+			return "", fmt.Errorf("failed to get working directory: %w", err)
+		}
+	}
+
+	data := &SubagentSystemPromptData{
+		WorkingDirectory: wd,
+	}
+
+	// Try to collect git info
+	gitInfo, err := collectGitInfo()
+	if err == nil {
+		data.GitInfo = gitInfo
+	}
+
+	tmpl, err := template.New("subagent_system_prompt").Parse(subagentSystemPromptTemplate)
+	if err != nil {
+		return "", fmt.Errorf("failed to parse subagent template: %w", err)
+	}
+
+	var buf strings.Builder
+	err = tmpl.Execute(&buf, data)
+	if err != nil {
+		return "", fmt.Errorf("failed to execute subagent template: %w", err)
+	}
+
+	return buf.String(), nil
+}

test/server_test.go 🔗

@@ -1132,3 +1132,134 @@ func TestGitStateChangeCreatesGitInfoMessage(t *testing.T) {
 		t.Fatal("Expected a gitinfo message to be created after git commit, but none was found")
 	}
 }
+
+func TestSubagentEndToEnd(t *testing.T) {
+	// Create temporary database
+	tempDB := t.TempDir() + "/test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+	defer database.Close()
+
+	// Run migrations
+	if err := database.Migrate(context.Background()); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	// Create logger
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+		Level: slog.LevelDebug,
+	}))
+
+	// Create LLM service manager with predictable service
+	llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+
+	// Set up tools config
+	toolSetConfig := claudetool.ToolSetConfig{
+		WorkingDir:    t.TempDir(),
+		EnableBrowser: false,
+	}
+
+	// Create server (predictable-only mode)
+	svr := server.NewServer(database, llmManager, toolSetConfig, logger, true, "", "", "", nil)
+
+	// Set up HTTP server
+	mux := http.NewServeMux()
+	svr.RegisterRoutes(mux)
+	ts := httptest.NewServer(mux)
+	defer ts.Close()
+
+	client := &http.Client{Timeout: 60 * time.Second}
+
+	// Create a new conversation that will spawn a subagent
+	// The predictable service will respond with a subagent tool call for "subagent: test-worker echo hello"
+	chatReq := map[string]interface{}{
+		"message": "subagent: test-worker echo hello",
+		"model":   "predictable",
+	}
+	reqBody, _ := json.Marshal(chatReq)
+
+	resp, err := client.Post(ts.URL+"/api/conversations/new", "application/json", bytes.NewBuffer(reqBody))
+	if err != nil {
+		t.Fatalf("Failed to create conversation: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+		body, _ := io.ReadAll(resp.Body)
+		t.Fatalf("Expected 200/201, got %d: %s", resp.StatusCode, string(body))
+	}
+
+	var createResp struct {
+		ConversationID string `json:"conversation_id"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
+		t.Fatalf("Failed to decode response: %v", err)
+	}
+
+	parentConvID := createResp.ConversationID
+	t.Logf("Created parent conversation: %s", parentConvID)
+
+	// Wait for the conversation to complete (subagent should be created and executed)
+	time.Sleep(3 * time.Second)
+
+	// Check that subagents were created
+	subagentsResp, err := client.Get(ts.URL + "/api/conversation/" + parentConvID + "/subagents")
+	if err != nil {
+		t.Fatalf("Failed to get subagents: %v", err)
+	}
+	defer subagentsResp.Body.Close()
+
+	var subagents []generated.Conversation
+	if err := json.NewDecoder(subagentsResp.Body).Decode(&subagents); err != nil {
+		t.Fatalf("Failed to decode subagents: %v", err)
+	}
+
+	if len(subagents) == 0 {
+		t.Fatal("Expected at least one subagent to be created")
+	}
+
+	t.Logf("Created %d subagent(s)", len(subagents))
+	for _, sub := range subagents {
+		t.Logf("  - Subagent: %s (slug: %v)", sub.ConversationID, sub.Slug)
+	}
+
+	// Verify the subagent has the expected slug (or a suffixed version)
+	foundExpectedSlug := false
+	for _, sub := range subagents {
+		if sub.Slug != nil && (strings.HasPrefix(*sub.Slug, "test-worker")) {
+			foundExpectedSlug = true
+			break
+		}
+	}
+	if !foundExpectedSlug {
+		t.Errorf("Expected to find subagent with slug starting with 'test-worker'")
+	}
+
+	// Verify the subagent has a parent_conversation_id set
+	for _, sub := range subagents {
+		if sub.ParentConversationID == nil || *sub.ParentConversationID != parentConvID {
+			t.Errorf("Subagent %s has wrong parent_conversation_id: %v", sub.ConversationID, sub.ParentConversationID)
+		}
+	}
+
+	// Verify the subagent conversation has messages
+	subConvResp, err := client.Get(ts.URL + "/api/conversation/" + subagents[0].ConversationID)
+	if err != nil {
+		t.Fatalf("Failed to get subagent conversation: %v", err)
+	}
+	defer subConvResp.Body.Close()
+
+	var subConvData struct {
+		Messages []json.RawMessage `json:"messages"`
+	}
+	if err := json.NewDecoder(subConvResp.Body).Decode(&subConvData); err != nil {
+		t.Fatalf("Failed to decode subagent conversation: %v", err)
+	}
+
+	if len(subConvData.Messages) == 0 {
+		t.Error("Expected subagent conversation to have messages")
+	}
+	t.Logf("Subagent conversation has %d messages", len(subConvData.Messages))
+}

ui/src/App.tsx 🔗

@@ -67,6 +67,8 @@ function App() {
   const [diffViewerTrigger, setDiffViewerTrigger] = useState(0);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
+  const [subagentUpdate, setSubagentUpdate] = useState<Conversation | null>(null);
+  const [subagentStateUpdate, setSubagentStateUpdate] = useState<{ conversation_id: string; working: boolean } | null>(null);
   const initialSlugResolved = useRef(false);
 
   // Resolve initial slug from URL - uses the captured initialSlugFromUrl
@@ -120,6 +122,11 @@ function App() {
   // Handle conversation list updates from the message stream
   const handleConversationListUpdate = useCallback((update: ConversationListUpdate) => {
     if (update.type === "update" && update.conversation) {
+      // Handle subagent conversations separately
+      if (update.conversation.parent_conversation_id) {
+        setSubagentUpdate(update.conversation);
+        return;
+      }
       setConversations((prev) => {
         // Check if this conversation already exists
         const existingIndex = prev.findIndex(
@@ -148,13 +155,21 @@ function App() {
   // Handle conversation state updates (working state changes)
   const handleConversationStateUpdate = useCallback(
     (state: { conversation_id: string; working: boolean }) => {
-      setConversations((prev) =>
-        prev.map((conv) =>
-          conv.conversation_id === state.conversation_id
-            ? { ...conv, working: state.working }
-            : conv,
-        ),
-      );
+      // Check if this is a top-level conversation
+      setConversations((prev) => {
+        const found = prev.find((conv) => conv.conversation_id === state.conversation_id);
+        if (found) {
+          return prev.map((conv) =>
+            conv.conversation_id === state.conversation_id
+              ? { ...conv, working: state.working }
+              : conv,
+          );
+        }
+        // Not a top-level conversation, might be a subagent
+        // Pass the state update to the drawer
+        setSubagentStateUpdate(state);
+        return prev;
+      });
     },
     [],
   );
@@ -209,6 +224,10 @@ function App() {
   };
 
   const updateConversation = (updatedConversation: Conversation) => {
+    // Skip subagent conversations for the main list
+    if (updatedConversation.parent_conversation_id) {
+      return;
+    }
     setConversations((prev) =>
       prev.map((conv) =>
         conv.conversation_id === updatedConversation.conversation_id
@@ -307,6 +326,8 @@ function App() {
         onConversationArchived={handleConversationArchived}
         onConversationUnarchived={handleConversationUnarchived}
         onConversationRenamed={handleConversationRenamed}
+        subagentUpdate={subagentUpdate}
+        subagentStateUpdate={subagentStateUpdate}
       />
 
       {/* Main chat interface */}

ui/src/components/ChatInterface.tsx 🔗

@@ -23,6 +23,7 @@ import ReadImageTool from "./ReadImageTool";
 import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
 import ChangeDirTool from "./ChangeDirTool";
 import BrowserResizeTool from "./BrowserResizeTool";
+import SubagentTool from "./SubagentTool";
 import DirectoryPickerModal from "./DirectoryPickerModal";
 
 interface ContextUsageBarProps {
@@ -152,6 +153,7 @@ const TOOL_COMPONENTS: Record<string, React.ComponentType<any>> = {
   browser_clear_console_logs: BrowserConsoleLogsTool,
   change_dir: ChangeDirTool,
   browser_resize: BrowserResizeTool,
+  subagent: SubagentTool,
 };
 
 function CoalescedToolCall({

ui/src/components/ConversationDrawer.tsx 🔗

@@ -14,6 +14,8 @@ interface ConversationDrawerProps {
   onConversationArchived?: (id: string) => void;
   onConversationUnarchived?: (conversation: Conversation) => void;
   onConversationRenamed?: (conversation: Conversation) => void;
+  subagentUpdate?: Conversation | null; // When a subagent is created/updated
+  subagentStateUpdate?: { conversation_id: string; working: boolean } | null; // When a subagent's working state changes
 }
 
 function ConversationDrawer({
@@ -28,12 +30,16 @@ function ConversationDrawer({
   onConversationArchived,
   onConversationUnarchived,
   onConversationRenamed,
+  subagentUpdate,
+  subagentStateUpdate,
 }: ConversationDrawerProps) {
   const [showArchived, setShowArchived] = useState(false);
   const [archivedConversations, setArchivedConversations] = useState<Conversation[]>([]);
   const [loadingArchived, setLoadingArchived] = useState(false);
   const [editingId, setEditingId] = useState<string | null>(null);
   const [editingSlug, setEditingSlug] = useState("");
+  const [subagents, setSubagents] = useState<Record<string, ConversationWithState[]>>({});
+  const [expandedSubagents, setExpandedSubagents] = useState<Set<string>>(new Set());
   const renameInputRef = React.useRef<HTMLInputElement>(null);
 
   useEffect(() => {
@@ -42,6 +48,88 @@ function ConversationDrawer({
     }
   }, [showArchived]);
 
+  // Load subagents for the current conversation
+  useEffect(() => {
+    if (currentConversationId && !showArchived) {
+      loadSubagents(currentConversationId);
+      // Auto-expand the current conversation's subagents
+      setExpandedSubagents((prev) => new Set([...prev, currentConversationId]));
+    }
+  }, [currentConversationId, showArchived]);
+
+  // Handle real-time subagent updates
+  useEffect(() => {
+    if (subagentUpdate && subagentUpdate.parent_conversation_id) {
+      const parentId = subagentUpdate.parent_conversation_id;
+      setSubagents((prev) => {
+        const existing = prev[parentId] || [];
+        // Check if this subagent already exists
+        const existingIndex = existing.findIndex(
+          (s) => s.conversation_id === subagentUpdate.conversation_id,
+        );
+        if (existingIndex >= 0) {
+          // Update existing, preserving working state
+          const updated = [...existing];
+          updated[existingIndex] = { ...subagentUpdate, working: existing[existingIndex].working };
+          return { ...prev, [parentId]: updated };
+        } else {
+          // Add new subagent (not working by default)
+          return { ...prev, [parentId]: [...existing, { ...subagentUpdate, working: false }] };
+        }
+      });
+      // Auto-expand parent to show the new subagent
+      setExpandedSubagents((prev) => new Set([...prev, parentId]));
+    }
+  }, [subagentUpdate]);
+
+  // Handle subagent working state updates
+  useEffect(() => {
+    if (subagentStateUpdate) {
+      setSubagents((prev) => {
+        // Find which parent contains this subagent
+        for (const [parentId, subs] of Object.entries(prev)) {
+          const subIndex = subs.findIndex((s) => s.conversation_id === subagentStateUpdate.conversation_id);
+          if (subIndex >= 0) {
+            const updated = [...subs];
+            updated[subIndex] = { ...updated[subIndex], working: subagentStateUpdate.working };
+            return { ...prev, [parentId]: updated };
+          }
+        }
+        return prev;
+      });
+    }
+  }, [subagentStateUpdate]);
+
+  const loadSubagents = async (conversationId: string) => {
+    // Skip if already loaded
+    if (subagents[conversationId]) return;
+    try {
+      const subs = await api.getSubagents(conversationId);
+      if (subs && subs.length > 0) {
+        // Add working: false to each subagent
+        const subsWithState = subs.map((s) => ({ ...s, working: false }));
+        setSubagents((prev) => ({ ...prev, [conversationId]: subsWithState }));
+      }
+    } catch (err) {
+      console.error("Failed to load subagents:", err);
+    }
+  };
+
+  const toggleSubagents = (e: React.MouseEvent, conversationId: string) => {
+    e.stopPropagation();
+    setExpandedSubagents((prev) => {
+      const next = new Set(prev);
+      if (next.has(conversationId)) {
+        next.delete(conversationId);
+      } else {
+        next.add(conversationId);
+        // Load subagents if not already loaded
+        loadSubagents(conversationId);
+      }
+      return next;
+    });
+  };
+
   const loadArchivedConversations = async () => {
     setLoadingArchived(true);
     try {
@@ -267,171 +355,267 @@ function ConversationDrawer({
             <div className="conversation-list">
               {displayedConversations.map((conversation) => {
                 const isActive = conversation.conversation_id === currentConversationId;
+                const hasSubagents = subagents[conversation.conversation_id]?.length > 0;
+                const isExpanded = expandedSubagents.has(conversation.conversation_id);
+                const conversationSubagents = subagents[conversation.conversation_id] || [];
                 return (
-                  <div
-                    key={conversation.conversation_id}
-                    className={`conversation-item ${isActive ? "active" : ""}`}
-                    onClick={() => {
-                      if (!showArchived) {
-                        onSelectConversation(conversation.conversation_id);
-                      }
-                    }}
-                    style={{ cursor: showArchived ? "default" : "pointer" }}
-                  >
-                    <div style={{ flex: 1, minWidth: 0 }}>
-                      <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
-                        <div style={{ flex: 1, minWidth: 0 }}>
-                          {editingId === conversation.conversation_id ? (
-                            <input
-                              ref={renameInputRef}
-                              type="text"
-                              value={editingSlug}
-                              onChange={(e) => setEditingSlug(e.target.value)}
-                              onBlur={() => handleRename(conversation.conversation_id)}
-                              onKeyDown={(e) =>
-                                handleRenameKeyDown(e, conversation.conversation_id)
-                              }
-                              onClick={(e) => e.stopPropagation()}
-                              autoFocus
-                              className="conversation-title"
+                  <React.Fragment key={conversation.conversation_id}>
+                    <div
+                      className={`conversation-item ${isActive ? "active" : ""}`}
+                      onClick={() => {
+                        if (!showArchived) {
+                          onSelectConversation(conversation.conversation_id);
+                        }
+                      }}
+                      style={{ cursor: showArchived ? "default" : "pointer" }}
+                    >
+                      <div style={{ flex: 1, minWidth: 0 }}>
+                        <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
+                          <div style={{ flex: 1, minWidth: 0 }}>
+                            {editingId === conversation.conversation_id ? (
+                              <input
+                                ref={renameInputRef}
+                                type="text"
+                                value={editingSlug}
+                                onChange={(e) => setEditingSlug(e.target.value)}
+                                onBlur={() => handleRename(conversation.conversation_id)}
+                                onKeyDown={(e) =>
+                                  handleRenameKeyDown(e, conversation.conversation_id)
+                                }
+                                onClick={(e) => e.stopPropagation()}
+                                autoFocus
+                                className="conversation-title"
+                                style={{
+                                  width: "100%",
+                                  background: "transparent",
+                                  border: "none",
+                                  borderBottom: "1px solid var(--text-secondary)",
+                                  outline: "none",
+                                  padding: 0,
+                                  font: "inherit",
+                                  color: "inherit",
+                                }}
+                              />
+                            ) : (
+                              <div className="conversation-title">
+                                {getConversationPreview(conversation)}
+                              </div>
+                            )}
+                          </div>
+                          {(conversation as ConversationWithState).working && (
+                            <span
+                              className="working-indicator"
+                              title="Agent is working"
                               style={{
-                                width: "100%",
-                                background: "transparent",
-                                border: "none",
-                                borderBottom: "1px solid var(--text-secondary)",
-                                outline: "none",
-                                padding: 0,
-                                font: "inherit",
-                                color: "inherit",
+                                width: "8px",
+                                height: "8px",
+                                borderRadius: "50%",
+                                backgroundColor: "var(--accent-color, #3b82f6)",
+                                flexShrink: 0,
+                                animation: "pulse 2s ease-in-out infinite",
                               }}
                             />
-                          ) : (
-                            <div className="conversation-title">
-                              {getConversationPreview(conversation)}
-                            </div>
                           )}
                         </div>
-                        {(conversation as ConversationWithState).working && (
-                          <span
-                            className="working-indicator"
-                            title="Agent is working"
-                            style={{
-                              width: "8px",
-                              height: "8px",
-                              borderRadius: "50%",
-                              backgroundColor: "var(--accent-color, #3b82f6)",
-                              flexShrink: 0,
-                              animation: "pulse 2s ease-in-out infinite",
-                            }}
-                          />
-                        )}
-                      </div>
-                      <div className="conversation-meta">
-                        <span className="conversation-date">
-                          {formatDate(conversation.updated_at)}
-                        </span>
-                        {conversation.cwd && (
-                          <span className="conversation-cwd" title={conversation.cwd}>
-                            {formatCwdForDisplay(conversation.cwd)}
+                        <div className="conversation-meta">
+                          <span className="conversation-date">
+                            {formatDate(conversation.updated_at)}
                           </span>
-                        )}
-                        {!showArchived && (
-                          <div
-                            className="conversation-actions"
-                            style={{ display: "flex", gap: "0.25rem", marginLeft: "auto" }}
-                          >
-                            <button
-                              onClick={(e) => handleStartRename(e, conversation)}
-                              className="btn-icon-sm"
-                              title="Rename"
-                              aria-label="Rename conversation"
+                          {conversation.cwd && (
+                            <span className="conversation-cwd" title={conversation.cwd}>
+                              {formatCwdForDisplay(conversation.cwd)}
+                            </span>
+                          )}
+                          {!showArchived && (
+                            <div
+                              className="conversation-actions"
+                              style={{ display: "flex", gap: "0.25rem", marginLeft: "auto" }}
                             >
-                              <svg
-                                fill="none"
-                                stroke="currentColor"
-                                viewBox="0 0 24 24"
-                                style={{ width: "1rem", height: "1rem" }}
+                              <button
+                                onClick={(e) => handleStartRename(e, conversation)}
+                                className="btn-icon-sm"
+                                title="Rename"
+                                aria-label="Rename conversation"
                               >
-                                <path
-                                  strokeLinecap="round"
-                                  strokeLinejoin="round"
-                                  strokeWidth={2}
-                                  d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
-                                />
-                              </svg>
-                            </button>
-                            <button
-                              onClick={(e) => handleArchive(e, conversation.conversation_id)}
-                              className="btn-icon-sm"
-                              title="Archive"
-                              aria-label="Archive conversation"
-                            >
-                              <svg
-                                fill="none"
-                                stroke="currentColor"
-                                viewBox="0 0 24 24"
-                                style={{ width: "1rem", height: "1rem" }}
+                                <svg
+                                  fill="none"
+                                  stroke="currentColor"
+                                  viewBox="0 0 24 24"
+                                  style={{ width: "1rem", height: "1rem" }}
+                                >
+                                  <path
+                                    strokeLinecap="round"
+                                    strokeLinejoin="round"
+                                    strokeWidth={2}
+                                    d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+                                  />
+                                </svg>
+                              </button>
+                              <button
+                                onClick={(e) => handleArchive(e, conversation.conversation_id)}
+                                className="btn-icon-sm"
+                                title="Archive"
+                                aria-label="Archive conversation"
                               >
-                                <path
-                                  strokeLinecap="round"
-                                  strokeLinejoin="round"
-                                  strokeWidth={2}
-                                  d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
-                                />
-                              </svg>
-                            </button>
-                          </div>
-                        )}
+                                <svg
+                                  fill="none"
+                                  stroke="currentColor"
+                                  viewBox="0 0 24 24"
+                                  style={{ width: "1rem", height: "1rem" }}
+                                >
+                                  <path
+                                    strokeLinecap="round"
+                                    strokeLinejoin="round"
+                                    strokeWidth={2}
+                                    d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
+                                  />
+                                </svg>
+                              </button>
+                              {/* Subagent count indicator */}
+                              {hasSubagents && (
+                                <button
+                                  onClick={(e) => toggleSubagents(e, conversation.conversation_id)}
+                                  className="btn-icon-sm"
+                                  style={{
+                                    display: "flex",
+                                    alignItems: "center",
+                                    gap: "0.125rem",
+                                    fontSize: "0.75rem",
+                                    minWidth: "auto",
+                                    padding: "0.125rem 0.25rem",
+                                  }}
+                                  title={isExpanded ? "Hide subagents" : "Show subagents"}
+                                  aria-label={isExpanded ? "Collapse subagents" : "Expand subagents"}
+                                >
+                                  <span style={{ fontWeight: 500 }}>
+                                    {conversationSubagents.length}
+                                  </span>
+                                  <svg
+                                    fill="none"
+                                    stroke="currentColor"
+                                    viewBox="0 0 24 24"
+                                    style={{
+                                      width: "0.625rem",
+                                      height: "0.625rem",
+                                      transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+                                      transition: "transform 0.15s ease",
+                                    }}
+                                  >
+                                    <path
+                                      strokeLinecap="round"
+                                      strokeLinejoin="round"
+                                      strokeWidth={2}
+                                      d="M9 5l7 7-7 7"
+                                    />
+                                  </svg>
+                                </button>
+                              )}
+                            </div>
+                          )}
+                        </div>
                       </div>
-                    </div>
-                    {showArchived && (
-                      <div
-                        className="conversation-actions"
-                        style={{ display: "flex", gap: "0.25rem", marginLeft: "0.5rem" }}
-                      >
-                        <button
-                          onClick={(e) => handleUnarchive(e, conversation.conversation_id)}
-                          className="btn-icon-sm"
-                          title="Restore"
-                          aria-label="Restore conversation"
+                      {showArchived && (
+                        <div
+                          className="conversation-actions"
+                          style={{ display: "flex", gap: "0.25rem", marginLeft: "0.5rem" }}
                         >
-                          <svg
-                            fill="none"
-                            stroke="currentColor"
-                            viewBox="0 0 24 24"
-                            style={{ width: "1rem", height: "1rem" }}
+                          <button
+                            onClick={(e) => handleUnarchive(e, conversation.conversation_id)}
+                            className="btn-icon-sm"
+                            title="Restore"
+                            aria-label="Restore conversation"
                           >
-                            <path
-                              strokeLinecap="round"
-                              strokeLinejoin="round"
-                              strokeWidth={2}
-                              d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
-                            />
-                          </svg>
-                        </button>
-                        <button
-                          onClick={(e) => handleDelete(e, conversation.conversation_id)}
-                          className="btn-icon-sm btn-danger"
-                          title="Delete permanently"
-                          aria-label="Delete conversation"
-                        >
-                          <svg
-                            fill="none"
-                            stroke="currentColor"
-                            viewBox="0 0 24 24"
-                            style={{ width: "1rem", height: "1rem" }}
+                            <svg
+                              fill="none"
+                              stroke="currentColor"
+                              viewBox="0 0 24 24"
+                              style={{ width: "1rem", height: "1rem" }}
+                            >
+                              <path
+                                strokeLinecap="round"
+                                strokeLinejoin="round"
+                                strokeWidth={2}
+                                d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+                              />
+                            </svg>
+                          </button>
+                          <button
+                            onClick={(e) => handleDelete(e, conversation.conversation_id)}
+                            className="btn-icon-sm btn-danger"
+                            title="Delete permanently"
+                            aria-label="Delete conversation"
                           >
-                            <path
-                              strokeLinecap="round"
-                              strokeLinejoin="round"
-                              strokeWidth={2}
-                              d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
-                            />
-                          </svg>
-                        </button>
+                            <svg
+                              fill="none"
+                              stroke="currentColor"
+                              viewBox="0 0 24 24"
+                              style={{ width: "1rem", height: "1rem" }}
+                            >
+                              <path
+                                strokeLinecap="round"
+                                strokeLinejoin="round"
+                                strokeWidth={2}
+                                d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+                              />
+                            </svg>
+                          </button>
+                        </div>
+                      )}
+                    </div>
+                    {/* Render subagents if expanded */}
+                    {!showArchived && isExpanded && conversationSubagents.length > 0 && (
+                      <div className="subagent-list" style={{ marginLeft: "1.5rem" }}>
+                        {conversationSubagents.map((sub) => {
+                          const isSubActive = sub.conversation_id === currentConversationId;
+                          return (
+                            <div
+                              key={sub.conversation_id}
+                              className={`conversation-item subagent-item ${isSubActive ? "active" : ""}`}
+                              onClick={() => onSelectConversation(sub.conversation_id)}
+                              style={{
+                                cursor: "pointer",
+                                fontSize: "0.9em",
+                                paddingLeft: "0.5rem",
+                                borderLeft: "2px solid var(--border-color)",
+                              }}
+                            >
+                              <div style={{ flex: 1, minWidth: 0 }}>
+                                <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
+                                  <div style={{ flex: 1, minWidth: 0 }}>
+                                    <div className="conversation-title">
+                                      {sub.slug || sub.conversation_id}
+                                    </div>
+                                  </div>
+                                  {sub.working && (
+                                    <span
+                                      className="working-indicator"
+                                      title="Subagent is working"
+                                      style={{
+                                        width: "6px",
+                                        height: "6px",
+                                        borderRadius: "50%",
+                                        backgroundColor: "var(--accent-color, #3b82f6)",
+                                        flexShrink: 0,
+                                        animation: "pulse 2s ease-in-out infinite",
+                                      }}
+                                    />
+                                  )}
+                                </div>
+                                <div className="conversation-meta">
+                                  <span
+                                    className="conversation-date"
+                                    style={{ fontSize: "0.85em" }}
+                                  >
+                                    {formatDate(sub.updated_at)}
+                                  </span>
+                                </div>
+                              </div>
+                            </div>
+                          );
+                        })}
                       </div>
                     )}
-                  </div>
+                  </React.Fragment>
                 );
               })}
             </div>

ui/src/components/Message.tsx 🔗

@@ -13,6 +13,7 @@ import ReadImageTool from "./ReadImageTool";
 import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
 import ChangeDirTool from "./ChangeDirTool";
 import BrowserResizeTool from "./BrowserResizeTool";
+import SubagentTool from "./SubagentTool";
 import UsageDetailModal from "./UsageDetailModal";
 import MessageActionBar from "./MessageActionBar";
 
@@ -370,6 +371,10 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
         if (content.ToolName === "browser_resize") {
           return <BrowserResizeTool toolInput={content.ToolInput} isRunning={true} />;
         }
+        // Use specialized component for subagent tool
+        if (content.ToolName === "subagent") {
+          return <SubagentTool toolInput={content.ToolInput} isRunning={true} />;
+        }
         // Use specialized component for browser console logs tools
         if (
           content.ToolName === "browser_recent_console_logs" ||
@@ -569,6 +574,20 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
           );
         }
 
+        // Use specialized component for subagent tool
+        if (toolName === "subagent") {
+          return (
+            <SubagentTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+              displayData={content.Display as { slug?: string; conversation_id?: string }}
+            />
+          );
+        }
+
         // Use specialized component for browser console logs tools
         if (
           toolName === "browser_recent_console_logs" ||

ui/src/components/SubagentTool.tsx 🔗

@@ -0,0 +1,143 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface SubagentToolProps {
+  // For tool_use (pending state)
+  toolInput?: unknown; // { slug: string, prompt: string, timeout_seconds?: number, wait?: boolean }
+  isRunning?: boolean;
+
+  // For tool_result (completed state)
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+  displayData?: { slug?: string; conversation_id?: string };
+}
+
+function SubagentTool({
+  toolInput,
+  isRunning,
+  toolResult,
+  hasError,
+  executionTime,
+  displayData,
+}: SubagentToolProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Extract fields from toolInput
+  const input =
+    typeof toolInput === "object" && toolInput !== null
+      ? (toolInput as { slug?: string; prompt?: string; timeout_seconds?: number; wait?: boolean })
+      : {};
+
+  const slug = input.slug || displayData?.slug || "subagent";
+  const prompt = input.prompt || "";
+  const wait = input.wait !== false;
+  const timeout = input.timeout_seconds || 60;
+
+  // Extract result text
+  const resultText =
+    toolResult
+      ?.filter((r) => r.Type === 2) // ContentTypeText
+      .map((r) => r.Text)
+      .join("\n") || "";
+
+  // Truncate prompt for display
+  const truncateText = (text: string, maxLen: number = 60) => {
+    if (!text) return "";
+    const firstLine = text.split("\n")[0];
+    if (firstLine.length <= maxLen) return firstLine;
+    return firstLine.substring(0, maxLen) + "...";
+  };
+
+  const displayPrompt = truncateText(prompt);
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+      <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="tool-summary">
+          <span className={`tool-emoji ${isRunning ? "running" : ""}`}>⚡</span>
+          <span className="tool-name">subagent</span>
+          {isComplete && hasError && <span className="tool-error">✗</span>}
+          {isComplete && !hasError && <span className="tool-success">✓</span>}
+          <span className="tool-command">
+            Subagent '{slug}' {isRunning ? (wait ? "running..." : "started") : ""}
+            {displayPrompt && !isRunning && ` ${displayPrompt}`}
+          </span>
+        </div>
+        <button
+          className="tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="tool-details">
+          <div className="tool-section">
+            <div className="tool-label">
+              Prompt to '{slug}':
+              {!wait && <span className="tool-badge">fire-and-forget</span>}
+              {timeout !== 60 && <span className="tool-badge">timeout: {timeout}s</span>}
+            </div>
+            <div className="tool-code">{prompt || "(no prompt)"}</div>
+          </div>
+
+          {isComplete && (
+            <div className="tool-section">
+              <div className="tool-label">
+                Response:
+                {executionTime && <span className="tool-time">{executionTime}</span>}
+              </div>
+              <div className={`tool-code ${hasError ? "error" : ""}`}>
+                {resultText || "(no response)"}
+              </div>
+            </div>
+          )}
+
+          {displayData?.conversation_id && (
+            <div className="tool-section">
+              <div className="tool-label">Conversation:</div>
+              <div className="tool-code">
+                <a
+                  href={`/c/${slug}`}
+                  onClick={(e) => {
+                    e.preventDefault();
+                    // Navigate to the subagent conversation
+                    window.history.pushState({}, "", `/c/${slug}`);
+                    window.dispatchEvent(new PopStateEvent("popstate"));
+                  }}
+                  style={{ color: "var(--link-color)", textDecoration: "underline" }}
+                >
+                  View subagent conversation →
+                </a>
+              </div>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default SubagentTool;

ui/src/generated-types.ts 🔗

@@ -4,59 +4,61 @@
 // DO NOT EDIT. This file is automatically generated.
 
 export interface Conversation {
-  conversation_id: string;
-  slug: string | null;
-  user_initiated: boolean;
-  created_at: string;
-  updated_at: string;
-  cwd: string | null;
-  archived: boolean;
+	conversation_id: string;
+	slug: string | null;
+	user_initiated: boolean;
+	created_at: string;
+	updated_at: string;
+	cwd: string | null;
+	archived: boolean;
+	parent_conversation_id: string | null;
 }
 
 export interface Usage {
-  input_tokens: number;
-  cache_creation_input_tokens: number;
-  cache_read_input_tokens: number;
-  output_tokens: number;
-  cost_usd: number;
-  model?: string;
-  start_time?: string | null;
-  end_time?: string | null;
+	input_tokens: number;
+	cache_creation_input_tokens: number;
+	cache_read_input_tokens: number;
+	output_tokens: number;
+	cost_usd: number;
+	model?: string;
+	start_time?: string | null;
+	end_time?: string | null;
 }
 
 export interface ApiMessageForTS {
-  message_id: string;
-  conversation_id: string;
-  sequence_id: number;
-  type: string;
-  llm_data?: string | null;
-  user_data?: string | null;
-  usage_data?: string | null;
-  created_at: string;
-  display_data?: string | null;
-  end_of_turn?: boolean | null;
+	message_id: string;
+	conversation_id: string;
+	sequence_id: number;
+	type: string;
+	llm_data?: string | null;
+	user_data?: string | null;
+	usage_data?: string | null;
+	created_at: string;
+	display_data?: string | null;
+	end_of_turn?: boolean | null;
 }
 
 export interface ConversationStateForTS {
-  conversation_id: string;
-  working: boolean;
+	conversation_id: string;
+	working: boolean;
 }
 
 export interface StreamResponseForTS {
-  messages: ApiMessageForTS[] | null;
-  conversation: Conversation;
-  conversation_state?: ConversationStateForTS | null;
+	messages: ApiMessageForTS[] | null;
+	conversation: Conversation;
+	conversation_state?: ConversationStateForTS | null;
 }
 
 export interface ConversationWithStateForTS {
-  conversation_id: string;
-  slug: string | null;
-  user_initiated: boolean;
-  created_at: string;
-  updated_at: string;
-  cwd: string | null;
-  archived: boolean;
-  working: boolean;
+	conversation_id: string;
+	slug: string | null;
+	user_initiated: boolean;
+	created_at: string;
+	updated_at: string;
+	cwd: string | null;
+	archived: boolean;
+	parent_conversation_id: string | null;
+	working: boolean;
 }
 
-export type MessageType = "user" | "agent" | "tool" | "error" | "system" | "gitinfo";
+export type MessageType = 'user' | 'agent' | 'tool' | 'error' | 'system' | 'gitinfo';

ui/src/services/api.ts 🔗

@@ -200,6 +200,14 @@ class ApiService {
     }
     return response.json();
   }
+
+  async getSubagents(conversationId: string): Promise<Conversation[]> {
+    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/subagents`);
+    if (!response.ok) {
+      throw new Error(`Failed to get subagents: ${response.statusText}`);
+    }
+    return response.json();
+  }
 }
 
 export const api = new ApiService();