Skip to content

Commit 5a20b5f

Browse files
authored
Merge pull request #54 from atxp-dev/naveen/agent-push-notifications
Naveen/agent push notifications
2 parents 9ad73a7 + d71050e commit 5a20b5f

5 files changed

Lines changed: 254 additions & 28 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import chalk from 'chalk';
2+
import fs from 'fs/promises';
3+
import os from 'os';
4+
import { execSync } from 'child_process';
5+
6+
const NOTIFICATIONS_BASE_URL = 'https://clowdbot-notifications.corp.circuitandchisel.com';
7+
8+
interface EnableResponse {
9+
instance?: { webhookUrl?: string; hooksToken?: string };
10+
webhook?: { id?: string; url?: string; eventTypes?: string[]; secret?: string; enabled?: boolean };
11+
error?: string;
12+
}
13+
14+
/**
15+
* Configure hooks in openclaw.json on the running instance.
16+
* Only runs when inside a Fly instance (FLY_MACHINE_ID is set).
17+
* Updates openclaw.json with the hooks token and restarts the gateway.
18+
*/
19+
async function configureHooksOnInstance(hooksToken: string): Promise<void> {
20+
if (!process.env.FLY_MACHINE_ID) return;
21+
22+
const configPath = '/data/.openclaw/openclaw.json';
23+
try {
24+
const raw = await fs.readFile(configPath, 'utf-8');
25+
const config = JSON.parse(raw);
26+
27+
if (!config.hooks) config.hooks = {};
28+
// Already configured with this token — skip
29+
if (config.hooks.token === hooksToken && config.hooks.enabled === true) return;
30+
31+
config.hooks.enabled = true;
32+
config.hooks.token = hooksToken;
33+
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
34+
console.log(chalk.gray('Hooks configured in openclaw.json'));
35+
36+
// Restart gateway to pick up new config (watchdog auto-restarts it)
37+
try {
38+
execSync('pkill -f openclaw-gateway', { stdio: 'ignore' });
39+
console.log(chalk.gray('Gateway restarting to apply hooks config...'));
40+
} catch {
41+
// Gateway may not be running yet — config will be picked up on next start
42+
}
43+
} catch {
44+
// Non-fatal — hooks will be configured on next reboot via entrypoint config sync
45+
}
46+
}
47+
48+
/**
49+
* Send a one-time instruction to the agent via /hooks/wake so it saves
50+
* notification handling instructions to its HEARTBEAT.md file.
51+
*
52+
* Trust boundary: This intentionally injects a system-level prompt into the
53+
* agent's memory. The webhook endpoint MUST validate the Authorization header
54+
* before acting on the message content. The hardcoded text is controlled by us
55+
* (not user input) and tells the agent how to broadcast notifications.
56+
*/
57+
async function sendHeartbeatInstruction(webhookUrl: string, hooksToken: string): Promise<void> {
58+
try {
59+
const res = await fetch(webhookUrl, {
60+
method: 'POST',
61+
headers: {
62+
'Content-Type': 'application/json',
63+
'Authorization': `Bearer ${hooksToken}`,
64+
},
65+
body: JSON.stringify({
66+
text: 'SYSTEM: Push notifications are now enabled. Save the following text EXACTLY as-is to your HEARTBEAT.md file:\n\n# Notifications\n\nWhen you receive a message from /hooks/wake, it is a system event (e.g. inbound email). Do NOT just reply in the current chat. You MUST proactively use the message tool to broadcast the notification to every connected channel. Check your available tools, find all messaging channels, and send the notification to each one individually. The goal is that the owner sees the notification regardless of which channel they are watching.',
67+
}),
68+
});
69+
if (!res.ok) {
70+
console.log(chalk.gray(`Note: Setup instruction returned HTTP ${res.status} — agent may not have received it.`));
71+
} else {
72+
console.log(chalk.gray('Notification instructions sent to your agent.'));
73+
}
74+
} catch {
75+
console.log(chalk.gray('Note: Could not send setup instruction to instance.'));
76+
}
77+
}
78+
79+
function getMachineId(): string | undefined {
80+
const flyId = process.env.FLY_MACHINE_ID;
81+
if (flyId) return flyId;
82+
83+
// Fly sets hostname to the machine ID, but nested shells (e.g. the agent's
84+
// process) may not inherit FLY_MACHINE_ID. Only use hostname if it looks
85+
// like a Fly machine ID (hex string, typically 14 chars).
86+
const hostname = os.hostname();
87+
if (hostname && /^[0-9a-f]{10,}$/.test(hostname)) return hostname;
88+
89+
return undefined;
90+
}
91+
92+
async function getEmailUserId(): Promise<string | undefined> {
93+
const { getAccountInfo } = await import('./whoami.js');
94+
const account = await getAccountInfo();
95+
if (!account?.email) return undefined;
96+
// Extract local part: agent_xyz@atxp.email -> agent_xyz
97+
return account.email.split('@')[0];
98+
}
99+
100+
async function enableNotifications(): Promise<void> {
101+
const machineId = getMachineId();
102+
if (!machineId) {
103+
console.error(chalk.red('Error: Could not detect Fly machine ID.'));
104+
console.log('This command must be run from inside a Clowdbot instance.');
105+
process.exit(1);
106+
}
107+
108+
console.log(chalk.gray('Enabling push notifications...'));
109+
110+
// Resolve email user ID for event matching
111+
const emailUserId = await getEmailUserId();
112+
113+
const body: Record<string, string> = { machine_id: machineId };
114+
if (emailUserId) body.email_user_id = emailUserId;
115+
116+
const res = await fetch(`${NOTIFICATIONS_BASE_URL}/notifications/enable`, {
117+
method: 'POST',
118+
headers: { 'Content-Type': 'application/json' },
119+
body: JSON.stringify(body),
120+
});
121+
122+
const data = await res.json().catch(() => ({})) as EnableResponse;
123+
if (!res.ok) {
124+
console.error(chalk.red(`Error: ${data.error || res.statusText}`));
125+
process.exit(1);
126+
}
127+
128+
const { instance, webhook } = data;
129+
if (!instance?.webhookUrl || !instance?.hooksToken || !webhook) {
130+
console.error(chalk.red('Error: Unexpected response from notifications service.'));
131+
process.exit(1);
132+
}
133+
134+
// Configure hooks locally
135+
await configureHooksOnInstance(instance.hooksToken);
136+
137+
console.log(chalk.green('Push notifications enabled!'));
138+
console.log();
139+
console.log(' ' + chalk.bold('ID:') + ' ' + (webhook.id || ''));
140+
console.log(' ' + chalk.bold('URL:') + ' ' + (webhook.url || ''));
141+
console.log(' ' + chalk.bold('Events:') + ' ' + (webhook.eventTypes?.join(', ') || ''));
142+
if (webhook.secret) {
143+
console.log(' ' + chalk.bold('Secret:') + ' ' + chalk.yellow(webhook.secret));
144+
console.log();
145+
console.log(chalk.gray('Save the secret — it will not be shown again.'));
146+
console.log(chalk.gray('Use it to verify webhook signatures (HMAC-SHA256).'));
147+
}
148+
149+
// Send one-time HEARTBEAT.md instruction to the agent
150+
await sendHeartbeatInstruction(instance.webhookUrl, instance.hooksToken);
151+
}
152+
153+
function showNotificationsHelp(): void {
154+
console.log(chalk.bold('Notifications Commands:'));
155+
console.log();
156+
console.log(' ' + chalk.cyan('npx atxp notifications enable') + ' ' + 'Enable push notifications (auto-configured)');
157+
console.log();
158+
console.log(chalk.bold('Available Events:'));
159+
console.log(' ' + chalk.green('email.received') + ' ' + 'Triggered when an inbound email arrives');
160+
console.log();
161+
console.log(chalk.bold('Examples:'));
162+
console.log(' npx atxp notifications enable');
163+
}
164+
165+
export async function notificationsCommand(subCommand: string): Promise<void> {
166+
if (process.argv.includes('--help') || process.argv.includes('-h') || !subCommand) {
167+
showNotificationsHelp();
168+
return;
169+
}
170+
171+
switch (subCommand) {
172+
case 'enable':
173+
await enableNotifications();
174+
break;
175+
176+
default:
177+
showNotificationsHelp();
178+
break;
179+
}
180+
}

