agentgate 0.1.8 → 0.2.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/README.md CHANGED
@@ -109,7 +109,31 @@ curl -H "Authorization: Bearer rms_your_key" \
109
109
  https://your-server.com/api/skill > SKILL.md
110
110
  ```
111
111
 
112
+ ### Webhook Notifications
113
+
114
+ agentgate can notify your agent when queue items are completed, failed, or rejected — closing the feedback loop so your agent knows immediately when requests are processed.
115
+
116
+ **Setup in Admin UI:**
117
+ 1. Go to **Advanced → Clawdbot Notifications**
118
+ 2. Enter your OpenClaw/Clawdbot webhook URL (e.g., `https://your-gateway.example.com/hooks/wake`)
119
+ 3. Enter the hooks token from your OpenClaw config
120
+ 4. Choose events: `completed`, `failed`, `rejected`
121
+
122
+ **Features:**
123
+ - **Real-time notifications** — Instant webhook on each approval/rejection
124
+ - **Notification status** — See ✓ notified / ⚠ failed on each queue entry
125
+ - **Retry individual** — Resend failed notifications one at a time
126
+ - **Batch catch-up** — "Retry All" sends missed notifications in one batched message
127
+
128
+ **Webhook payload:**
129
+ ```json
130
+ {
131
+ "text": "✅ [agentgate] Queue #abc123 completed\n→ github/monteslu\n→ https://github.com/...\nOriginal: \"Create PR for fix\"",
132
+ "mode": "now"
133
+ }
134
+ ```
112
135
 
136
+ Compatible with OpenClaw's `/hooks/wake` endpoint. See [OpenClaw webhook docs](https://docs.openclaw.ai/automation/webhook).
113
137
 
114
138
  ## API Key Management
115
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgate",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "API gateway for AI agents with human-in-the-loop write approval",
6
6
  "main": "src/index.js",
package/public/style.css CHANGED
@@ -582,3 +582,12 @@ body > p:first-of-type {
582
582
  background: rgba(99, 102, 241, 0.3);
583
583
  color: white;
584
584
  }
585
+
586
+ /* Success message */
587
+ .success-message {
588
+ background: rgba(16, 185, 129, 0.15);
589
+ color: #34d399;
590
+ padding: 12px 16px;
591
+ border-radius: 8px;
592
+ border: 1px solid rgba(16, 185, 129, 0.3);
593
+ }
package/src/index.js CHANGED
@@ -2,7 +2,7 @@ import express from 'express';
2
2
  import cookieParser from 'cookie-parser';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
- import { validateApiKey, getAccountsByService, getCookieSecret } from './lib/db.js';
5
+ import { validateApiKey, getAccountsByService, getCookieSecret, getSetting } from './lib/db.js';
6
6
  import { connectHsync } from './lib/hsyncManager.js';
7
7
  import githubRoutes, { serviceInfo as githubInfo } from './routes/github.js';
8
8
  import blueskyRoutes, { serviceInfo as blueskyInfo } from './routes/bluesky.js';
@@ -126,7 +126,7 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
126
126
  description: 'For write operations (POST/PUT/DELETE), you must submit requests to the write queue. A human will review and approve or reject your request. You cannot execute write operations directly.',
127
127
  workflow: [
128
128
  '1. Submit your write request(s) with a comment explaining your intent',
129
- '2. Poll the status endpoint to check if approved/rejected',
129
+ '2. Wait for webhook notification OR poll the status endpoint',
130
130
  '3. If rejected, check rejection_reason and adjust your approach',
131
131
  '4. If approved and completed, results contain the API responses',
132
132
  '5. If failed, results show which request failed and why'
@@ -205,6 +205,27 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
205
205
  }
206
206
  }
207
207
  },
