Browse Source

Add Go CLI and fix volume consistency race condition

- Add complete Go CLI for managing schedules (letta-schedules)
  - Config management for API credentials
  - CRUD operations for recurring and one-time schedules
  - Execution results viewer
  - Pretty table output with tablewriter
  - Single binary distribution

- Fix race condition where deleted schedules could still execute
  - Add volume.reload() in cron job before listing schedules
  - Add volume.reload() in execute_schedule before checking existence
  - Improves eventual consistency handling for Modal volumes

- Improve logging for deleted schedule attempts
  - Use debug level for expected one-time schedule deletions
  - Use info level for user-deleted recurring schedules
Cameron Pfiffer 5 months ago
parent
commit
887a156749
15 changed files with 1422 additions and 3 deletions
  1. 15 3
      app.py
  2. 14 0
      cli/.gitignore
  3. 45 0
      cli/Makefile
  4. 215 0
      cli/README.md
  5. 74 0
      cli/cmd/config.go
  6. 182 0
      cli/cmd/onetime.go
  7. 199 0
      cli/cmd/recurring.go
  8. 107 0
      cli/cmd/results.go
  9. 33 0
      cli/cmd/root.go
  10. 36 0
      cli/go.mod
  11. 88 0
      cli/go.sum
  12. 197 0
      cli/internal/client/client.go
  13. 97 0
      cli/internal/client/types.go
  14. 105 0
      cli/internal/config/config.go
  15. 15 0
      cli/main.go

+ 15 - 3
app.py

@@ -422,16 +422,24 @@ async def execute_schedule(
     execute_at: str = None,
     execute_at: str = None,
 ):
 ):
     logger.info(f"Executing {schedule_type} schedule {schedule_id} for agent {agent_id}")
     logger.info(f"Executing {schedule_type} schedule {schedule_id} for agent {agent_id}")
-    
+
+    # Reload volume to see latest changes (in case schedule was deleted)
+    volume.reload()
+
     # Check if schedule still exists before executing
     # Check if schedule still exists before executing
     if schedule_type == "one-time" and execute_at:
     if schedule_type == "one-time" and execute_at:
         file_path = get_onetime_schedule_path(api_key, execute_at, schedule_id)
         file_path = get_onetime_schedule_path(api_key, execute_at, schedule_id)
     else:
     else:
         file_path = get_recurring_schedule_path(api_key, schedule_id)
         file_path = get_recurring_schedule_path(api_key, schedule_id)
-    
+
     schedule = load_schedule(file_path)
     schedule = load_schedule(file_path)
     if not schedule:
     if not schedule:
-        logger.warning(f"Schedule {schedule_id} no longer exists, skipping execution")
+        # For one-time schedules, this is expected (already executed or deleted)
+        # For recurring schedules, this indicates the schedule was deleted by the user
+        if schedule_type == "one-time":
+            logger.debug(f"One-time schedule {schedule_id} not found (already executed or deleted)")
+        else:
+            logger.info(f"Recurring schedule {schedule_id} was deleted, skipping execution")
         return {"success": False, "error": "Schedule deleted"}
         return {"success": False, "error": "Schedule deleted"}
     
     
     # For one-time schedules: DELETE IMMEDIATELY to prevent race condition
     # For one-time schedules: DELETE IMMEDIATELY to prevent race condition
@@ -549,6 +557,10 @@ def cleanup_empty_directories():
 )
 )
 async def check_and_execute_schedules():
 async def check_and_execute_schedules():
     logger.info("Checking schedules...")
     logger.info("Checking schedules...")
+
+    # Reload volume to see latest changes (deletes, updates, etc.)
+    volume.reload()
+
     current_time = datetime.now(timezone.utc)
     current_time = datetime.now(timezone.utc)
     
     
     # Check recurring schedules (all users)
     # Check recurring schedules (all users)

+ 14 - 0
cli/.gitignore

@@ -0,0 +1,14 @@
+# Binaries
+letta-schedules
+letta-schedules-*
+*.exe
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store

+ 45 - 0
cli/Makefile

@@ -0,0 +1,45 @@
+.PHONY: build clean install build-all help
+
+# Default target
+all: build
+
+# Build for current platform
+build:
+	go build -o letta-schedules
+
+# Build for all platforms
+build-all:
+	GOOS=darwin GOARCH=amd64 go build -o dist/letta-schedules-darwin-amd64
+	GOOS=darwin GOARCH=arm64 go build -o dist/letta-schedules-darwin-arm64
+	GOOS=linux GOARCH=amd64 go build -o dist/letta-schedules-linux-amd64
+	GOOS=linux GOARCH=arm64 go build -o dist/letta-schedules-linux-arm64
+	GOOS=windows GOARCH=amd64 go build -o dist/letta-schedules-windows-amd64.exe
+
+# Install to /usr/local/bin
+install: build
+	sudo mv letta-schedules /usr/local/bin/
+
+# Clean build artifacts
+clean:
+	rm -f letta-schedules
+	rm -rf dist/
+
+# Download dependencies
+deps:
+	go mod download
+	go mod tidy
+
+# Run tests
+test:
+	go test ./...
+
+# Show help
+help:
+	@echo "Available targets:"
+	@echo "  build      - Build for current platform"
+	@echo "  build-all  - Build for all platforms (creates dist/ directory)"
+	@echo "  install    - Build and install to /usr/local/bin"
+	@echo "  clean      - Remove build artifacts"
+	@echo "  deps       - Download and tidy dependencies"
+	@echo "  test       - Run tests"
+	@echo "  help       - Show this help message"