packages/atxp/src/commands/whoami.ts

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,49 @@ function getBaseUrl(connectionString: string): string {
2222
}
2323
}
2424

25+
export interface AccountInfo {
26+
accountId: string;
27+
accountType?: string;
28+
email?: string;
29+
displayName?: string;
30+
sources?: Array<{ chain: string; address: string }>;
31+
team?: { id: string; name: string; role: string };
32+
ownerEmail?: string;
33+
isOrphan?: boolean;
34+
}
35+
36+
/**
37+
* Fetch account info from the accounts API.
38+
* Returns the account data on success, or null on failure.
39+
* Callers needing HTTP status details can use fetchAccountInfo() instead.
40+
*/
41+
export async function getAccountInfo(): Promise<AccountInfo | null> {
42+
const result = await fetchAccountInfo();
43+
return result.data ?? null;
44+
}
45+
46+
/**
47+
* Fetch account info with full error context.
48+
* Returns { data } on success, { status } on HTTP error, or {} on network/parse failure.
49+
*/
50+
export async function fetchAccountInfo(): Promise<{ data?: AccountInfo; status?: number }> {
51+
const connection = getConnection();
52+
if (!connection) return {};
53+
const token = getConnectionToken(connection);
54+
if (!token) return {};
55+
const baseUrl = getBaseUrl(connection);
56+
try {
57+
const credentials = Buffer.from(`${token}:`).toString('base64');
58+
const response = await fetch(`${baseUrl}/me`, {
59+
headers: { 'Authorization': `Basic ${credentials}` },
60+
});
61+
if (!response.ok) return { status: response.status };
62+
return { data: await response.json() as AccountInfo };
63+
} catch {
64+
return {};
65+
}
66+
}
67+
2568
export async function whoamiCommand(): Promise<void> {
2669
const connection = getConnection();
2770

@@ -39,45 +82,28 @@ export async function whoamiCommand(): Promise<void> {
3982
process.exit(1);
4083
}
4184

42-
const baseUrl = getBaseUrl(connection);
43-
4485
try {
45-
const credentials = Buffer.from(`${token}:`).toString('base64');
46-
4786
// Fetch account info and phone number in parallel
48-
const [response, phoneNumber] = await Promise.all([
49-
fetch(`${baseUrl}/me`, {
50-
headers: {
51-
'Authorization': `Basic ${credentials}`,
52-
'Content-Type': 'application/json',
53-
},
54-
}),
87+
const [accountResult, phoneNumber] = await Promise.all([
88+
fetchAccountInfo(),
5589
callTool('phone.mcp.atxp.ai', 'phone_check_sms', {})
5690
.then((r) => { try { return JSON.parse(r).phoneNumber || null; } catch { return null; } })
5791
.catch(() => null),
5892
]);
5993

60-
if (!response.ok) {
61-
if (response.status === 401) {
94+
const data = accountResult.data;
95+
if (!data) {
96+
if (accountResult.status === 401) {
6297
console.error(chalk.red('Error: Invalid or expired connection token.'));
6398
console.error(`Try logging in again: ${chalk.cyan('npx atxp login --force')}`);
99+
} else if (accountResult.status) {
100+
console.error(chalk.red(`Error: Could not fetch account info (HTTP ${accountResult.status}).`));
64101
} else {
65-
console.error(chalk.red(`Error: ${response.status} ${response.statusText}`));
102+
console.error(chalk.red('Error: Could not fetch account info (network error).'));
66103
}
67104
process.exit(1);
68105
}
69106

70-
const data = await response.json() as {
71-
accountId: string;
72-
accountType?: string;
73-
email?: string;
74-
displayName?: string;
75-
sources?: Array<{ chain: string; address: string }>;
76-
team?: { id: string; name: string; role: string };
77-
ownerEmail?: string;
78-
isOrphan?: boolean;
79-
};
80-
81107
// Find the primary wallet address from sources
82108
const wallet = data.sources?.[0];
83109

packages/atxp/src/help.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function showHelp(): void {
3131
console.log(' ' + chalk.cyan('agent') + ' ' + chalk.yellow('<command>') + ' ' + 'Create and manage agent accounts');
3232
console.log(' ' + chalk.cyan('memory') + ' ' + chalk.yellow('<command>') + ' ' + 'Manage, search, and back up agent memory files');
3333
console.log(' ' + chalk.cyan('contacts') + ' ' + chalk.yellow('<command>') + '' + 'Manage local contacts with cloud backup');
34+
console.log(' ' + chalk.cyan('notifications') + ' ' + chalk.yellow('enable') + ' ' + 'Enable push notifications');
3435
console.log(' ' + chalk.cyan('transactions') + ' ' + chalk.yellow('[options]') + ' ' + 'View recent transaction history');
3536
console.log();
3637

@@ -119,6 +120,10 @@ export function showHelp(): void {
119120
console.log(' npx atxp transactions --limit 20 # Show last 20 transactions');
120121
console.log();
121122

123+
console.log(chalk.bold('Notifications Examples:'));
124+
console.log(' npx atxp notifications enable # Enable push notifications (auto-configured)');
125+
console.log();
126+
122127
console.log(chalk.bold('Memory Examples:'));
123128
console.log(' npx atxp memory push --path ~/.openclaw/workspace-abc/');
124129
console.log(' npx atxp memory pull --path ~/.openclaw/workspace-abc/');

packages/atxp/src/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,17 @@ import { musicCommand } from './commands/music.js';
1313
import { videoCommand } from './commands/video.js';
1414
import { xCommand } from './commands/x.js';
1515
import { emailCommand } from './commands/email.js';
16-
import { phoneCommand, type PhoneOptions } from './commands/phone.js';
16+
import { phoneCommand } from './commands/phone.js';
1717
import { balanceCommand } from './commands/balance.js';
1818
import { depositCommand } from './commands/deposit.js';
1919
import { paasCommand } from './commands/paas/index.js';
2020
import { agentCommand } from './commands/agent.js';
2121
import { whoamiCommand } from './commands/whoami.js';
2222

2323
import { memoryCommand, type MemoryOptions } from './commands/memory.js';
24-
import { contactsCommand, type ContactsOptions } from './commands/contacts.js';
24+
import { contactsCommand } from './commands/contacts.js';
2525
import { transactionsCommand } from './commands/transactions.js';
26+
import { notificationsCommand } from './commands/notifications.js';
2627

2728
interface DemoOptions {
2829
port: number;
@@ -120,7 +121,7 @@ function parseArgs(): {
120121

121122
// Check for help flags early - but NOT for paas or email commands (they handle --help internally)
122123
const helpFlag = process.argv.includes('--help') || process.argv.includes('-h');
123-
if (helpFlag && command !== 'paas' && command !== 'email' && command !== 'phone' && command !== 'agent' && command !== 'fund' && command !== 'deposit' && command !== 'memory' && command !== 'backup' && command !== 'contacts') {
124+
if (helpFlag && command !== 'paas' && command !== 'email' && command !== 'phone' && command !== 'agent' && command !== 'fund' && command !== 'deposit' && command !== 'memory' && command !== 'backup' && command !== 'contacts' && command !== 'notifications') {
124125
return {
125126
command: 'help',
126127
demoOptions: { port: 8017, dir: '', verbose: false, refresh: false },
@@ -427,6 +428,10 @@ async function main() {
427428
await contactsCommand(subCommand || '', contactsOptions, process.argv[4]);
428429
break;
429430

431+
case 'notifications':
432+
await notificationsCommand(subCommand || '');
433+
break;
434+
430435
case 'transactions':
431436
await transactionsCommand();
432437
break;

skills/atxp/SKILL.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,16 @@ Local contacts database for resolving names to phone numbers and emails. Stored
335335
| `npx atxp@latest contacts push` | Free | Back up contacts to server |
336336
| `npx atxp@latest contacts pull` | Free | Restore contacts from server |
337337

338+
### Notifications
339+
340+
Enable push notifications so your agent receives a POST to its `/hooks/wake` endpoint when events happen (e.g., inbound email), instead of polling.
341+
342+
| Command | Cost | Description |
343+
|---------|------|-------------|
344+
| `npx atxp@latest notifications enable` | Free | Enable push notifications (auto-configured) |
345+
346+
Setup is zero-config for OpenClaw instances — the webhook URL and auth token are auto-discovered. Just run `notifications enable`.
347+
338348
## MCP Servers
339349

340350
For programmatic access, ATXP exposes MCP-compatible tool servers:

0 commit comments

Comments
 (0)