208
+ notifications: (() => {
209
+ const config = getSetting('notifications');
210
+ const enabled = config?.clawdbot?.enabled || false;
211
+ return {
212
+ enabled,
213
+ description: enabled
214
+ ? 'Webhook notifications are ENABLED. You will receive automatic notifications when queue items complete, fail, or are rejected. No need to poll.'
215
+ : 'Webhook notifications are NOT configured. You must poll the status endpoint to check request status.',
216
+ setup: 'Configure in Admin UI under Advanced → Clawdbot Notifications',
217
+ webhookFormat: {
218
+ description: 'POST to your webhook URL with JSON body',
219
+ payload: {
220
+ text: 'Notification message with status, service, result URL, and original comment',
221
+ mode: 'now'
222
+ },
223
+ example: '✅ [agentgate] Queue #abc123 completed\\n→ github/monteslu\\n→ https://github.com/...\\nOriginal: "Create PR"'
224
+ },
225
+ events: enabled ? (config.clawdbot.events || ['completed', 'failed']) : ['completed', 'failed', 'rejected'],
226
+ compatible: 'OpenClaw/Clawdbot /hooks/wake endpoint'
227
+ };
228
+ })(),
208
229
  skill: {
209
230
  description: 'Generate a SKILL.md file for OpenClaw/AgentSkills compatible systems',
210
231
  endpoint: 'GET /api/skill',
package/src/lib/db.js CHANGED
@@ -42,10 +42,34 @@ db.exec(`
42
42
  submitted_by TEXT,
43
43
  submitted_at TEXT DEFAULT CURRENT_TIMESTAMP,
44
44
  reviewed_at TEXT,
45
- completed_at TEXT
45
+ completed_at TEXT,
46
+ notified INTEGER DEFAULT 0,
47
+ notified_at TEXT,
48
+ notify_error TEXT
46
49
  );
47
50
  `);
48
51
 
52
+ // Migrate write_queue table to add notification columns
53
+ try {
54
+ const queueInfo = db.prepare('PRAGMA table_info(write_queue)').all();
55
+ const hasNotified = queueInfo.some(col => col.name === 'notified');
56
+
57
+ if (queueInfo.length > 0 && !hasNotified) {
58
+ console.log('Migrating write_queue table to add notification columns...');
59
+ db.exec(`
60
+ ALTER TABLE write_queue ADD COLUMN notified INTEGER DEFAULT 0;
61
+ ALTER TABLE write_queue ADD COLUMN notified_at TEXT;
62
+ ALTER TABLE write_queue ADD COLUMN notify_error TEXT;
63
+ `);
64
+ console.log('Migration complete.');
65
+ }
66
+ } catch (err) {
67
+ // Columns might already exist or table doesn't exist yet
68
+ if (!err.message.includes('duplicate column')) {
69
+ console.error('Error migrating write_queue:', err.message);
70
+ }
71
+ }
72
+
49
73
  // Initialize api_keys table with migration support for old schema
50
74
  // Old schema had: id, name, key, created_at
51
75
  // New schema has: id, name, key_prefix, key_hash, created_at
@@ -228,7 +252,8 @@ export function getQueueEntry(id) {
228
252
  return {
229
253
  ...row,
230
254
  requests: JSON.parse(row.requests),
231
- results: row.results ? JSON.parse(row.results) : null
255
+ results: row.results ? JSON.parse(row.results) : null,
256
+ notified: Boolean(row.notified)
232
257
  };
233
258
  }
234
259
 
@@ -242,7 +267,39 @@ export function listQueueEntries(status) {
242
267
  return rows.map(row => ({
243
268
  ...row,
244
269
  requests: JSON.parse(row.requests),
245
- results: row.results ? JSON.parse(row.results) : null
270
+ results: row.results ? JSON.parse(row.results) : null,
271
+ notified: Boolean(row.notified)
272
+ }));
273
+ }
274
+
275
+ export function updateQueueNotification(id, success, error = null) {
276
+ if (success) {
277
+ db.prepare(`
278
+ UPDATE write_queue
279
+ SET notified = 1, notified_at = CURRENT_TIMESTAMP, notify_error = NULL
280
+ WHERE id = ?
281
+ `).run(id);
282
+ } else {
283
+ db.prepare(`
284
+ UPDATE write_queue
285
+ SET notified = 0, notify_error = ?
286
+ WHERE id = ?
287
+ `).run(error, id);
288
+ }
289
+ }
290
+
291
+ export function listUnnotifiedEntries() {
292
+ const rows = db.prepare(`
293
+ SELECT * FROM write_queue
294
+ WHERE status IN ('completed', 'failed', 'rejected')
295
+ AND notified = 0
296
+ ORDER BY completed_at DESC
297
+ `).all();
298
+ return rows.map(row => ({
299
+ ...row,
300
+ requests: JSON.parse(row.requests),
301
+ results: row.results ? JSON.parse(row.results) : null,
302
+ notified: false
246
303
  }));
247
304
  }
248
305
 
@@ -0,0 +1,286 @@
1
+ import { getSetting, updateQueueNotification } from './db.js';
2
+
3
+ /**
4
+ * Send a notification to Clawdbot webhook when queue items complete/fail
5
+ */
6
+
7
+ const DEFAULT_RETRY_ATTEMPTS = 3;
8
+ const DEFAULT_RETRY_DELAY_MS = 5000;
9
+
10
+ /**
11
+ * Find the most relevant result to display (usually last one with a URL, or first failure)
12
+ */
13
+ function findRelevantResult(results, forError = false) {
14
+ if (!results?.length) return null;
15
+
16
+ if (forError) {
17
+ // For errors, find the first non-ok result
18
+ return results.find(r => !r.ok) || results[results.length - 1];
19
+ }
20
+
21
+ // For success, find the last result with a useful URL (PR, issue, etc.)
22
+ // Search backwards to get the most relevant one (e.g., PR URL, not branch ref)
23
+ for (let i = results.length - 1; i >= 0; i--) {
24
+ const r = results[i];
25
+ if (r.body?.html_url || r.body?.url) {
26
+ return r;
27
+ }
28
+ }
29
+
30
+ // Fallback to last result
31
+ return results[results.length - 1];
32
+ }
33
+
34
+ /**
35
+ * Format the notification text for a queue entry
36
+ */
37
+ function formatNotification(entry) {
38
+ const emoji = entry.status === 'completed' ? '✅' :
39
+ entry.status === 'failed' ? '❌' : '🚫';
40
+
41
+ let text = `${emoji} [agentgate] Queue #${entry.id} ${entry.status}`;
42
+ text += `\n→ ${entry.service}/${entry.account_name}`;
43
+
44
+ // Include key result info (e.g., PR URL, issue URL)
45
+ if (entry.status === 'completed' && entry.results?.length) {
46
+ const relevantResult = findRelevantResult(entry.results, false);
47
+ if (relevantResult?.body) {
48
+ // GitHub PR/Issue
49
+ if (relevantResult.body.html_url) {
50
+ text += `\n→ ${relevantResult.body.html_url}`;
51
+ }
52
+ // Other useful fields
53
+ else if (relevantResult.body.url) {
54
+ text += `\n→ ${relevantResult.body.url}`;
55
+ }
56
+ }
57
+ }
58
+
59
+ // Include error info for failures
60
+ if (entry.status === 'failed' && entry.results?.length) {
61
+ const failingResult = findRelevantResult(entry.results, true);
62
+ if (failingResult.error) {
63
+ text += `\n→ Error: ${failingResult.error}`;
64
+ } else if (failingResult.body?.message) {
65
+ text += `\n→ Error: ${failingResult.body.message}`;
66
+ } else if (!failingResult.ok && failingResult.status) {
67
+ text += `\n→ Error: HTTP ${failingResult.status}`;
68
+ }
69
+ }
70
+
71
+ // Include rejection reason
72
+ if (entry.status === 'rejected' && entry.rejection_reason) {
73
+ text += `\n→ Reason: ${entry.rejection_reason}`;
74
+ }
75
+
76
+ // Original comment/intent
77
+ if (entry.comment) {
78
+ // Truncate long comments
79
+ const comment = entry.comment.length > 100
80
+ ? entry.comment.substring(0, 100) + '...'
81
+ : entry.comment;
82
+ text += `\nOriginal: "${comment}"`;
83
+ }
84
+
85
+ return text;
86
+ }
87
+
88
+ /**
89
+ * Sleep helper
90
+ */
91
+ function sleep(ms) {
92
+ return new Promise(resolve => setTimeout(resolve, ms));
93
+ }
94
+
95
+ /**
96
+ * Send notification to Clawdbot
97
+ * @param {object} entry - The queue entry to notify about
98
+ * @returns {Promise<{success: boolean, error?: string}>}
99
+ */
100
+ export async function notifyClawdbot(entry) {
101
+ const config = getSetting('notifications');
102
+
103
+ if (!config?.clawdbot?.enabled) {
104
+ return { success: true, skipped: true };
105
+ }
106
+
107
+ const { url, token, events, retryAttempts, retryDelayMs } = config.clawdbot;
108
+
109
+ // Check if this event type should be notified
110
+ const allowedEvents = events || ['completed', 'failed'];
111
+ if (!allowedEvents.includes(entry.status)) {
112
+ return { success: true, skipped: true };
113
+ }
114
+
115
+ if (!url || !token) {
116
+ const error = 'Clawdbot notification config incomplete (missing url or token)';
117
+ updateQueueNotification(entry.id, false, error);
118
+ return { success: false, error };
119
+ }
120
+
121
+ const payload = {
122
+ text: formatNotification(entry),
123
+ mode: 'now'
124
+ };
125
+
126
+ const maxAttempts = retryAttempts || DEFAULT_RETRY_ATTEMPTS;
127
+ const delay = retryDelayMs || DEFAULT_RETRY_DELAY_MS;
128
+ let lastError = null;
129
+
130
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
131
+ try {
132
+ const response = await fetch(url, {
133
+ method: 'POST',
134
+ headers: {
135
+ 'Authorization': `Bearer ${token}`,
136
+ 'Content-Type': 'application/json'
137
+ },
138
+ body: JSON.stringify(payload)
139
+ });
140
+
141
+ if (response.ok) {
142
+ // Success
143
+ updateQueueNotification(entry.id, true, null);
144
+ return { success: true };
145
+ }
146
+
147
+ // Non-OK response
148
+ const text = await response.text().catch(() => '');
149
+ lastError = `HTTP ${response.status}: ${text.substring(0, 100)}`;
150
+
151
+ } catch (err) {
152
+ lastError = err.message;
153
+ }
154
+
155
+ // Retry after delay (unless last attempt)
156
+ if (attempt < maxAttempts) {
157
+ await sleep(delay);
158
+ }
159
+ }
160
+
161
+ // All retries failed
162
+ updateQueueNotification(entry.id, false, lastError);
163
+ return { success: false, error: lastError };
164
+ }
165
+
166
+ /**
167
+ * Retry notification for a specific queue entry
168
+ */
169
+ export async function retryNotification(entryId, getQueueEntry) {
170
+ const entry = getQueueEntry(entryId);
171
+ if (!entry) {
172
+ return { success: false, error: 'Queue entry not found' };
173
+ }
174
+
175
+ // Only retry for completed/failed/rejected entries
176
+ if (!['completed', 'failed', 'rejected'].includes(entry.status)) {
177
+ return { success: false, error: 'Can only notify for completed/failed/rejected entries' };
178
+ }
179
+
180
+ return notifyClawdbot(entry);
181
+ }
182
+
183
+ /**
184
+ * Format a short summary line for batch notifications
185
+ */
186
+ function formatBatchLine(entry) {
187
+ const emoji = entry.status === 'completed' ? '✅' :
188
+ entry.status === 'failed' ? '❌' : '🚫';
189
+
190
+ let line = `${emoji} #${entry.id.substring(0, 8)} - ${entry.service}/${entry.account_name}`;
191
+
192
+ // Add brief result info
193
+ if (entry.status === 'completed') {
194
+ const relevantResult = findRelevantResult(entry.results, false);
195
+ if (relevantResult?.body?.html_url) {
196
+ line += ` - ${relevantResult.body.html_url}`;
197
+ }
198
+ } else if (entry.status === 'failed') {
199
+ const failingResult = findRelevantResult(entry.results, true);
200
+ const err = failingResult?.error || failingResult?.body?.message || (failingResult && !failingResult.ok ? `HTTP ${failingResult.status || '?'}` : 'Unknown error');
201
+ line += ` - ${err.substring(0, 50)}`;
202
+ } else if (entry.status === 'rejected') {
203
+ line += ` - ${(entry.rejection_reason || 'rejected').substring(0, 50)}`;
204
+ }
205
+
206
+ return line;
207
+ }
208
+
209
+ /**
210
+ * Send a single batched notification for multiple queue entries
211
+ * @param {object[]} entries - Array of queue entries to notify about
212
+ * @returns {Promise<{success: boolean, error?: string}>}
213
+ */
214
+ export async function notifyClawdbotBatch(entries) {
215
+ const config = getSetting('notifications');
216
+
217
+ if (!config?.clawdbot?.enabled) {
218
+ return { success: true, skipped: true };
219
+ }
220
+
221
+ const { url, token, retryAttempts, retryDelayMs } = config.clawdbot;
222
+
223
+ if (!url || !token) {
224
+ const error = 'Clawdbot notification config incomplete (missing url or token)';
225
+ for (const entry of entries) {
226
+ updateQueueNotification(entry.id, false, error);
227
+ }
228
+ return { success: false, error };
229
+ }
230
+
231
+ // Build batched message
232
+ const completed = entries.filter(e => e.status === 'completed').length;
233
+ const failed = entries.filter(e => e.status === 'failed').length;
234
+ const rejected = entries.filter(e => e.status === 'rejected').length;
235
+
236
+ const counts = [];
237
+ if (completed) counts.push(`${completed} completed`);
238
+ if (failed) counts.push(`${failed} failed`);
239
+ if (rejected) counts.push(`${rejected} rejected`);
240
+
241
+ let text = `📬 [agentgate] Catch-up: ${entries.length} item${entries.length > 1 ? 's' : ''} (${counts.join(', ')})\n\n`;
242
+ text += entries.map(formatBatchLine).join('\n');
243
+
244
+ const payload = { text, mode: 'now' };
245
+
246
+ const maxAttempts = retryAttempts || DEFAULT_RETRY_ATTEMPTS;
247
+ const delay = retryDelayMs || DEFAULT_RETRY_DELAY_MS;
248
+ let lastError = null;
249
+
250
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
251
+ try {
252
+ const response = await fetch(url, {
253
+ method: 'POST',
254
+ headers: {
255
+ 'Authorization': `Bearer ${token}`,
256
+ 'Content-Type': 'application/json'
257
+ },
258
+ body: JSON.stringify(payload)
259
+ });
260
+
261
+ if (response.ok) {
262
+ // Success - mark all as notified
263
+ for (const entry of entries) {
264
+ updateQueueNotification(entry.id, true, null);
265
+ }
266
+ return { success: true };
267
+ }
268
+
269
+ const respText = await response.text().catch(() => '');
270
+ lastError = `HTTP ${response.status}: ${respText.substring(0, 100)}`;
271
+
272
+ } catch (err) {
273
+ lastError = err.message;
274
+ }
275
+
276
+ if (attempt < maxAttempts) {
277
+ await sleep(delay);
278
+ }
279
+ }
280
+
281
+ // All retries failed
282
+ for (const entry of entries) {
283
+ updateQueueNotification(entry.id, false, lastError);
284
+ }
285
+ return { success: false, error: lastError };
286
+ }
@@ -1,4 +1,5 @@
1
- import { getAccountCredentials, setAccountCredentials, updateQueueStatus } from './db.js';
1
+ import { getAccountCredentials, setAccountCredentials, updateQueueStatus, getQueueEntry } from './db.js';
2
+ import { notifyClawdbot } from './notifier.js';
2
3
 
