Browse Source

Redesign UI and improve error handling

- Redesign landing page and dashboard with clean, readable styling
- Replace monospace/all-caps aesthetic with modern system fonts
- Add timezone support for recurring schedules (model, scheduler, UI)
- Improve error messages: extract clean messages from verbose API errors
- Only remove recurring schedules on permanent errors (401, 404)
- Keep schedules on transient errors (timeouts, rate limits, 5xx)
Cameron Pfiffer 4 months ago
parent
commit
b184658e4f
5 changed files with 702 additions and 504 deletions
  1. 212 168
      app.py
  2. 382 320
      dashboard.html
  3. 87 6
      letta_executor.py
  4. 2 0
      models.py
  5. 19 10
      scheduler.py

+ 212 - 168
app.py

@@ -296,215 +296,258 @@ async def root(request: Request):
             <meta charset="UTF-8">
             <meta name="viewport" content="width=device-width, initial-scale=1.0">
             <style>
+                * { box-sizing: border-box; }
                 body {
-                    font-family: 'Courier New', 'Consolas', monospace;
-                    background: #f5f5dc;
+                    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+                    background: #f8f9fa;
                     margin: 0;
-                    padding: 15px;
-                    line-height: 1.5;
-                    font-weight: 500;
+                    padding: 24px;
+                    line-height: 1.6;
+                    color: #1a1a1a;
                 }
                 .container {
-                    max-width: 900px;
+                    max-width: 720px;
                     margin: 0 auto;
-                    background: white;
-                    padding: 30px;
-                    box-shadow: 0 0 20px rgba(0,0,0,0.1);
                 }
                 h1 {
-                    color: #000;
-                    margin-bottom: 8px;
-                    font-size: 24px;
-                    letter-spacing: 3px;
-                    font-weight: bold;
-                    text-transform: uppercase;
-                    border-bottom: 2px solid #000;
-                    padding-bottom: 8px;
-                }
-                h2 {
-                    color: #000;
-                    margin: 20px 0 10px;
-                    font-size: 16px;
-                    letter-spacing: 2px;
-                    font-weight: bold;
-                    text-transform: uppercase;
-                    border-bottom: 2px solid #000;
-                    padding-bottom: 3px;
+                    font-size: 28px;
+                    font-weight: 600;
+                    margin: 0 0 8px 0;
                 }
