Browse Source

Initial commit: Letta Schedules serverless scheduling service

Built a production-ready scheduling service for Letta agents with:
- Modal + polling pattern for dynamic user-submitted schedules
- FastAPI REST API with Bearer token authentication
- Hash-based directories + time bucketing for O(user schedules) performance
- Encryption at rest with Fernet (AES-128-CBC)
- API key validation against Letta API
- Execution results storage with run_id tracking
- Race condition prevention via atomic file deletion
- Dev mode for local development with plaintext files
- Comprehensive test scripts (Python + Bash)

Architecture:
- Recurring schedules: /data/schedules/recurring/{api_key_hash}/{uuid}.json
- One-time schedules: /data/schedules/one-time/{date}/{hour}/{api_key_hash}/{uuid}.json
- Results: /data/results/{api_key_hash}/{schedule_uuid}.json

Security features:
- All data encrypted at rest (production)
- API keys validated on every request
- User isolation via hash-based directories
- Run IDs tracked for audit trail

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>
Cameron Pfiffer 5 months ago
commit
84f859ee77
12 changed files with 1651 additions and 0 deletions
  1. 36 0
      .gitignore
  2. 433 0
      README.md
  3. 589 0
      app.py
  4. 53 0
      crypto_utils.py
  5. 39 0
      letta_executor.py
  6. 41 0
      models.py
  7. 7 0
      requirements.txt
  8. 46 0
      scheduler.py
  9. 55 0
      setup_encryption.sh
  10. 0 0
      test-lines.bash
  11. 238 0
      test_api.py
  12. 114 0
      test_api.sh

+ 36 - 0
.gitignore

@@ -0,0 +1,36 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+*.egg-info/
+dist/
+build/
+.venv/
+venv/
+env/
+
+# Modal
+.modal/
+
+# Letta
+.letta/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env files
+.env
+.env.local
+
+# Test outputs
+*.log

+ 433 - 0
README.md

@@ -0,0 +1,433 @@
+# Letta Schedules
+
+A serverless scheduling service for Letta agents built on Modal. Schedule recurring (cron-based) or one-time messages to be sent to your Letta agents at specified times.
+
+## Architecture
+
+- **FastAPI** - REST API for managing schedules
+- **Modal Cron** - Runs every minute to check and execute due schedules
+- **Modal Volume** - Persistent JSON storage for schedule definitions
+- **Letta Client** - Executes scheduled messages via Letta API
+
+## Features
+
+- ✅ Schedule recurring messages with cron expressions
+- ✅ Schedule one-time messages with ISO 8601 timestamps
+- ✅ Full CRUD operations for schedules
+- ✅ Timezone support for one-time schedules
+- ✅ Automatic cleanup of executed one-time schedules
+- ✅ Async execution via Modal
+
+## Installation
+
+1. Clone the repository:
+```bash
+cd letta-schedules
+```
+
+2. Install Modal CLI:
+```bash
+pip install modal
+```
+
+3. Authenticate with Modal:
+```bash
+modal setup
+```
+
+## Deployment
+
+### 1. Set Encryption Key (Required)
+
+**Option A: Automated Setup Script**
+
+```bash
+./setup_encryption.sh
+```
+
+This will:
+- Generate a secure encryption key
+- Create the Modal secret
+- Display the key for safekeeping
+
+**Option B: Manual Setup**
+
+```bash
+# Generate a new encryption key
+ENCRYPTION_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
+
+# Display and save the key
+echo "Your encryption key: $ENCRYPTION_KEY"
+
+# Create Modal secret
+modal secret create letta-schedules-encryption \
+  LETTA_SCHEDULES_ENCRYPTION_KEY="$ENCRYPTION_KEY"
+```
+
+**⚠️ CRITICAL:** Save the encryption key securely! If lost, all encrypted schedules become unrecoverable.
+
+### 2. Deploy to Modal
+
+```bash
+modal deploy app.py
+```
+
+This will:
+- Create the Modal app and volume
+- Set up the scheduler cron job (runs every minute)
+- Deploy the FastAPI endpoints with encryption
+
+### 3. Get Your API URL
+
+```bash
+modal app list
+```
+
+Look for `letta-schedules` and note the API endpoint URL.
+
+## Local Development
+
+Run locally with hot reloading:
+```bash
+# Enable dev mode (no encryption, easier debugging)
+export LETTA_SCHEDULES_DEV_MODE=true
+export LETTA_SCHEDULES_ENCRYPTION_KEY="any-value-ignored-in-dev-mode"
+
+modal serve app.py
+```
+
+This starts a local development server with auto-reload on file changes.
+
+**Dev Mode Features:**
+- 🔓 Files stored in **plaintext JSON** (no encryption)
+- Easy to inspect with `cat`, `jq`, etc.
+- Clearly logged: `DEV MODE: Encryption disabled`
+- Perfect for local debugging
+
+**Important:** Never use dev mode in production! Set `LETTA_SCHEDULES_DEV_MODE=false` or leave unset for production.
+
+**Inspecting files in dev mode:**
+```bash
+# View a schedule
+cat /tmp/letta-schedules-volume/schedules/recurring/abc123/uuid.json | jq
+
+# View an execution result
+cat /tmp/letta-schedules-volume/results/abc123/uuid.json | jq
+
+# List all schedules for a user
+ls -la /tmp/letta-schedules-volume/schedules/recurring/abc123/
+```
+
+## Testing
+
+Two test scripts are provided. Both require environment variables:
+
+### Setup Environment Variables
+
+```bash
+export LETTA_API_KEY="sk-..."              # Required: Your valid Letta API key
+export LETTA_AGENT_ID="agent-xxx"          # Optional: Agent to test with
+export LETTA_SCHEDULES_URL="https://..."   # Optional: Your Modal app URL
+```
+
+**Important:** The API key must be valid and will be validated against Letta's API during testing.
+
+### Python Test Script
+
+```bash
+python test_api.py
+```
+
+This will test all endpoints (create, list, get, delete) for both recurring and one-time schedules.
+
+**Features:**
+- Validates API key before running
+- Shows configuration at startup
+- Tests create, list, get, and delete operations
+- Pretty prints all responses
+
+### Bash Test Script
+
+```bash
+./test_api.sh
+```
+
+Same functionality using curl commands.
+
+**Example with inline variables:**
+```bash
+LETTA_API_KEY=sk-xxx LETTA_AGENT_ID=agent-yyy python test_api.py
+```
+
+## API Usage
+
+Base URL: `https://your-modal-app.modal.run`
+
+### Authentication
+
+All endpoints require Bearer token authentication using your Letta API key:
+
+```bash
+curl -H "Authorization: Bearer your-letta-api-key" https://your-modal-app.modal.run/schedules/recurring
+```
+
+**Security Model:**
+- **API Key Validation**: All requests validate your API key against Letta's API (lightweight `list agents` call with `limit=1`)
+- **Create endpoints**: Verify API key is valid before creating schedule
+- **List endpoints**: Returns only schedules created with your API key
+- **Get/Delete endpoints**: Returns 403 Forbidden if the schedule wasn't created with your API key
+- **Privacy**: API keys are never returned in responses, only used for authentication and execution
+
+**Error Codes:**
+- `401 Unauthorized`: Invalid or expired Letta API key
+- `403 Forbidden`: Valid API key, but trying to access someone else's schedule
+- `404 Not Found`: Schedule doesn't exist
+
+### Create Recurring Schedule
+
+Schedule a message to be sent on a cron schedule:
+
+```bash
+curl -X POST https://your-modal-app.modal.run/schedules/recurring \
+  -H "Content-Type: application/json" \
+  -d '{
+    "agent_id": "agent-123",
+    "api_key": "your-letta-api-key",
+    "cron": "0 9 * * *",
+    "message": "Good morning! Time for your daily check-in.",
+    "role": "user"
+  }'
+```
+
+**Cron Format:** `minute hour day month day_of_week`
+- `0 9 * * *` - Every day at 9:00 AM
+- `*/15 * * * *` - Every 15 minutes
+- `0 */2 * * *` - Every 2 hours
+- `0 0 * * 0` - Every Sunday at midnight
+
+### Create One-Time Schedule
+
+Schedule a message for a specific time:
+
+```bash
+curl -X POST https://your-modal-app.modal.run/schedules/one-time \
+  -H "Content-Type: application/json" \
+  -d '{
+    "agent_id": "agent-123",
+    "api_key": "your-letta-api-key",
+    "execute_at": "2025-11-07T14:30:00-05:00",
+    "message": "Reminder: Meeting in 30 minutes",
+    "role": "user"
+  }'
+```
+
+**Timestamp Format:** ISO 8601 with timezone
+- `2025-11-07T14:30:00-05:00` (EST)
+- `2025-11-07T14:30:00Z` (UTC)
+
+### List All Schedules
+
+```bash
+# List your recurring schedules
+curl -H "Authorization: Bearer your-letta-api-key" \
+  https://your-modal-app.modal.run/schedules/recurring
+
+# List your one-time schedules
+curl -H "Authorization: Bearer your-letta-api-key" \
+  https://your-modal-app.modal.run/schedules/one-time
+```
+
+### Get Specific Schedule
+
+```bash
+# Get recurring schedule
+curl -H "Authorization: Bearer your-letta-api-key" \
+  https://your-modal-app.modal.run/schedules/recurring/{schedule_id}
+
+# Get one-time schedule
+curl -H "Authorization: Bearer your-letta-api-key" \
+  https://your-modal-app.modal.run/schedules/one-time/{schedule_id}
+```
+
+### Delete Schedule
+
+```bash
+# Delete recurring schedule
+curl -X DELETE -H "Authorization: Bearer your-letta-api-key" \
+  https://your-modal-app.modal.run/schedules/recurring/{schedule_id}
+
+# Delete one-time schedule
+curl -X DELETE -H "Authorization: Bearer your-letta-api-key" \
+  https://your-modal-app.modal.run/schedules/one-time/{schedule_id}
+```
+
+### Get Execution Results
+
+```bash
+# List all execution results
+curl -H "Authorization: Bearer your-letta-api-key" \
+  https://your-modal-app.modal.run/results
+
+# Get result for specific schedule
+curl -H "Authorization: Bearer your-letta-api-key" \
+  https://your-modal-app.modal.run/results/{schedule_id}
+```
+
+**Result Format:**
+```json
+{
+  "schedule_id": "uuid",
+  "schedule_type": "recurring",
+  "run_id": "run_abc123",
+  "agent_id": "agent-123",
+  "message": "The scheduled message",
+  "executed_at": "2025-11-07T00:15:00"
+}
+```
+
+**Note:** Results are stored when the message is queued to Letta. To check the actual run status, use the Letta API:
+```bash
+# Get the run_id from results
+RESULT=$(curl -H "Authorization: Bearer your-letta-api-key" \
+  https://your-modal-app.modal.run/results/{schedule_id})
+
+RUN_ID=$(echo $RESULT | jq -r '.run_id')
+
+# Check run status with Letta
+curl -H "Authorization: Bearer your-letta-api-key" \
+  https://api.letta.com/v1/runs/$RUN_ID
+```
+
+## Response Format
+
+### Recurring Schedule Response
+```json
+{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "agent_id": "agent-123",
+  "cron": "0 9 * * *",
+  "message": "Good morning!",
+  "role": "user",
+  "created_at": "2025-11-06T10:00:00",
+  "last_run": "2025-11-06T09:00:00"
+}
+```
+
+**Note:** API keys are stored securely and never returned in responses.
+
+### One-Time Schedule Response
+```json
+{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "agent_id": "agent-123",
+  "execute_at": "2025-11-07T14:30:00-05:00",
+  "message": "Reminder!",
+  "role": "user",
+  "created_at": "2025-11-06T10:00:00"
+}
+```
+
+**Note:** 
+- API keys are stored securely and never returned in responses
+- Once executed, one-time schedules are deleted from storage (check `/results` endpoint for execution history)
+
+## How It Works
+
+1. **API receives schedule request** → Validates and stores as JSON in Modal Volume
+2. **Cron job runs every minute** → Checks all schedules in Volume
+3. **Due schedules identified** → Spawns async executor functions
+4. **Executor verifies schedule exists** → Skips if schedule was deleted after spawn
+5. **For one-time schedules** → Deletes schedule file immediately (prevents re-execution)
+6. **Executor calls Letta API** → Sends message to specified agent
+7. **Saves execution result** → Stores run_id and metadata in results folder
+8. **For recurring schedules** → Updates `last_run` timestamp in schedule file
+
+**Race Condition Prevention:**
+- One-time schedules are **deleted before execution** (not after)
+- If multiple executors spawn, only first one successfully deletes
+- Second executor finds no file → skips gracefully
+- Filesystem is source of truth: file exists = hasn't run yet
+
+## Storage Structure
+
+Schedules and execution results are stored in a hash-based directory structure:
+
+```
+/data/
+├── schedules/
+│   ├── recurring/
+│   │   ├── {api_key_hash}/        # SHA256 hash of API key (first 16 chars)
+│   │   │   ├── {uuid-1}.json.enc  # Encrypted schedule files
+│   │   │   └── {uuid-2}.json.enc
+│   │   └── {another_hash}/
+│   │       └── {uuid-3}.json.enc
+│   └── one-time/
+│       ├── 2025-11-06/             # Date bucket
+│       │   ├── 14/                 # Hour bucket (00-23)
+│       │   │   ├── {api_key_hash}/
+│       │   │   │   └── {uuid}.json.enc
+│       │   │   └── {another_hash}/
+│       │   │       └── {uuid}.json.enc
+│       │   └── 15/
+│       └── 2025-11-07/
+└── results/
+    ├── {api_key_hash}/
+    │   ├── {schedule_uuid}.json.enc  # Execution results with run_id
+    │   └── {schedule_uuid}.json.enc
+    └── {another_hash}/
+```
+
+**Security Features:**
+- All schedule files are encrypted at rest using Fernet (AES-128-CBC)
+- API keys never stored in plaintext
+- User isolation via hash-based directories
+- Time-based bucketing for efficient queries
+
+**Performance Benefits:**
+- **Recurring schedules:** O(user's schedules) instead of O(all schedules)
+- **One-time schedules:** O(schedules in current hour) instead of O(all schedules)
+- Only checks relevant time buckets during cron execution
+- Automatic cleanup: Empty directories are removed after each cron run
+
+## Monitoring
+
+View logs in Modal dashboard:
+```bash
+modal app logs letta-schedules
+```
+
+Or watch logs in real-time:
+```bash
+modal app logs letta-schedules --follow
+```
+
+## Limitations
+
+- **Minimum granularity:** 1 minute (cron runs every minute)
+- **Timezone handling:** One-time schedules support timezones; recurring schedules run in UTC
+- **Authentication:** Bearer token authentication with validation against Letta API
+- **Encryption key management:** Single master key for all schedules (consider key rotation strategy for production)
+
+## Future Improvements
+
+- [ ] Encryption key rotation mechanism
+- [ ] Execution history/logs API endpoint
+- [ ] Rate limiting per user
+- [ ] Email/webhook notifications on failures
+- [ ] Pagination for list endpoints
+- [ ] Timezone support for recurring schedules
+- [ ] Schedule validation (max schedules per user)
+- [ ] Cleanup of old date buckets (>7 days) to prevent unbounded growth
+
+## Costs
+
+Modal pricing (as of 2024):
+- **Compute:** ~$0.000162/second for basic CPU
+- **Volume storage:** ~$0.10/GB/month
+- **Estimated monthly cost:** $5-10 for moderate usage (hundreds of schedules)
+
+Free tier: 30 credits/month (~$30 value)
+
+## License
+
+MIT

+ 589 - 0
app.py

@@ -0,0 +1,589 @@
+import modal
+import json
+import logging
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import List
+from fastapi import FastAPI, HTTPException, Header, Security
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from fastapi.responses import JSONResponse
+from typing import Optional
+
+security = HTTPBearer()
+
+from models import (
+    RecurringScheduleCreate,
+    RecurringSchedule,
+    OneTimeScheduleCreate,
+    OneTimeSchedule,
+)
+from scheduler import is_recurring_schedule_due, is_onetime_schedule_due
+from letta_executor import execute_letta_message, validate_api_key
+from crypto_utils import get_api_key_hash, get_encryption_key, encrypt_json, decrypt_json
+from dateutil import parser as date_parser
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+app = modal.App("letta-schedules")
+
+import os as local_os
+
+# Read dev mode setting from local environment
+dev_mode_enabled = local_os.getenv("LETTA_SCHEDULES_DEV_MODE", "false")
+
+image = (
+    modal.Image.debian_slim()
+    .pip_install_from_requirements("requirements.txt")
+    .env({"LETTA_SCHEDULES_DEV_MODE": dev_mode_enabled})  # Must come before add_local_*
+    .add_local_python_source("models", "scheduler", "letta_executor", "crypto_utils")
+)
+
+volume = modal.Volume.from_name("letta-schedules-volume", create_if_missing=True)
+
+try:
+    encryption_secret = modal.Secret.from_name("letta-schedules-encryption")
+except Exception:
+    logger.warning("letta-schedules-encryption secret not found, will use env var or generate temporary key")
+    encryption_secret = None
+
+VOLUME_PATH = "/data"
+SCHEDULES_BASE = f"{VOLUME_PATH}/schedules"
+RESULTS_BASE = f"{VOLUME_PATH}/results"
+
+web_app = FastAPI()
+
+# Lazy-load encryption key (will check env vars at runtime)
+_encryption_key = None
+
+def get_encryption_key_cached():
+    global _encryption_key
+    if _encryption_key is None:
+        _encryption_key = get_encryption_key()
+    return _encryption_key
+
+
+def get_recurring_schedule_path(api_key: str, schedule_id: str) -> str:
+    """Get file path for recurring schedule."""
+    api_key_hash = get_api_key_hash(api_key)
+    return f"{SCHEDULES_BASE}/recurring/{api_key_hash}/{schedule_id}.json"
+
+
+def get_onetime_schedule_path(api_key: str, execute_at: str, schedule_id: str) -> str:
+    """Get file path for one-time schedule with time bucketing."""
+    api_key_hash = get_api_key_hash(api_key)
+    dt = date_parser.parse(execute_at)
+    date_str = dt.strftime("%Y-%m-%d")
+    hour_str = dt.strftime("%H")
+    return f"{SCHEDULES_BASE}/one-time/{date_str}/{hour_str}/{api_key_hash}/{schedule_id}.json"
+
+
+def save_schedule(file_path: str, schedule_data: dict):
+    """Save encrypted schedule to file."""
+    Path(file_path).parent.mkdir(parents=True, exist_ok=True)
+    encrypted_data = encrypt_json(schedule_data, get_encryption_key_cached())
+    with open(file_path, "wb") as f:
+        f.write(encrypted_data)
+    volume.commit()
+
+
+def load_schedule(file_path: str) -> dict:
+    """Load and decrypt schedule from file."""
+    try:
+        with open(file_path, "rb") as f:
+            encrypted_data = f.read()
+        return decrypt_json(encrypted_data, get_encryption_key_cached())
+    except FileNotFoundError:
+        return None
+
+
+def delete_schedule(file_path: str):
+    """Delete schedule file."""
+    try:
+        Path(file_path).unlink()
+        volume.commit()
+        return True
+    except FileNotFoundError:
+        return False
+
+
+def list_recurring_schedules_for_user(api_key: str) -> List[dict]:
+    """List all recurring schedules for a specific user."""
+    api_key_hash = get_api_key_hash(api_key)
+    user_dir = f"{SCHEDULES_BASE}/recurring/{api_key_hash}"
+    schedules = []
+    
+    if not Path(user_dir).exists():
+        return schedules
+    
+    for file_path in Path(user_dir).glob("*.json"):
+        try:
+            with open(file_path, "rb") as f:
+                encrypted_data = f.read()
+            schedule = decrypt_json(encrypted_data, get_encryption_key_cached())
+            schedules.append(schedule)
+        except Exception as e:
+            logger.error(f"Failed to load schedule {file_path}: {e}")
+    
+    return schedules
+
+
+def list_onetime_schedules_for_user(api_key: str) -> List[dict]:
+    """List all one-time schedules for a specific user."""
+    api_key_hash = get_api_key_hash(api_key)
+    base_dir = f"{SCHEDULES_BASE}/one-time"
+    schedules = []
+    
+    if not Path(base_dir).exists():
+        return schedules
+    
+    # Traverse all date/hour buckets
+    for date_dir in Path(base_dir).iterdir():
+        if not date_dir.is_dir():
+            continue
+        for hour_dir in date_dir.iterdir():
+            if not hour_dir.is_dir():
+                continue
+            user_dir = hour_dir / api_key_hash
+            if not user_dir.exists():
+                continue
+            
+            for file_path in user_dir.glob("*.json"):
+                try:
+                    with open(file_path, "rb") as f:
+                        encrypted_data = f.read()
+                    schedule = decrypt_json(encrypted_data, get_encryption_key_cached())
+                    schedules.append(schedule)
+                except Exception as e:
+                    logger.error(f"Failed to load schedule {file_path}: {e}")
+    
+    return schedules
+
+
+def list_all_recurring_schedules() -> List[dict]:
+    """List all recurring schedules across all users (for cron job)."""
+    schedules = []
+    recurring_dir = f"{SCHEDULES_BASE}/recurring"
+    
+    if not Path(recurring_dir).exists():
+        return schedules
+    
+    for user_dir in Path(recurring_dir).iterdir():
+        if not user_dir.is_dir():
+            continue
+        for file_path in user_dir.glob("*.json"):
+            try:
+                with open(file_path, "rb") as f:
+                    encrypted_data = f.read()
+                schedule = decrypt_json(encrypted_data, get_encryption_key_cached())
+                schedules.append(schedule)
+            except Exception as e:
+                logger.error(f"Failed to load schedule {file_path}: {e}")
+    
+    return schedules
+
+
+def list_onetime_schedules_for_time(date_str: str, hour_str: str) -> List[dict]:
+    """List all one-time schedules for a specific date/hour (for cron job)."""
+    schedules = []
+    time_dir = f"{SCHEDULES_BASE}/one-time/{date_str}/{hour_str}"
+    
+    if not Path(time_dir).exists():
+        return schedules
+    
+    for user_dir in Path(time_dir).iterdir():
+        if not user_dir.is_dir():
+            continue
+        for file_path in user_dir.glob("*.json"):
+            try:
+                with open(file_path, "rb") as f:
+                    encrypted_data = f.read()
+                schedule = decrypt_json(encrypted_data, get_encryption_key_cached())
+                schedules.append(schedule)
+            except Exception as e:
+                logger.error(f"Failed to load schedule {file_path}: {e}")
+    
+    return schedules
+
+
+def find_onetime_schedule_for_user(api_key: str, schedule_id: str) -> tuple[dict, str]:
+    """Find a one-time schedule by ID for a specific user. Returns (schedule, file_path)."""
+    api_key_hash = get_api_key_hash(api_key)
+    base_dir = f"{SCHEDULES_BASE}/one-time"
+    
+    if not Path(base_dir).exists():
+        return None, None
+    
+    # Search through all time buckets for this user
+    for date_dir in Path(base_dir).iterdir():
+        if not date_dir.is_dir():
+            continue
+        for hour_dir in date_dir.iterdir():
+            if not hour_dir.is_dir():
+                continue
+            user_dir = hour_dir / api_key_hash
+            if not user_dir.exists():
+                continue
+            
+            file_path = user_dir / f"{schedule_id}.json"
+            if file_path.exists():
+                try:
+                    with open(file_path, "rb") as f:
+                        encrypted_data = f.read()
+                    schedule = decrypt_json(encrypted_data, get_encryption_key_cached())
+                    return schedule, str(file_path)
+                except Exception as e:
+                    logger.error(f"Failed to load schedule {file_path}: {e}")
+    
+    return None, None
+
+
+@web_app.post("/schedules/recurring")
+async def create_recurring_schedule(schedule: RecurringScheduleCreate):
+    if not validate_api_key(schedule.api_key):
+        raise HTTPException(status_code=401, detail="Invalid Letta API key")
+    
+    schedule_obj = RecurringSchedule(**schedule.model_dump())
+    schedule_dict = schedule_obj.model_dump(mode='json')
+    file_path = get_recurring_schedule_path(schedule.api_key, schedule_obj.id)
+    save_schedule(file_path, schedule_dict)
+    response_dict = schedule_dict.copy()
+    response_dict.pop("api_key", None)
+    return JSONResponse(content=response_dict, status_code=201)
+
+
+@web_app.post("/schedules/one-time")
+async def create_onetime_schedule(schedule: OneTimeScheduleCreate):
+    if not validate_api_key(schedule.api_key):
+        raise HTTPException(status_code=401, detail="Invalid Letta API key")
+    
+    schedule_obj = OneTimeSchedule(**schedule.model_dump())
+    schedule_dict = schedule_obj.model_dump(mode='json')
+    file_path = get_onetime_schedule_path(schedule.api_key, schedule.execute_at, schedule_obj.id)
+    save_schedule(file_path, schedule_dict)
+    response_dict = schedule_dict.copy()
+    response_dict.pop("api_key", None)
+    return JSONResponse(content=response_dict, status_code=201)
+
+
+@web_app.get("/schedules/recurring")
+async def list_recurring_schedules(credentials: HTTPAuthorizationCredentials = Security(security)):
+    api_key = credentials.credentials
+    if not validate_api_key(api_key):
+        raise HTTPException(status_code=401, detail="Invalid Letta API key")
+    
+    schedules = list_recurring_schedules_for_user(api_key)
+    for schedule in schedules:
+        schedule.pop("api_key", None)
+    return JSONResponse(content=schedules)
+
+
+@web_app.get("/schedules/one-time")
+async def list_onetime_schedules(credentials: HTTPAuthorizationCredentials = Security(security)):
+    api_key = credentials.credentials
+    if not validate_api_key(api_key):
+        raise HTTPException(status_code=401, detail="Invalid Letta API key")
+    
+    schedules = list_onetime_schedules_for_user(api_key)
+    for schedule in schedules:
+        schedule.pop("api_key", None)
+    return JSONResponse(content=schedules)
+
+
+@web_app.get("/schedules/recurring/{schedule_id}")
+async def get_recurring_schedule(schedule_id: str, credentials: HTTPAuthorizationCredentials = Security(security)):
+    api_key = credentials.credentials
+    if not validate_api_key(api_key):
+        raise HTTPException(status_code=401, detail="Invalid Letta API key")
+    
+    file_path = get_recurring_schedule_path(api_key, schedule_id)
+    schedule = load_schedule(file_path)
+    if schedule is None:
+        raise HTTPException(status_code=404, detail="Schedule not found")
+    if schedule.get("api_key") != api_key:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    schedule_copy = schedule.copy()
+    schedule_copy.pop("api_key", None)
+    return JSONResponse(content=schedule_copy)
+
+
+@web_app.get("/schedules/one-time/{schedule_id}")
+async def get_onetime_schedule(schedule_id: str, credentials: HTTPAuthorizationCredentials = Security(security)):
+    api_key = credentials.credentials
+    if not validate_api_key(api_key):
+        raise HTTPException(status_code=401, detail="Invalid Letta API key")
+    
+    schedule, _ = find_onetime_schedule_for_user(api_key, schedule_id)
+    if schedule is None:
+        raise HTTPException(status_code=404, detail="Schedule not found")
+    if schedule.get("api_key") != api_key:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    schedule_copy = schedule.copy()
+    schedule_copy.pop("api_key", None)
+    return JSONResponse(content=schedule_copy)
+
+
+@web_app.delete("/schedules/recurring/{schedule_id}")
+async def delete_recurring_schedule(schedule_id: str, credentials: HTTPAuthorizationCredentials = Security(security)):
+    api_key = credentials.credentials
+    if not validate_api_key(api_key):
+        raise HTTPException(status_code=401, detail="Invalid Letta API key")
+    
+    file_path = get_recurring_schedule_path(api_key, schedule_id)
+    schedule = load_schedule(file_path)
+    if schedule is None:
+        raise HTTPException(status_code=404, detail="Schedule not found")
+    if schedule.get("api_key") != api_key:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    if delete_schedule(file_path):
+        return JSONResponse(content={"message": "Schedule deleted"})
+    else:
+        raise HTTPException(status_code=404, detail="Schedule not found")
+
+
+@web_app.delete("/schedules/one-time/{schedule_id}")
+async def delete_onetime_schedule(schedule_id: str, credentials: HTTPAuthorizationCredentials = Security(security)):
+    api_key = credentials.credentials
+    if not validate_api_key(api_key):
+        raise HTTPException(status_code=401, detail="Invalid Letta API key")
+    
+    schedule, file_path = find_onetime_schedule_for_user(api_key, schedule_id)
+    if schedule is None:
+        raise HTTPException(status_code=404, detail="Schedule not found")
+    if schedule.get("api_key") != api_key:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    if delete_schedule(file_path):
+        return JSONResponse(content={"message": "Schedule deleted"})
+    else:
+        raise HTTPException(status_code=404, detail="Schedule not found")
+
+
+@web_app.get("/results")
+async def list_execution_results(credentials: HTTPAuthorizationCredentials = Security(security)):
+    """List all execution results for the authenticated user."""
+    api_key = credentials.credentials
+    if not validate_api_key(api_key):
+        raise HTTPException(status_code=401, detail="Invalid Letta API key")
+    
+    api_key_hash = get_api_key_hash(api_key)
+    results_dir = f"{RESULTS_BASE}/{api_key_hash}"
+    results = []
+    
+    if Path(results_dir).exists():
+        for result_file in Path(results_dir).glob("*.json"):
+            try:
+                with open(result_file, "rb") as f:
+                    encrypted_data = f.read()
+                result = decrypt_json(encrypted_data, get_encryption_key_cached())
+                results.append(result)
+            except Exception as e:
+                logger.error(f"Failed to load result {result_file}: {e}")
+    
+    return JSONResponse(content=results)
+
+
+@web_app.get("/results/{schedule_id}")
+async def get_execution_result(schedule_id: str, credentials: HTTPAuthorizationCredentials = Security(security)):
+    """Get execution result for a specific schedule."""
+    api_key = credentials.credentials
+    if not validate_api_key(api_key):
+        raise HTTPException(status_code=401, detail="Invalid Letta API key")
+    
+    api_key_hash = get_api_key_hash(api_key)
+    result_file = f"{RESULTS_BASE}/{api_key_hash}/{schedule_id}.json"
+    
+    if not Path(result_file).exists():
+        raise HTTPException(status_code=404, detail="Result not found")
+    
+    try:
+        with open(result_file, "rb") as f:
+            encrypted_data = f.read()
+        result = decrypt_json(encrypted_data, get_encryption_key_cached())
+        return JSONResponse(content=result)
+    except Exception as e:
+        logger.error(f"Failed to load result: {e}")
+        raise HTTPException(status_code=500, detail="Failed to load result")
+
+
+@app.function(image=image, volumes={VOLUME_PATH: volume}, secrets=[encryption_secret] if encryption_secret else [])
+@modal.asgi_app()
+def api():
+    return web_app
+
+
+@app.function(image=image, volumes={VOLUME_PATH: volume}, secrets=[encryption_secret] if encryption_secret else [])
+async def execute_schedule(
+    schedule_id: str,
+    agent_id: str,
+    api_key: str,
+    message: str,
+    role: str,
+    schedule_type: str,
+    execute_at: str = None,
+):
+    logger.info(f"Executing {schedule_type} schedule {schedule_id} for agent {agent_id}")
+    
+    # Check if schedule still exists before executing
+    if schedule_type == "one-time" and execute_at:
+        file_path = get_onetime_schedule_path(api_key, execute_at, schedule_id)
+    else:
+        file_path = get_recurring_schedule_path(api_key, schedule_id)
+    
+    schedule = load_schedule(file_path)
+    if not schedule:
+        logger.warning(f"Schedule {schedule_id} no longer exists, skipping execution")
+        return {"success": False, "error": "Schedule deleted"}
+    
+    # For one-time schedules: DELETE IMMEDIATELY to prevent race condition
+    # Filesystem becomes source of truth: if file exists, it hasn't run
+    if schedule_type == "one-time":
+        try:
+            Path(file_path).unlink()
+            volume.commit()
+            logger.info(f"Deleted one-time schedule {schedule_id} to prevent re-execution")
+        except Exception as e:
+            logger.error(f"Failed to delete schedule {schedule_id}, may re-execute: {e}")
+            return {"success": False, "error": "Could not lock schedule for execution"}
+    
+    # For recurring schedules: update last_run timestamp
+    elif schedule_type == "recurring":
+        schedule["last_run"] = datetime.utcnow().isoformat()
+        save_schedule(file_path, schedule)
+        logger.info(f"Updated last_run for recurring schedule {schedule_id}")
+    
+    # Execute the message
+    result = await execute_letta_message(agent_id, api_key, message, role)
+    
+    # Save execution result if successful
+    if result.get("success") and result.get("run_id"):
+        save_execution_result(
+            api_key=api_key,
+            schedule_id=schedule_id,
+            run_id=result["run_id"],
+            schedule_type=schedule_type,
+            agent_id=agent_id,
+            message=message,
+        )
+    
+    return result
+
+
+def save_execution_result(api_key: str, schedule_id: str, run_id: str, schedule_type: str, agent_id: str, message: str):
+    """Save execution result to results folder."""
+    api_key_hash = get_api_key_hash(api_key)
+    result_dir = f"{RESULTS_BASE}/{api_key_hash}"
+    Path(result_dir).mkdir(parents=True, exist_ok=True)
+    
+    result_file = f"{result_dir}/{schedule_id}.json"
+    
+    result_data = {
+        "schedule_id": schedule_id,
+        "schedule_type": schedule_type,
+        "run_id": run_id,
+        "agent_id": agent_id,
+        "message": message,
+        "executed_at": datetime.utcnow().isoformat(),
+    }
+    
+    encrypted_data = encrypt_json(result_data, get_encryption_key_cached())
+    with open(result_file, "wb") as f:
+        f.write(encrypted_data)
+    volume.commit()
+    
+    logger.info(f"Saved execution result for schedule {schedule_id}, run_id: {run_id}")
+
+
+def cleanup_empty_directories():
+    """Remove empty directories to keep filesystem clean."""
+    removed_count = 0
+    
+    # Clean up one-time schedule directories (date/hour/user structure)
+    onetime_base = f"{SCHEDULES_BASE}/one-time"
+    if Path(onetime_base).exists():
+        for date_dir in Path(onetime_base).iterdir():
+            if not date_dir.is_dir():
+                continue
+            
+            for hour_dir in date_dir.iterdir():
+                if not hour_dir.is_dir():
+                    continue
+                
+                # Remove empty user directories
+                for user_dir in hour_dir.iterdir():
+                    if user_dir.is_dir() and not any(user_dir.iterdir()):
+                        user_dir.rmdir()
+                        removed_count += 1
+                        logger.debug(f"Removed empty directory: {user_dir}")
+                
+                # Remove empty hour directory
+                if not any(hour_dir.iterdir()):
+                    hour_dir.rmdir()
+                    removed_count += 1
+                    logger.debug(f"Removed empty directory: {hour_dir}")
+            
+            # Remove empty date directory
+            if not any(date_dir.iterdir()):
+                date_dir.rmdir()
+                removed_count += 1
+                logger.debug(f"Removed empty directory: {date_dir}")
+    
+    # Clean up recurring schedule directories (user structure only)
+    recurring_base = f"{SCHEDULES_BASE}/recurring"
+    if Path(recurring_base).exists():
+        for user_dir in Path(recurring_base).iterdir():
+            if user_dir.is_dir() and not any(user_dir.iterdir()):
+                user_dir.rmdir()
+                removed_count += 1
+                logger.debug(f"Removed empty directory: {user_dir}")
+    
+    if removed_count > 0:
+        logger.info(f"Cleanup: Removed {removed_count} empty directories")
+        volume.commit()
+
+
+@app.function(
+    image=image,
+    volumes={VOLUME_PATH: volume},
+    secrets=[encryption_secret] if encryption_secret else [],
+    schedule=modal.Cron("* * * * *"),
+)
+async def check_and_execute_schedules():
+    logger.info("Checking schedules...")
+    current_time = datetime.now(timezone.utc)
+    
+    # Check recurring schedules (all users)
+    recurring_schedules = list_all_recurring_schedules()
+    for schedule in recurring_schedules:
+        if is_recurring_schedule_due(schedule, current_time):
+            logger.info(f"Executing recurring schedule {schedule['id']}")
+            execute_schedule.spawn(
+                schedule_id=schedule["id"],
+                agent_id=schedule["agent_id"],
+                api_key=schedule["api_key"],
+                message=schedule["message"],
+                role=schedule["role"],
+                schedule_type="recurring",
+            )
+    
+    # Check one-time schedules (only current date/hour bucket)
+    date_str = current_time.strftime("%Y-%m-%d")
+    hour_str = current_time.strftime("%H")
+    onetime_schedules = list_onetime_schedules_for_time(date_str, hour_str)
+    
+    for schedule in onetime_schedules:
+        if is_onetime_schedule_due(schedule, current_time):
+            logger.info(f"Executing one-time schedule {schedule['id']}")
+            execute_schedule.spawn(
+                schedule_id=schedule["id"],
+                agent_id=schedule["agent_id"],
+                api_key=schedule["api_key"],
+                message=schedule["message"],
+                role=schedule["role"],
+                schedule_type="one-time",
+                execute_at=schedule["execute_at"],
+            )
+    
+    # Clean up empty directories
+    cleanup_empty_directories()
+    
+    logger.info("Schedule check complete")

+ 53 - 0
crypto_utils.py

@@ -0,0 +1,53 @@
+import hashlib
+import json
+from cryptography.fernet import Fernet
+import logging
+import os
+
+logger = logging.getLogger(__name__)
+
+
+def get_api_key_hash(api_key: str) -> str:
+    """Generate a hash of the API key for directory naming."""
+    return hashlib.sha256(api_key.encode()).hexdigest()[:16]
+
+
+def is_dev_mode() -> bool:
+    """Check if we're in dev mode (no encryption)."""
+    return os.getenv("LETTA_SCHEDULES_DEV_MODE", "").lower() in ("true", "1", "yes")
+
+
+def get_encryption_key() -> bytes:
+    """Get or generate encryption key for JSON encryption."""
+    key = os.getenv("LETTA_SCHEDULES_ENCRYPTION_KEY")
+    
+    if is_dev_mode():
+        logger.warning("🔓 DEV MODE: Encryption disabled - files stored in plaintext")
+        return b"dev-mode-no-encryption"
+    
+    if not key:
+        logger.warning("LETTA_SCHEDULES_ENCRYPTION_KEY not set, generating temporary key")
+        key = Fernet.generate_key().decode()
+    
+    return key.encode()
+
+
+def encrypt_json(data: dict, encryption_key: bytes) -> bytes:
+    """Encrypt a JSON object (or return plaintext in dev mode)."""
+    json_bytes = json.dumps(data, default=str, indent=2).encode()
+    
+    if is_dev_mode():
+        return json_bytes
+    
+    fernet = Fernet(encryption_key)
+    return fernet.encrypt(json_bytes)
+
+
+def decrypt_json(encrypted_data: bytes, encryption_key: bytes) -> dict:
+    """Decrypt an encrypted JSON object (or parse plaintext in dev mode)."""
+    if is_dev_mode():
+        return json.loads(encrypted_data)
+    
+    fernet = Fernet(encryption_key)
+    json_bytes = fernet.decrypt(encrypted_data)
+    return json.loads(json_bytes)

+ 39 - 0
letta_executor.py

@@ -0,0 +1,39 @@
+from letta_client import Letta, MessageCreate, TextContent
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def validate_api_key(api_key: str) -> bool:
+    try:
+        client = Letta(token=api_key)
+        client.agents.list(limit=1)
+        return True
+    except Exception as e:
+        logger.error(f"API key validation failed: {str(e)}")
+        return False
+
+
+async def execute_letta_message(agent_id: str, api_key: str, message: str, role: str = "user"):
+    try:
+        client = Letta(token=api_key)
+        
+        # Use create_async() with proper MessageCreate objects
+        run = client.agents.messages.create_async(
+            agent_id=agent_id,
+            messages=[
+                MessageCreate(
+                    role=role,
+                    content=[
+                        TextContent(text=message)
+                    ]
+                )
+            ]
+        )
+        
+        logger.info(f"Successfully queued message for agent {agent_id}, run_id: {run.id}")
+        return {"success": True, "run_id": run.id}
+    
+    except Exception as e:
+        logger.error(f"Failed to send message to agent {agent_id}: {str(e)}")
+        return {"success": False, "error": str(e)}

+ 41 - 0
models.py

@@ -0,0 +1,41 @@
+from pydantic import BaseModel, Field
+from typing import Optional
+from datetime import datetime
+import uuid
+
+
+class RecurringScheduleCreate(BaseModel):
+    agent_id: str
+    api_key: str
+    cron: str
+    message: str
+    role: str = "user"
+
+
+class RecurringSchedule(BaseModel):
+    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
+    agent_id: str
+    api_key: str
+    cron: str
+    message: str
+    role: str = "user"
+    created_at: datetime = Field(default_factory=datetime.utcnow)
+    last_run: Optional[datetime] = None
+
+
+class OneTimeScheduleCreate(BaseModel):
+    agent_id: str
+    api_key: str
+    execute_at: str
+    message: str
+    role: str = "user"
+
+
+class OneTimeSchedule(BaseModel):
+    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
+    agent_id: str
+    api_key: str
+    execute_at: str
+    message: str
+    role: str = "user"
+    created_at: datetime = Field(default_factory=datetime.utcnow)

+ 7 - 0
requirements.txt

@@ -0,0 +1,7 @@
+modal
+fastapi
+pydantic
+croniter
+python-dateutil
+cryptography
+letta-client

+ 46 - 0
scheduler.py

@@ -0,0 +1,46 @@
+from croniter import croniter
+from datetime import datetime, timezone
+from dateutil import parser
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def is_recurring_schedule_due(schedule_dict: dict, current_time: datetime) -> bool:
+    cron_expression = schedule_dict["cron"]
+    last_run = schedule_dict.get("last_run")
+    
+    if last_run:
+        last_run_dt = parser.parse(last_run)
+    else:
+        last_run_dt = parser.parse(schedule_dict["created_at"])
+    
+    if last_run_dt.tzinfo is None:
+        last_run_dt = last_run_dt.replace(tzinfo=timezone.utc)
+    
+    if current_time.tzinfo is None:
+        current_time = current_time.replace(tzinfo=timezone.utc)
+    
+    cron = croniter(cron_expression, last_run_dt)
+    next_run = cron.get_next(datetime)
+    
+    if next_run.tzinfo is None:
+        next_run = next_run.replace(tzinfo=timezone.utc)
+    
+    return current_time >= next_run
+
+
+def is_onetime_schedule_due(schedule_dict: dict, current_time: datetime) -> bool:
+    if schedule_dict.get("executed", False):
+        return False
+    
+    execute_at_str = schedule_dict["execute_at"]
+    execute_at = parser.parse(execute_at_str)
+    
+    if execute_at.tzinfo is None:
+        execute_at = execute_at.replace(tzinfo=timezone.utc)
+    
+    if current_time.tzinfo is None:
+        current_time = current_time.replace(tzinfo=timezone.utc)
+    
+    return current_time >= execute_at

+ 55 - 0
setup_encryption.sh

@@ -0,0 +1,55 @@
+#!/bin/bash
+
+echo "=== Letta Schedules Encryption Setup ==="
+echo ""
+
+# Check if modal is installed
+if ! command -v modal &> /dev/null; then
+    echo "ERROR: modal CLI not found. Install with: pip install modal"
+    exit 1
+fi
+
+# Check if user is authenticated
+if ! modal profile list &> /dev/null; then
+    echo "ERROR: Not authenticated with Modal. Run: modal setup"
+    exit 1
+fi
+
+echo "Generating encryption key..."
+ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
+
+if [ -z "$ENCRYPTION_KEY" ]; then
+    echo "ERROR: Failed to generate encryption key"
+    exit 1
+fi
+
+echo "Generated encryption key: ${ENCRYPTION_KEY:0:20}..."
+echo ""
+
+echo "Creating Modal secret 'letta-schedules-encryption'..."
+
+# Check if secret already exists
+if modal secret list | grep -q "letta-schedules-encryption"; then
+    echo "WARNING: Secret 'letta-schedules-encryption' already exists."
+    echo "Delete it first with: modal secret delete letta-schedules-encryption"
+    exit 1
+fi
+
+# Create the secret
+modal secret create letta-schedules-encryption LETTA_SCHEDULES_ENCRYPTION_KEY="$ENCRYPTION_KEY" 2>&1
+
+if [ $? -eq 0 ]; then
+    echo ""
+    echo "✅ Success! Encryption secret created."
+    echo ""
+    echo "IMPORTANT: Save this key securely!"
+    echo "Encryption key: $ENCRYPTION_KEY"
+    echo ""
+    echo "If you lose this key, all encrypted schedules will be unrecoverable."
+    echo ""
+    echo "Next step: modal deploy app.py"
+else
+    echo ""
+    echo "❌ Failed to create secret. Error above."
+    exit 1
+fi

+ 0 - 0
test-lines.bash


+ 238 - 0
test_api.py

@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+import requests
+import os
+import json
+from datetime import datetime, timedelta, timezone
+
+BASE_URL = os.getenv("LETTA_SCHEDULES_URL", "https://letta--letta-schedules-api-dev.modal.run")
+API_KEY = os.getenv("LETTA_API_KEY")
+AGENT_ID = os.getenv("LETTA_AGENT_ID", "agent-a29146cc-2fb3-452d-8c0c-bf71e5db609a")
+
+if not API_KEY:
+    print("ERROR: LETTA_API_KEY environment variable not set!")
+    print("This must be a VALID Letta API key that will be validated against Letta's API.")
+    print("Set it with: export LETTA_API_KEY=sk-...")
+    exit(1)
+
+print("Configuration:")
+print(f"  Base URL: {BASE_URL}")
+print(f"  Agent ID: {AGENT_ID}")
+print(f"  API Key: {API_KEY[:20]}..." if API_KEY else "  API Key: Not set")
+
+
+def test_create_recurring_schedule():
+    print("\n=== Testing: Create Recurring Schedule ===")
+    payload = {
+        "agent_id": AGENT_ID,
+        "api_key": API_KEY,
+        "cron": "*/5 * * * *",
+        "message": "This is a test recurring message every 5 minutes",
+        "role": "user"
+    }
+    
+    response = requests.post(f"{BASE_URL}/schedules/recurring", json=payload)
+    print(f"Status: {response.status_code}")
+    
+    if response.status_code == 201:
+        data = response.json()
+        print(f"Created schedule ID: {data['id']}")
+        print(json.dumps(data, indent=2))
+        return data['id']
+    else:
+        print(f"Error: {response.text}")
+        return None
+
+
+def test_create_onetime_schedule():
+    print("\n=== Testing: Create One-Time Schedule ===")
+    
+    execute_time = datetime.now(timezone.utc) + timedelta(minutes=1)
+    execute_time_str = execute_time.isoformat()
+    
+    payload = {
+        "agent_id": AGENT_ID,
+        "api_key": API_KEY,
+        "execute_at": execute_time_str,
+        "message": f"This is a test one-time message scheduled for {execute_time_str}",
+        "role": "user"
+    }
+    
+    response = requests.post(f"{BASE_URL}/schedules/one-time", json=payload)
+    print(f"Status: {response.status_code}")
+    
+    if response.status_code == 201:
+        data = response.json()
+        print(f"Created schedule ID: {data['id']}")
+        print(json.dumps(data, indent=2))
+        return data['id']
+    else:
+        print(f"Error: {response.text}")
+        return None
+
+
+def test_list_recurring_schedules():
+    print("\n=== Testing: List Recurring Schedules ===")
+    headers = {"Authorization": f"Bearer {API_KEY}"}
+    response = requests.get(f"{BASE_URL}/schedules/recurring", headers=headers)
+    print(f"Status: {response.status_code}")
+    
+    if response.status_code == 200:
+        data = response.json()
+        print(f"Found {len(data)} recurring schedules")
+        for schedule in data:
+            print(f"  - ID: {schedule['id']}, Cron: {schedule['cron']}")
+    else:
+        print(f"Error: {response.text}")
+
+
+def test_list_onetime_schedules():
+    print("\n=== Testing: List One-Time Schedules ===")
+    headers = {"Authorization": f"Bearer {API_KEY}"}
+    response = requests.get(f"{BASE_URL}/schedules/one-time", headers=headers)
+    print(f"Status: {response.status_code}")
+    
+    if response.status_code == 200:
+        data = response.json()
+        print(f"Found {len(data)} one-time schedules")
+        for schedule in data:
+            print(f"  - ID: {schedule['id']}, Execute at: {schedule['execute_at']}")
+    else:
+        print(f"Error: {response.text}")
+
+
+def test_list_results():
+    print("\n=== Testing: List Execution Results ===")
+    headers = {"Authorization": f"Bearer {API_KEY}"}
+    response = requests.get(f"{BASE_URL}/results", headers=headers)
+    print(f"Status: {response.status_code}")
+    
+    if response.status_code == 200:
+        data = response.json()
+        print(f"Found {len(data)} execution results")
+        for result in data:
+            print(f"  - Schedule ID: {result['schedule_id']}")
+            print(f"    Type: {result['schedule_type']}")
+            print(f"    Run ID: {result['run_id']}")
+            print(f"    Executed at: {result['executed_at']}")
+    else:
+        print(f"Error: {response.text}")
+
+
+def test_get_result(schedule_id):
+    print(f"\n=== Testing: Get Execution Result for {schedule_id} ===")
+    headers = {"Authorization": f"Bearer {API_KEY}"}
+    response = requests.get(f"{BASE_URL}/results/{schedule_id}", headers=headers)
+    print(f"Status: {response.status_code}")
+    
+    if response.status_code == 200:
+        data = response.json()
+        print(f"Run ID: {data['run_id']}")
+        print(json.dumps(data, indent=2))
+    elif response.status_code == 404:
+        print("No execution result yet (schedule may not have executed)")
+    else:
+        print(f"Error: {response.text}")
+
+
+def test_get_recurring_schedule(schedule_id):
+    print(f"\n=== Testing: Get Recurring Schedule {schedule_id} ===")
+    headers = {"Authorization": f"Bearer {API_KEY}"}
+    response = requests.get(f"{BASE_URL}/schedules/recurring/{schedule_id}", headers=headers)
+    print(f"Status: {response.status_code}")
+    
+    if response.status_code == 200:
+        data = response.json()
+        print(json.dumps(data, indent=2))
+    else:
+        print(f"Error: {response.text}")
+
+
+def test_get_onetime_schedule(schedule_id):
+    print(f"\n=== Testing: Get One-Time Schedule {schedule_id} ===")
+    headers = {"Authorization": f"Bearer {API_KEY}"}
+    response = requests.get(f"{BASE_URL}/schedules/one-time/{schedule_id}", headers=headers)
+    print(f"Status: {response.status_code}")
+    
+    if response.status_code == 200:
+        data = response.json()
+        print(json.dumps(data, indent=2))
+    else:
+        print(f"Error: {response.text}")
+
+
+def test_delete_recurring_schedule(schedule_id):
+    print(f"\n=== Testing: Delete Recurring Schedule {schedule_id} ===")
+    headers = {"Authorization": f"Bearer {API_KEY}"}
+    response = requests.delete(f"{BASE_URL}/schedules/recurring/{schedule_id}", headers=headers)
+    print(f"Status: {response.status_code}")
+    
+    if response.status_code == 200:
+        print("Schedule deleted successfully")
+    else:
+        print(f"Error: {response.text}")
+
+
+def test_delete_onetime_schedule(schedule_id):
+    print(f"\n=== Testing: Delete One-Time Schedule {schedule_id} ===")
+    headers = {"Authorization": f"Bearer {API_KEY}"}
+    response = requests.delete(f"{BASE_URL}/schedules/one-time/{schedule_id}", headers=headers)
+    print(f"Status: {response.status_code}")
+    
+    if response.status_code == 200:
+        print("Schedule deleted successfully")
+    else:
+        print(f"Error: {response.text}")
+
+
+def main():
+    print("=" * 60)
+    print("Letta Schedules API Test Suite")
+    print("=" * 60)
+    print(f"Base URL: {BASE_URL}")
+    print(f"Agent ID: {AGENT_ID}")
+    
+    recurring_id = test_create_recurring_schedule()
+    onetime_id = test_create_onetime_schedule()
+    
+    test_list_recurring_schedules()
+    test_list_onetime_schedules()
+    
+    if recurring_id:
+        test_get_recurring_schedule(recurring_id)
+    
+    if onetime_id:
+        test_get_onetime_schedule(onetime_id)
+    
+    # Check execution results
+    test_list_results()
+    
+    if recurring_id:
+        test_get_result(recurring_id)
+    
+    if onetime_id:
+        test_get_result(onetime_id)
+    
+    input("\n\nPress Enter to delete test schedules...")
+    
+    if recurring_id:
+        test_delete_recurring_schedule(recurring_id)
+    
+    if onetime_id:
+        test_delete_onetime_schedule(onetime_id)
+    
+    test_list_recurring_schedules()
+    test_list_onetime_schedules()
+    
+    # Show final results
+    print("\n=== Final Results After Deletion ===")
+    test_list_results()
+    
+    print("\n" + "=" * 60)
+    print("Test suite complete!")
+    print("=" * 60)
+    print("\nNote: Execution results remain even after schedules are deleted.")
+    print("Check run status at: https://api.letta.com/v1/runs/{run_id}")
+
+
+if __name__ == "__main__":
+    main()

+ 114 - 0
test_api.sh

@@ -0,0 +1,114 @@
+#!/bin/bash
+
+BASE_URL="${LETTA_SCHEDULES_URL:-https://your-modal-app-url.modal.run}"
+AGENT_ID="${LETTA_AGENT_ID:-your-agent-id}"
+API_KEY="${LETTA_API_KEY}"
+
+if [ -z "$API_KEY" ]; then
+  echo "ERROR: LETTA_API_KEY environment variable not set!"
+  echo "This must be a VALID Letta API key that will be validated against Letta's API."
+  echo "Set it with: export LETTA_API_KEY=sk-..."
+  exit 1
+fi
+
+echo "========================================"
+echo "Letta Schedules API Test (curl)"
+echo "========================================"
+echo "Base URL: $BASE_URL"
+echo "Agent ID: $AGENT_ID"
+echo "API Key: ${API_KEY:0:20}..."
+echo ""
+
+echo "1. Creating recurring schedule (every 5 minutes)..."
+RECURRING_RESPONSE=$(curl -s -X POST "$BASE_URL/schedules/recurring" \
+  -H "Content-Type: application/json" \
+  -d "{
+    \"agent_id\": \"$AGENT_ID\",
+    \"api_key\": \"$API_KEY\",
+    \"cron\": \"*/5 * * * *\",
+    \"message\": \"Test recurring message\",
+    \"role\": \"user\"
+  }")
+
+echo "$RECURRING_RESPONSE" | python3 -m json.tool
+RECURRING_ID=$(echo "$RECURRING_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])" 2>/dev/null)
+echo "Recurring Schedule ID: $RECURRING_ID"
+echo ""
+
+echo "2. Creating one-time schedule (2 minutes from now)..."
+EXECUTE_AT=$(python3 -c "from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc) + timedelta(minutes=2)).isoformat())")
+ONETIME_RESPONSE=$(curl -s -X POST "$BASE_URL/schedules/one-time" \
+  -H "Content-Type: application/json" \
+  -d "{
+    \"agent_id\": \"$AGENT_ID\",
+    \"api_key\": \"$API_KEY\",
+    \"execute_at\": \"$EXECUTE_AT\",
+    \"message\": \"Test one-time message\",
+    \"role\": \"user\"
+  }")
+
+echo "$ONETIME_RESPONSE" | python3 -m json.tool
+ONETIME_ID=$(echo "$ONETIME_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])" 2>/dev/null)
+echo "One-Time Schedule ID: $ONETIME_ID"
+echo ""
+
+echo "3. Listing all recurring schedules..."
+curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/schedules/recurring" | python3 -m json.tool
+echo ""
+
+echo "4. Listing all one-time schedules..."
+curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/schedules/one-time" | python3 -m json.tool
+echo ""
+
+if [ ! -z "$RECURRING_ID" ]; then
+  echo "5. Getting specific recurring schedule..."
+  curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/schedules/recurring/$RECURRING_ID" | python3 -m json.tool
+  echo ""
+fi
+
+if [ ! -z "$ONETIME_ID" ]; then
+  echo "6. Getting specific one-time schedule..."
+  curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/schedules/one-time/$ONETIME_ID" | python3 -m json.tool
+  echo ""
+fi
+
+echo "7. Listing execution results..."
+curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/results" | python3 -m json.tool
+echo ""
+
+if [ ! -z "$RECURRING_ID" ]; then
+  echo "8. Getting execution result for recurring schedule..."
+  curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/results/$RECURRING_ID" | python3 -m json.tool
+  echo ""
+fi
+
+if [ ! -z "$ONETIME_ID" ]; then
+  echo "9. Getting execution result for one-time schedule..."
+  curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/results/$ONETIME_ID" | python3 -m json.tool
+  echo ""
+fi
+
+read -p "Press Enter to delete test schedules..."
+
+if [ ! -z "$RECURRING_ID" ]; then
+  echo "Deleting recurring schedule..."
+  curl -s -X DELETE -H "Authorization: Bearer $API_KEY" "$BASE_URL/schedules/recurring/$RECURRING_ID" | python3 -m json.tool
+  echo ""
+fi
+
+if [ ! -z "$ONETIME_ID" ]; then
+  echo "Deleting one-time schedule..."
+  curl -s -X DELETE -H "Authorization: Bearer $API_KEY" "$BASE_URL/schedules/one-time/$ONETIME_ID" | python3 -m json.tool
+  echo ""
+fi
+
+echo "Final results after deletion..."
+curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/results" | python3 -m json.tool
+echo ""
+
+echo "========================================"
+echo "Test complete!"
+echo "========================================"
+echo ""
+echo "Note: Execution results remain even after schedules are deleted."
+echo "Check run status at: https://api.letta.com/v1/runs/{run_id}"