3
4
  // Service base URLs
4
5
  const SERVICE_URLS = {
@@ -270,6 +271,17 @@ function buildHeaders(service, token, customHeaders = {}) {
270
271
  return headers;
271
272
  }
272
273
 
274
+ // Helper to update status and send notification
275
+ function finalizeEntry(entryId, status, results) {
276
+ updateQueueStatus(entryId, status, { results });
277
+
278
+ // Send notification to Clawdbot (async, don't block)
279
+ const updatedEntry = getQueueEntry(entryId);
280
+ notifyClawdbot(updatedEntry).catch(err => {
281
+ console.error('[notifier] Failed to notify Clawdbot:', err.message);
282
+ });
283
+ }
284
+
273
285
  // Execute a single queued entry (batch of requests)
274
286
  export async function executeQueueEntry(entry) {
275
287
  const results = [];
@@ -290,7 +302,7 @@ export async function executeQueueEntry(entry) {
290
302
  ok: false,
291
303
  error: `Failed to get access token for ${service}/${account_name}`
292
304
  });
293
- updateQueueStatus(entry.id, 'failed', { results });
305
+ finalizeEntry(entry.id, 'failed', results);
294
306
  return { success: false, results };
295
307
  }
296
308
 
@@ -302,7 +314,7 @@ export async function executeQueueEntry(entry) {
302
314
  ok: false,
303
315
  error: `Unknown service or invalid configuration: ${service}`
304
316
  });