-                .info-box {
-                    border: 2px solid #000;
-                    padding: 12px;
-                    margin: 15px 0;
-                    background: #fafafa;
+                .subtitle {
+                    color: #666;
+                    margin-bottom: 24px;
                 }
-                .info-box pre {
-                    font-family: 'Courier New', monospace;
+                .status {
+                    display: inline-flex;
+                    align-items: center;
+                    gap: 6px;
+                    background: #e8f5e9;
+                    color: #2e7d32;
+                    padding: 4px 10px;
+                    border-radius: 4px;
                     font-size: 13px;
-                    line-height: 1.6;
-                    margin: 0;
                     font-weight: 500;
+                    margin-bottom: 24px;
                 }
-                .feature-list {
-                    list-style: none;
-                    padding: 0;
-                    margin: 10px 0;
+                .status::before {
+                    content: '';
+                    width: 8px;
+                    height: 8px;
+                    background: #4caf50;
+                    border-radius: 50%;
                 }
-                .feature-list li {
-                    padding: 4px 0;
+                .dashboard-btn {
+                    display: inline-block;
+                    background: #1a1a1a;
+                    color: white;
+                    padding: 12px 24px;
+                    text-decoration: none;
+                    border-radius: 6px;
                     font-weight: 500;
+                    margin-bottom: 32px;
+                }
+                .dashboard-btn:hover {
+                    background: #333;
+                }
+                h2 {
+                    font-size: 18px;
+                    font-weight: 600;
+                    margin: 32px 0 12px 0;
+                    padding-bottom: 8px;
+                    border-bottom: 1px solid #e0e0e0;
                 }
-                .feature-list li:before {
-                    content: "▪ ";
-                    color: #000;
-                    font-weight: bold;
+                .features {
+                    display: grid;
+                    grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+                    gap: 12px;
+                    margin-bottom: 8px;
+                }
+                .feature {
+                    background: white;
+                    padding: 12px 16px;
+                    border-radius: 6px;
+                    border: 1px solid #e0e0e0;
+                }
+                .feature-title {
+                    font-weight: 600;
+                    margin-bottom: 2px;
+                }
+                .feature-desc {
+                    color: #666;
+                    font-size: 14px;
+                }
+                pre {
+                    background: #1a1a1a;
+                    color: #e0e0e0;
+                    padding: 16px;
+                    border-radius: 6px;
+                    overflow-x: auto;
+                    font-family: 'SF Mono', Consolas, monospace;
+                    font-size: 13px;
+                    line-height: 1.5;
+                    margin: 12px 0;
                 }
+                pre .comment { color: #6a9955; }
+                pre .string { color: #ce9178; }
                 code {
                     background: #f0f0f0;
                     padding: 2px 6px;
-                    border: 1px solid #000;
-                    font-weight: 500;
+                    border-radius: 3px;
+                    font-family: 'SF Mono', Consolas, monospace;
+                    font-size: 13px;
                 }
-                pre {
-                    background: #fafafa;
-                    border: 2px solid #000;
-                    padding: 12px;
-                    overflow-x: auto;
-                    margin: 10px 0;
-                    font-weight: 500;
+                .endpoints {
+                    background: white;
+                    border: 1px solid #e0e0e0;
+                    border-radius: 6px;
+                    overflow: hidden;
                 }
                 .endpoint {
-                    background: #fafafa;
-                    padding: 8px 12px;
-                    margin: 6px 0;
-                    border: 2px solid #000;
-                    font-weight: 500;
+                    display: flex;
+                    padding: 10px 16px;
+                    border-bottom: 1px solid #e0e0e0;
+                    font-size: 14px;
                 }
-                a {
-                    color: #000;
-                    text-decoration: underline;
-                    font-weight: bold;
+                .endpoint:last-child { border-bottom: none; }
+                .endpoint-method {
+                    font-family: 'SF Mono', Consolas, monospace;
+                    font-weight: 600;
+                    width: 220px;
+                    flex-shrink: 0;
                 }
-                a:hover {
-                    background: #000;
-                    color: white;
+                .endpoint-desc {
+                    color: #666;
                 }
                 .note {
-                    background: #fafafa;
-                    border: 2px solid #000;
-                    padding: 12px;
-                    margin: 15px 0;
-                    font-weight: 500;
+                    background: #fff3e0;
+                    border-left: 3px solid #ff9800;
+                    padding: 12px 16px;
+                    margin: 16px 0;
+                    border-radius: 0 6px 6px 0;
                 }
-                .dashboard-link {
-                    display: block;
-                    background: #000;
-                    color: white;
-                    padding: 15px;
-                    text-align: center;
+                .links {
+                    display: flex;
+                    gap: 24px;
+                    flex-wrap: wrap;
+                }
+                .links a {
+                    color: #1a1a1a;
                     text-decoration: none;
-                    font-weight: bold;
-                    font-size: 16px;
-                    letter-spacing: 2px;
-                    margin: 20px 0;
-                    border: 3px solid #000;
+                    font-weight: 500;
                 }
-                .dashboard-link:hover {
-                    background: white;
-                    color: #000;
+                .links a:hover {
+                    text-decoration: underline;
+                }
+                @media (max-width: 600px) {
+                    .endpoint { flex-direction: column; gap: 4px; }
+                    .endpoint-method { width: auto; }
                 }
             </style>
         </head>
         <body>
             <div class="container">
-            <h1>LETTA SWITCHBOARD</h1>
-            <div class="info-box">
-                <pre>SERVICE:  MESSAGE ROUTING FOR LETTA AGENTS
-VERSION:  1.0.0
-STATUS:   OPERATIONAL
-HOSTING:  FREE SERVERLESS DEPLOYMENT</pre>
-            </div>
-            
-            <a href="/dashboard" class="dashboard-link">→ OPEN WEB DASHBOARD ←</a>
-            
-            <h2>FEATURES</h2>
-            <ul class="feature-list">
-                <li>IMMEDIATE OR SCHEDULED MESSAGE DELIVERY</li>
-                <li>RECURRING SCHEDULES WITH CRON EXPRESSIONS</li>
-                <li>SECURE API KEY ISOLATION</li>
-                <li>EXECUTION TRACKING WITH RUN IDS</li>
-            </ul>
-            
-            <h2>QUICK START</h2>
-            
-            <div class="example">
-                <p style="font-weight:bold;margin:10px 0 6px 0;">SEND ONE-TIME MESSAGE:</p>
+                <h1>Letta Switchboard</h1>
+                <p class="subtitle">Message scheduling and routing for Letta agents</p>
+                <div class="status">Operational</div>
+                <br>
+                <a href="/dashboard" class="dashboard-btn">Open Dashboard</a>
+
+                <h2>Features</h2>
+                <div class="features">
+                    <div class="feature">
+                        <div class="feature-title">Scheduled Messages</div>
+                        <div class="feature-desc">Send messages now or schedule for later</div>
+                    </div>
+                    <div class="feature">
+                        <div class="feature-title">Recurring Schedules</div>
+                        <div class="feature-desc">Cron expressions for repeated delivery</div>
+                    </div>
+                    <div class="feature">
+                        <div class="feature-title">API Key Isolation</div>
+                        <div class="feature-desc">Secure per-user schedule storage</div>
+                    </div>
+                    <div class="feature">
+                        <div class="feature-title">Execution Tracking</div>
+                        <div class="feature-desc">Track results with run IDs</div>
+                    </div>
+                </div>
+
+                <h2>Quick Start</h2>
+                <p>Schedule a one-time message:</p>
                 <pre>curl -X POST https://letta--switchboard-api.modal.run/schedules/one-time \\
   -H 'Authorization: Bearer YOUR_LETTA_API_KEY' \\
   -H 'Content-Type: application/json' \\
   -d '{
-    "agent_id": "agent-xxx",
-    "execute_at": "2025-11-13T09:00:00Z",
-    "message": "Hello from Switchboard!"
+    <span class="string">"agent_id"</span>: <span class="string">"agent-xxx"</span>,
+    <span class="string">"execute_at"</span>: <span class="string">"2025-12-09T09:00:00Z"</span>,
+    <span class="string">"message"</span>: <span class="string">"Hello from Switchboard!"</span>
   }'</pre>
-            </div>
-            
-            <div class="example">
-                <p style="font-weight:bold;margin:10px 0 6px 0;">CREATE RECURRING SCHEDULE:</p>
+
+                <p>Create a recurring schedule:</p>
                 <pre>curl -X POST https://letta--switchboard-api.modal.run/schedules/recurring \\
   -H 'Authorization: Bearer YOUR_LETTA_API_KEY' \\
   -H 'Content-Type: application/json' \\
   -d '{
-    "agent_id": "agent-xxx",
-    "cron": "0 9 * * 1-5",
-    "message": "Daily standup reminder"
+    <span class="string">"agent_id"</span>: <span class="string">"agent-xxx"</span>,
+    <span class="string">"cron"</span>: <span class="string">"0 9 * * 1-5"</span>,
+    <span class="string">"message"</span>: <span class="string">"Daily standup reminder"</span>
   }'</pre>
-            </div>
-            
-            <h2>CLI TOOL</h2>
-            <p style="margin:8px 0;">NATURAL LANGUAGE SCHEDULING SUPPORT</p>
-            
-            <div class="example">
-                <p style="font-weight:bold;margin:10px 0 6px 0;">INSTALLATION:</p>
-                <pre>git clone https://github.com/cpfiffer/letta-switchboard.git
-cd letta-switchboard/cli
-go build -o letta-switchboard
-./letta-switchboard config set-api-key YOUR_LETTA_API_KEY</pre>
-            </div>
-            
-            <div class="example">
-                <p style="font-weight:bold;margin:10px 0 6px 0;">USAGE:</p>
-                <pre># Send immediately
-./letta-switchboard send --agent-id agent-xxx --message "Hello!"
-
-# Schedule with natural language
-./letta-switchboard send --agent-id agent-xxx --message "Reminder" --execute-at "tomorrow at 9am"
 
-# Recurring schedule
-./letta-switchboard recurring create --agent-id agent-xxx --message "Daily standup" --cron "every weekday"</pre>
-            </div>
-            
-            <h2>API ENDPOINTS</h2>
-            <div class="endpoint"><code>POST /schedules/one-time</code> - Create a one-time schedule</div>
-            <div class="endpoint"><code>POST /schedules/recurring</code> - Create a recurring schedule</div>
-            <div class="endpoint"><code>GET /schedules/one-time</code> - List your one-time schedules</div>
-            <div class="endpoint"><code>GET /schedules/recurring</code> - List your recurring schedules</div>
-            <div class="endpoint"><code>GET /schedules/one-time/{id}</code> - Get specific one-time schedule</div>
-            <div class="endpoint"><code>GET /schedules/recurring/{id}</code> - Get specific recurring schedule</div>
-            <div class="endpoint"><code>DELETE /schedules/one-time/{id}</code> - Delete one-time schedule</div>
-            <div class="endpoint"><code>DELETE /schedules/recurring/{id}</code> - Delete recurring schedule</div>
-            <div class="endpoint"><code>GET /results</code> - List execution results</div>
-            <div class="endpoint"><code>GET /results/{schedule_id}</code> - Get result for specific schedule</div>
-            
-            <div class="note">
-                <strong>AUTHENTICATION:</strong> ALL ENDPOINTS REQUIRE<br>
-                <code>Authorization: Bearer YOUR_LETTA_API_KEY</code>
-            </div>
-            
-            <h2>DOCUMENTATION & SUPPORT</h2>
-            <p style="margin: 8px 0;"><a href="/dashboard">WEB DASHBOARD</a> - Manage schedules in browser</p>
-            <p style="margin: 8px 0;"><a href="https://github.com/cpfiffer/letta-switchboard">DOCUMENTATION</a> - Full technical reference</p>
-            <p style="margin: 8px 0;"><a href="https://github.com/cpfiffer/letta-switchboard/issues">SUPPORT</a> - Issue tracker</p>
-            <p style="margin: 8px 0;"><a href="/?json">JSON API</a> - View as JSON response</p>
+                <h2>CLI Tool</h2>
+                <p>Natural language scheduling from your terminal:</p>
+                <pre><span class="comment"># Install</span>
+git clone https://github.com/cpfiffer/letta-switchboard.git
+cd letta-switchboard/cli && go build -o letta-schedules
+./letta-schedules config set-api-key YOUR_LETTA_API_KEY
+
+<span class="comment"># Send a message</span>
+./letta-schedules send --agent-id agent-xxx --message "Hello!"
+
+<span class="comment"># Schedule with natural language</span>
+./letta-schedules send --agent-id agent-xxx --message "Reminder" --at "tomorrow 9am"
+
+<span class="comment"># Create recurring schedule</span>
+./letta-schedules recurring create --agent-id agent-xxx --cron "every weekday" --message "Standup"</pre>
+
+                <h2>API Endpoints</h2>
+                <div class="endpoints">
+                    <div class="endpoint">
+                        <span class="endpoint-method">POST /schedules/one-time</span>
+                        <span class="endpoint-desc">Create one-time schedule</span>
+                    </div>
+                    <div class="endpoint">
+                        <span class="endpoint-method">POST /schedules/recurring</span>
+                        <span class="endpoint-desc">Create recurring schedule</span>
+                    </div>
+                    <div class="endpoint">
+                        <span class="endpoint-method">GET /schedules/one-time</span>
+                        <span class="endpoint-desc">List one-time schedules</span>
+                    </div>
+                    <div class="endpoint">
+                        <span class="endpoint-method">GET /schedules/recurring</span>
+                        <span class="endpoint-desc">List recurring schedules</span>
+                    </div>
+                    <div class="endpoint">
+                        <span class="endpoint-method">DELETE /schedules/{type}/{id}</span>
+                        <span class="endpoint-desc">Delete a schedule</span>
+                    </div>
+                    <div class="endpoint">
+                        <span class="endpoint-method">GET /results</span>
+                        <span class="endpoint-desc">List execution results</span>
+                    </div>
+                </div>
+
+                <div class="note">
+                    <strong>Authentication required:</strong> All endpoints need <code>Authorization: Bearer YOUR_LETTA_API_KEY</code>
+                </div>
+
+                <h2>Links</h2>
+                <div class="links">
+                    <a href="/dashboard">Dashboard</a>
+                    <a href="https://github.com/cpfiffer/letta-switchboard">Documentation</a>
+                    <a href="https://github.com/cpfiffer/letta-switchboard/issues">Support</a>
+                </div>
             </div>
         </body>
         </html>
@@ -779,13 +822,14 @@ async def execute_schedule(
             error=error_msg,
             status="failed"
         )
-        
-        # Terminate recurring schedules on failure (no retries)
-        if schedule_type == "recurring":
+
+        # Only terminate recurring schedules on permanent errors (401, 404)
+        # Transient errors (timeouts, rate limits, 5xx) should not remove the schedule
+        if schedule_type == "recurring" and result.get("permanent", False):
             try:
                 Path(file_path).unlink()
                 volume.commit()
-                logger.warning(f"Terminated recurring schedule {schedule_id} due to execution failure: {error_msg}")
+                logger.warning(f"Terminated recurring schedule {schedule_id} due to permanent error: {error_msg}")
             except Exception as e:
                 logger.error(f"Failed to delete failed recurring schedule {schedule_id}: {e}")
     

+ 382 - 320
dashboard.html

@@ -7,370 +7,436 @@
     <style>
         * { box-sizing: border-box; margin: 0; padding: 0; }
         body {
-            font-family: 'Courier New', 'Consolas', monospace;
-            background: #f5f5dc;
-            color: #000;
-            padding: 15px;
-            line-height: 1.5;
-            font-weight: 500;
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #f8f9fa;
+            color: #1a1a1a;
+            padding: 24px;
+            line-height: 1.6;
+        }
+        .container { max-width: 1100px; margin: 0 auto; }
+
+        /* Header */
+        .header {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            margin-bottom: 24px;
+            flex-wrap: wrap;
+            gap: 16px;
         }
-        .container { max-width: 1400px; margin: 0 auto; background: white; padding: 30px; box-shadow: 0 0 20px rgba(0,0,0,0.1); }
-        h1 { 
-            color: #000;
-            margin-bottom: 8px;
+        .header h1 {
             font-size: 24px;
-            letter-spacing: 3px;
-            font-weight: bold;
-            text-transform: uppercase;
+            font-weight: 600;
+        }
+        .header-links {
+            display: flex;
+            gap: 16px;
         }
-        h2 { 
-            color: #000;
-            margin: 20px 0 10px;
+        .header-links a {
+            color: #666;
+            text-decoration: none;
+            font-size: 14px;
+        }
+        .header-links a:hover {
+            color: #1a1a1a;
+        }
+
+        h2 {
             font-size: 16px;
-            letter-spacing: 2px;
-            font-weight: bold;
-            text-transform: uppercase;
-            border-bottom: 2px solid #000;
-            padding-bottom: 3px;
+            font-weight: 600;
+            margin: 24px 0 12px 0;
+            color: #1a1a1a;
         }
-        
+
         /* API Key Section */
-        #api-key-section {
-            background: #fafafa;
-            border: 2px solid #000;
-            padding: 15px;
+        .card {
+            background: white;
+            border: 1px solid #e0e0e0;
+            border-radius: 8px;
+            padding: 20px;
             margin-bottom: 20px;
         }
+        .card-title {
+            font-size: 14px;
+            font-weight: 600;
+            margin-bottom: 12px;
+            color: #666;
+        }
         #api-key-section input {
             width: 100%;
-            padding: 8px;
-            border: 2px solid #666;
-            background: white;
-            color: #000;
-            font-family: 'Courier New', monospace;
+            padding: 10px 12px;
+            border: 1px solid #e0e0e0;
+            border-radius: 6px;
             font-size: 14px;
-            font-weight: 500;
+            margin-bottom: 12px;
         }
         #api-key-section input:focus {
             outline: none;
-            border-color: #000;
-            box-shadow: none;
+            border-color: #1a1a1a;
+        }
+        .btn-group {
+            display: flex;
+            gap: 8px;
         }
-        
+
         /* Buttons */
-        button {
-            background: white;
-            color: #000;
-            border: 2px solid #000;
+        button, .btn {
+            background: #1a1a1a;
+            color: white;
+            border: none;
             padding: 8px 16px;
+            border-radius: 6px;
             cursor: pointer;
-            font-size: 13px;
-            font-family: 'Courier New', monospace;
-            margin-top: 8px;
-            text-transform: uppercase;
-            letter-spacing: 1px;
-            font-weight: bold;
-        }
-        button:hover { 
-            background: #000;
-            color: white;
+            font-size: 14px;
+            font-weight: 500;
         }
-        button.danger { 
-            border-color: #000;
-            color: #000;
+        button:hover { background: #333; }
+        button.secondary {
+            background: white;
+            color: #1a1a1a;
+            border: 1px solid #e0e0e0;
+        }
+        button.secondary:hover {
+            background: #f5f5f5;
+        }
+        button.danger {
+            background: white;
+            color: #dc2626;
+            border: 1px solid #fecaca;
         }
         button.danger:hover {
-            background: #000;
-            color: white;
+            background: #fef2f2;
+        }
+        button.sm {
+            padding: 6px 12px;
+            font-size: 13px;
         }
-        
+
         /* Tabs */
         .tabs {
             display: flex;
-            gap: 2px;
-            margin-bottom: 0;
-            border-bottom: 3px solid #000;
+            gap: 4px;
+            border-bottom: 1px solid #e0e0e0;
+            margin-bottom: 20px;
         }
         .tab {
             padding: 10px 20px;
-            background: #e8e8e8;
-            color: #000;
-            border: 2px solid #000;
-            border-bottom: none;
+            background: transparent;
+            color: #666;
+            border: none;
+            border-bottom: 2px solid transparent;
             cursor: pointer;
-            font-weight: bold;
-            font-family: 'Courier New', monospace;
-            text-transform: uppercase;
-            letter-spacing: 1px;
-            font-size: 13px;
+            font-weight: 500;
+            font-size: 14px;
+            border-radius: 0;
+            margin-bottom: -1px;
         }
-        .tab:hover { background: #d0d0d0; }
-        .tab.active { 
-            background: white;
-            color: #000;
-            border-bottom: 2px solid white;
-            margin-bottom: -2px;
+        .tab:hover {
+            color: #1a1a1a;
+            background: transparent;
         }
-        
+        .tab.active {
+            color: #1a1a1a;
+            border-bottom-color: #1a1a1a;
+            background: transparent;
+        }
+
         /* Content */
         .content {
-            background: white;
-            border: 2px solid #000;
-            border-top: none;
-            padding: 15px;
             display: none;
         }
         .content.active { display: block; }
-        
+
         /* Tables */
+        .table-container {
+            background: white;
+            border: 1px solid #e0e0e0;
+            border-radius: 8px;
+            overflow: hidden;
+            margin-bottom: 16px;
+        }
         table {
             width: 100%;
             border-collapse: collapse;
-            margin-top: 10px;
-            border: 2px solid #000;
         }
         th, td {
-            padding: 8px 12px;
+            padding: 12px 16px;
             text-align: left;
-            border: 1px solid #000;
+            border-bottom: 1px solid #e0e0e0;
         }
-        th { 
-            background: #f0f0f0;
-            font-weight: bold;
-            color: #000;
-            text-transform: uppercase;
-            font-size: 12px;
-            letter-spacing: 1px;
+        th {
+            background: #f8f9fa;
+            font-weight: 600;
+            font-size: 13px;
+            color: #666;
         }
-        tr:hover { background: #fafafa; }
-        td { font-size: 14px; font-weight: 500; }
-        
+        tr:last-child td { border-bottom: none; }
+        tr:hover td { background: #fafafa; }
+        td { font-size: 14px; }
+
         /* Forms */
         .form-group {
-            margin-bottom: 15px;
+            margin-bottom: 16px;
         }
         label {
             display: block;
             margin-bottom: 6px;
-            font-weight: bold;
-            color: #000;
-            text-transform: uppercase;
-            font-size: 12px;
-            letter-spacing: 1px;
+            font-weight: 500;
+            font-size: 14px;
         }
         input[type="text"], input[type="datetime-local"], textarea, select {
             width: 100%;
-            padding: 8px;
-            border: 2px solid #666;
-            background: white;
-            color: #000;
-            font-family: 'Courier New', monospace;
-            font-size: 15px;
-            font-weight: 500;
+            padding: 10px 12px;
+            border: 1px solid #e0e0e0;
+            border-radius: 6px;
+            font-size: 14px;
+            font-family: inherit;
         }
         input:focus, textarea:focus, select:focus {
             outline: none;
-            border-color: #000;
-            box-shadow: none;
+            border-color: #1a1a1a;
         }
-        textarea { min-height: 80px; }
-        small { color: #666; font-size: 12px; font-weight: 500; }
-        
+        textarea { min-height: 100px; resize: vertical; }
+        .help-text { color: #666; font-size: 13px; margin-top: 4px; }
+
         /* Status badges */
         .badge {
-            padding: 3px 8px;
-            border: 2px solid;
-            font-size: 11px;
-            font-weight: bold;
-            font-family: 'Courier New', monospace;
-            text-transform: uppercase;
-            letter-spacing: 1px;
-        }
-        .badge.success { 
-            border-color: #000;
-            color: #000;
-            background: white;
+            display: inline-flex;
+            align-items: center;
+            padding: 4px 10px;
+            border-radius: 4px;
+            font-size: 12px;
+            font-weight: 500;
         }
-        .badge.failed { 
-            border-color: #000;
-            color: white;
-            background: #000;
+        .badge.success {
+            background: #dcfce7;
+            color: #166534;
         }
-        
+        .badge.failed {
+            background: #fef2f2;
+            color: #dc2626;
+        }
+
         .hidden { display: none; }
-        .error { color: #000; margin-top: 8px; font-weight: bold; }
-        .empty { text-align: center; padding: 30px; color: #666; font-weight: 500; }
-        
+        .error { color: #dc2626; margin-top: 8px; font-size: 14px; }
+        .empty {
+            text-align: center;
+            padding: 40px;
+            color: #666;
+        }
+
+        /* Status indicator */
+        .status-msg {
+            display: flex;
+            align-items: center;
+            gap: 8px;
+            padding: 8px 0;
+            font-size: 14px;
+        }
+        .status-msg.success { color: #166534; }
+        .status-msg.error { color: #dc2626; }
+
         /* Loading spinner */
         .loading {
             text-align: center;
             padding: 40px;
-            color: #000;
+            color: #666;
         }
         .spinner {
             display: inline-block;
-            width: 40px;
-            height: 40px;
-            border: 4px solid #e0e0e0;
-            border-top-color: #000;
+            width: 32px;
+            height: 32px;
+            border: 3px solid #e0e0e0;
+            border-top-color: #1a1a1a;
             border-radius: 50%;
-            animation: spin 1s linear infinite;
-            margin-bottom: 10px;
+            animation: spin 0.8s linear infinite;
+            margin-bottom: 8px;
         }
         @keyframes spin {
             to { transform: rotate(360deg); }
         }
-        @keyframes blink {
-            0%, 50% { opacity: 1; }
-            51%, 100% { opacity: 0.3; }
+
+        .mono {
+            font-family: 'SF Mono', Consolas, monospace;
+            font-size: 13px;
+        }
+
+        @media (max-width: 768px) {
+            body { padding: 16px; }
+            th, td { padding: 10px 12px; }
+            .header { flex-direction: column; align-items: flex-start; }
         }
-        .blink { animation: blink 1s infinite; }
     </style>
 </head>
 <body>
     <div class="container">
-        <h1>LETTA SWITCHBOARD — MESSAGE ROUTING SERVICE</h1>
-        <div style="border: 2px solid #000; padding: 12px; margin-bottom: 20px; background: #fafafa;">
-            <pre style="font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.6; margin: 0; font-weight: normal;">SYSTEM:   MESSAGE ROUTING TERMINAL
-VERSION:  1.0.0
-STATUS:   OPERATIONAL
-PROTOCOL: REST API + SCHEDULED EXECUTION</pre>
+        <div class="header">
+            <h1>Switchboard Dashboard</h1>
+            <div class="header-links">
+                <a href="/">Home</a>
+                <a href="https://github.com/cpfiffer/letta-switchboard">Docs</a>
+            </div>
         </div>
-        
+
         <!-- API Key Input -->
-        <div id="api-key-section">
-            <label for="api-key-input">AUTHENTICATION</label>
-            <input type="password" id="api-key-input" placeholder="sk-let-...">
-            <button onclick="saveApiKey()">SAVE KEY</button>
-            <button onclick="clearApiKey()" class="danger">CLEAR KEY</button>
+        <div id="api-key-section" class="card">
+            <div class="card-title">Authentication</div>
+            <input type="password" id="api-key-input" placeholder="Enter your Letta API key (sk-let-...)">
+            <div class="btn-group">
+                <button onclick="saveApiKey()">Connect</button>
+                <button onclick="clearApiKey()" class="secondary">Disconnect</button>
+            </div>
             <div id="api-key-status"></div>
         </div>
-        
+
         <div id="main-content" class="hidden">
             <!-- Tabs -->
             <div class="tabs">
-                <button class="tab active" onclick="showTab('schedules')">SCHEDULES</button>
-                <button class="tab" onclick="showTab('create')">CREATE NEW</button>
-                <button class="tab" onclick="showTab('results')">RESULTS</button>
+                <button class="tab active" onclick="showTab('schedules')">Schedules</button>
+                <button class="tab" onclick="showTab('create')">Create New</button>
+                <button class="tab" onclick="showTab('results')">Results</button>
             </div>
-            
+
             <!-- Schedules Tab -->
             <div id="schedules-tab" class="content active">
-                <h2>ONE-TIME SCHEDULES</h2>
-                <table id="onetime-table">
-                    <thead>
-                        <tr>
-                            <th>ID</th>
-                            <th>Agent ID</th>
-                            <th>Execute At</th>
-                            <th>Message</th>
-                            <th>Actions</th>
-                        </tr>
-                    </thead>
-                    <tbody></tbody>
-                </table>
-                
-                <h2>RECURRING SCHEDULES</h2>
-                <table id="recurring-table">
-                    <thead>
-                        <tr>
-                            <th>ID</th>
-                            <th>Agent ID</th>
-                            <th>Cron</th>
-                            <th>Message</th>
-                            <th>Last Run</th>
-                            <th>Actions</th>
-                        </tr>
-                    </thead>
-                    <tbody></tbody>
-                </table>
-                
-                <button onclick="loadSchedules()" style="margin-top: 20px;">REFRESH</button>
+                <h2>One-Time Schedules</h2>
+                <div class="table-container">
+                    <table id="onetime-table">
+                        <thead>
+                            <tr>
+                                <th>ID</th>
+                                <th>Agent ID</th>
+                                <th>Execute At</th>
+                                <th>Message</th>
+                                <th></th>
+                            </tr>
+                        </thead>
+                        <tbody></tbody>
+                    </table>
+                </div>
+
+                <h2>Recurring Schedules</h2>
+                <div class="table-container">
+                    <table id="recurring-table">
+                        <thead>
+                            <tr>
+                                <th>ID</th>
+                                <th>Agent ID</th>
+                                <th>Cron</th>
+                                <th>Timezone</th>
+                                <th>Message</th>
+                                <th>Last Run</th>
+                                <th></th>
+                            </tr>
+                        </thead>
+                        <tbody></tbody>
+                    </table>
+                </div>
+
+                <button onclick="loadSchedules()" class="secondary">Refresh</button>
             </div>
-            
+
             <!-- Create Tab -->
             <div id="create-tab" class="content">
-                <h2>CREATE SCHEDULE</h2>
-                
-                <div class="form-group">
-                    <label>SCHEDULE TYPE</label>
-                    <select id="schedule-type" onchange="toggleScheduleType()">
-                        <option value="onetime">One-Time</option>
-                        <option value="recurring">Recurring</option>
-                    </select>
-                </div>
-                
-                <div class="form-group">
-                    <label for="agent-id">AGENT ID</label>
-                    <input type="text" id="agent-id" placeholder="agent-xxx">
-                </div>
-                
-                <div class="form-group">
-                    <label for="message">MESSAGE</label>
-                    <textarea id="message" placeholder="Your message to the agent"></textarea>
-                </div>
-                
-                <div id="onetime-fields">
+                <div class="card">
+                    <h2 style="margin-top: 0;">Create Schedule</h2>
+
                     <div class="form-group">
-                        <label for="execute-at">EXECUTE AT</label>
-                        <input type="datetime-local" id="execute-at">
+                        <label>Schedule Type</label>
+                        <select id="schedule-type" onchange="toggleScheduleType()">
+                            <option value="onetime">One-Time</option>
+                            <option value="recurring">Recurring</option>
+                        </select>
                     </div>
+
                     <div class="form-group">
-                        <label for="timezone">TIMEZONE</label>
-                        <select id="timezone">
-                            <option value="local" selected>Local Browser Time</option>
-                            <option value="UTC">UTC</option>
-                            <option value="America/New_York">Eastern (US)</option>
-                            <option value="America/Chicago">Central (US)</option>
-                            <option value="America/Denver">Mountain (US)</option>
-                            <option value="America/Los_Angeles">Pacific (US)</option>
-                            <option value="Europe/London">London</option>
-                            <option value="Europe/Paris">Paris</option>
-                            <option value="Asia/Tokyo">Tokyo</option>
-                        </select>
-                        <small style="display:block;margin-top:5px;color:#6b7280;">Time will be converted to UTC for storage</small>
+                        <label for="agent-id">Agent ID</label>
+                        <input type="text" id="agent-id" placeholder="agent-xxx">
                     </div>
-                </div>
-                
-                <div id="recurring-fields" class="hidden">
+
                     <div class="form-group">
-                        <label for="cron">CRON EXPRESSION</label>
-                        <input type="text" id="cron" placeholder="0 9 * * 1-5">
-                        <small style="display:block;margin-top:5px;color:#6b7280;">Examples: "0 9 * * *" (daily at 9am), "*/5 * * * *" (every 5 min)</small>
+                        <label for="message">Message</label>
+                        <textarea id="message" placeholder="Your message to the agent"></textarea>
                     </div>
+
+                    <div id="onetime-fields">
+                        <div class="form-group">
+                            <label for="execute-at">Execute At</label>
+                            <input type="datetime-local" id="execute-at">
+                        </div>
+                        <div class="form-group">
+                            <label for="timezone">Timezone</label>
+                            <select id="timezone">
+                                <option value="local" selected>Local Browser Time</option>
+                                <option value="UTC">UTC</option>
+                                <option value="America/New_York">Eastern (US)</option>
+                                <option value="America/Chicago">Central (US)</option>
+                                <option value="America/Denver">Mountain (US)</option>
+                                <option value="America/Los_Angeles">Pacific (US)</option>
+                                <option value="Europe/London">London</option>
+                                <option value="Europe/Paris">Paris</option>
+                                <option value="Asia/Tokyo">Tokyo</option>
+                            </select>
+                            <div class="help-text">Time will be converted to UTC for storage</div>
+                        </div>
+                    </div>
+
+                    <div id="recurring-fields" class="hidden">
+                        <div class="form-group">
+                            <label for="cron">Cron Expression</label>
+                            <input type="text" id="cron" placeholder="0 9 * * 1-5">
+                            <div class="help-text">Examples: "0 9 * * *" (daily at 9am), "*/5 * * * *" (every 5 min)</div>
+                        </div>
+                        <div class="form-group">
+                            <label for="recurring-timezone">Timezone</label>
+                            <select id="recurring-timezone">
+                                <option value="UTC" selected>UTC</option>
+                                <option value="America/New_York">Eastern (US)</option>
+                                <option value="America/Chicago">Central (US)</option>
+                                <option value="America/Denver">Mountain (US)</option>
+                                <option value="America/Los_Angeles">Pacific (US)</option>
+                                <option value="Europe/London">London</option>
+                                <option value="Europe/Paris">Paris</option>
+                                <option value="Asia/Tokyo">Tokyo</option>
+                            </select>
+                            <div class="help-text">Cron expression will be evaluated in this timezone</div>
+                        </div>
+                    </div>
+
+                    <button onclick="createSchedule()">Create Schedule</button>
+                    <div id="create-error" class="error"></div>
                 </div>
-                
-                <button onclick="createSchedule()">CREATE SCHEDULE</button>
-                <div id="create-error" class="error"></div>
             </div>
-            
+
             <!-- Results Tab -->
             <div id="results-tab" class="content">
-                <h2>EXECUTION RESULTS</h2>
-                <table id="results-table">
-                    <thead>
-                        <tr>
-                            <th>Schedule ID</th>
-                            <th>Type</th>
-                            <th>Status</th>
-                            <th>Agent ID</th>
-                            <th>Message</th>
-                            <th>Run ID / Error</th>
-                            <th>Executed At</th>
-                        </tr>
-                    </thead>
-                    <tbody></tbody>
-                </table>
-                
-                <button onclick="loadResults()" style="margin-top: 20px;">REFRESH</button>
+                <h2>Execution Results</h2>
+                <div class="table-container">
+                    <table id="results-table">
+                        <thead>
+                            <tr>
+                                <th>Schedule ID</th>
+                                <th>Type</th>
+                                <th>Status</th>
+                                <th>Agent ID</th>
+                                <th>Message</th>
+                                <th>Run ID / Error</th>
+                                <th>Executed At</th>
+                            </tr>
+                        </thead>
+                        <tbody></tbody>
+                    </table>
+                </div>
+
+                <button onclick="loadResults()" class="secondary">Refresh</button>
             </div>
         </div>
     </div>
-    
+
     <script>
         const API_BASE = window.location.origin;
         let API_KEY = sessionStorage.getItem('letta_api_key') || '';
-        
+
         // Initialize
         if (API_KEY) {
             document.getElementById('api-key-input').value = API_KEY;
@@ -378,49 +444,53 @@ PROTOCOL: REST API + SCHEDULED EXECUTION</pre>
             loadSchedules();
             loadResults();
         }
-        
+
         function saveApiKey() {
             API_KEY = document.getElementById('api-key-input').value.trim();
             if (!API_KEY) {
-                alert('ERROR: API KEY REQUIRED');
+                showStatus('api-key-status', 'API key is required', 'error');
                 return;
             }
             sessionStorage.setItem('letta_api_key', API_KEY);
             document.getElementById('main-content').classList.remove('hidden');
-            document.getElementById('api-key-status').innerHTML = '<p style="color:#000;margin-top:10px;font-weight:bold;">✓ API KEY AUTHENTICATED</p>';
+            showStatus('api-key-status', 'Connected successfully', 'success');
             loadSchedules();
             loadResults();
         }
-        
+
         function clearApiKey() {
             sessionStorage.removeItem('letta_api_key');
             API_KEY = '';
             document.getElementById('api-key-input').value = '';
             document.getElementById('main-content').classList.add('hidden');
-            document.getElementById('api-key-status').innerHTML = '<p style="color:#000;margin-top:10px;font-weight:bold;">✗ SESSION TERMINATED</p>';
+            showStatus('api-key-status', 'Disconnected', 'error');
+        }
+
+        function showStatus(elementId, message, type) {
+            const el = document.getElementById(elementId);
+            el.innerHTML = `<div class="status-msg ${type}">${message}</div>`;
         }
-        
+
         function showTab(tab) {
             document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
             document.querySelectorAll('.content').forEach(c => c.classList.remove('active'));
             event.target.classList.add('active');
             document.getElementById(`${tab}-tab`).classList.add('active');
         }
-        
+
         function toggleScheduleType() {
             const type = document.getElementById('schedule-type').value;
             document.getElementById('onetime-fields').classList.toggle('hidden', type !== 'onetime');
             document.getElementById('recurring-fields').classList.toggle('hidden', type !== 'recurring');
         }
-        
+
         async function loadSchedules() {
             const onetimeBody = document.querySelector('#onetime-table tbody');
             const recurringBody = document.querySelector('#recurring-table tbody');
-            
-            // Show loading spinners
+
             onetimeBody.innerHTML = '<tr><td colspan="5" class="loading"><div class="spinner"></div><p>Loading...</p></td></tr>';
-            recurringBody.innerHTML = '<tr><td colspan="6" class="loading"><div class="spinner"></div><p>Loading...</p></td></tr>';
-            
+            recurringBody.innerHTML = '<tr><td colspan="7" class="loading"><div class="spinner"></div><p>Loading...</p></td></tr>';
+
             try {
                 const [onetime, recurring] = await Promise.all([
                     fetch(`${API_BASE}/schedules/one-time`, {
@@ -430,107 +500,98 @@ PROTOCOL: REST API + SCHEDULED EXECUTION</pre>
                         headers: { 'Authorization': `Bearer ${API_KEY}` }
                     }).then(r => r.json())
                 ]);
-                
-                // One-time schedules
+
                 onetimeBody.innerHTML = onetime.length ? onetime.map(s => `
                     <tr>
-                        <td>${s.id.substring(0, 8)}...</td>
-                        <td>${s.agent_id}</td>
+                        <td class="mono">${s.id.substring(0, 8)}...</td>
+                        <td class="mono">${s.agent_id}</td>
                         <td>${new Date(s.execute_at).toLocaleString()}</td>
                         <td>${truncate(s.message, 50)}</td>
-                        <td><button class="danger" onclick="deleteSchedule('one-time', '${s.id}')">DEL</button></td>
+                        <td><button class="danger sm" onclick="deleteSchedule('one-time', '${s.id}')">Delete</button></td>
                     </tr>
                 `).join('') : '<tr><td colspan="5" class="empty">No one-time schedules</td></tr>';
-                
-                // Recurring schedules
-                const recurringBody = document.querySelector('#recurring-table tbody');
+
                 recurringBody.innerHTML = recurring.length ? recurring.map(s => `
                     <tr>
-                        <td>${s.id.substring(0, 8)}...</td>
-                        <td>${s.agent_id}</td>
-                        <td>${s.cron}</td>
+                        <td class="mono">${s.id.substring(0, 8)}...</td>
+                        <td class="mono">${s.agent_id}</td>
+                        <td class="mono">${s.cron}</td>
+                        <td>${s.timezone || 'UTC'}</td>
                         <td>${truncate(s.message, 50)}</td>
                         <td>${s.last_run ? new Date(s.last_run).toLocaleString() : 'Never'}</td>
-                        <td><button class="danger" onclick="deleteSchedule('recurring', '${s.id}')">DEL</button></td>
+                        <td><button class="danger sm" onclick="deleteSchedule('recurring', '${s.id}')">Delete</button></td>
                     </tr>
-                `).join('') : '<tr><td colspan="6" class="empty">No recurring schedules</td></tr>';
+                `).join('') : '<tr><td colspan="7" class="empty">No recurring schedules</td></tr>';
             } catch (error) {
-                alert('ERROR LOADING SCHEDULES: ' + error.message);
+                onetimeBody.innerHTML = `<tr><td colspan="5" class="empty">Error loading schedules</td></tr>`;
+                recurringBody.innerHTML = `<tr><td colspan="7" class="empty">Error loading schedules</td></tr>`;
             }
         }
-        
+
         async function loadResults() {
             const resultsBody = document.querySelector('#results-table tbody');
-            
-            // Show loading spinner
+
             resultsBody.innerHTML = '<tr><td colspan="7" class="loading"><div class="spinner"></div><p>Loading...</p></td></tr>';
-            
+
             try {
                 const results = await fetch(`${API_BASE}/results`, {
                     headers: { 'Authorization': `Bearer ${API_KEY}` }
                 }).then(r => r.json());
-                
+
                 resultsBody.innerHTML = results.length ? results.map(r => `
                     <tr>
-                        <td>${r.schedule_id.substring(0, 8)}...</td>
+                        <td class="mono">${r.schedule_id.substring(0, 8)}...</td>
                         <td>${r.schedule_type}</td>
                         <td><span class="badge ${r.status}">${r.status}</span></td>
-                        <td>${r.agent_id}</td>
+                        <td class="mono">${r.agent_id}</td>
                         <td>${truncate(r.message, 30)}</td>
-                        <td>${r.run_id || r.error || 'N/A'}</td>
+                        <td class="mono">${r.run_id || r.error || '-'}</td>
                         <td>${new Date(r.executed_at).toLocaleString()}</td>
                     </tr>
                 `).join('') : '<tr><td colspan="7" class="empty">No execution results</td></tr>';
             } catch (error) {
-                alert('ERROR LOADING RESULTS: ' + error.message);
+                resultsBody.innerHTML = `<tr><td colspan="7" class="empty">Error loading results</td></tr>`;
             }
         }
-        
+
         async function createSchedule() {
             const type = document.getElementById('schedule-type').value;
             const agentId = document.getElementById('agent-id').value.trim();
             const message = document.getElementById('message').value.trim();
             const errorDiv = document.getElementById('create-error');
-            
+
             errorDiv.textContent = '';
-            
+
             if (!agentId || !message) {
-                errorDiv.textContent = 'Please fill in all fields';
+                errorDiv.textContent = 'Please fill in all required fields';
                 return;
             }
-            
+
             try {
                 const payload = {
                     agent_id: agentId,
                     message: message,
                     role: 'user'
                 };
-                
+
                 if (type === 'onetime') {
                     const executeAt = document.getElementById('execute-at').value;
                     const timezone = document.getElementById('timezone').value;
-                    
+
                     if (!executeAt) {
                         errorDiv.textContent = 'Please specify execution time';
                         return;
                     }
-                    
-                    // Convert datetime-local to ISO 8601 with timezone handling
+
                     let executeDate;
                     if (timezone === 'local') {
-                        // Use local browser timezone
                         executeDate = new Date(executeAt);
                     } else if (timezone === 'UTC') {
-                        // Treat input as UTC
                         executeDate = new Date(executeAt + 'Z');
                     } else {
-                        // For named timezones, we assume the datetime-local is in that timezone
-                        // Convert to UTC by parsing as local and adjusting
                         executeDate = new Date(executeAt);
-                        // Note: This is a simplification. For accurate timezone conversion,
-                        // we'd need a library, but for now we assume UTC or local
                     }
-                    
+
                     payload.execute_at = executeDate.toISOString();
                 } else {
                     const cron = document.getElementById('cron').value.trim();
@@ -539,8 +600,9 @@ PROTOCOL: REST API + SCHEDULED EXECUTION</pre>
                         return;
                     }
                     payload.cron = cron;
+                    payload.timezone = document.getElementById('recurring-timezone').value;
                 }
-                
+
                 const endpoint = type === 'onetime' ? '/schedules/one-time' : '/schedules/recurring';
                 const response = await fetch(`${API_BASE}${endpoint}`, {
                     method: 'POST',
@@ -550,43 +612,43 @@ PROTOCOL: REST API + SCHEDULED EXECUTION</pre>
                     },
                     body: JSON.stringify(payload)
                 });
-                
+
                 if (!response.ok) {
                     const error = await response.json();
                     throw new Error(error.detail || 'Failed to create schedule');
                 }
-                
-                alert('SCHEDULE CREATED SUCCESSFULLY');
+
                 document.getElementById('agent-id').value = '';
                 document.getElementById('message').value = '';
                 document.getElementById('execute-at').value = '';
                 document.getElementById('cron').value = '';
-                
+                document.getElementById('recurring-timezone').value = 'UTC';
+
                 showTab('schedules');
+                document.querySelector('.tab').click();
                 loadSchedules();
             } catch (error) {
-                errorDiv.textContent = 'Error: ' + error.message;
+                errorDiv.textContent = error.message;
             }
         }
-        
+
         async function deleteSchedule(type, id) {
-            if (!confirm('CONFIRM: Delete this schedule?')) return;
-            
+            if (!confirm('Delete this schedule?')) return;
+
             try {
                 const response = await fetch(`${API_BASE}/schedules/${type}/${id}`, {
                     method: 'DELETE',
                     headers: { 'Authorization': `Bearer ${API_KEY}` }
                 });
-                
+
                 if (!response.ok) throw new Error('Failed to delete');
-                
-                alert('SCHEDULE DELETED');
+
                 loadSchedules();
             } catch (error) {
-                alert('ERROR DELETING SCHEDULE: ' + error.message);
+                alert('Error: ' + error.message);
             }
         }
-        
+
         function truncate(str, len) {
             return str.length > len ? str.substring(0, len) + '...' : str;
         }

+ 87 - 6
letta_executor.py

@@ -1,23 +1,99 @@
 from letta_client import Letta, MessageCreate, TextContent
 import logging
+import json
 
 logger = logging.getLogger(__name__)
 
 
+def parse_error(e: Exception) -> dict:
+    """Extract error message and metadata from exceptions."""
+    error_str = str(e)
+    result = {"message": error_str, "permanent": False}
+
+    # Check for common timeout errors
+    if "timed out" in error_str.lower():
+        result["message"] = "Request timed out"
+        return result
+
+    # Check for connection errors
+    if "connection" in error_str.lower() and "error" in error_str.lower():
+        result["message"] = "Connection error"
+        return result
+
+    # Try to extract body from HTTP errors (letta_client format)
+    if "body:" in error_str:
+        try:
+            # Extract the body part
+            body_start = error_str.find("body:") + 5
+            body_str = error_str[body_start:].strip()
+            body = eval(body_str)  # Safe here since it's from our own API response
+
+            if isinstance(body, dict):
+                error_msg = body.get("error", "")
+                reasons = body.get("reasons", [])
+                status_code = None
+
+                # Try to get status code
+                if "status_code:" in error_str:
+                    try:
+                        sc_start = error_str.find("status_code:") + 12
+                        sc_end = error_str.find(",", sc_start)
+                        status_code = int(error_str[sc_start:sc_end].strip())
+                    except (ValueError, IndexError):
+                        pass
+
+                # For auth errors - permanent, should remove schedule
+                if status_code in (401, 403):
+                    result["message"] = f"Authentication failed: {error_msg}"
+                    result["permanent"] = True
+                    return result
+
+                # For not found - permanent, should remove schedule
+                if status_code == 404 or "not-found" in str(reasons).lower():
+                    result["message"] = "Agent not found"
+                    result["permanent"] = True
+                    return result
+
+                # For rate limits - transient, keep schedule
+                if status_code == 429 or error_msg == "Rate limited":
+                    if reasons:
+                        result["message"] = f"Rate limited: {', '.join(reasons)}"
+                    else:
+                        result["message"] = "Rate limited"
+                    return result
+
+                # Generic API error
+                if error_msg:
+                    if reasons:
+                        result["message"] = f"{error_msg}: {', '.join(reasons)}"
+                    else:
+                        result["message"] = error_msg
+                    return result
+        except Exception:
+            pass
+
+    # Fallback: truncate if too long
+    if len(error_str) > 100:
+        result["message"] = error_str[:100] + "..."
+
+    return result
+
+
 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)}")
+        error_info = parse_error(e)
+        logger.error(f"API key validation failed: {error_info['message']}")
         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,
@@ -30,10 +106,15 @@ async def execute_letta_message(agent_id: str, api_key: str, message: str, role:
                 )
             ]
         )
-        
+
         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)}
+        error_info = parse_error(e)
+        logger.error(f"Failed to send message to agent {agent_id}: {error_info['message']}")
+        return {
+            "success": False,
+            "error": error_info["message"],
+            "permanent": error_info["permanent"]
+        }

+ 2 - 0
models.py

@@ -9,6 +9,7 @@ class RecurringScheduleCreate(BaseModel):
     cron: str
     message: str
     role: str = "user"
+    timezone: str = "UTC"
 
 
 class RecurringSchedule(BaseModel):
@@ -18,6 +19,7 @@ class RecurringSchedule(BaseModel):
     cron: str
     message: str
     role: str = "user"
+    timezone: str = "UTC"
     created_at: datetime = Field(default_factory=datetime.utcnow)
     last_run: Optional[datetime] = None
 

+ 19 - 10
scheduler.py

@@ -1,6 +1,7 @@
 from croniter import croniter
 from datetime import datetime, timezone
 from dateutil import parser
+from zoneinfo import ZoneInfo
 import logging
 
 logger = logging.getLogger(__name__)
@@ -9,25 +10,33 @@ 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")
-    
+    schedule_tz_str = schedule_dict.get("timezone", "UTC")
+
+    # Get the schedule's timezone
+    try:
+        schedule_tz = ZoneInfo(schedule_tz_str)
+    except Exception:
+        schedule_tz = ZoneInfo("UTC")
+
     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)
+
+    # Convert current time to schedule's timezone for cron evaluation
+    current_time_in_tz = current_time.astimezone(schedule_tz)
+    last_run_in_tz = last_run_dt.astimezone(schedule_tz)
+
+    cron = croniter(cron_expression, last_run_in_tz)
     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
+
+    return current_time_in_tz >= next_run
 
 
 def is_onetime_schedule_due(schedule_dict: dict, current_time: datetime) -> bool: