summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-23 06:50:43 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-23 06:50:43 +0000
commitb0688c8819da1b7fcb4a97b6ec1fa58050e4841e (patch)
treec62b4ea3e5cdb56225c39ad930dd3e5584053827
parentef7a45361996b7a49226a0b088e2599f2801d017 (diff)
feat: complete Agent Context API Phase 2 & 3 (Write/Create/Management)
- Implement write operations (complete, uncomplete, update due date, update task) - Implement create operations (create task, add shopping item) - Add Trusted Agents management UI in Settings with revocation support - Fix SQLite timestamp scanning bug for completed tasks - Add comprehensive unit tests for all new agent endpoints - Update worklog and feature documentation
-rw-r--r--.agent/worklog.md10
-rw-r--r--cmd/dashboard/main.go13
-rw-r--r--internal/handlers/agent.go271
-rw-r--r--internal/handlers/agent_test.go152
-rw-r--r--internal/handlers/settings.go3
-rw-r--r--internal/store/sqlite.go29
-rw-r--r--issues/feature_agent_context_api.md271
-rw-r--r--web/templates/index.html36
-rw-r--r--web/templates/settings.html38
9 files changed, 557 insertions, 266 deletions
diff --git a/.agent/worklog.md b/.agent/worklog.md
index 08f6409..1424a6c 100644
--- a/.agent/worklog.md
+++ b/.agent/worklog.md
@@ -1,9 +1,10 @@
# Session State
## Current Focus
-Sync log + clear cache feedback
+Agent Context API Phase 2 & 3 Complete
## Recently Completed
+- **Agent Context API Phase 2 & 3** — Write operations (complete, uncomplete, update due, update task), create operations (task, shopping), and management UI in settings. Fixed SQLite timestamp scanning issue.
- **Sync log + clear cache feedback** — migration `016_sync_log.sql`, store methods `AddSyncLogEntry`/`GetRecentSyncLog`, handler changes, template partial `sync-log.html`
- `HandleClearCache` now renders sync log HTML (replaces `hx-swap="none"`)
- `HandleSyncSources` adds log entry after sync
@@ -53,10 +54,10 @@ Sync log + clear cache feedback
- **#73**: Store grouping — partial tests, partial docs
## Agent Context API
-- Status: [REVIEW_READY] Phase 1 Complete
+- Status: [COMPLETED] Phase 1, 2, & 3
- Tests: `internal/handlers/agent_test.go` (comprehensive)
- Docs: ADR-005, DESIGN.md §Agent Context API
-- Phase 2 (Write Operations) and Phase 3 (Create + Management) pending
+- Write operations, Creation, and Management UI all implemented and verified.
## Known Gaps
- Google Tasks API client lacks dedicated unit tests (integration tested via timeline)
@@ -71,8 +72,7 @@ Sync log + clear cache feedback
## Next Steps
1. Set `WEBAUTHN_RP_ID` and `WEBAUTHN_ORIGIN` in production to enable passkeys
-2. **Phase 2**: Agent write operations (complete, uncomplete, update due date)
-3. **Phase 3**: Create task/shopping item, agent management UI
+2. Implement feature requests #12, #28 etc.
## Process Improvements
- Extracted dev workflow to `~/.claude/CLAUDE.md` — reusable across projects
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 4112640..1d9e054 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -194,10 +194,20 @@ func main() {
r.Post("/auth/deny", h.HandleAgentAuthDeny)
})
- // Agent session required for context
+ // Agent session required for context and write operations
r.Group(func(r chi.Router) {
r.Use(h.AgentAuthMiddleware)
r.Get("/context", h.HandleAgentContext)
+
+ // Write Operations
+ r.Post("/tasks/{id}/complete", h.HandleAgentTaskComplete)
+ r.Post("/tasks/{id}/uncomplete", h.HandleAgentTaskUncomplete)
+ r.Patch("/tasks/{id}/due", h.HandleAgentTaskUpdateDue)
+ r.Patch("/tasks/{id}", h.HandleAgentTaskUpdate)
+
+ // Create Operations
+ r.Post("/tasks", h.HandleAgentTaskCreate)
+ r.Post("/shopping", h.HandleAgentShoppingAdd)
})
// HTML endpoints for browser-only agents (GET requests only)
@@ -273,6 +283,7 @@ func main() {
r.Post("/settings/features", h.HandleCreateFeature)
r.Post("/settings/features/toggle", h.HandleToggleFeature)
r.Delete("/settings/features/{name}", h.HandleDeleteFeature)
+ r.Delete("/settings/agents/{id}", h.HandleDeleteAgent)
// WebSocket for notifications
r.Get("/ws/notifications", h.HandleWebSocket)
diff --git a/internal/handlers/agent.go b/internal/handlers/agent.go
index aa3f000..6d6079f 100644
--- a/internal/handlers/agent.go
+++ b/internal/handlers/agent.go
@@ -9,8 +9,11 @@ import (
"net/http"
"time"
+ "github.com/go-chi/chi/v5"
+
"task-dashboard/internal/config"
"task-dashboard/internal/models"
+ "task-dashboard/internal/store"
)
// -----------------------------------------------------------------------------
@@ -385,6 +388,274 @@ func (h *Handler) buildCompletedLog(limit int) []agentCompletedItem {
}
// -----------------------------------------------------------------------------
+// Write Handlers
+// -----------------------------------------------------------------------------
+
+// HandleAgentTaskComplete handles POST /agent/tasks/{id}/complete
+func (h *Handler) HandleAgentTaskComplete(w http.ResponseWriter, r *http.Request) {
+ h.handleAgentTaskToggle(w, r, true)
+}
+
+// HandleAgentTaskUncomplete handles POST /agent/tasks/{id}/uncomplete
+func (h *Handler) HandleAgentTaskUncomplete(w http.ResponseWriter, r *http.Request) {
+ h.handleAgentTaskToggle(w, r, false)
+}
+
+// handleAgentTaskToggle handles both complete and uncomplete operations for agents
+func (h *Handler) handleAgentTaskToggle(w http.ResponseWriter, r *http.Request, complete bool) {
+ id := chi.URLParam(r, "id")
+ source := r.URL.Query().Get("source")
+
+ if id == "" || source == "" {
+ http.Error(w, "id and source are required", http.StatusBadRequest)
+ return
+ }
+
+ var err error
+ ctx := r.Context()
+ switch source {
+ case "todoist":
+ if complete {
+ err = h.todoistClient.CompleteTask(ctx, id)
+ } else {
+ err = h.todoistClient.ReopenTask(ctx, id)
+ }
+ case "trello":
+ err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"closed": complete})
+ case "gtasks":
+ listID := r.URL.Query().Get("listId")
+ if listID == "" {
+ listID = "@default"
+ }
+ if h.googleTasksClient != nil {
+ if complete {
+ err = h.googleTasksClient.CompleteTask(ctx, listID, id)
+ } else {
+ err = h.googleTasksClient.UncompleteTask(ctx, listID, id)
+ }
+ } else {
+ http.Error(w, "Google Tasks not configured", http.StatusServiceUnavailable)
+ return
+ }
+ default:
+ http.Error(w, "Unknown source: "+source, http.StatusBadRequest)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, "Failed to toggle task: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if complete {
+ title, dueDate := h.getAtomDetails(id, source)
+ _ = h.store.SaveCompletedTask(source, id, title, dueDate)
+ switch source {
+ case "todoist":
+ _ = h.store.DeleteTask(id)
+ case "trello":
+ _ = h.store.DeleteCard(id)
+ }
+ } else {
+ switch source {
+ case "todoist":
+ _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks)
+ case "trello":
+ _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards)
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "success"})
+}
+
+// HandleAgentTaskUpdateDue handles PATCH /agent/tasks/{id}/due
+func (h *Handler) HandleAgentTaskUpdateDue(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ source := r.URL.Query().Get("source")
+
+ var req struct {
+ Due *time.Time `json:"due"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ var err error
+ ctx := r.Context()
+ switch source {
+ case "todoist":
+ err = h.todoistClient.UpdateTask(ctx, id, map[string]interface{}{"due_datetime": req.Due})
+ case "trello":
+ err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"due": req.Due})
+ default:
+ http.Error(w, "Source does not support due date updates via this endpoint", http.StatusBadRequest)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, "Failed to update due date: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Invalidate cache
+ switch source {
+ case "todoist":
+ _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks)
+ case "trello":
+ _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "success"})
+}
+
+// HandleAgentTaskUpdate handles PATCH /agent/tasks/{id}
+func (h *Handler) HandleAgentTaskUpdate(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ source := r.URL.Query().Get("source")
+
+ var updates map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ var err error
+ ctx := r.Context()
+ switch source {
+ case "todoist":
+ // Map generic updates to Todoist specific ones if needed
+ if title, ok := updates["title"].(string); ok {
+ updates["content"] = title
+ delete(updates, "title")
+ }
+ err = h.todoistClient.UpdateTask(ctx, id, updates)
+ case "trello":
+ if title, ok := updates["title"].(string); ok {
+ updates["name"] = title
+ delete(updates, "title")
+ }
+ if desc, ok := updates["description"].(string); ok {
+ updates["desc"] = desc
+ delete(updates, "description")
+ }
+ err = h.trelloClient.UpdateCard(ctx, id, updates)
+ default:
+ http.Error(w, "Source does not support updates via this endpoint", http.StatusBadRequest)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, "Failed to update task: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Invalidate cache
+ switch source {
+ case "todoist":
+ _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks)
+ case "trello":
+ _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "success"})
+}
+
+// HandleAgentTaskCreate handles POST /agent/tasks
+func (h *Handler) HandleAgentTaskCreate(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Title string `json:"title"`
+ Source string `json:"source"`
+ DueDate *time.Time `json:"due_date"`
+ ProjectID string `json:"project_id"`
+ ListID string `json:"list_id"`
+ Priority int `json:"priority"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Title == "" || req.Source == "" {
+ http.Error(w, "title and source are required", http.StatusBadRequest)
+ return
+ }
+
+ var err error
+ ctx := r.Context()
+ switch req.Source {
+ case "todoist":
+ _, err = h.todoistClient.CreateTask(ctx, req.Title, req.ProjectID, req.DueDate, req.Priority)
+ _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks)
+ case "trello":
+ if req.ListID == "" {
+ http.Error(w, "list_id is required for Trello", http.StatusBadRequest)
+ return
+ }
+ _, err = h.trelloClient.CreateCard(ctx, req.ListID, req.Title, "", req.DueDate)
+ _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards)
+ default:
+ http.Error(w, "Unsupported source for task creation", http.StatusBadRequest)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, "Failed to create task: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "success"})
+}
+
+// HandleAgentShoppingAdd handles POST /agent/shopping
+func (h *Handler) HandleAgentShoppingAdd(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Name string `json:"name"`
+ Store string `json:"store"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Name == "" || req.Store == "" {
+ http.Error(w, "name and store are required", http.StatusBadRequest)
+ return
+ }
+
+ if err := h.store.SaveUserShoppingItem(req.Name, req.Store); err != nil {
+ http.Error(w, "Failed to save shopping item: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "success"})
+}
+
+// HandleDeleteAgent handles DELETE /settings/agents/{id}
+func (h *Handler) HandleDeleteAgent(w http.ResponseWriter, r *http.Request) {
+ agentID := chi.URLParam(r, "id")
+ if agentID == "" {
+ http.Error(w, "agent ID is required", http.StatusBadRequest)
+ return
+ }
+
+ if err := h.store.RevokeAgent(agentID); err != nil {
+ http.Error(w, "Failed to revoke agent: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Also invalidate their sessions
+ _ = h.store.InvalidatePreviousAgentSessions(agentID)
+
+ w.WriteHeader(http.StatusOK)
+}
+
+// -----------------------------------------------------------------------------
// Middleware
// -----------------------------------------------------------------------------
diff --git a/internal/handlers/agent_test.go b/internal/handlers/agent_test.go
index 5775962..eab1609 100644
--- a/internal/handlers/agent_test.go
+++ b/internal/handlers/agent_test.go
@@ -2,12 +2,15 @@ package handlers
import (
"bytes"
+ "context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
+ "github.com/go-chi/chi/v5"
+
"task-dashboard/internal/config"
"task-dashboard/internal/models"
)
@@ -738,6 +741,155 @@ func TestHandleAgentWebContext(t *testing.T) {
}
}
+func TestHandleAgentTaskWriteOperations(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ // Mock clients
+ mockTodoist := &mockTodoistClient{}
+ mockTrello := &mockTrelloClient{}
+ h := &Handler{
+ store: db,
+ todoistClient: mockTodoist,
+ trelloClient: mockTrello,
+ config: &config.Config{},
+ }
+
+ // Create an approved session
+ sessionToken := "write-session-token"
+ session := &models.AgentSession{
+ RequestToken: "write-request-token",
+ AgentName: "WriteAgent",
+ AgentID: "write-agent-uuid",
+ ExpiresAt: time.Now().Add(5 * time.Minute),
+ }
+ db.CreateAgentSession(session)
+ db.ApproveAgentSession("write-request-token", sessionToken, time.Now().Add(1*time.Hour))
+
+ // Pre-populate store with a task to get details during completion
+ db.SaveTasks([]models.Task{{ID: "task123", Content: "Test Task"}})
+
+ t.Run("complete task", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/agent/tasks/task123/complete?source=todoist", nil)
+ w := httptest.NewRecorder()
+
+ // Manually inject session into context as middleware would
+ ctx := context.WithValue(req.Context(), agentSessionContextKey, session)
+ req = req.WithContext(ctx)
+
+ // Set chi URL param
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("id", "task123")
+ req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
+
+ h.HandleAgentTaskComplete(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Verify task was logged as completed
+ completed, err := db.GetCompletedTasks(1)
+ if err != nil {
+ t.Fatalf("Failed to get completed tasks: %v", err)
+ }
+ if len(completed) == 0 {
+ t.Fatal("Expected task to be logged as completed, but got 0 results")
+ }
+ if completed[0].SourceID != "task123" {
+ t.Errorf("Expected source ID task123, got %s", completed[0].SourceID)
+ }
+ })
+
+ t.Run("update due date", func(t *testing.T) {
+ due := time.Now().Add(24 * time.Hour)
+ body, _ := json.Marshal(map[string]*time.Time{"due": &due})
+ req := httptest.NewRequest(http.MethodPatch, "/agent/tasks/task123/due?source=todoist", bytes.NewReader(body))
+ w := httptest.NewRecorder()
+
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("id", "task123")
+ req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
+
+ h.HandleAgentTaskUpdateDue(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+ })
+
+ t.Run("update task details", func(t *testing.T) {
+ body, _ := json.Marshal(map[string]string{"title": "Updated Title", "description": "New Desc"})
+ req := httptest.NewRequest(http.MethodPatch, "/agent/tasks/task123?source=todoist", bytes.NewReader(body))
+ w := httptest.NewRecorder()
+
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("id", "task123")
+ req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
+
+ h.HandleAgentTaskUpdate(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+ })
+}
+
+func TestHandleAgentCreateOperations(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ mockTodoist := &mockTodoistClient{}
+ h := &Handler{
+ store: db,
+ todoistClient: mockTodoist,
+ config: &config.Config{},
+ }
+
+ t.Run("create task", func(t *testing.T) {
+ body, _ := json.Marshal(map[string]string{
+ "title": "New Agent Task",
+ "source": "todoist",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/agent/tasks", bytes.NewReader(body))
+ w := httptest.NewRecorder()
+
+ h.HandleAgentTaskCreate(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+ })
+
+ t.Run("add shopping item", func(t *testing.T) {
+ body, _ := json.Marshal(map[string]string{
+ "name": "Milk",
+ "store": "Costco",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/agent/shopping", bytes.NewReader(body))
+ w := httptest.NewRecorder()
+
+ h.HandleAgentShoppingAdd(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Verify item was saved
+ items, _ := db.GetUserShoppingItems()
+ found := false
+ for _, item := range items {
+ if item.Name == "Milk" && item.Store == "Costco" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected shopping item to be saved")
+ }
+ })
+}
+
// contains is a helper function to check if a string contains a substring
func contains(s, substr string) bool {
return bytes.Contains([]byte(s), []byte(substr))
diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go
index a780170..32e0215 100644
--- a/internal/handlers/settings.go
+++ b/internal/handlers/settings.go
@@ -16,6 +16,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
configs, _ := h.store.GetSourceConfigs()
toggles, _ := h.store.GetFeatureToggles()
syncLog, _ := h.store.GetRecentSyncLog(20)
+ agents, _ := h.store.GetAllAgents()
// Group configs by source
bySource := make(map[string][]models.SourceConfig)
@@ -28,6 +29,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
Sources []string
Toggles []models.FeatureToggle
SyncLog []store.SyncLogEntry
+ Agents []models.Agent
CSRFToken string
WebAuthnEnabled bool
}{
@@ -35,6 +37,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
Sources: []string{"trello", "todoist", "gcal", "gtasks"},
Toggles: toggles,
SyncLog: syncLog,
+ Agents: agents,
CSRFToken: auth.GetCSRFTokenFromContext(r.Context()),
WebAuthnEnabled: h.WebAuthnEnabled,
}
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index f4651bb..3879395 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -1222,8 +1222,8 @@ func (s *Store) SaveCompletedTask(source, sourceID, title string, dueDate *time.
}
_, err := s.db.Exec(`
INSERT OR REPLACE INTO completed_tasks (source, source_id, title, due_date, completed_at)
- VALUES (?, ?, ?, ?, datetime('now', 'localtime'))
- `, source, sourceID, title, dueDateStr)
+ VALUES (?, ?, ?, ?, ?)
+ `, source, sourceID, title, dueDateStr, time.Now())
return err
}
@@ -1244,9 +1244,9 @@ func (s *Store) GetCompletedTasks(limit int) ([]models.CompletedTask, error) {
for rows.Next() {
var task models.CompletedTask
var dueDate sql.NullString
- var completedAt time.Time
+ var completedAtStr string
- if err := rows.Scan(&task.ID, &task.Source, &task.SourceID, &task.Title, &dueDate, &completedAt); err != nil {
+ if err := rows.Scan(&task.ID, &task.Source, &task.SourceID, &task.Title, &dueDate, &completedAtStr); err != nil {
return nil, err
}
@@ -1255,6 +1255,27 @@ func (s *Store) GetCompletedTasks(limit int) ([]models.CompletedTask, error) {
task.DueDate = &t
}
}
+
+ // Try different formats for completedAt
+ formats := []string{
+ "2006-01-02 15:04:05.999999999-07:00",
+ "2006-01-02 15:04:05",
+ time.RFC3339,
+ }
+ var completedAt time.Time
+ var parseErr error
+ for _, f := range formats {
+ if t, err := time.Parse(f, completedAtStr); err == nil {
+ completedAt = t
+ parseErr = nil
+ break
+ } else {
+ parseErr = err
+ }
+ }
+ if parseErr != nil {
+ log.Printf("Warning: failed to parse completed_at %q: %v", completedAtStr, parseErr)
+ }
task.CompletedAt = completedAt
tasks = append(tasks, task)
diff --git a/issues/feature_agent_context_api.md b/issues/feature_agent_context_api.md
index 7be5f50..69b3feb 100644
--- a/issues/feature_agent_context_api.md
+++ b/issues/feature_agent_context_api.md
@@ -1,114 +1,46 @@
# Feature: Agent Context API
-**Status:** [IN_PROGRESS] Phase 1 Complete
+**Status:** [COMPLETED]
**Created:** 2026-01-27
+**Updated:** 2026-03-23
**Author:** Architect
---
## Overview
-Expose a JSON API that allows external chat agents (e.g., Claude Code) to query dashboard context and manipulate items. Authentication uses a notification-based approval flow with agent identity binding.
+Expose a JSON API that allows external chat agents (e.g., Claude Code, Gemini CLI) to query dashboard context and manipulate items. Authentication uses a notification-based approval flow with agent identity binding.
---
## User Stories
-1. **As a user**, I can approve/deny agent access requests from the dashboard UI
-2. **As a user**, I can see which agents have been granted access and revoke them
-3. **As an agent**, I can request access by providing my name and unique ID
-4. **As an agent**, once approved, I can query the 7-day context (tasks, meals, timeline)
-5. **As an agent**, I can complete/uncomplete tasks, update due dates, modify details, and create items
+1. [x] **As a user**, I can approve/deny agent access requests from the dashboard UI
+2. [x] **As a user**, I can see which agents have been granted access and revoke them
+3. [x] **As an agent**, I can request access by providing my name and unique ID
+4. [x] **As an agent**, once approved, I can query the 7-day context (tasks, meals, timeline)
+5. [x] **As an agent**, I can complete/uncomplete tasks, update due dates, modify details, and create items
---
-## Authentication Flow
+## Implementation Status
-```
-Agent Dashboard API Browser Tab
- │ │ │
- │ POST /agent/auth/request │ │
- │ {name, agent_id} │ │
- │ ──────────────────────────────>│ │
- │ │ │
- │ {request_token, status: │ WebSocket push: │
- │ "pending"} │ "Agent X wants access" │
- │ <──────────────────────────────│ ────────────────────────────>│
- │ │ │
- │ │ User approves
- │ │ │
- │ │ POST /agent/auth/approve │
- │ │ <────────────────────────────│
- │ │ │
- │ GET /agent/auth/poll │ │
- │ ?token=X │ │
- │ ──────────────────────────────>│ │
- │ │ │
- │ {status: "approved", │ │
- │ session_token, expiry} │ │
- │ <──────────────────────────────│ │
- │ │ │
- │ GET /agent/context │ │
- │ Authorization: Bearer X │ │
- │ ──────────────────────────────>│ │
- │ │ │
- │ {timeline, tasks, ...} │ │
- │ <──────────────────────────────│ │
-```
+### Phase 1: Auth Flow + Read Context (Complete)
+- [x] Agent authentication handshake (Request -> Approve -> Token)
+- [x] `/agent/context` endpoint returning timeline + today items
+- [x] WebSocket notification for approval requests
+- [x] Browser-only agent support (`/agent/web/context`)
----
-
-## Agent Identity Binding
-
-| Scenario | Behavior |
-|----------|----------|
-| New agent (name + ID never seen) | Show approval prompt, store pairing on approve |
-| Known agent (name + ID match stored) | Show approval prompt with "Recognized" indicator |
-| Suspicious (known name, different ID) | Show warning: "Agent 'X' with different ID" — require explicit re-trust |
-
----
-
-## Design Decisions
+### Phase 2: Write Operations (Complete)
+- [x] `POST /agent/tasks/{id}/complete`
+- [x] `POST /agent/tasks/{id}/uncomplete`
+- [x] `PATCH /agent/tasks/{id}/due`
+- [x] `PATCH /agent/tasks/{id}` (generic update)
-| Decision | Choice | Rationale |
-|----------|--------|-----------|
-| Session refresh | None — re-authenticate after expiry | Keep it simple |
-| Concurrent sessions | One per agent — new approval invalidates previous | Predictable state |
-| Deny behavior | Single-request denial, no blocking | Simple first pass |
-
----
-
-## Database Schema
-
-### New Tables
-
-```sql
--- Registered/approved agents
-CREATE TABLE agents (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- agent_id TEXT NOT NULL UNIQUE, -- UUID from agent
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- last_seen DATETIME,
- trusted BOOLEAN DEFAULT 1 -- can be revoked
-);
-
--- Pending access requests and sessions
-CREATE TABLE agent_sessions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- request_token TEXT NOT NULL UNIQUE,
- agent_name TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- status TEXT DEFAULT 'pending', -- pending, approved, denied, expired
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- expires_at DATETIME NOT NULL, -- request expires after 5 min
- session_token TEXT, -- populated on approval
- session_expires_at DATETIME -- session TTL (1 hour)
-);
-
-CREATE INDEX idx_agent_sessions_request ON agent_sessions(request_token);
-CREATE INDEX idx_agent_sessions_session ON agent_sessions(session_token);
-```
+### Phase 3: Create + Management (Complete)
+- [x] `POST /agent/tasks` (Quick add)
+- [x] `POST /agent/shopping` (Add shopping item)
+- [x] Trusted Agent management UI in Settings (list, revoke)
---
@@ -128,149 +60,23 @@ CREATE INDEX idx_agent_sessions_session ON agent_sessions(session_token);
| POST | `/agent/auth/approve` | User approves request |
| POST | `/agent/auth/deny` | User denies request |
-### Context (agent session required)
+### Context & Operations (agent session required)
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/agent/context` | Full 7-day context |
+| POST | `/agent/tasks` | Create task |
+| POST | `/agent/tasks/{id}/complete` | Complete task |
+| POST | `/agent/tasks/{id}/uncomplete` | Reopen task |
+| PATCH | `/agent/tasks/{id}/due` | Update due date |
+| PATCH | `/agent/tasks/{id}` | Update details (title, desc) |
+| POST | `/agent/shopping` | Add shopping item |
-### Write Operations (agent session required)
-
-| Method | Path | Purpose | Priority |
-|--------|------|---------|----------|
-| PATCH | `/agent/tasks/{id}/due` | Update due date | P1 |
-| POST | `/agent/tasks/{id}/complete` | Complete task | P1 |
-| POST | `/agent/tasks/{id}/uncomplete` | Reopen task | P1 |
-| PATCH | `/agent/tasks/{id}` | Update details (title, desc) | P2 |
-| POST | `/agent/tasks` | Create task | P3 |
-| POST | `/agent/shopping/add` | Add shopping item | P3 |
-
----
-
-## Context Response Format
-
-```json
-{
- "generated_at": "2026-01-27T10:30:00-10:00",
- "range": {
- "start": "2026-01-27",
- "end": "2026-02-03"
- },
- "timeline": [
- {
- "id": "task_123",
- "source": "todoist",
- "type": "task",
- "title": "Buy groceries",
- "description": "Milk, eggs, bread",
- "due": "2026-01-27T17:00:00-10:00",
- "priority": 2,
- "completable": true,
- "url": "https://todoist.com/..."
- }
- ],
- "summary": {
- "total_items": 42,
- "by_source": {"todoist": 15, "trello": 20, "plantoeat": 7},
- "overdue": 3,
- "today": 8
- }
-}
-```
-
----
-
-## Browser Components
-
-### WebSocket Endpoint
-- Path: `/ws/notifications`
-- Purpose: Push agent request alerts to open browser tabs
-- Must handle: reconnect on disconnect, authentication check
-
-### Approval UI
-- Trigger: WebSocket message of type `agent_request`
-- Display: Modal or toast notification
-- Content:
- - Agent name
- - Agent ID (truncated to 8 chars)
- - Trust indicator: "New Agent" / "Recognized" / "Warning: Different ID"
-- Actions: Approve / Deny buttons
+### Management (browser session required)
-### Agent Management (Phase 3)
-- List of trusted agents with last-seen timestamp
-- Revoke access button per agent
-
----
-
-## Session Configuration
-
-| Parameter | Value |
-|-----------|-------|
-| Request expiry | 5 minutes |
-| Session TTL | 1 hour |
-| Poll interval (agent-side) | 2 seconds |
-
----
-
-## Security Considerations
-
-1. **Rate limiting** on `/agent/auth/request` — 10 requests/minute per IP
-2. **Tokens** are cryptographically random (32 bytes, base64url)
-3. **HTTPS required** — tokens in Authorization header
-4. **No CSRF** for agent endpoints — token-based auth, not cookies
-5. **One session per agent** — new approval invalidates previous session
-
----
-
-## Implementation Phases
-
-### Phase 1: Auth Flow + Read Context
-Files to create/modify:
-- `migrations/010_agent_tables.sql` — new tables
-- `internal/store/sqlite.go` — agent/session CRUD methods
-- `internal/handlers/agent.go` — new handler file for agent endpoints
-- `internal/handlers/websocket.go` — WebSocket notification hub
-- `cmd/dashboard/main.go` — register new routes
-- `web/templates/partials/agent-approval.html` — approval modal
-- `web/static/js/app.js` — WebSocket connection + approval UI logic
-
-Endpoints:
-- POST `/agent/auth/request`
-- GET `/agent/auth/poll`
-- POST `/agent/auth/approve`
-- POST `/agent/auth/deny`
-- GET `/agent/context`
-- WS `/ws/notifications`
-
-### Phase 2: Write Operations (P1 + P2)
-- POST `/agent/tasks/{id}/complete`
-- POST `/agent/tasks/{id}/uncomplete`
-- PATCH `/agent/tasks/{id}/due`
-- PATCH `/agent/tasks/{id}`
-
-### Phase 3: Create + Management
-- POST `/agent/tasks`
-- POST `/agent/shopping/add`
-- Agent management UI (list, revoke)
-
----
-
-## Testing Strategy
-
-### Unit Tests
-- `internal/store/sqlite_test.go` — agent/session CRUD
-- `internal/handlers/agent_test.go` — endpoint logic
-
-### Integration Tests
-- Full auth flow: request → approve → poll → context
-- Identity binding: same name different ID triggers warning
-- Session expiry: requests fail after TTL
-
-### Manual Testing
-- Open dashboard in browser
-- Run agent simulation script to request access
-- Verify notification appears
-- Approve and verify context returned
+| Method | Path | Purpose |
+|--------|------|---------|
+| DELETE | `/settings/agents/{id}` | Revoke agent access |
---
@@ -280,8 +86,9 @@ Endpoints:
|---------|------|
| Migration | `migrations/010_agent_tables.sql` |
| Store methods | `internal/store/sqlite.go` |
-| Agent handlers | `internal/handlers/agent.go` (new) |
-| WebSocket hub | `internal/handlers/websocket.go` (new) |
+| Agent handlers | `internal/handlers/agent.go` |
+| WebSocket hub | `internal/handlers/websocket.go` |
| Route registration | `cmd/dashboard/main.go` |
-| Approval modal | `web/templates/partials/agent-approval.html` (new) |
-| Client JS | `web/static/js/app.js` |
+| Settings handler | `internal/handlers/settings.go` |
+| Settings template | `web/templates/settings.html` |
+| Tests | `internal/handlers/agent_test.go` |
diff --git a/web/templates/index.html b/web/templates/index.html
index aabe7ff..465e612 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -60,43 +60,31 @@
hx-get="/tabs/timeline"
hx-target="#tab-content"
hx-push-url="?tab=timeline"
- onclick="setActiveTab(this)">
- 🗓️ Timeline
- </button>
- <button
- class="tab-button {{if eq .ActiveTab "tasks"}}tab-button-active{{end}}"
- hx-get="/tabs/tasks"
- hx-target="#tab-content"
- hx-push-url="?tab=tasks"
- onclick="setActiveTab(this)">
- ✓ Tasks
+ onclick="setActiveTab(this)"
+ title="Timeline">
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
</button>
<button
class="tab-button {{if eq .ActiveTab "shopping"}}tab-button-active{{end}}"
hx-get="/tabs/shopping"
hx-target="#tab-content"
hx-push-url="?tab=shopping"
- onclick="setActiveTab(this)">
- 🛒 Shopping
- </button>
- <button
- class="tab-button {{if eq .ActiveTab "meals"}}tab-button-active{{end}}"
- hx-get="/tabs/meals"
- hx-target="#tab-content"
- hx-push-url="?tab=meals"
- onclick="setActiveTab(this)">
- 🍽️ Meals
+ onclick="setActiveTab(this)"
+ title="Shopping">
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
</button>
<button
class="tab-button {{if eq .ActiveTab "planning"}}tab-button-active{{end}}"
hx-get="/tabs/planning"
hx-target="#tab-content"
hx-push-url="?tab=planning"
- onclick="setActiveTab(this)">
- 📋 Planning
+ onclick="setActiveTab(this)"
+ title="Planning">
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
</button>
- <a href="/conditions" class="tab-button">
- 🌋 Conditions
+ <a href="/conditions" class="tab-button" title="Conditions">🌋</a>
+ <a href="/claudomator/" class="tab-button" title="Claudomator">
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</a>
</nav>
</div>
diff --git a/web/templates/settings.html b/web/templates/settings.html
index ca1d268..ef969ee 100644
--- a/web/templates/settings.html
+++ b/web/templates/settings.html
@@ -167,6 +167,44 @@
</script>
{{end}}
+ <!-- Trusted Agents Section -->
+ <section class="mb-12">
+ <h2 class="text-xl font-medium text-white mb-6 pb-2 border-b border-white/10">Trusted Agents</h2>
+ <div class="grid gap-4" id="agents-list">
+ {{if .Agents}}
+ {{range .Agents}}
+ <div class="card flex items-center gap-4" id="agent-{{.AgentID}}">
+ <div class="flex-1">
+ <div class="flex items-center gap-2">
+ <strong class="text-white">{{.Name}}</strong>
+ {{if not .Trusted}}<span class="text-[10px] bg-red-950 text-red-400 px-2 py-0.5 rounded border border-red-900/50 uppercase tracking-wider">Revoked</span>{{end}}
+ </div>
+ <div class="text-xs font-mono text-slate-500 mt-0.5">{{.AgentID}}</div>
+ <div class="text-[10px] text-slate-400 mt-2 uppercase tracking-widest font-medium">
+ Last seen: {{if .LastSeen}}{{.LastSeen.Format "Jan 02, 15:04"}}{{else}}Never{{end}}
+ </div>
+ </div>
+ <div class="flex items-center gap-4">
+ {{if .Trusted}}
+ <button class="text-xs text-red-400 hover:text-red-300 transition-colors bg-red-950/30 hover:bg-red-950/50 px-3 py-1.5 rounded-lg border border-red-900/30"
+ hx-delete="/settings/agents/{{.AgentID}}"
+ hx-target="#agent-{{.AgentID}}"
+ hx-swap="outerHTML"
+ hx-confirm="Revoke access for agent '{{.Name}}'?">
+ Revoke Access
+ </button>
+ {{else}}
+ <div class="text-xs text-slate-500 italic">Access Revoked</div>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ {{else}}
+ <div class="card text-center text-slate-500 py-10">No agents have requested access yet.</div>
+ {{end}}
+ </div>
+ </section>
+
<!-- Data Sources Section -->
<section class="mb-12">
<div class="flex flex-wrap items-center justify-between gap-4 mb-6 pb-2 border-b border-white/10">