+ 215 - 0
cli/README.md

@@ -0,0 +1,215 @@
+# Letta Schedules CLI
+
+A command-line interface for managing scheduled messages for Letta AI agents.
+
+## Features
+
+- Create and manage recurring schedules with cron expressions
+- Create and manage one-time schedules
+- View execution results
+- Beautiful table output
+- Easy configuration management
+
+## Installation
+
+### From Source
+
+```bash
+# Clone the repository
+cd cli
+
+# Build the binary
+go build -o letta-schedules
+
+# Move to your PATH (optional)
+sudo mv letta-schedules /usr/local/bin/
+```
+
+### Using Go Install
+
+```bash
+go install github.com/letta/letta-schedules-cli@latest
+```
+
+### Cross-Platform Build
+
+```bash
+# macOS
+GOOS=darwin GOARCH=amd64 go build -o letta-schedules-darwin-amd64
+GOOS=darwin GOARCH=arm64 go build -o letta-schedules-darwin-arm64
+
+# Linux
+GOOS=linux GOARCH=amd64 go build -o letta-schedules-linux-amd64
+GOOS=linux GOARCH=arm64 go build -o letta-schedules-linux-arm64
+
+# Windows
+GOOS=windows GOARCH=amd64 go build -o letta-schedules-windows-amd64.exe
+```
+
+## Quick Start
+
+### 1. Configure API Credentials
+
+```bash
+# Set your Letta API key
+letta-schedules config set-api-key sk-xxx...
+
+# Set the API URL (optional, defaults to Modal deployment)
+letta-schedules config set-url https://your-api-url.com
+
+# View current configuration
+letta-schedules config show
+```
+
+### 2. Create a Recurring Schedule
+
+```bash
+letta-schedules recurring create \
+  --agent-id agent-xxx \
+  --message "Daily check-in" \
+  --cron "0 9 * * *"
+```
+
+### 3. List Schedules
+
+```bash
+letta-schedules recurring list
+```
+
+## Usage
+
+### Configuration Commands
+
+```bash
+# Set API key
+letta-schedules config set-api-key <key>
+
+# Set base URL
+letta-schedules config set-url <url>
+
+# Show configuration
+letta-schedules config show
+```
+
+### Recurring Schedules
+
+```bash
+# Create a recurring schedule
+letta-schedules recurring create \
+  --agent-id <agent-id> \
+  --message "Your message" \
+  --cron "0 9 * * *" \
+  --role user
+
+# List all recurring schedules
+letta-schedules recurring list
+
+# Get details of a specific schedule
+letta-schedules recurring get <schedule-id>
+
+# Delete a schedule
+letta-schedules recurring delete <schedule-id>
+```
+
+#### Cron Expression Examples
+
+- `0 9 * * *` - Every day at 9:00 AM
+- `0 */6 * * *` - Every 6 hours
+- `0 0 * * 1` - Every Monday at midnight
+- `*/30 * * * *` - Every 30 minutes
+
+### One-Time Schedules
+
+```bash
+# Create a one-time schedule
+letta-schedules onetime create \
+  --agent-id <agent-id> \
+  --message "Reminder message" \
+  --execute-at "2025-11-07T10:00:00Z" \
+  --role user
+
+# List all one-time schedules
+letta-schedules onetime list
+
+# Get details of a specific schedule
+letta-schedules onetime get <schedule-id>
+
+# Delete a schedule
+letta-schedules onetime delete <schedule-id>
+```
+
+### Execution Results
+
+```bash
+# List all execution results
+letta-schedules results list
+
+# Get result for a specific schedule
+letta-schedules results get <schedule-id>
+```
+
+## Configuration
+
+The CLI stores configuration in `~/.letta-schedules/config.yaml`:
+
+```yaml
+api_key: sk-xxx...
+base_url: https://letta--letta-schedules-api.modal.run
+```
+
+## Examples
+
+### Daily Agent Check-in
+
+```bash
+letta-schedules recurring create \
+  --agent-id agent-123 \
+  --message "Good morning! Please provide a daily summary." \
+  --cron "0 9 * * *"
+```
+
+### Hourly Status Update
+
+```bash
+letta-schedules recurring create \
+  --agent-id agent-123 \
+  --message "Status update please" \
+  --cron "0 * * * *"
+```
+
+### One-Time Reminder
+
+```bash
+letta-schedules onetime create \
+  --agent-id agent-123 \
+  --message "Meeting in 1 hour" \
+  --execute-at "2025-11-07T14:00:00Z"
+```
+
+## Development
+
+### Prerequisites
+
+- Go 1.21+
+
+### Build
+
+```bash
+go build -o letta-schedules
+```
+
+### Run Tests
+
+```bash
+go test ./...
+```
+
+### Update Dependencies
+
+```bash
+go mod tidy
+```
+
+## License
+
+MIT

+ 74 - 0
cli/cmd/config.go

