agentgate 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes/ui.js CHANGED
@@ -3,12 +3,15 @@ import {
3
3
  listAccounts, getSetting, setSetting, deleteSetting,
4
4
  setAdminPassword, verifyAdminPassword, hasAdminPassword,
5
5
  listQueueEntries, getQueueEntry, updateQueueStatus, clearQueueByStatus, deleteQueueEntry, getPendingQueueCount, getQueueCounts,
6
- listApiKeys, createApiKey, deleteApiKey,
7
- listUnnotifiedEntries
6
+ listApiKeys, createApiKey, deleteApiKey, updateAgentWebhook, getApiKeyById,
7
+ listUnnotifiedEntries,
8
+ getMessagingMode, setMessagingMode, listPendingMessages, listAgentMessages,
9
+ approveAgentMessage, rejectAgentMessage, deleteAgentMessage, clearAgentMessagesByStatus, getMessageCounts, getAgentMessage
8
10
  } from '../lib/db.js';
9
11
  import { connectHsync, disconnectHsync, getHsyncUrl, isHsyncConnected } from '../lib/hsyncManager.js';
10
12
  import { executeQueueEntry } from '../lib/queueExecutor.js';
11
13
  import { notifyClawdbot, retryNotification } from '../lib/notifier.js';
14
+ import { notifyAgentMessage, notifyMessageRejected } from '../lib/agentNotifier.js';
12
15
  import { registerAllRoutes, renderAllCards } from './ui/index.js';
13
16
 
14
17
  const router = Router();
@@ -124,8 +127,10 @@ router.get('/', (req, res) => {
124
127
  const hsyncConnected = isHsyncConnected();
125
128
  const pendingQueueCount = getPendingQueueCount();
126
129
  const notificationsConfig = getSetting('notifications');
130
+ const messagingMode = getMessagingMode();
131
+ const pendingMessagesCount = listPendingMessages().length;
127
132
 
128
- res.send(renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig }));
133
+ res.send(renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig, messagingMode, pendingMessagesCount }));
129
134
  });
130
135
 
131
136
  // Register all service routes (github, bluesky, reddit, etc.)
@@ -225,6 +230,120 @@ router.post('/notifications/test', async (req, res) => {
225
230
  }
226
231
  });
227
232
 
233
+ // Agent Messaging settings
234
+ router.post('/messaging/mode', (req, res) => {
235
+ const { mode } = req.body;
236
+ try {
237
+ setMessagingMode(mode);
238
+ res.redirect('/ui');
239
+ } catch (err) {
240
+ res.status(400).send(err.message);
241
+ }
242
+ });
243
+
244
+ // Agent Messages Queue
245
+ router.get('/messages', (req, res) => {
246
+ const filter = req.query.filter || 'all';
247
+ let messages;
248
+ if (filter === 'all') {
249
+ messages = listAgentMessages();
250
+ } else {
251
+ messages = listAgentMessages(filter);
252
+ }
253
+ const counts = getMessageCounts();
254
+ const mode = getMessagingMode();
255
+ res.send(renderMessagesPage(messages, filter, counts, mode));
256
+ });
257
+
258
+ router.post('/messages/:id/approve', async (req, res) => {
259
+ const { id } = req.params;
260
+ const wantsJson = req.headers.accept?.includes('application/json');
261
+
262
+ const msg = getAgentMessage(id);
263
+ if (!msg) {
264
+ return wantsJson
265
+ ? res.status(404).json({ error: 'Message not found' })
266
+ : res.status(404).send('Message not found');
267
+ }
268
+
269
+ if (msg.status !== 'pending') {
270
+ return wantsJson
271
+ ? res.status(400).json({ error: 'Can only approve pending messages' })
272
+ : res.status(400).send('Can only approve pending messages');
273
+ }
274
+
275
+ approveAgentMessage(id);
276
+ const updated = getAgentMessage(id);
277
+ const counts = getMessageCounts();
278
+
279
+ // Try to notify the recipient agent
280
+ notifyAgentMessage(updated).catch(err => {
281
+ console.error('[agentNotifier] Failed to notify agent:', err.message);
282
+ });
283
+
284
+ if (wantsJson) {
285
+ return res.json({ success: true, message: updated, counts });
286
+ }
287
+ res.redirect('/ui/messages');
288
+ });
289
+
290
+ router.post('/messages/:id/reject', (req, res) => {
291
+ const { id } = req.params;
292
+ const { reason } = req.body;
293
+ const wantsJson = req.headers.accept?.includes('application/json');
294
+
295
+ const msg = getAgentMessage(id);
296
+ if (!msg) {
297
+ return wantsJson
298
+ ? res.status(404).json({ error: 'Message not found' })
299
+ : res.status(404).send('Message not found');
300
+ }
301
+
302
+ if (msg.status !== 'pending') {
303
+ return wantsJson
304
+ ? res.status(400).json({ error: 'Can only reject pending messages' })
305
+ : res.status(400).send('Can only reject pending messages');
306
+ }
307
+
308
+ rejectAgentMessage(id, reason);
309
+ const updated = getAgentMessage(id);
310
+ const counts = getMessageCounts();
311
+
312
+ // Notify sender that their message was rejected
313
+ notifyMessageRejected(updated);
314
+
315
+ if (wantsJson) {
316
+ return res.json({ success: true, message: updated, counts });
317
+ }
318
+ res.redirect('/ui/messages');
319
+ });
320
+
321
+ router.post('/messages/:id/delete', (req, res) => {
322
+ const { id } = req.params;
323
+ const wantsJson = req.headers.accept?.includes('application/json');
324
+
325
+ deleteAgentMessage(id);
326
+ const counts = getMessageCounts();
327
+
328
+ if (wantsJson) {
329
+ return res.json({ success: true, counts });
330
+ }
331
+ res.redirect('/ui/messages');
332
+ });
333
+
334
+ router.post('/messages/clear', (req, res) => {
335
+ const { status } = req.body;
336
+ const wantsJson = req.headers.accept?.includes('application/json');
337
+
338
+ clearAgentMessagesByStatus(status || 'all');
339
+ const counts = getMessageCounts();
340
+
341
+ if (wantsJson) {
342
+ return res.json({ success: true, counts });
343
+ }
344
+ res.redirect('/ui/messages');
345
+ });
346
+
228
347
  // Write Queue Management