305
- updateQueueStatus(entry.id, 'failed', { results });
317
+ finalizeEntry(entry.id, 'failed', results);
306
318
  return { success: false, results };
307
319
  }
308
320
 
@@ -346,7 +358,7 @@ export async function executeQueueEntry(entry) {
346
358
 
347
359
  // Stop on first failure
348
360
  if (!response.ok) {
349
- updateQueueStatus(entry.id, 'failed', { results });
361
+ finalizeEntry(entry.id, 'failed', results);
350
362
  return { success: false, results };
351
363
  }
352
364
 
@@ -356,12 +368,12 @@ export async function executeQueueEntry(entry) {
356
368
  ok: false,
357
369
  error: err.message
358
370
  });
359
- updateQueueStatus(entry.id, 'failed', { results });
371
+ finalizeEntry(entry.id, 'failed', results);
360
372
  return { success: false, results };
361
373
  }
362
374
  }
363
375
 
364
376
  // All requests succeeded
365
- updateQueueStatus(entry.id, 'completed', { results });
377
+ finalizeEntry(entry.id, 'completed', results);
366
378
  return { success: true, results };
367
379
  }
package/src/routes/ui.js CHANGED
@@ -3,10 +3,12 @@ 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
6
+ listApiKeys, createApiKey, deleteApiKey,
7
+ listUnnotifiedEntries
7
8
  } from '../lib/db.js';