@@ -0,0 +1,74 @@
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/fatih/color"
+	"github.com/letta/letta-schedules-cli/internal/config"
+	"github.com/spf13/cobra"
+)
+
+var configCmd = &cobra.Command{
+	Use:   "config",
+	Short: "Manage CLI configuration",
+	Long:  "Configure API credentials and settings for the Letta Schedules CLI",
+}
+
+var setAPIKeyCmd = &cobra.Command{
+	Use:   "set-api-key [api-key]",
+	Short: "Set the Letta API key",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		apiKey := args[0]
+		if err := config.SetAPIKey(apiKey); err != nil {
+			return fmt.Errorf("failed to set API key: %w", err)
+		}
+		color.Green("✓ API key set successfully")
+		return nil
+	},
+}
+
+var setURLCmd = &cobra.Command{
+	Use:   "set-url [url]",
+	Short: "Set the API base URL",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		url := args[0]
+		if err := config.SetBaseURL(url); err != nil {
+			return fmt.Errorf("failed to set base URL: %w", err)
+		}
+		color.Green("✓ Base URL set successfully")
+		return nil
+	},
+}
+
+var showConfigCmd = &cobra.Command{
+	Use:   "show",
+	Short: "Show current configuration",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		cfg, err := config.Load()
+		if err != nil {
+			return fmt.Errorf("failed to load config: %w", err)
+		}
+
+		fmt.Println("Current configuration:")
+		fmt.Printf("  Base URL: %s\n", cfg.BaseURL)
+		if cfg.APIKey != "" {
+			fmt.Printf("  API Key:  %s...%s\n", cfg.APIKey[:8], cfg.APIKey[len(cfg.APIKey)-4:])
+		} else {
+			fmt.Println("  API Key:  (not set)")
+		}
+
+		configDir, _ := config.GetConfigDir()
+		fmt.Printf("\nConfig file: %s/config.yaml\n", configDir)
+
+		return nil
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(configCmd)
+	configCmd.AddCommand(setAPIKeyCmd)
+	configCmd.AddCommand(setURLCmd)
+	configCmd.AddCommand(showConfigCmd)
+}

+ 182 - 0
cli/cmd/onetime.go

@@ -0,0 +1,182 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/fatih/color"
+	"github.com/letta/letta-schedules-cli/internal/client"
+	"github.com/letta/letta-schedules-cli/internal/config"
+	"github.com/olekukonko/tablewriter"
+	"github.com/spf13/cobra"
+)
+
+var onetimeCmd = &cobra.Command{
+	Use:   "onetime",
+	Short: "Manage one-time schedules",
+	Long:  "Create, list, view, and delete one-time schedules for Letta agents",
+}
+
+var onetimeCreateCmd = &cobra.Command{
+	Use:   "create",
+	Short: "Create a new one-time schedule",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		agentID, _ := cmd.Flags().GetString("agent-id")
+		message, _ := cmd.Flags().GetString("message")
+		role, _ := cmd.Flags().GetString("role")
+		executeAt, _ := cmd.Flags().GetString("execute-at")
+
+		if agentID == "" || message == "" || executeAt == "" {
+			return fmt.Errorf("agent-id, message, and execute-at are required")
+		}
+
+		cfg, err := config.Load()
+		if err != nil {
+			return err
+		}
+		if err := cfg.Validate(); err != nil {
+			return err
+		}
+
+		apiClient := client.NewClient(cfg.BaseURL, cfg.APIKey)
+		schedule, err := apiClient.CreateOneTimeSchedule(client.OneTimeScheduleCreate{
+			AgentID:   agentID,
+			Message:   message,
+			Role:      role,
+			ExecuteAt: executeAt,
+		})
+		if err != nil {
+			return fmt.Errorf("failed to create schedule: %w", err)
+		}
+
+		color.Green("✓ One-time schedule created successfully")
+		fmt.Printf("\nSchedule ID:  %s\n", schedule.ID)
+		fmt.Printf("Agent ID:     %s\n", schedule.AgentID)
+		fmt.Printf("Execute At:   %s\n", schedule.ExecuteAt)
+		fmt.Printf("Message:      %s\n", schedule.Message)
+
+		return nil
+	},
+}
+
+var onetimeListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List all one-time schedules",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		cfg, err := config.Load()
+		if err != nil {
+			return err
+		}
+		if err := cfg.Validate(); err != nil {
+			return err
+		}
+
+		apiClient := client.NewClient(cfg.BaseURL, cfg.APIKey)
+		schedules, err := apiClient.ListOneTimeSchedules()
+		if err != nil {
+			return fmt.Errorf("failed to list schedules: %w", err)
+		}
+
+		if len(schedules) == 0 {
+			fmt.Println("No one-time schedules found")
+			return nil
+		}
+
+		table := tablewriter.NewWriter(os.Stdout)
+		table.SetHeader([]string{"Schedule ID", "Agent ID", "Execute At", "Message"})
+		table.SetAutoWrapText(false)
+		table.SetAutoFormatHeaders(true)
+		table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
+		table.SetAlignment(tablewriter.ALIGN_LEFT)
+		table.SetCenterSeparator("")
+		table.SetColumnSeparator("")
+		table.SetRowSeparator("")
+		table.SetHeaderLine(false)
+		table.SetBorder(false)
+		table.SetTablePadding("\t")
+		table.SetNoWhiteSpace(true)
+
+		for _, s := range schedules {
+			table.Append([]string{
+				s.ID,
+				s.AgentID,
+				s.ExecuteAt,
+				truncate(s.Message, 50),
+			})
+		}
+
+		table.Render()
+		return nil
+	},
+}
+
+var onetimeGetCmd = &cobra.Command{
+	Use:   "get [schedule-id]",
+	Short: "Get details of a one-time schedule",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		scheduleID := args[0]
+
+		cfg, err := config.Load()
+		if err != nil {
+			return err
+		}
+		if err := cfg.Validate(); err != nil {
+			return err
+		}
+
+		apiClient := client.NewClient(cfg.BaseURL, cfg.APIKey)
+		schedule, err := apiClient.GetOneTimeSchedule(scheduleID)
+		if err != nil {
+			return fmt.Errorf("failed to get schedule: %w", err)
+		}
+
+		fmt.Printf("Schedule ID:  %s\n", schedule.ID)
+		fmt.Printf("Agent ID:     %s\n", schedule.AgentID)
+		fmt.Printf("Execute At:   %s\n", schedule.ExecuteAt)
+		fmt.Printf("Message:      %s\n", schedule.Message)
+		fmt.Printf("Role:         %s\n", schedule.Role)
+		fmt.Printf("Created At:   %s\n", schedule.CreatedAt.Format("2006-01-02 15:04:05"))
+
+		return nil
+	},
+}
+
+var onetimeDeleteCmd = &cobra.Command{
+	Use:   "delete [schedule-id]",
+	Short: "Delete a one-time schedule",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		scheduleID := args[0]
+
+		cfg, err := config.Load()
+		if err != nil {
+			return err
+		}
+		if err := cfg.Validate(); err != nil {
+			return err
+		}
+
+		apiClient := client.NewClient(cfg.BaseURL, cfg.APIKey)
+		if err := apiClient.DeleteOneTimeSchedule(scheduleID); err != nil {
+			return fmt.Errorf("failed to delete schedule: %w", err)
+		}
+
+		color.Green("✓ Schedule deleted successfully")
+		return nil
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(onetimeCmd)
+
+	onetimeCmd.AddCommand(onetimeCreateCmd)
+	onetimeCreateCmd.Flags().String("agent-id", "", "Agent ID (required)")
+	onetimeCreateCmd.Flags().String("message", "", "Message to send (required)")
+	onetimeCreateCmd.Flags().String("role", "user", "Message role (default: user)")
+	onetimeCreateCmd.Flags().String("execute-at", "", "ISO 8601 timestamp (e.g., 2025-11-07T10:00:00Z) (required)")
+
+	onetimeCmd.AddCommand(onetimeListCmd)
+	onetimeCmd.AddCommand(onetimeGetCmd)
+	onetimeCmd.AddCommand(onetimeDeleteCmd)
+}

+ 199 - 0
cli/cmd/recurring.go

@@ -0,0 +1,199 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/fatih/color"
+	"github.com/letta/letta-schedules-cli/internal/client"
+	"github.com/letta/letta-schedules-cli/internal/config"
+	"github.com/olekukonko/tablewriter"
+	"github.com/spf13/cobra"
+)
+
+var recurringCmd = &cobra.Command{
+	Use:   "recurring",
+	Short: "Manage recurring schedules",
+	Long:  "Create, list, view, and delete recurring schedules for Letta agents",
+}
+
+var recurringCreateCmd = &cobra.Command{
+	Use:   "create",
+	Short: "Create a new recurring schedule",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		agentID, _ := cmd.Flags().GetString("agent-id")
+		message, _ := cmd.Flags().GetString("message")
+		role, _ := cmd.Flags().GetString("role")
+		cronString, _ := cmd.Flags().GetString("cron")
+
+		if agentID == "" || message == "" || cronString == "" {
+			return fmt.Errorf("agent-id, message, and cron are required")
+		}
+
+		cfg, err := config.Load()
+		if err != nil {
+			return err
+		}
+		if err := cfg.Validate(); err != nil {
+			return err
+		}
+
+		apiClient := client.NewClient(cfg.BaseURL, cfg.APIKey)
+		schedule, err := apiClient.CreateRecurringSchedule(client.RecurringScheduleCreate{
+			AgentID:    agentID,
+			Message:    message,
+			Role:       role,
+			CronString: cronString,
+		})
+		if err != nil {
+			return fmt.Errorf("failed to create schedule: %w", err)
+		}
+
+		color.Green("✓ Recurring schedule created successfully")
+		fmt.Printf("\nSchedule ID: %s\n", schedule.ID)
+		fmt.Printf("Agent ID:    %s\n", schedule.AgentID)
+		fmt.Printf("Cron:        %s\n", schedule.CronString)
+		fmt.Printf("Message:     %s\n", schedule.Message)
+
+		return nil
+	},
+}
+
+var recurringListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List all recurring schedules",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		cfg, err := config.Load()
+		if err != nil {
+			return err
+		}
+		if err := cfg.Validate(); err != nil {
+			return err
+		}
+
+		apiClient := client.NewClient(cfg.BaseURL, cfg.APIKey)
+		schedules, err := apiClient.ListRecurringSchedules()
+		if err != nil {
+			return fmt.Errorf("failed to list schedules: %w", err)
+		}
+
+		if len(schedules) == 0 {
+			fmt.Println("No recurring schedules found")
+			return nil
+		}
+
+		table := tablewriter.NewWriter(os.Stdout)
+		table.SetHeader([]string{"Schedule ID", "Agent ID", "Cron", "Message", "Last Run"})
+		table.SetAutoWrapText(false)
+		table.SetAutoFormatHeaders(true)
+		table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
+		table.SetAlignment(tablewriter.ALIGN_LEFT)
+		table.SetCenterSeparator("")
+		table.SetColumnSeparator("")
+		table.SetRowSeparator("")
+		table.SetHeaderLine(false)
+		table.SetBorder(false)
+		table.SetTablePadding("\t")
+		table.SetNoWhiteSpace(true)
+
+		for _, s := range schedules {
+			lastRun := "never"
+			if s.LastRun != nil && *s.LastRun != "" {
+				lastRun = *s.LastRun
+			}
+			table.Append([]string{
+				s.ID,
+				s.AgentID,
+				s.CronString,
+				truncate(s.Message, 50),
+				lastRun,
+			})
+		}
+
+		table.Render()
+		return nil
+	},
+}
+
+var recurringGetCmd = &cobra.Command{
+	Use:   "get [schedule-id]",
+	Short: "Get details of a recurring schedule",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		scheduleID := args[0]
+
+		cfg, err := config.Load()
+		if err != nil {
+			return err
+		}
+		if err := cfg.Validate(); err != nil {
+			return err
+		}
+
+		apiClient := client.NewClient(cfg.BaseURL, cfg.APIKey)
+		schedule, err := apiClient.GetRecurringSchedule(scheduleID)
+		if err != nil {
+			return fmt.Errorf("failed to get schedule: %w", err)
+		}
+
+		fmt.Printf("Schedule ID:  %s\n", schedule.ID)
+		fmt.Printf("Agent ID:     %s\n", schedule.AgentID)
+		fmt.Printf("Cron:         %s\n", schedule.CronString)
+		fmt.Printf("Message:      %s\n", schedule.Message)
+		fmt.Printf("Role:         %s\n", schedule.Role)
+		if schedule.LastRun != nil {
+			fmt.Printf("Last Run:     %s\n", *schedule.LastRun)
+		} else {
+			fmt.Printf("Last Run:     never\n")
+		}
+		fmt.Printf("Created At:   %s\n", schedule.CreatedAt.Format("2006-01-02 15:04:05"))
+
+		return nil
+	},
+}
+
+var recurringDeleteCmd = &cobra.Command{
+	Use:   "delete [schedule-id]",
+	Short: "Delete a recurring schedule",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		scheduleID := args[0]
+
+		cfg, err := config.Load()
+		if err != nil {
+			return err
+		}
+		if err := cfg.Validate(); err != nil {
+			return err
+		}
+
+		apiClient := client.NewClient(cfg.BaseURL, cfg.APIKey)
+		if err := apiClient.DeleteRecurringSchedule(scheduleID); err != nil {
+			return fmt.Errorf("failed to delete schedule: %w", err)
+		}
+
+		color.Green("✓ Schedule deleted successfully")
+		return nil
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(recurringCmd)
+
+	recurringCmd.AddCommand(recurringCreateCmd)
+	recurringCreateCmd.Flags().String("agent-id", "", "Agent ID (required)")
+	recurringCreateCmd.Flags().String("message", "", "Message to send (required)")
+	recurringCreateCmd.Flags().String("role", "user", "Message role (default: user)")
+	recurringCreateCmd.Flags().String("cron", "", "Cron expression (required)")
+
+	recurringCmd.AddCommand(recurringListCmd)
+	recurringCmd.AddCommand(recurringGetCmd)
+	recurringCmd.AddCommand(recurringDeleteCmd)
+}
+
+func truncate(s string, maxLen int) string {
+	if len(s) <= maxLen {
+		return s
+	}
+	return s[:maxLen-3] + "..."
+}