229
348
  router.get('/queue', (req, res) => {
230
349
  const filter = req.query.filter || 'all';
@@ -422,6 +541,27 @@ router.post('/keys/:id/delete', (req, res) => {
422
541
  res.redirect('/ui/keys');
423
542
  });
424
543
 
544
+ router.post('/keys/:id/webhook', (req, res) => {
545
+ const { id } = req.params;
546
+ const { webhook_url, webhook_token } = req.body;
547
+ const wantsJson = req.headers.accept?.includes('application/json');
548
+
549
+ const agent = getApiKeyById(id);
550
+ if (!agent) {
551
+ return wantsJson
552
+ ? res.status(404).json({ error: 'Agent not found' })
553
+ : res.status(404).send('Agent not found');
554
+ }
555
+
556
+ updateAgentWebhook(id, webhook_url, webhook_token);
557
+ const keys = listApiKeys();
558
+
559
+ if (wantsJson) {
560
+ return res.json({ success: true, keys });
561
+ }
562
+ res.redirect('/ui/keys');
563
+ });
564
+
425
565
  router.delete('/keys/:id', (req, res) => {
426
566
  const { id } = req.params;
427
567
  const wantsJson = req.headers.accept?.includes('application/json');
@@ -437,7 +577,7 @@ router.delete('/keys/:id', (req, res) => {
437
577
 
438
578
  // HTML Templates
439
579
 
440
- function renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig }) {
580
+ function renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig, messagingMode, pendingMessagesCount }) {
441
581
  const clawdbotConfig = notificationsConfig?.clawdbot;
442
582
  return `<!DOCTYPE html>
443
583
  <html>
@@ -467,6 +607,12 @@ function renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQu
467
607
  Write Queue
468
608
  ${pendingQueueCount > 0 ? `<span class="badge">${pendingQueueCount}</span>` : ''}
469
609
  </a>
610
+ ${messagingMode !== 'off' ? `
611
+ <a href="/ui/messages" class="nav-btn nav-btn-default" style="position: relative;">
612
+ Messages
613
+ ${pendingMessagesCount > 0 ? `<span class="badge">${pendingMessagesCount}</span>` : ''}
614
+ </a>
615
+ ` : ''}
470
616
  <div class="nav-divider"></div>
471
617
  <form method="POST" action="/ui/logout" style="margin: 0;">
472
618
  <button type="submit" class="nav-btn nav-btn-default" style="color: #f87171;">Logout</button>
@@ -497,6 +643,33 @@ curl -X POST http://localhost:${PORT}/api/queue/github/personal/submit \\
497
643
  </div>
498
644
 
499
645
  <h2>Advanced</h2>
646
+ <div class="card">
647
+ <details ${messagingMode !== 'off' ? 'open' : ''}>
648
+ <summary>Agent Messaging ${messagingMode !== 'off' ? `<span class="status configured">${messagingMode}</span>` : ''}</summary>
649
+ <div style="margin-top: 16px;">
650
+ <p class="help">Allow agents to send messages to each other. Messages can require human approval (supervised) or be delivered immediately (open).</p>
651
+ <form method="POST" action="/ui/messaging/mode" style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
652
+ <label style="display: flex; align-items: center; gap: 6px; margin: 0; cursor: pointer;">
653
+ <input type="radio" name="mode" value="off" ${messagingMode === 'off' ? 'checked' : ''}>
654
+ <span>Off</span>
655
+ </label>
656
+ <label style="display: flex; align-items: center; gap: 6px; margin: 0; cursor: pointer;">
657
+ <input type="radio" name="mode" value="supervised" ${messagingMode === 'supervised' ? 'checked' : ''}>
658
+ <span>Supervised</span>
659
+ </label>
660
+ <label style="display: flex; align-items: center; gap: 6px; margin: 0; cursor: pointer;">
661
+ <input type="radio" name="mode" value="open" ${messagingMode === 'open' ? 'checked' : ''}>
662
+ <span>Open</span>
663
+ </label>
664
+ <button type="submit" class="btn-primary btn-sm">Save</button>
665
+ </form>
666
+ ${messagingMode === 'supervised' && pendingMessagesCount > 0 ? `
667
+ <p style="margin-top: 12px;"><a href="/ui/messages" style="color: #818cf8;">${pendingMessagesCount} pending message${pendingMessagesCount > 1 ? 's' : ''} awaiting approval →</a></p>
668
+ ` : ''}
669
+ </div>
670
+ </details>
671
+ </div>
672
+
500
673
  <div class="card">
501
674
  <details ${hsyncConfig?.enabled ? 'open' : ''}>
502
675
  <summary>hsync (Remote Access) ${hsyncConnected ? '<span class="status configured">Connected</span>' : hsyncConfig?.enabled ? '<span class="status not-configured">Disconnected</span>' : ''}</summary>
@@ -1259,6 +1432,384 @@ function renderQueuePage(entries, filter, counts, unnotifiedCount = 0) {
1259
1432
  </html>`;
1260
1433
  }
1261
1434
 
1435
+ function renderMessagesPage(messages, filter, counts, mode) {
1436
+ const escapeHtml = (str) => {
1437
+ if (typeof str !== 'string') str = String(str);
1438
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1439
+ };
1440
+
1441
+ const formatDate = (dateStr) => {
1442
+ if (!dateStr) return '';
1443
+ const d = new Date(dateStr);
1444
+ return d.toLocaleString();
1445
+ };
1446
+
1447
+ const statusBadge = (status) => {
1448
+ const colors = {
1449
+ pending: 'background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3);',
1450
+ delivered: 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);',
1451
+ rejected: 'background: rgba(156, 163, 175, 0.15); color: #9ca3af; border: 1px solid rgba(156, 163, 175, 0.3);'
1452
+ };
1453
+ return `<span class="status" style="${colors[status] || ''}">${status}</span>`;
1454
+ };
1455
+
1456
+ const renderMessage = (msg) => {
1457
+ let actions = '';
1458
+ if (msg.status === 'pending') {
1459
+ actions = `
1460
+ <div class="message-actions">
1461
+ <button type="button" class="btn-primary btn-sm" onclick="approveMessage('${msg.id}')">Approve</button>
1462
+ <input type="text" id="reason-${msg.id}" placeholder="Rejection reason (optional)" class="reject-input" style="width: 200px;">
1463
+ <button type="button" class="btn-danger btn-sm" onclick="rejectMessage('${msg.id}')">Reject</button>
1464
+ </div>
1465
+ `;
1466
+ }
1467
+
1468
+ let rejectionSection = '';
1469
+ if (msg.rejection_reason) {
1470
+ rejectionSection = `
1471
+ <div class="rejection-reason">
1472
+ <strong>Rejection reason:</strong> ${escapeHtml(msg.rejection_reason)}
1473
+ </div>
1474
+ `;
1475
+ }
1476
+
1477
+ return `
1478
+ <div class="card message-entry" id="message-${msg.id}" data-status="${msg.status}">
1479
+ <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
1480
+ <div class="entry-header">
1481
+ <strong>${escapeHtml(msg.from_agent)}</strong> → <strong>${escapeHtml(msg.to_agent)}</strong>
1482
+ <span class="status-badge">${statusBadge(msg.status)}</span>
1483
+ </div>
1484
+ <div style="display: flex; align-items: center; gap: 12px;">
1485
+ <span class="help" style="margin: 0;">${formatDate(msg.created_at)}</span>
1486
+ <button type="button" class="delete-btn" onclick="deleteMessage('${msg.id}')" title="Delete">&times;</button>
1487
+ </div>
1488
+ </div>
1489
+
1490
+ <div class="message-content">
1491
+ <pre style="white-space: pre-wrap; word-wrap: break-word; margin: 0; background: rgba(0,0,0,0.2); padding: 12px; border-radius: 8px;">${escapeHtml(msg.message)}</pre>
1492
+ </div>
1493
+
1494
+ ${rejectionSection}
1495
+ ${actions}
1496
+ </div>
1497
+ `;
1498
+ };
1499
+
1500
+ const filters = ['all', 'pending', 'delivered', 'rejected'];
1501
+ const filterLinks = filters.map(f =>
1502
+ `<a href="/ui/messages?filter=${f}" class="filter-link ${filter === f ? 'active' : ''}">${f}${counts[f] > 0 ? ` (${counts[f]})` : ''}</a>`
1503
+ ).join('');
1504
+
1505
+ return `<!DOCTYPE html>
1506
+ <html>
1507
+ <head>
1508
+ <title>agentgate - Agent Messages</title>
1509
+ <link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
1510
+ <link rel="stylesheet" href="/public/style.css">
1511
+ <style>
1512
+ .filter-bar { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; align-items: center; }
1513
+ .filter-link {
1514
+ padding: 10px 20px;
1515
+ border-radius: 25px;
1516
+ text-decoration: none;
1517
+ background: rgba(255, 255, 255, 0.05);
1518
+ color: #9ca3af;
1519
+ font-weight: 600;
1520
+ font-size: 13px;
1521
+ border: 1px solid rgba(255, 255, 255, 0.1);
1522
+ transition: all 0.3s ease;
1523
+ }
1524
+ .filter-link:hover {
1525
+ background: rgba(255, 255, 255, 0.1);
1526
+ color: #e5e7eb;
1527
+ border-color: rgba(255, 255, 255, 0.2);
1528
+ }
1529
+ .filter-link.active {
1530
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
1531
+ color: white;
1532
+ border-color: transparent;
1533
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
1534
+ }
1535
+ .message-entry { margin-bottom: 20px; }
1536
+ .message-actions {
1537
+ margin-top: 16px;
1538
+ padding-top: 16px;
1539
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
1540
+ display: flex;
1541
+ align-items: center;
1542
+ gap: 12px;
1543
+ flex-wrap: wrap;
1544
+ }
1545
+ .back-link {
1546
+ color: #818cf8;
1547
+ text-decoration: none;
1548
+ font-weight: 600;
1549
+ transition: color 0.2s ease;
1550
+ }
1551
+ .back-link:hover { color: #ffffff; }
1552
+ .delete-btn {
1553
+ background: rgba(239, 68, 68, 0.1);
1554
+ border: 1px solid rgba(239, 68, 68, 0.2);
1555
+ color: #f87171;
1556
+ font-size: 18px;
1557
+ cursor: pointer;
1558
+ padding: 4px 10px;
1559
+ line-height: 1;
1560
+ font-weight: bold;
1561
+ border-radius: 6px;
1562
+ transition: all 0.2s ease;
1563
+ }
1564
+ .delete-btn:hover {
1565
+ background: rgba(239, 68, 68, 0.2);
1566
+ border-color: rgba(239, 68, 68, 0.4);
1567
+ }
1568
+ .clear-section { margin-left: auto; display: flex; gap: 10px; }
1569
+ .entry-header {
1570
+ display: flex;
1571
+ align-items: center;
1572
+ gap: 12px;
1573
+ }
1574
+ .entry-header strong {
1575
+ color: #f3f4f6;
1576
+ font-size: 16px;
1577
+ }
1578
+ .rejection-reason {
1579
+ margin-top: 16px;
1580
+ padding: 16px;
1581
+ background: rgba(239, 68, 68, 0.1);
1582
+ border-radius: 10px;
1583
+ border-left: 4px solid #f87171;
1584
+ color: #e5e7eb;
1585
+ }
1586
+ .rejection-reason strong { color: #f87171; }
1587
+ .empty-state {
1588
+ text-align: center;
1589
+ padding: 60px 40px;
1590
+ }
1591
+ .empty-state p { color: #6b7280; margin: 0; font-size: 16px; }
1592
+ .reject-input {
1593
+ padding: 10px 14px;
1594
+ margin: 0;
1595
+ font-size: 13px;
1596
+ background: rgba(15, 15, 25, 0.6);
1597
+ border: 2px solid rgba(239, 68, 68, 0.2);
1598
+ border-radius: 8px;
1599
+ color: #f3f4f6;
1600
+ }
1601
+ .reject-input:focus {
1602
+ outline: none;
1603
+ border-color: #f87171;
1604
+ box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.15);
1605
+ }
1606
+ .reject-input::placeholder { color: #6b7280; }
1607
+ .mode-badge {
1608
+ display: inline-flex;
1609
+ align-items: center;
1610
+ gap: 6px;
1611
+ padding: 6px 14px;
1612
+ border-radius: 20px;
1613
+ font-size: 12px;
1614
+ font-weight: 600;
1615
+ text-transform: uppercase;
1616
+ background: rgba(99, 102, 241, 0.15);
1617
+ color: #818cf8;
1618
+ border: 1px solid rgba(99, 102, 241, 0.3);
1619
+ }
1620
+ </style>
1621
+ </head>
1622
+ <body>
1623
+ <div style="display: flex; justify-content: space-between; align-items: center;">
1624
+ <h1>Agent Messages</h1>
1625
+ <div style="display: flex; align-items: center; gap: 16px;">
1626
+ <span class="mode-badge">Mode: ${mode}</span>
1627
+ <a href="/ui" class="back-link">← Back to Dashboard</a>
1628
+ </div>
1629
+ </div>
1630
+ <p>Review and approve messages between agents${mode === 'supervised' ? ' (supervised mode)' : ''}.</p>
1631
+
1632
+ <div class="filter-bar" id="filter-bar">
1633
+ ${filterLinks}
1634
+ <div class="clear-section">
1635
+ ${filter === 'delivered' && counts.delivered > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'delivered\')">Clear Delivered</button>' : ''}
1636
+ ${filter === 'rejected' && counts.rejected > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'rejected\')">Clear Rejected</button>' : ''}
1637
+ ${filter === 'all' && (counts.delivered > 0 || counts.rejected > 0) ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'all\')">Clear All Non-Pending</button>' : ''}
1638
+ </div>
1639
+ </div>
1640
+
1641
+ <div id="messages-container">
1642
+ ${messages.length === 0 ? `
1643
+ <div class="card empty-state">
1644
+ <p>No ${filter === 'all' ? '' : filter + ' '}messages</p>
1645
+ </div>
1646
+ ` : messages.map(renderMessage).join('')}
1647
+ </div>
1648
+
1649
+ <script>
1650
+ function escapeHtml(str) {
1651
+ if (typeof str !== 'string') str = JSON.stringify(str, null, 2);
1652
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1653
+ }
1654
+
1655
+ async function approveMessage(id) {
1656
+ const btn = event.target;
1657
+ btn.disabled = true;
1658
+ btn.textContent = 'Approving...';
1659
+
1660
+ try {
1661
+ const res = await fetch('/ui/messages/' + id + '/approve', {
1662
+ method: 'POST',
1663
+ headers: { 'Accept': 'application/json' }
1664
+ });
1665
+ const data = await res.json();
1666
+
1667
+ if (data.success) {
1668
+ const el = document.getElementById('message-' + id);
1669
+ if (el) {
1670
+ el.dataset.status = 'delivered';
1671
+ // Update status badge
1672
+ const badge = el.querySelector('.status-badge');
1673
+ if (badge) {
1674
+ badge.innerHTML = '<span class="status" style="background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);">delivered</span>';
1675
+ }
1676
+ // Remove actions
1677
+ const actions = el.querySelector('.message-actions');
1678
+ if (actions) actions.remove();
1679
+ }
1680
+ } else {
1681
+ alert(data.error || 'Failed to approve');
1682
+ btn.disabled = false;
1683
+ btn.textContent = 'Approve';
1684
+ }
1685
+ } catch (err) {
1686
+ alert('Error: ' + err.message);
1687
+ btn.disabled = false;
1688
+ btn.textContent = 'Approve';
1689
+ }
1690
+ }
1691
+
1692
+ async function rejectMessage(id) {
1693
+ const btn = event.target;
1694
+ const reason = document.getElementById('reason-' + id)?.value || '';
1695
+ btn.disabled = true;
1696
+ btn.textContent = 'Rejecting...';
1697
+
1698
+ try {
1699
+ const res = await fetch('/ui/messages/' + id + '/reject', {
1700
+ method: 'POST',
1701
+ headers: {
1702
+ 'Accept': 'application/json',
1703
+ 'Content-Type': 'application/json'
1704
+ },
1705
+ body: JSON.stringify({ reason })
1706
+ });
1707
+ const data = await res.json();
1708
+
1709
+ if (data.success) {
1710
+ const el = document.getElementById('message-' + id);
1711
+ if (el) {
1712
+ el.dataset.status = 'rejected';
1713
+ // Update status badge
1714
+ const badge = el.querySelector('.status-badge');
1715
+ if (badge) {
1716
+ badge.innerHTML = '<span class="status" style="background: rgba(156, 163, 175, 0.15); color: #9ca3af; border: 1px solid rgba(156, 163, 175, 0.3);">rejected</span>';
1717
+ }
1718
+ // Remove actions and add rejection reason
1719
+ const actions = el.querySelector('.message-actions');
1720
+ if (actions) {
1721
+ actions.outerHTML = '<div class="rejection-reason"><strong>Rejection reason:</strong> ' + escapeHtml(reason || 'No reason provided') + '</div>';
1722
+ }
1723
+ }
1724
+ } else {
1725
+ alert(data.error || 'Failed to reject');
1726
+ btn.disabled = false;
1727
+ btn.textContent = 'Reject';
1728
+ }
1729
+ } catch (err) {
1730
+ alert('Error: ' + err.message);
1731
+ btn.disabled = false;
1732
+ btn.textContent = 'Reject';
1733
+ }
1734
+ }
1735
+
1736
+ async function deleteMessage(id) {
1737
+ if (!confirm('Delete this message?')) return;
1738
+
1739
+ try {
1740
+ const res = await fetch('/ui/messages/' + id + '/delete', {
1741
+ method: 'POST',
1742
+ headers: { 'Accept': 'application/json' }
1743
+ });
1744
+ const data = await res.json();
1745
+
1746
+ if (data.success) {
1747
+ const el = document.getElementById('message-' + id);
1748
+ if (el) el.remove();
1749
+
1750
+ // Show empty message if no messages left
1751
+ const container = document.getElementById('messages-container');
1752
+ if (container.querySelectorAll('.message-entry').length === 0) {
1753
+ container.innerHTML = '<div class="card empty-state"><p>No messages</p></div>';
1754
+ }
1755
+ } else {
1756
+ alert(data.error || 'Failed to delete');
1757
+ }
1758
+ } catch (err) {
1759
+ alert('Error: ' + err.message);
1760
+ }
1761
+ }
1762
+
1763
+ async function clearByStatus(status) {
1764
+ const btn = event.target;
1765
+ const originalText = btn.textContent;
1766
+ btn.disabled = true;
1767
+ btn.textContent = 'Clearing...';
1768
+
1769
+ try {
1770
+ const res = await fetch('/ui/messages/clear', {
1771
+ method: 'POST',
1772
+ headers: {
1773
+ 'Accept': 'application/json',
1774
+ 'Content-Type': 'application/json'
1775
+ },
1776
+ body: JSON.stringify({ status })
1777
+ });
1778
+ const data = await res.json();
1779
+
1780
+ if (data.success) {
1781
+ // Remove cleared messages from DOM
1782
+ const container = document.getElementById('messages-container');
1783
+ if (status === 'all') {
1784
+ container.querySelectorAll('.message-entry').forEach(el => {
1785
+ if (el.dataset.status !== 'pending') el.remove();
1786
+ });
1787
+ } else {
1788
+ container.querySelectorAll('.message-entry[data-status="' + status + '"]').forEach(el => el.remove());
1789
+ }
1790
+
1791
+ // Show empty message if no messages left
1792
+ if (container.querySelectorAll('.message-entry').length === 0) {
1793
+ container.innerHTML = '<div class="card empty-state"><p>No messages</p></div>';
1794
+ }
1795
+
1796
+ // Hide the clear button
1797
+ btn.style.display = 'none';
1798
+ }
1799
+
1800
+ btn.disabled = false;
1801
+ btn.textContent = originalText;
1802
+ } catch (err) {
1803
+ alert('Error: ' + err.message);
1804
+ btn.disabled = false;
1805
+ btn.textContent = originalText;
1806
+ }
1807
+ }
1808
+ </script>
1809
+ </body>
1810
+ </html>`;
1811
+ }
1812
+
1262
1813
  function renderKeysPage(keys, error = null, newKey = null) {
1263
1814
  const escapeHtml = (str) => {
1264
1815
  if (typeof str !== 'string') return '';
@@ -1275,6 +1826,14 @@ function renderKeysPage(keys, error = null, newKey = null) {
1275
1826
  <tr id="key-${k.id}">
1276
1827
  <td><strong>${escapeHtml(k.name)}</strong></td>
1277
1828
  <td><code class="key-value">${escapeHtml(k.key_prefix)}</code></td>
1829
+ <td>
1830
+ ${k.webhook_url ? `
1831
+ <span class="webhook-status webhook-configured" title="${escapeHtml(k.webhook_url)}">✓ Configured</span>
1832
+ ` : `
1833
+ <span class="webhook-status webhook-none">Not set</span>
1834
+ `}
1835
+ <button type="button" class="btn-sm webhook-btn" data-id="${k.id}" data-name="${escapeHtml(k.name)}" data-url="${escapeHtml(k.webhook_url || '')}" data-token="${escapeHtml(k.webhook_token || '')}">Configure</button>
1836
+ </td>
1278
1837
  <td>${formatDate(k.created_at)}</td>
1279
1838
  <td>
1280
1839
  <button type="button" class="delete-btn" onclick="deleteKey('${k.id}')" title="Delete">&times;</button>
@@ -1290,16 +1849,30 @@ function renderKeysPage(keys, error = null, newKey = null) {
1290
1849
  <link rel="stylesheet" href="/public/style.css">
1291
1850
  <style>
1292
1851
  .keys-table { width: 100%; border-collapse: collapse; margin-top: 16px; }
1293
- .keys-table th, .keys-table td { padding: 12px; text-align: left; border-bottom: 1px solid var(--gray-200); }
1294
- .keys-table th { font-weight: 600; color: var(--gray-600); font-size: 14px; }
1295
- .key-value { background: var(--gray-100); padding: 4px 8px; border-radius: 4px; font-size: 13px; }
1296
- .new-key-banner { background: #d1fae5; border: 1px solid #10b981; padding: 16px; border-radius: 8px; margin-bottom: 20px; }
1297
- .new-key-banner code { background: white; padding: 8px 12px; border-radius: 4px; display: block; margin-top: 8px; font-size: 14px; word-break: break-all; }
1298
- .delete-btn { background: none; border: none; color: #dc2626; font-size: 20px; cursor: pointer; padding: 0 4px; line-height: 1; font-weight: bold; }
1299
- .delete-btn:hover { color: #991b1b; }
1300
- .back-link { color: var(--primary); text-decoration: none; font-weight: 500; }
1852
+ .keys-table th, .keys-table td { padding: 12px; text-align: left; border-bottom: 1px solid #374151; }
1853
+ .keys-table th { font-weight: 600; color: #9ca3af; font-size: 14px; }
1854
+ .key-value { background: #1f2937; padding: 4px 8px; border-radius: 4px; font-size: 13px; color: #e5e7eb; }
1855
+ .new-key-banner { background: #065f46; border: 1px solid #10b981; padding: 16px; border-radius: 8px; margin-bottom: 20px; color: #d1fae5; }
1856
+ .new-key-banner code { background: #1f2937; color: #10b981; padding: 8px 12px; border-radius: 4px; display: block; margin-top: 8px; font-size: 14px; word-break: break-all; }
1857
+ .delete-btn { background: none; border: none; color: #f87171; font-size: 20px; cursor: pointer; padding: 0 4px; line-height: 1; font-weight: bold; }
1858
+ .delete-btn:hover { color: #dc2626; }
1859
+ .back-link { color: #a78bfa; text-decoration: none; font-weight: 500; }
1301
1860
  .back-link:hover { text-decoration: underline; }
1302
- .error-message { background: #fee2e2; color: #991b1b; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
1861
+ .error-message { background: #7f1d1d; color: #fecaca; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
1862
+ .webhook-status { font-size: 12px; padding: 4px 8px; border-radius: 4px; margin-right: 8px; }
1863
+ .webhook-configured { background: #065f46; color: #6ee7b7; }
1864
+ .webhook-none { background: #374151; color: #9ca3af; }
1865
+ .btn-sm { font-size: 12px; padding: 4px 8px; background: #4f46e5; color: white; border: none; border-radius: 4px; cursor: pointer; }
1866
+ .btn-sm:hover { background: #4338ca; }
1867
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; }
1868
+ .modal-overlay.active { display: flex; }
1869
+ .modal { background: #1f2937; border-radius: 12px; padding: 24px; max-width: 500px; width: 90%; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); }
1870
+ .modal h3 { margin: 0 0 16px 0; color: #f3f4f6; }
1871
+ .modal label { display: block; margin-bottom: 4px; color: #d1d5db; font-size: 14px; }
1872
+ .modal input { width: 100%; padding: 10px; border: 1px solid #374151; border-radius: 6px; background: #111827; color: #f3f4f6; margin-bottom: 12px; box-sizing: border-box; }
1873
+ .modal input:focus { border-color: #6366f1; outline: none; }
1874
+ .modal-buttons { display: flex; gap: 12px; justify-content: flex-end; margin-top: 16px; }
1875
+ .modal .help-text { font-size: 12px; color: #9ca3af; margin-top: -8px; margin-bottom: 12px; }
1303
1876
  </style>
1304
1877
  </head>
1305
1878
  <body>
@@ -1340,6 +1913,7 @@ function renderKeysPage(keys, error = null, newKey = null) {
1340
1913
  <tr>
1341
1914
  <th>Name</th>
1342
1915
  <th>Key Prefix</th>
1916
+ <th>Webhook</th>
1343
1917
  <th>Created</th>
1344
1918
  <th></th>
1345
1919
  </tr>
@@ -1351,6 +1925,31 @@ function renderKeysPage(keys, error = null, newKey = null) {
1351
1925
  `}
1352
1926
  </div>
1353
1927
 
1928
+ <!-- Webhook Modal -->
1929
+ <div id="webhook-modal" class="modal-overlay">
1930
+ <div class="modal">
1931
+ <h3>Configure Webhook for <span id="modal-agent-name"></span></h3>
1932
+ <p style="color: #9ca3af; font-size: 14px; margin-bottom: 16px;">
1933
+ When messages or queue updates are ready, agentgate will POST to this URL.
1934
+ </p>
1935
+ <form id="webhook-form">
1936
+ <input type="hidden" id="webhook-agent-id" name="id">
1937
+ <label for="webhook-url">Webhook URL</label>
1938
+ <input type="url" id="webhook-url" name="webhook_url" placeholder="https://your-agent-gateway.com/webhook">
1939
+ <p class="help-text">The endpoint that will receive POST notifications</p>
1940
+
1941
+ <label for="webhook-token">Authorization Token (optional)</label>
1942
+ <input type="text" id="webhook-token" name="webhook_token" placeholder="secret-token">
1943
+ <p class="help-text">Sent as Bearer token in Authorization header</p>
1944
+
1945
+ <div class="modal-buttons">
1946
+ <button type="button" class="btn-secondary" onclick="closeWebhookModal()">Cancel</button>
1947
+ <button type="submit" class="btn-primary">Save Webhook</button>
1948
+ </div>
1949
+ </form>
1950
+ </div>
1951
+ </div>
1952
+
1354
1953
  <script>
1355
1954
  function copyKey(key, btn) {
1356
1955
  navigator.clipboard.writeText(key).then(() => {
@@ -1360,6 +1959,56 @@ function renderKeysPage(keys, error = null, newKey = null) {
1360
1959
  });
1361
1960
  }
1362
1961
 
1962
+ function showWebhookModal(btn) {
1963
+ document.getElementById('webhook-agent-id').value = btn.dataset.id;
1964
+ document.getElementById('modal-agent-name').textContent = btn.dataset.name;
1965
+ document.getElementById('webhook-url').value = btn.dataset.url;
1966
+ document.getElementById('webhook-token').value = btn.dataset.token;
1967
+ document.getElementById('webhook-modal').classList.add('active');
1968
+ }
1969
+
1970
+ function closeWebhookModal() {
1971
+ document.getElementById('webhook-modal').classList.remove('active');
1972
+ }
1973
+
1974
+ // Attach click handlers to webhook buttons
1975
+ document.querySelectorAll('.webhook-btn').forEach(btn => {
1976
+ btn.addEventListener('click', () => showWebhookModal(btn));
1977
+ });
1978
+
1979
+ document.getElementById('webhook-form').addEventListener('submit', async (e) => {
1980
+ e.preventDefault();
1981
+ const id = document.getElementById('webhook-agent-id').value;
1982
+ const webhookUrl = document.getElementById('webhook-url').value;
1983
+ const webhookToken = document.getElementById('webhook-token').value;
1984
+
1985
+ try {
1986
+ const res = await fetch('/ui/keys/' + id + '/webhook', {
1987
+ method: 'POST',
1988
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
1989
+ body: JSON.stringify({ webhook_url: webhookUrl, webhook_token: webhookToken })
1990
+ });
1991
+ const data = await res.json();
1992
+
1993
+ if (data.success) {
1994
+ closeWebhookModal();
1995
+ // Reload to show updated status
1996
+ window.location.reload();
1997
+ } else {
1998
+ alert(data.error || 'Failed to save webhook');
1999
+ }
2000
+ } catch (err) {
2001
+ alert('Error: ' + err.message);
2002
+ }
2003
+ });
2004
+
2005
+ // Close modal on overlay click
2006
+ document.getElementById('webhook-modal').addEventListener('click', (e) => {
2007
+ if (e.target.classList.contains('modal-overlay')) {
2008
+ closeWebhookModal();
2009
+ }
2010
+ });
2011
+
1363
2012
  async function deleteKey(id) {
1364
2013
  if (!confirm('Delete this API key? Any agents using it will lose access.')) return;
1365
2014
 
@@ -1383,7 +2032,7 @@ function renderKeysPage(keys, error = null, newKey = null) {
1383
2032
  if (count === 0) {
1384
2033
  const table = document.querySelector('.keys-table');
1385
2034
  if (table) {
1386
- table.outerHTML = '<p style="color: var(--gray-500); text-align: center; padding: 20px;">No API keys yet. Create one above.</p>';
2035
+ table.outerHTML = '<p style="color: #9ca3af; text-align: center; padding: 20px;">No API keys yet. Create one above.</p>';
1387
2036
  }
1388
2037
  }
1389
2038
  } else {