8
9
  import { connectHsync, disconnectHsync, getHsyncUrl, isHsyncConnected } from '../lib/hsyncManager.js';
9
10
  import { executeQueueEntry } from '../lib/queueExecutor.js';
11
+ import { notifyClawdbot, retryNotification } from '../lib/notifier.js';
10
12
  import { registerAllRoutes, renderAllCards } from './ui/index.js';
11
13
 
12
14
  const router = Router();
@@ -121,8 +123,9 @@ router.get('/', (req, res) => {
121
123
  const hsyncUrl = getHsyncUrl();
122
124
  const hsyncConnected = isHsyncConnected();
123
125
  const pendingQueueCount = getPendingQueueCount();
126
+ const notificationsConfig = getSetting('notifications');
124
127
 
125
- res.send(renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount }));
128
+ res.send(renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig }));
126
129
  });
127
130
 
128
131
  // Register all service routes (github, bluesky, reddit, etc.)
@@ -149,6 +152,79 @@ router.post('/hsync/delete', async (req, res) => {
149
152
  res.redirect('/ui');
150
153
  });
151
154
 
155
+ // Notification settings
156
+ router.post('/notifications/setup', (req, res) => {
157
+ const { url, token, events } = req.body;
158
+ if (!url) {
159
+ return res.status(400).send('Webhook URL required');
160
+ }
161
+
162
+ // Parse events - could be comma-separated string or array
163
+ let eventList = ['completed', 'failed'];
164
+ if (events) {
165
+ eventList = Array.isArray(events) ? events : events.split(',').map(e => e.trim());
166
+ }
167
+
168
+ setSetting('notifications', {
169
+ clawdbot: {
170
+ enabled: true,
171
+ url: url.replace(/\/$/, ''),
172
+ token: token || '',
173
+ events: eventList,
174
+ retryAttempts: 3,
175
+ retryDelayMs: 5000
176
+ }
177
+ });
178
+ res.redirect('/ui');
179
+ });
180
+
181
+ router.post('/notifications/delete', (req, res) => {
182
+ deleteSetting('notifications');
183
+ res.redirect('/ui');
184
+ });
185
+
186
+ router.post('/notifications/test', async (req, res) => {
187
+ const wantsJson = req.headers.accept?.includes('application/json');
188
+ const config = getSetting('notifications');
189
+
190
+ if (!config?.clawdbot?.enabled || !config.clawdbot.url || !config.clawdbot.token) {
191
+ const error = 'Notifications not configured';
192
+ return wantsJson
193
+ ? res.status(400).json({ success: false, error })
194
+ : res.status(400).send(error);
195
+ }
196
+
197
+ try {
198
+ const response = await fetch(config.clawdbot.url, {
199
+ method: 'POST',
200
+ headers: {
201
+ 'Authorization': `Bearer ${config.clawdbot.token}`,
202
+ 'Content-Type': 'application/json'
203
+ },
204
+ body: JSON.stringify({
205
+ text: '🧪 [agentgate] Test notification - webhook is working!',
206
+ mode: 'now'
207
+ })
208
+ });
209
+
210
+ if (response.ok) {
211
+ return wantsJson
212
+ ? res.json({ success: true })
213
+ : res.redirect('/ui?notification_test=success');
214
+ } else {
215
+ const text = await response.text().catch(() => '');
216
+ const error = `HTTP ${response.status}: ${text.substring(0, 100)}`;
217
+ return wantsJson
218
+ ? res.status(400).json({ success: false, error })
219
+ : res.redirect('/ui?notification_test=failed');
220
+ }
221
+ } catch (err) {
222
+ return wantsJson
223
+ ? res.status(500).json({ success: false, error: err.message })
224
+ : res.redirect('/ui?notification_test=failed');
225
+ }
226
+ });
227
+
152
228
  // Write Queue Management