+ 107 - 0
cli/cmd/results.go

@@ -0,0 +1,107 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/letta/letta-schedules-cli/internal/client"
+	"github.com/letta/letta-schedules-cli/internal/config"
+	"github.com/olekukonko/tablewriter"
+	"github.com/spf13/cobra"
+)
+
+var resultsCmd = &cobra.Command{
+	Use:   "results",
+	Short: "View schedule execution results",
+	Long:  "List and view execution results for scheduled messages",
+}
+
+var resultsListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List all execution results",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		cfg, err := config.Load()
+		if err != nil {
+			return err
+		}
+		if err := cfg.Validate(); err != nil {
+			return err
+		}
+
+		apiClient := client.NewClient(cfg.BaseURL, cfg.APIKey)
+		results, err := apiClient.ListResults()
+		if err != nil {
+			return fmt.Errorf("failed to list results: %w", err)
+		}
+
+		if len(results) == 0 {
+			fmt.Println("No execution results found")
+			return nil
+		}
+
+		table := tablewriter.NewWriter(os.Stdout)
+		table.SetHeader([]string{"Schedule ID", "Type", "Agent ID", "Run ID", "Executed At"})
+		table.SetAutoWrapText(false)
+		table.SetAutoFormatHeaders(true)
+		table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
+		table.SetAlignment(tablewriter.ALIGN_LEFT)
+		table.SetCenterSeparator("")
+		table.SetColumnSeparator("")
+		table.SetRowSeparator("")
+		table.SetHeaderLine(false)
+		table.SetBorder(false)
+		table.SetTablePadding("\t")
+		table.SetNoWhiteSpace(true)
+
+		for _, r := range results {
+			table.Append([]string{
+				r.ScheduleID,
+				r.ScheduleType,
+				r.AgentID,
+				r.RunID,
+				r.ExecutedAt,
+			})
+		}
+
+		table.Render()
+		return nil
+	},
+}
+
+var resultsGetCmd = &cobra.Command{
+	Use:   "get [schedule-id]",
+	Short: "Get execution result for a specific schedule",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		scheduleID := args[0]
+
+		cfg, err := config.Load()
+		if err != nil {
+			return err
+		}
+		if err := cfg.Validate(); err != nil {
+			return err
+		}
+
+		apiClient := client.NewClient(cfg.BaseURL, cfg.APIKey)
+		result, err := apiClient.GetResult(scheduleID)
+		if err != nil {
+			return fmt.Errorf("failed to get result: %w", err)
+		}
+
+		fmt.Printf("Schedule ID:   %s\n", result.ScheduleID)
+		fmt.Printf("Schedule Type: %s\n", result.ScheduleType)
+		fmt.Printf("Agent ID:      %s\n", result.AgentID)
+		fmt.Printf("Run ID:        %s\n", result.RunID)
+		fmt.Printf("Message:       %s\n", result.Message)
+		fmt.Printf("Executed At:   %s\n", result.ExecutedAt)
+
+		return nil
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(resultsCmd)
+	resultsCmd.AddCommand(resultsListCmd)
+	resultsCmd.AddCommand(resultsGetCmd)
+}

