summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-28 22:19:28 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-28 22:19:28 -1000
commit05b1930e04ac222d73ffb2f45c1b1febb69f893d (patch)
treebc451d72b5265ff044c4655ed90685c601688b6d /web
parent058ff7d699f088edb851336928dd3eea2934cc07 (diff)
Add Agent Context API for external agent integration
Phase 1: Authentication and read-only context - POST /agent/auth/request - request access with name + agent_id - GET /agent/auth/poll - poll for approval status - POST /agent/auth/approve|deny - user approval (browser auth required) - GET /agent/context - 7-day timeline context (agent session required) Phase 1.5: Browser-only agent endpoints (HTML pages) - GET /agent/web/request - request page with token - GET /agent/web/status - status page with polling - GET /agent/web/context - context page with timeline data WebSocket notifications: - GET /ws/notifications - push agent requests to browsers - Approval modal with trust indicators and countdown timer Database: - agents table for registered agent tracking - agent_sessions table for pending/active sessions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'web')
-rw-r--r--web/static/js/app.js175
-rw-r--r--web/templates/agent-context.html121
-rw-r--r--web/templates/agent-error.html28
-rw-r--r--web/templates/agent-request.html61
-rw-r--r--web/templates/agent-status.html80
5 files changed, 465 insertions, 0 deletions
diff --git a/web/static/js/app.js b/web/static/js/app.js
index f103ae8..380bb70 100644
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -265,3 +265,178 @@ function toggleTask(taskId) {
}, { passive: true });
});
})();
+
+// Agent Access Request Notifications
+(function() {
+ let wsConnection = null;
+ let reconnectAttempts = 0;
+ const MAX_RECONNECT_ATTEMPTS = 5;
+ const RECONNECT_DELAY_BASE = 1000;
+
+ function connectWebSocket() {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/ws/notifications`;
+
+ try {
+ wsConnection = new WebSocket(wsUrl);
+
+ wsConnection.onopen = function() {
+ console.log('WebSocket connected');
+ reconnectAttempts = 0;
+ };
+
+ wsConnection.onmessage = function(event) {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === 'agent_request') {
+ showAgentApprovalModal(msg.payload);
+ }
+ } catch (e) {
+ console.error('Failed to parse WebSocket message:', e);
+ }
+ };
+
+ wsConnection.onclose = function(event) {
+ console.log('WebSocket disconnected');
+ if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
+ const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts);
+ reconnectAttempts++;
+ console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
+ setTimeout(connectWebSocket, delay);
+ }
+ };
+
+ wsConnection.onerror = function(error) {
+ console.error('WebSocket error:', error);
+ };
+ } catch (e) {
+ console.error('Failed to create WebSocket:', e);
+ }
+ }
+
+ function showAgentApprovalModal(payload) {
+ // Remove any existing modal
+ const existingModal = document.getElementById('agent-approval-modal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Truncate agent ID for display
+ const shortAgentId = payload.agent_id.substring(0, 8);
+
+ // Determine trust indicator
+ let trustBadge = '';
+ let trustClass = '';
+ switch (payload.trust_level) {
+ case 'recognized':
+ trustBadge = 'Recognized';
+ trustClass = 'bg-green-500';
+ break;
+ case 'suspicious':
+ trustBadge = 'Warning: Different ID';
+ trustClass = 'bg-yellow-500';
+ break;
+ default:
+ trustBadge = 'New Agent';
+ trustClass = 'bg-blue-500';
+ }
+
+ // Calculate time remaining
+ const expiresAt = new Date(payload.expires_at);
+ const timeRemaining = Math.max(0, Math.floor((expiresAt - new Date()) / 1000));
+
+ const modal = document.createElement('div');
+ modal.id = 'agent-approval-modal';
+ modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
+ modal.innerHTML = `
+ <div class="bg-gray-900 rounded-lg p-6 max-w-md mx-4 shadow-xl border border-gray-700">
+ <div class="flex items-center justify-between mb-4">
+ <h2 class="text-xl font-semibold text-white">Agent Access Request</h2>
+ <span class="px-2 py-1 text-xs rounded ${trustClass} text-white">${trustBadge}</span>
+ </div>
+ <div class="mb-6 text-gray-300">
+ <p class="mb-2"><strong class="text-white">Agent Name:</strong> ${escapeHtml(payload.agent_name)}</p>
+ <p class="mb-2"><strong class="text-white">Agent ID:</strong> <code class="bg-gray-800 px-1 rounded">${shortAgentId}...</code></p>
+ <p class="text-sm text-gray-400">Expires in <span id="agent-countdown">${timeRemaining}</span>s</p>
+ </div>
+ <div class="flex gap-3">
+ <button id="agent-approve-btn" class="flex-1 bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded transition">
+ Approve
+ </button>
+ <button id="agent-deny-btn" class="flex-1 bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded transition">
+ Deny
+ </button>
+ </div>
+ </div>
+ `;
+
+ document.body.appendChild(modal);
+
+ // Countdown timer
+ const countdownEl = document.getElementById('agent-countdown');
+ const countdownInterval = setInterval(() => {
+ const remaining = Math.max(0, Math.floor((expiresAt - new Date()) / 1000));
+ countdownEl.textContent = remaining;
+ if (remaining <= 0) {
+ clearInterval(countdownInterval);
+ modal.remove();
+ }
+ }, 1000);
+
+ // Button handlers
+ document.getElementById('agent-approve-btn').addEventListener('click', async () => {
+ await handleAgentDecision(payload.request_token, 'approve');
+ clearInterval(countdownInterval);
+ modal.remove();
+ });
+
+ document.getElementById('agent-deny-btn').addEventListener('click', async () => {
+ await handleAgentDecision(payload.request_token, 'deny');
+ clearInterval(countdownInterval);
+ modal.remove();
+ });
+
+ // Click outside to dismiss (treat as no action)
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) {
+ clearInterval(countdownInterval);
+ modal.remove();
+ }
+ });
+ }
+
+ async function handleAgentDecision(requestToken, decision) {
+ try {
+ const response = await fetch(`/agent/auth/${decision}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': getCSRFToken()
+ },
+ body: JSON.stringify({ request_token: requestToken })
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ console.error(`Failed to ${decision} agent:`, error);
+ alert(`Failed to ${decision} agent request. Please try again.`);
+ } else {
+ console.log(`Agent request ${decision}d successfully`);
+ }
+ } catch (e) {
+ console.error(`Error during agent ${decision}:`, e);
+ alert(`Error processing request. Please try again.`);
+ }
+ }
+
+ function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ // Initialize WebSocket on page load
+ document.addEventListener('DOMContentLoaded', function() {
+ connectWebSocket();
+ });
+})();
diff --git a/web/templates/agent-context.html b/web/templates/agent-context.html
new file mode 100644
index 0000000..3a4778a
--- /dev/null
+++ b/web/templates/agent-context.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Agent Context - {{.AgentName}}</title>
+ <style>
+ body { font-family: system-ui, sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
+ .card { background: #16213e; border-radius: 8px; padding: 24px; margin-bottom: 20px; }
+ h1 { color: #e94560; margin-top: 0; }
+ h2 { color: #0f3460; font-size: 1.2em; margin-top: 24px; }
+ .label { color: #888; font-size: 0.9em; margin-bottom: 4px; }
+ .value { font-family: monospace; background: #0f0f23; padding: 8px 12px; border-radius: 4px; word-break: break-all; margin-bottom: 16px; }
+ .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 20px; }
+ .summary-item { background: #0f3460; padding: 12px; border-radius: 6px; text-align: center; }
+ .summary-value { font-size: 1.5em; font-weight: bold; color: #e94560; }
+ .summary-label { font-size: 0.8em; color: #888; }
+ table { width: 100%; border-collapse: collapse; margin-top: 16px; }
+ th, td { padding: 10px; text-align: left; border-bottom: 1px solid #333; }
+ th { background: #0f3460; color: #fff; }
+ tr:hover { background: #1a1a2e; }
+ .source { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 0.8em; }
+ .source-todoist { background: #e44332; color: #fff; }
+ .source-trello { background: #0079bf; color: #fff; }
+ .source-plantoeat { background: #5cb85c; color: #fff; }
+ .due-overdue { color: #dc3545; }
+ .due-today { color: #ffc107; }
+ .due-future { color: #28a745; }
+ a { color: #4da6ff; }
+ </style>
+</head>
+<body>
+ <script type="application/json" id="agent-data">
+{
+ "generated_at": "{{.GeneratedAt}}",
+ "range": {
+ "start": "{{.RangeStart}}",
+ "end": "{{.RangeEnd}}"
+ },
+ "summary": {
+ "total_items": {{with .Summary}}{{.total_items}}{{else}}0{{end}},
+ "overdue": {{with .Summary}}{{.overdue}}{{else}}0{{end}},
+ "today": {{with .Summary}}{{.today}}{{else}}0{{end}}
+ },
+ "timeline": [{{range $i, $item := .Timeline}}{{if $i}},{{end}}
+ {
+ "id": "{{$item.ID}}",
+ "source": "{{$item.Source}}",
+ "type": "{{$item.Type}}",
+ "title": "{{$item.Title}}",
+ "description": "{{$item.Description}}",
+ "due": {{if $item.Due}}"{{$item.Due.Format "2006-01-02T15:04:05Z07:00"}}"{{else}}null{{end}},
+ "priority": {{$item.Priority}},
+ "completable": {{$item.Completable}},
+ "url": "{{$item.URL}}"
+ }{{end}}
+ ]
+}
+ </script>
+
+ <div class="card">
+ <h1>Agent Context</h1>
+ <p>Timeline data for <strong>{{.AgentName}}</strong></p>
+
+ <div class="label">Generated At</div>
+ <div class="value">{{.GeneratedAt}}</div>
+
+ <div class="label">Date Range</div>
+ <div class="value">{{.RangeStart}} to {{.RangeEnd}}</div>
+ </div>
+
+ <div class="card">
+ <h2>Summary</h2>
+ <div class="summary">
+ <div class="summary-item">
+ <div class="summary-value">{{with .Summary}}{{.total_items}}{{else}}0{{end}}</div>
+ <div class="summary-label">Total Items</div>
+ </div>
+ <div class="summary-item">
+ <div class="summary-value">{{with .Summary}}{{.overdue}}{{else}}0{{end}}</div>
+ <div class="summary-label">Overdue</div>
+ </div>
+ <div class="summary-item">
+ <div class="summary-value">{{with .Summary}}{{.today}}{{else}}0{{end}}</div>
+ <div class="summary-label">Due Today</div>
+ </div>
+ </div>
+ </div>
+
+ <div class="card">
+ <h2>Timeline</h2>
+ {{if .Timeline}}
+ <table>
+ <thead>
+ <tr>
+ <th>Source</th>
+ <th>Title</th>
+ <th>Due</th>
+ <th>Type</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{range .Timeline}}
+ <tr>
+ <td><span class="source source-{{.Source}}">{{.Source}}</span></td>
+ <td>
+ {{if .URL}}<a href="{{.URL}}" target="_blank">{{.Title}}</a>{{else}}{{.Title}}{{end}}
+ {{if .Description}}<br><small style="color: #888;">{{.Description}}</small>{{end}}
+ </td>
+ <td>{{if .Due}}{{.Due.Format "Jan 2, 3:04 PM"}}{{else}}-{{end}}</td>
+ <td>{{.Type}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ {{else}}
+ <p>No items in the timeline for this date range.</p>
+ {{end}}
+ </div>
+</body>
+</html>
diff --git a/web/templates/agent-error.html b/web/templates/agent-error.html
new file mode 100644
index 0000000..afb3603
--- /dev/null
+++ b/web/templates/agent-error.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Error - Agent API</title>
+ <style>
+ body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
+ .card { background: #16213e; border-radius: 8px; padding: 24px; margin-bottom: 20px; border-left: 4px solid #dc3545; }
+ h1 { color: #dc3545; margin-top: 0; }
+ .status-code { font-size: 3em; color: #dc3545; font-weight: bold; }
+ </style>
+</head>
+<body>
+ <script type="application/json" id="agent-data">
+{
+ "error": "{{.Error}}",
+ "status": {{.Status}}
+}
+ </script>
+
+ <div class="card">
+ <div class="status-code">{{.Status}}</div>
+ <h1>Error</h1>
+ <p>{{.Error}}</p>
+ </div>
+</body>
+</html>
diff --git a/web/templates/agent-request.html b/web/templates/agent-request.html
new file mode 100644
index 0000000..fee5ca4
--- /dev/null
+++ b/web/templates/agent-request.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Agent Auth Request - {{.AgentName}}</title>
+ <style>
+ body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
+ .card { background: #16213e; border-radius: 8px; padding: 24px; margin-bottom: 20px; }
+ h1 { color: #e94560; margin-top: 0; }
+ .label { color: #888; font-size: 0.9em; margin-bottom: 4px; }
+ .value { font-family: monospace; background: #0f0f23; padding: 8px 12px; border-radius: 4px; word-break: break-all; margin-bottom: 16px; }
+ .status { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 0.9em; }
+ .status-pending { background: #ffc107; color: #000; }
+ .instructions { background: #1a1a2e; border: 1px solid #333; border-radius: 4px; padding: 16px; font-size: 0.9em; }
+ .instructions ol { margin: 0; padding-left: 20px; }
+ .instructions li { margin-bottom: 8px; }
+ code { background: #0f0f23; padding: 2px 6px; border-radius: 3px; }
+ </style>
+</head>
+<body>
+ <script type="application/json" id="agent-data">
+{
+ "request_token": "{{.RequestToken}}",
+ "status": "{{.Status}}",
+ "poll_url": "{{.PollURL}}",
+ "expires_at": "{{.ExpiresAt}}",
+ "agent_name": "{{.AgentName}}",
+ "agent_id": "{{.AgentID}}"
+}
+ </script>
+
+ <div class="card">
+ <h1>Agent Authentication Request</h1>
+ <p>Agent <strong>{{.AgentName}}</strong> is requesting access to your dashboard.</p>
+
+ <div class="label">Status</div>
+ <div><span class="status status-pending">{{.Status}}</span></div>
+ <br>
+
+ <div class="label">Request Token</div>
+ <div class="value">{{.RequestToken}}</div>
+
+ <div class="label">Poll URL</div>
+ <div class="value">{{.PollURL}}</div>
+
+ <div class="label">Expires At</div>
+ <div class="value">{{.ExpiresAt}}</div>
+ </div>
+
+ <div class="instructions">
+ <strong>Next Steps:</strong>
+ <ol>
+ <li>Wait for human approval on the dashboard</li>
+ <li>Poll the status URL: <code>GET {{.PollURL}}</code></li>
+ <li>When status is "approved", extract the session token from the response</li>
+ <li>Use the context URL to fetch your timeline data</li>
+ </ol>
+ </div>
+</body>
+</html>
diff --git a/web/templates/agent-status.html b/web/templates/agent-status.html
new file mode 100644
index 0000000..a77bb97
--- /dev/null
+++ b/web/templates/agent-status.html
@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Agent Status - {{.AgentName}}</title>
+ <style>
+ body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
+ .card { background: #16213e; border-radius: 8px; padding: 24px; margin-bottom: 20px; }
+ h1 { color: #e94560; margin-top: 0; }
+ .label { color: #888; font-size: 0.9em; margin-bottom: 4px; }
+ .value { font-family: monospace; background: #0f0f23; padding: 8px 12px; border-radius: 4px; word-break: break-all; margin-bottom: 16px; }
+ .status { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 0.9em; }
+ .status-pending { background: #ffc107; color: #000; }
+ .status-approved { background: #28a745; color: #fff; }
+ .status-denied { background: #dc3545; color: #fff; }
+ .status-expired { background: #6c757d; color: #fff; }
+ .success-box { background: #155724; border: 1px solid #28a745; border-radius: 4px; padding: 16px; margin-top: 20px; }
+ .instructions { background: #1a1a2e; border: 1px solid #333; border-radius: 4px; padding: 16px; font-size: 0.9em; }
+ code { background: #0f0f23; padding: 2px 6px; border-radius: 3px; }
+ </style>
+</head>
+<body>
+ <script type="application/json" id="agent-data">
+{
+ "status": "{{.Status}}"{{if .SessionToken}},
+ "session_token": "{{.SessionToken}}",
+ "context_url": "{{.ContextURL}}",
+ "expires_at": "{{.SessionExpiresAt}}"{{end}}
+}
+ </script>
+
+ <div class="card">
+ <h1>Agent Status</h1>
+ <p>Status for agent <strong>{{.AgentName}}</strong></p>
+
+ <div class="label">Status</div>
+ <div><span class="status status-{{.Status}}">{{.Status}}</span></div>
+
+ {{if eq .Status "approved"}}
+ <div class="success-box">
+ <strong>Access Granted!</strong>
+ <p>Your session has been approved. Use the credentials below to access the context.</p>
+ </div>
+
+ <br>
+ <div class="label">Session Token</div>
+ <div class="value">{{.SessionToken}}</div>
+
+ <div class="label">Context URL</div>
+ <div class="value">{{.ContextURL}}</div>
+
+ {{if .SessionExpiresAt}}
+ <div class="label">Session Expires At</div>
+ <div class="value">{{.SessionExpiresAt}}</div>
+ {{end}}
+
+ <div class="instructions">
+ <strong>Next Step:</strong>
+ <p>Fetch your timeline data by navigating to: <code>GET {{.ContextURL}}</code></p>
+ </div>
+ {{else if eq .Status "pending"}}
+ <div class="instructions">
+ <strong>Waiting for approval...</strong>
+ <p>Refresh this page to check the current status.</p>
+ </div>
+ {{else if eq .Status "denied"}}
+ <div class="instructions">
+ <strong>Access Denied</strong>
+ <p>Your request was denied by the dashboard owner.</p>
+ </div>
+ {{else if eq .Status "expired"}}
+ <div class="instructions">
+ <strong>Request Expired</strong>
+ <p>Your authentication request has expired. Please initiate a new request.</p>
+ </div>
+ {{end}}
+ </div>
+</body>
+</html>