153
229
  router.get('/queue', (req, res) => {
154
230
  const filter = req.query.filter || 'all';
@@ -159,7 +235,8 @@ router.get('/queue', (req, res) => {
159
235
  entries = listQueueEntries(filter);
160
236
  }
161
237
  const counts = getQueueCounts();
162
- res.send(renderQueuePage(entries, filter, counts));
238
+ const unnotified = listUnnotifiedEntries();
239
+ res.send(renderQueuePage(entries, filter, counts, unnotified.length));
163
240
  });
164
241
 
165
242
  router.post('/queue/:id/approve', async (req, res) => {
@@ -196,7 +273,7 @@ router.post('/queue/:id/approve', async (req, res) => {
196
273
  res.redirect('/ui/queue');
197
274
  });
198
275
 
199
- router.post('/queue/:id/reject', (req, res) => {
276
+ router.post('/queue/:id/reject', async (req, res) => {
200
277
  const { id } = req.params;
201
278
  const { reason } = req.body;
202
279
  const wantsJson = req.headers.accept?.includes('application/json');
@@ -216,7 +293,12 @@ router.post('/queue/:id/reject', (req, res) => {
216
293
 
217
294
  updateQueueStatus(id, 'rejected', { rejection_reason: reason || 'No reason provided' });
218
295
 
296
+ // Send notification to Clawdbot
219
297
  const updated = getQueueEntry(id);
298
+ notifyClawdbot(updated).catch(err => {
299
+ console.error('[notifier] Failed to notify Clawdbot:', err.message);
300
+ });
301
+
220
302
  const counts = getQueueCounts();
221
303
 
222
304
  if (wantsJson) {
@@ -266,6 +348,41 @@ router.delete('/queue/:id', (req, res) => {
266
348
  res.redirect('/ui/queue');
267
349
  });
268
350
 
351
+ // Retry notification for a specific queue entry
352
+ router.post('/queue/:id/notify', async (req, res) => {
353
+ const { id } = req.params;
354
+ const wantsJson = req.headers.accept?.includes('application/json');
355
+
356
+ const result = await retryNotification(id, getQueueEntry);
357
+ const updated = getQueueEntry(id);
358
+
359
+ if (wantsJson) {
360
+ return res.json({ success: result.success, error: result.error, entry: updated });
361
+ }
362
+ res.redirect('/ui/queue');
363
+ });
364
+
365
+ // Retry all failed notifications
366
+ router.post('/queue/notify-all', async (req, res) => {
367
+ const wantsJson = req.headers.accept?.includes('application/json');
368
+ const unnotified = listUnnotifiedEntries();
369
+
370
+ if (unnotified.length === 0) {
371
+ return wantsJson
372
+ ? res.json({ success: true, count: 0 })
373
+ : res.redirect('/ui/queue');
374
+ }
375
+
376
+ // Batch into single notification
377
+ const { notifyClawdbotBatch } = await import('../lib/notifier.js');
378
+ const result = await notifyClawdbotBatch(unnotified);
379
+
380
+ if (wantsJson) {
381
+ return res.json({ success: result.success, error: result.error, count: unnotified.length });
382
+ }
383
+ res.redirect('/ui/queue');
384
+ });
385
+
269
386
  // API Keys Management
270
387
  router.get('/keys', (req, res) => {
271
388
  const keys = listApiKeys();
@@ -320,7 +437,8 @@ router.delete('/keys/:id', (req, res) => {
320
437
 
321
438
  // HTML Templates
322
439
 
323
- function renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount }) {
440
+ function renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig }) {
441
+ const clawdbotConfig = notificationsConfig?.clawdbot;
324
442
  return `<!DOCTYPE html>
325
443
  <html>
326
444
  <head>
@@ -402,6 +520,64 @@ curl -X POST http://localhost:${PORT}/api/queue/github/personal/submit \\
402
520
  </div>
403
521
  </details>
404
522
  </div>
523
+
524
+ <div class="card">
525
+ <details ${clawdbotConfig?.enabled ? 'open' : ''}>
526
+ <summary>Clawdbot Notifications ${clawdbotConfig?.enabled ? '<span class="status configured">Configured</span>' : ''}</summary>
527
+ <div style="margin-top: 16px;">
528
+ <div id="notification-feedback"></div>
529
+ ${clawdbotConfig?.enabled ? `
530
+ <p>Webhook URL: <strong>${clawdbotConfig.url}</strong></p>
531
+ <p>Events: <code>${(clawdbotConfig.events || ['completed', 'failed']).join(', ')}</code></p>
532
+ <div style="display: flex; gap: 8px; margin-top: 12px;">
533
+ <button type="button" class="btn-primary btn-sm" id="test-notification-btn" onclick="testNotification()">Send Test</button>
534
+ <form method="POST" action="/ui/notifications/delete" style="margin: 0;">
535
+ <button type="submit" class="btn-danger btn-sm">Disable</button>
536
+ </form>
537
+ </div>
538
+ <script>
539
+ async function testNotification() {
540
+ const btn = document.getElementById('test-notification-btn');
541
+ const feedback = document.getElementById('notification-feedback');
542
+ btn.disabled = true;
543
+ btn.textContent = 'Sending...';
544
+ feedback.innerHTML = '';
545
+
546
+ try {
547
+ const res = await fetch('/ui/notifications/test', {
548
+ method: 'POST',
549
+ headers: { 'Accept': 'application/json' }
550
+ });
551
+ const data = await res.json();
552
+
553
+ if (data.success) {
554
+ feedback.innerHTML = '<div class="success-message" style="margin-bottom: 16px;">✓ Test notification sent!</div>';
555
+ } else {
556
+ feedback.innerHTML = '<div class="error-message" style="margin-bottom: 16px;">✗ ' + (data.error || 'Failed to send') + '</div>';
557
+ }
558
+ } catch (err) {
559
+ feedback.innerHTML = '<div class="error-message" style="margin-bottom: 16px;">✗ ' + err.message + '</div>';
560
+ }
561
+
562
+ btn.disabled = false;
563
+ btn.textContent = 'Send Test';
564
+ }
565
+ </script>
566
+ ` : `
567
+ <p class="help">Send notifications to <a href="https://docs.clawd.bot" target="_blank">Clawdbot</a> when queue items are completed, failed, or rejected.</p>
568
+ <form method="POST" action="/ui/notifications/setup">
569
+ <label>Webhook URL</label>
570
+ <input type="text" name="url" placeholder="https://your-gateway.example.com/hooks/wake" required>
571
+ <label>Token</label>
572
+ <input type="password" name="token" placeholder="Clawdbot hooks token" required>
573
+ <label>Events (comma-separated)</label>
574
+ <input type="text" name="events" placeholder="completed, failed, rejected" value="completed, failed, rejected">
575
+ <button type="submit" class="btn-primary">Enable Notifications</button>
576
+ </form>
577
+ `}
578
+ </div>
579
+ </details>
580
+ </div>
405
581
  </body>
406
582
  </html>`;
407
583
  }
@@ -455,7 +631,7 @@ function renderSetupPasswordPage(error = '') {
455
631
  </html>`;
456
632
  }
457
633
 
458
- function renderQueuePage(entries, filter, counts) {
634
+ function renderQueuePage(entries, filter, counts, unnotifiedCount = 0) {
459
635
  const escapeHtml = (str) => {
460
636
  if (typeof str !== 'string') str = JSON.stringify(str, null, 2);
461
637
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -525,8 +701,28 @@ function renderQueuePage(entries, filter, counts) {
525
701
  `;
526
702
  }
527
703
 
704
+ // Notification status (only show for completed/failed/rejected)
705
+ let notificationSection = '';
706
+ if (['completed', 'failed', 'rejected'].includes(entry.status)) {
707
+ const notifyStatus = entry.notified
708
+ ? `<span class="notify-status notify-sent" title="Notified at ${formatDate(entry.notified_at)}">✓ Notified</span>`
709
+ : entry.notify_error
710
+ ? `<span class="notify-status notify-failed" title="${escapeHtml(entry.notify_error)}">⚠ Notify failed</span>`
711
+ : '<span class="notify-status notify-pending">— Not notified</span>';
712
+
713
+ const retryBtn = !entry.notified
714
+ ? `<button type="button" class="btn-sm btn-link" onclick="retryNotify('${entry.id}')" id="retry-${entry.id}">Retry</button>`
715
+ : '';
716
+
717
+ notificationSection = `
718
+ <div class="notification-status" id="notify-status-${entry.id}">
719
+ ${notifyStatus} ${retryBtn}
720
+ </div>
721
+ `;
722
+ }
723
+
528
724
  return `
529
- <div class="card queue-entry" id="entry-${entry.id}" data-status="${entry.status}">
725
+ <div class="card queue-entry" id="entry-${entry.id}" data-status="${entry.status}" data-notified="${entry.notified ? '1' : '0'}">
530
726
  <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
531
727
  <div class="entry-header">
532
728
  <strong>${entry.service}</strong> / ${entry.account_name}
@@ -552,6 +748,7 @@ function renderQueuePage(entries, filter, counts) {
552
748
  </details>
553
749
 
554
750
  ${resultSection}
751
+ ${notificationSection}
555
752
  ${actions}
556
753
  </div>
557
754
  `;
@@ -704,6 +901,46 @@ function renderQueuePage(entries, filter, counts) {
704
901
  padding: 60px 40px;
705
902
  }
706
903
  .empty-state p { color: #6b7280; margin: 0; font-size: 16px; }
904
+ .notification-status {
905
+ margin-top: 12px;
906
+ padding-top: 12px;
907
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
908
+ display: flex;
909
+ align-items: center;
910
+ gap: 12px;
911
+ font-size: 13px;
912
+ }
913
+ .notify-status {
914
+ padding: 4px 10px;
915
+ border-radius: 6px;
916
+ font-weight: 500;
917
+ }
918
+ .notify-sent {
919
+ background: rgba(16, 185, 129, 0.15);
920
+ color: #34d399;
921
+ border: 1px solid rgba(16, 185, 129, 0.3);
922
+ }
923
+ .notify-failed {
924
+ background: rgba(245, 158, 11, 0.15);
925
+ color: #fbbf24;
926
+ border: 1px solid rgba(245, 158, 11, 0.3);
927
+ }
928
+ .notify-pending {
929
+ background: rgba(156, 163, 175, 0.15);
930
+ color: #9ca3af;
931
+ border: 1px solid rgba(156, 163, 175, 0.3);
932
+ }
933
+ .btn-link {
934
+ background: none;
935
+ border: none;
936
+ color: #818cf8;
937
+ cursor: pointer;
938
+ text-decoration: underline;
939
+ padding: 4px 8px;
940
+ font-size: 13px;
941
+ }
942
+ .btn-link:hover { color: #a5b4fc; }
943
+ .btn-link:disabled { color: #6b7280; cursor: not-allowed; text-decoration: none; }
707
944
  </style>
708
945
  </head>
709
946
  <body>
@@ -716,6 +953,7 @@ function renderQueuePage(entries, filter, counts) {
716
953
  <div class="filter-bar" id="filter-bar">
717
954
  ${filterLinks}
718
955
  <div class="clear-section">
956
+ ${unnotifiedCount > 0 ? `<button type="button" class="btn-sm btn-primary" onclick="retryAllNotifications()" id="retry-all-btn">Retry ${unnotifiedCount} Notification${unnotifiedCount > 1 ? 's' : ''}</button>` : ''}
719
957
  ${filter === 'completed' && counts.completed > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'completed\')">Clear Completed</button>' : ''}
720
958
  ${filter === 'failed' && counts.failed > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'failed\')">Clear Failed</button>' : ''}
721
959
  ${filter === 'rejected' && counts.rejected > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'rejected\')">Clear Rejected</button>' : ''}
@@ -894,6 +1132,9 @@ function renderQueuePage(entries, filter, counts) {
894
1132
 
895
1133
  // Hide the clear button if nothing left to clear
896
1134
  btn.style.display = 'none';
1135
+
1136
+ // Update retry notifications button
1137
+ updateRetryAllButton();
897
1138
  }
898
1139
 
899
1140
  btn.disabled = false;
@@ -930,6 +1171,89 @@ function renderQueuePage(entries, filter, counts) {
930
1171
  alert('Error: ' + err.message);
931
1172
  }
932
1173
  }
1174
+
1175
+ function updateRetryAllButton() {
1176
+ // Count remaining unnotified entries in DOM
1177
+ const unnotified = document.querySelectorAll('.queue-entry[data-notified="0"]').length;
1178
+ const btn = document.getElementById('retry-all-btn');
1179
+
1180
+ if (unnotified === 0) {
1181
+ if (btn) btn.remove();
1182
+ } else if (btn) {
1183
+ btn.textContent = 'Retry ' + unnotified + ' Notification' + (unnotified > 1 ? 's' : '');
1184
+ }
1185
+ }
1186
+
1187
+ async function retryAllNotifications() {
1188
+ const btn = document.getElementById('retry-all-btn');
1189
+ if (btn) {
1190
+ btn.disabled = true;
1191
+ btn.textContent = 'Sending...';
1192
+ }
1193
+
1194
+ try {
1195
+ const res = await fetch('/ui/queue/notify-all', {
1196
+ method: 'POST',
1197
+ headers: { 'Accept': 'application/json' }
1198
+ });
1199
+ const data = await res.json();
1200
+
1201
+ if (data.success) {
1202
+ // Refresh the page to show updated status
1203
+ window.location.reload();
1204
+ } else {
1205
+ alert(data.error || 'Failed to send notifications');
1206
+ if (btn) {
1207
+ btn.disabled = false;
1208
+ btn.textContent = 'Retry Notifications';
1209
+ }
1210
+ }
1211
+ } catch (err) {
1212
+ alert('Error: ' + err.message);
1213
+ if (btn) {
1214
+ btn.disabled = false;
1215
+ btn.textContent = 'Retry Notifications';
1216
+ }
1217
+ }
1218
+ }
1219
+
1220
+ async function retryNotify(id) {
1221
+ const btn = document.getElementById('retry-' + id);
1222
+ if (btn) {
1223
+ btn.disabled = true;
1224
+ btn.textContent = 'Sending...';
1225
+ }
1226
+
1227
+ try {
1228
+ const res = await fetch('/ui/queue/' + id + '/notify', {
1229
+ method: 'POST',
1230
+ headers: { 'Accept': 'application/json' }
1231
+ });
1232
+ const data = await res.json();
1233
+
1234
+ const statusEl = document.getElementById('notify-status-' + id);
1235
+ if (data.success && data.entry?.notified) {
1236
+ // Update to show success
1237
+ if (statusEl) {
1238
+ statusEl.innerHTML = '<span class="notify-status notify-sent">✓ Notified</span>';
1239
+ }
1240
+ const entryEl = document.getElementById('entry-' + id);
1241
+ if (entryEl) entryEl.dataset.notified = '1';
1242
+ } else {
1243
+ // Show error
1244
+ const error = data.error || 'Failed to send';
1245
+ if (statusEl) {
1246
+ statusEl.innerHTML = '<span class="notify-status notify-failed" title="' + escapeHtml(error) + '">⚠ Notify failed</span> <button type="button" class="btn-sm btn-link" onclick="retryNotify(\\''+id+'\\')">Retry</button>';
1247
+ }
1248
+ }
1249
+ } catch (err) {
1250
+ alert('Error: ' + err.message);
1251
+ if (btn) {
1252
+ btn.disabled = false;
1253
+ btn.textContent = 'Retry';
1254
+ }
1255
+ }
1256
+ }
933
1257
  </script>
934
1258
  </body>
935
1259
  </html>`;