+ 33 - 0
cli/cmd/root.go

@@ -0,0 +1,33 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/letta/letta-schedules-cli/internal/config"
+	"github.com/spf13/cobra"
+)
+
+var rootCmd = &cobra.Command{
+	Use:   "letta-schedules",
+	Short: "CLI for managing Letta scheduled messages",
+	Long: `A command-line interface for managing scheduled messages
+for Letta AI agents. Create recurring and one-time schedules,
+and view execution results.`,
+}
+
+// Execute runs the root command
+func Execute() error {
+	return rootCmd.Execute()
+}
+
+func init() {
+	cobra.OnInitialize(initConfig)
+}
+
+func initConfig() {
+	if err := config.InitConfig(); err != nil {
+		fmt.Fprintf(os.Stderr, "Error initializing config: %v\n", err)
+		os.Exit(1)
+	}
+}

+ 36 - 0
cli/go.mod

@@ -0,0 +1,36 @@
+module github.com/letta/letta-schedules-cli
+
+go 1.21
+
+require (
+	github.com/fatih/color v1.16.0
+	github.com/olekukonko/tablewriter v0.0.5
+	github.com/spf13/cobra v1.8.0
+	github.com/spf13/viper v1.18.2
+)
+
+require (
+	github.com/fsnotify/fsnotify v1.7.0 // indirect
+	github.com/hashicorp/hcl v1.0.0 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/magiconair/properties v1.8.7 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-runewidth v0.0.9 // indirect
+	github.com/mitchellh/mapstructure v1.5.0 // indirect
+	github.com/pelletier/go-toml/v2 v2.1.0 // indirect
+	github.com/sagikazarmark/locafero v0.4.0 // indirect
+	github.com/sagikazarmark/slog-shim v0.1.0 // indirect
+	github.com/sourcegraph/conc v0.3.0 // indirect
+	github.com/spf13/afero v1.11.0 // indirect
+	github.com/spf13/cast v1.6.0 // indirect
+	github.com/spf13/pflag v1.0.5 // indirect
+	github.com/subosito/gotenv v1.6.0 // indirect
+	go.uber.org/atomic v1.9.0 // indirect
+	go.uber.org/multierr v1.9.0 // indirect
+	golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
+	golang.org/x/sys v0.15.0 // indirect
+	golang.org/x/text v0.14.0 // indirect
+	gopkg.in/ini.v1 v1.67.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)

+ 88 - 0
cli/go.sum

@@ -0,0 +1,88 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
+github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
+github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
+github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
+github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
+github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
+github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
+github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
+github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
+github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
+github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
+github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
+go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
+golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
+golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 197 - 0
cli/internal/client/client.go

@@ -0,0 +1,197 @@
+package client
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+)
+
+// Client handles communication with the Letta Schedules API
+type Client struct {
+	BaseURL    string
+	APIKey     string
+	HTTPClient *http.Client
+}
+
+// NewClient creates a new API client
+func NewClient(baseURL, apiKey string) *Client {
+	return &Client{
+		BaseURL: baseURL,
+		APIKey:  apiKey,
+		HTTPClient: &http.Client{
+			Timeout: 60 * time.Second, // Increased for Modal cold starts
+		},
+	}
+}
+
+// doRequest executes an HTTP request
+func (c *Client) doRequest(method, path string, body interface{}) ([]byte, error) {
+	var reqBody io.Reader
+	if body != nil {
+		jsonData, err := json.Marshal(body)
+		if err != nil {
+			return nil, fmt.Errorf("failed to marshal request body: %w", err)
+		}
+		reqBody = bytes.NewBuffer(jsonData)
+	}
+
+	req, err := http.NewRequest(method, c.BaseURL+path, reqBody)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	req.Header.Set("Content-Type", "application/json")
+	if c.APIKey != "" {
+		req.Header.Set("Authorization", "Bearer "+c.APIKey)
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("request failed: %w", err)
+	}
+	defer resp.Body.Close()
+
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read response: %w", err)
+	}
+
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
+	}
+
+	return respBody, nil
+}
+
+// Recurring Schedule methods
+
+func (c *Client) CreateRecurringSchedule(schedule RecurringScheduleCreate) (*RecurringSchedule, error) {
+	schedule.APIKey = c.APIKey
+	respBody, err := c.doRequest("POST", "/schedules/recurring", schedule)
+	if err != nil {
+		return nil, err
+	}
+
+	var result RecurringSchedule
+	if err := json.Unmarshal(respBody, &result); err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	return &result, nil
+}
+
+func (c *Client) ListRecurringSchedules() ([]RecurringSchedule, error) {
+	respBody, err := c.doRequest("GET", "/schedules/recurring", nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var schedules []RecurringSchedule
+	if err := json.Unmarshal(respBody, &schedules); err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	return schedules, nil
+}
+
+func (c *Client) GetRecurringSchedule(scheduleID string) (*RecurringSchedule, error) {
+	respBody, err := c.doRequest("GET", "/schedules/recurring/"+scheduleID, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var schedule RecurringSchedule
+	if err := json.Unmarshal(respBody, &schedule); err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	return &schedule, nil
+}
+
+func (c *Client) DeleteRecurringSchedule(scheduleID string) error {
+	_, err := c.doRequest("DELETE", "/schedules/recurring/"+scheduleID, nil)
+	return err
+}
+
+// One-time Schedule methods
+
+func (c *Client) CreateOneTimeSchedule(schedule OneTimeScheduleCreate) (*OneTimeSchedule, error) {
+	schedule.APIKey = c.APIKey
+	respBody, err := c.doRequest("POST", "/schedules/one-time", schedule)
+	if err != nil {
+		return nil, err
+	}
+
+	var result OneTimeSchedule
+	if err := json.Unmarshal(respBody, &result); err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	return &result, nil
+}
+
+func (c *Client) ListOneTimeSchedules() ([]OneTimeSchedule, error) {
+	respBody, err := c.doRequest("GET", "/schedules/one-time", nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var schedules []OneTimeSchedule
+	if err := json.Unmarshal(respBody, &schedules); err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	return schedules, nil
+}
+
+func (c *Client) GetOneTimeSchedule(scheduleID string) (*OneTimeSchedule, error) {
+	respBody, err := c.doRequest("GET", "/schedules/one-time/"+scheduleID, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var schedule OneTimeSchedule
+	if err := json.Unmarshal(respBody, &schedule); err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	return &schedule, nil
+}
+
+func (c *Client) DeleteOneTimeSchedule(scheduleID string) error {
+	_, err := c.doRequest("DELETE", "/schedules/one-time/"+scheduleID, nil)
+	return err
+}
+
+// Results methods
+
+func (c *Client) ListResults() ([]ExecutionResult, error) {
+	respBody, err := c.doRequest("GET", "/results", nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var results []ExecutionResult
+	if err := json.Unmarshal(respBody, &results); err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	return results, nil
+}
+
+func (c *Client) GetResult(scheduleID string) (*ExecutionResult, error) {
+	respBody, err := c.doRequest("GET", "/results/"+scheduleID, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var result ExecutionResult
+	if err := json.Unmarshal(respBody, &result); err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	return &result, nil
+}

+ 97 - 0
cli/internal/client/types.go

@@ -0,0 +1,97 @@
+package client
+
+import (
+	"encoding/json"
+	"time"
+)
+
+// FlexTime is a custom time type that can parse both RFC3339 and ISO8601 without timezone
+type FlexTime struct {
+	time.Time
+}
+
+// UnmarshalJSON implements custom JSON unmarshaling for flexible time parsing
+func (ft *FlexTime) UnmarshalJSON(b []byte) error {
+	s := string(b)
+	// Remove quotes
+	if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
+		s = s[1 : len(s)-1]
+	}
+
+	// Try parsing with timezone first (RFC3339)
+	t, err := time.Parse(time.RFC3339, s)
+	if err == nil {
+		ft.Time = t
+		return nil
+	}
+
+	// Try parsing without timezone
+	t, err = time.Parse("2006-01-02T15:04:05.999999", s)
+	if err == nil {
+		ft.Time = t.UTC()
+		return nil
+	}
+
+	// Try parsing without microseconds
+	t, err = time.Parse("2006-01-02T15:04:05", s)
+	if err == nil {
+		ft.Time = t.UTC()
+		return nil
+	}
+
+	return err
+}
+
+// MarshalJSON implements custom JSON marshaling
+func (ft FlexTime) MarshalJSON() ([]byte, error) {
+	return json.Marshal(ft.Time)
+}
+
+// RecurringSchedule represents a recurring schedule
+type RecurringSchedule struct {
+	ID         string   `json:"id"`
+	AgentID    string   `json:"agent_id"`
+	Message    string   `json:"message"`
+	Role       string   `json:"role"`
+	CronString string   `json:"cron"`
+	LastRun    *string  `json:"last_run,omitempty"`
+	CreatedAt  FlexTime `json:"created_at"`
+}
+
+// RecurringScheduleCreate represents the payload to create a recurring schedule
+type RecurringScheduleCreate struct {
+	AgentID    string `json:"agent_id"`
+	Message    string `json:"message"`
+	Role       string `json:"role"`
+	CronString string `json:"cron"`
+	APIKey     string `json:"api_key"`
+}
+
+// OneTimeSchedule represents a one-time schedule
+type OneTimeSchedule struct {
+	ID        string   `json:"id"`
+	AgentID   string   `json:"agent_id"`
+	Message   string   `json:"message"`
+	Role      string   `json:"role"`
+	ExecuteAt string   `json:"execute_at"`
+	CreatedAt FlexTime `json:"created_at"`
+}
+
+// OneTimeScheduleCreate represents the payload to create a one-time schedule
+type OneTimeScheduleCreate struct {
+	AgentID   string `json:"agent_id"`
+	Message   string `json:"message"`
+	Role      string `json:"role"`
+	ExecuteAt string `json:"execute_at"`
+	APIKey    string `json:"api_key"`
+}
+
+// ExecutionResult represents the result of a schedule execution
+type ExecutionResult struct {
+	ScheduleID   string `json:"schedule_id"`
+	ScheduleType string `json:"schedule_type"`
+	RunID        string `json:"run_id"`
+	AgentID      string `json:"agent_id"`
+	Message      string `json:"message"`
+	ExecutedAt   string `json:"executed_at"`
+}

+ 105 - 0
cli/internal/config/config.go

@@ -0,0 +1,105 @@
+package config
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/spf13/viper"
+)
+
+const (
+	ConfigDirName  = ".letta-schedules"
+	ConfigFileName = "config"
+)
+
+// Config holds the CLI configuration
+type Config struct {
+	APIKey  string `mapstructure:"api_key"`
+	BaseURL string `mapstructure:"base_url"`
+}
+
+// GetConfigDir returns the config directory path
+func GetConfigDir() (string, error) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "", fmt.Errorf("failed to get home directory: %w", err)
+	}
+	return filepath.Join(home, ConfigDirName), nil
+}
+
+// InitConfig initializes the configuration
+func InitConfig() error {
+	configDir, err := GetConfigDir()
+	if err != nil {
+		return err
+	}
+
+	// Create config directory if it doesn't exist
+	if err := os.MkdirAll(configDir, 0755); err != nil {
+		return fmt.Errorf("failed to create config directory: %w", err)
+	}
+
+	viper.SetConfigName(ConfigFileName)
+	viper.SetConfigType("yaml")
+	viper.AddConfigPath(configDir)
+
+	// Set defaults
+	viper.SetDefault("base_url", "https://letta--letta-schedules-api-dev.modal.run")
+
+	// Read config file if it exists
+	if err := viper.ReadInConfig(); err != nil {
+		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
+			return fmt.Errorf("failed to read config: %w", err)
+		}
+	}
+
+	return nil
+}
+
+// Load loads the current configuration
+func Load() (*Config, error) {
+	var cfg Config
+	if err := viper.Unmarshal(&cfg); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal config: %w", err)
+	}
+	return &cfg, nil
+}
+
+// SetAPIKey sets the API key in the config
+func SetAPIKey(apiKey string) error {
+	viper.Set("api_key", apiKey)
+	return saveConfig()
+}
+
+// SetBaseURL sets the base URL in the config
+func SetBaseURL(baseURL string) error {
+	viper.Set("base_url", baseURL)
+	return saveConfig()
+}
+
+// saveConfig saves the current configuration to disk
+func saveConfig() error {
+	configDir, err := GetConfigDir()
+	if err != nil {
+		return err
+	}
+
+	configPath := filepath.Join(configDir, ConfigFileName+".yaml")
+	if err := viper.WriteConfigAs(configPath); err != nil {
+		return fmt.Errorf("failed to write config: %w", err)
+	}
+
+	return nil
+}
+
+// Validate checks if the configuration is valid
+func (c *Config) Validate() error {
+	if c.APIKey == "" {
+		return fmt.Errorf("API key not set. Run 'letta-schedules config set-api-key <key>'")
+	}
+	if c.BaseURL == "" {
+		return fmt.Errorf("base URL not set. Run 'letta-schedules config set-url <url>'")
+	}
+	return nil
+}

+ 15 - 0
cli/main.go

@@ -0,0 +1,15 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/letta/letta-schedules-cli/cmd"
+)
+
+func main() {
+	if err := cmd.Execute(); err != nil {
+		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+		os.Exit(1)
+	}
+}