Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions packages/atxp/src/commands/webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import chalk from 'chalk';
import fs from 'fs/promises';
import os from 'os';
import { execSync } from 'child_process';

const NOTIFICATIONS_BASE_URL = 'https://clowdbot-notifications.corp.circuitandchisel.com';

/**
* Configure hooks in openclaw.json on the running instance.
* Only runs when inside a Fly instance (FLY_MACHINE_ID is set).
* Updates openclaw.json with the hooks token and restarts the gateway.
*/
async function configureHooksOnInstance(hooksToken: string): Promise<void> {
if (!process.env.FLY_MACHINE_ID) return;

const configPath = '/data/.openclaw/openclaw.json';
try {
const raw = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(raw);

if (!config.hooks) config.hooks = {};
// Already configured with this token — skip
if (config.hooks.token === hooksToken && config.hooks.enabled === true) return;

config.hooks.enabled = true;
config.hooks.token = hooksToken;
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
console.log(chalk.gray('Hooks configured in openclaw.json'));

// Restart gateway to pick up new config (watchdog auto-restarts it)
try {
execSync('pkill -f openclaw-gateway', { stdio: 'ignore' });
console.log(chalk.gray('Gateway restarting to apply hooks config...'));
} catch {
// Gateway may not be running yet — config will be picked up on next start
}
} catch {
// Non-fatal — hooks will be configured on next reboot via entrypoint config sync
}
}

/**
* Send a one-time instruction to the agent via /hooks/wake so it saves
* notification handling instructions to its HEARTBEAT.md file.
*/
async function sendHeartbeatInstruction(webhookUrl: string, hooksToken: string): Promise<void> {
try {
await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${hooksToken}`,
},
body: JSON.stringify({
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.',
}),
});
console.log(chalk.gray('Notification instructions sent to your agent.'));
} catch {
console.log(chalk.gray('Note: Could not send setup instruction to instance.'));
}
}

function getMachineId(): string | undefined {
// Fly sets FLY_MACHINE_ID on the VM, but nested shells (e.g. the agent's
// process) may not inherit it. Fall back to hostname which Fly sets to the
// machine ID.
return process.env.FLY_MACHINE_ID || os.hostname() || undefined;
}

async function getEmailUserId(): Promise<string | undefined> {
const { getAccountInfo } = await import('./whoami.js');
const account = await getAccountInfo();
if (!account?.email) return undefined;
// Extract local part: agent_xyz@atxp.email -> agent_xyz
return account.email.split('@')[0];
}

async function enableNotifications(): Promise<void> {
const machineId = getMachineId();
if (!machineId) {
console.error(chalk.red('Error: Could not detect machine ID.'));
console.log('This command must be run from inside a Clowdbot instance.');
process.exit(1);
}

console.log(chalk.gray('Enabling push notifications...'));

// Resolve email user ID for event matching
const emailUserId = await getEmailUserId();

const body: Record<string, string> = { machine_id: machineId };
if (emailUserId) body.email_user_id = emailUserId;

const res = await fetch(`${NOTIFICATIONS_BASE_URL}/notifications/enable`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

const data = await res.json() as Record<string, unknown>;
if (!res.ok) {
const errorMsg = (data.error as string) || res.statusText;
console.error(chalk.red(`Error: ${errorMsg}`));
process.exit(1);
}

const instance = data.instance as { webhookUrl: string; hooksToken: string };
const webhook = data.webhook as Record<string, unknown>;

// Configure hooks locally
await configureHooksOnInstance(instance.hooksToken);

console.log(chalk.green('Push notifications enabled!'));
console.log();
console.log(' ' + chalk.bold('ID:') + ' ' + webhook.id);
console.log(' ' + chalk.bold('URL:') + ' ' + webhook.url);
console.log(' ' + chalk.bold('Events:') + ' ' + (webhook.eventTypes as string[]).join(', '));
if (webhook.secret) {
console.log(' ' + chalk.bold('Secret:') + ' ' + chalk.yellow(webhook.secret as string));
console.log();
console.log(chalk.gray('Save the secret — it will not be shown again.'));
console.log(chalk.gray('Use it to verify webhook signatures (HMAC-SHA256).'));
}

// Send one-time HEARTBEAT.md instruction to the agent
await sendHeartbeatInstruction(instance.webhookUrl, instance.hooksToken);
}

function showNotificationsHelp(): void {
console.log(chalk.bold('Notifications Commands:'));
console.log();
console.log(' ' + chalk.cyan('npx atxp notifications enable') + ' ' + 'Enable push notifications (auto-configured)');
console.log();
console.log(chalk.bold('Available Events:'));
console.log(' ' + chalk.green('email.received') + ' ' + 'Triggered when an inbound email arrives');
console.log();
console.log(chalk.bold('Examples:'));
console.log(' npx atxp notifications enable');
}

export async function notificationsCommand(subCommand: string, _positionalArg?: string): Promise<void> {
if (process.argv.includes('--help') || process.argv.includes('-h') || !subCommand) {
showNotificationsHelp();
return;
}

switch (subCommand) {
case 'enable':
case 'add': // backward compat
await enableNotifications();
break;

default:
showNotificationsHelp();
break;
}
}
63 changes: 34 additions & 29 deletions packages/atxp/src/commands/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@ function getBaseUrl(connectionString: string): string {
}
}

export interface AccountInfo {
accountId: string;
accountType?: string;
email?: string;
displayName?: string;
sources?: Array<{ chain: string; address: string }>;
team?: { id: string; name: string; role: string };
ownerEmail?: string;
isOrphan?: boolean;
}

export async function getAccountInfo(): Promise<AccountInfo | null> {
const connection = getConnection();
if (!connection) return null;
const token = getConnectionToken(connection);
if (!token) return null;
const baseUrl = getBaseUrl(connection);
try {
const credentials = Buffer.from(`${token}:`).toString('base64');
const response = await fetch(`${baseUrl}/me`, {
headers: { 'Authorization': `Basic ${credentials}` },
});
if (!response.ok) return null;
return await response.json() as AccountInfo;
} catch {
return null;
}
}

export async function whoamiCommand(): Promise<void> {
const connection = getConnection();

Expand All @@ -39,45 +68,21 @@ export async function whoamiCommand(): Promise<void> {
process.exit(1);
}

const baseUrl = getBaseUrl(connection);

try {
const credentials = Buffer.from(`${token}:`).toString('base64');

// Fetch account info and phone number in parallel
const [response, phoneNumber] = await Promise.all([
fetch(`${baseUrl}/me`, {
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/json',
},
}),
const [data, phoneNumber] = await Promise.all([
getAccountInfo(),
callTool('phone.mcp.atxp.ai', 'phone_check_sms', {})
.then((r) => { try { return JSON.parse(r).phoneNumber || null; } catch { return null; } })
.catch(() => null),
]);

if (!response.ok) {
if (response.status === 401) {
console.error(chalk.red('Error: Invalid or expired connection token.'));
console.error(`Try logging in again: ${chalk.cyan('npx atxp login --force')}`);
} else {
console.error(chalk.red(`Error: ${response.status} ${response.statusText}`));
}
if (!data) {
console.error(chalk.red('Error: Could not fetch account info. Your token may be invalid or expired.'));
console.error(`Try logging in again: ${chalk.cyan('npx atxp login --force')}`);
process.exit(1);
}

const data = await response.json() as {
accountId: string;
accountType?: string;
email?: string;
displayName?: string;
sources?: Array<{ chain: string; address: string }>;
team?: { id: string; name: string; role: string };
ownerEmail?: string;
isOrphan?: boolean;
};

// Find the primary wallet address from sources
const wallet = data.sources?.[0];

Expand Down
5 changes: 5 additions & 0 deletions packages/atxp/src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function showHelp(): void {
console.log(' ' + chalk.cyan('agent') + ' ' + chalk.yellow('<command>') + ' ' + 'Create and manage agent accounts');
console.log(' ' + chalk.cyan('memory') + ' ' + chalk.yellow('<command>') + ' ' + 'Manage, search, and back up agent memory files');
console.log(' ' + chalk.cyan('contacts') + ' ' + chalk.yellow('<command>') + '' + 'Manage local contacts with cloud backup');
console.log(' ' + chalk.cyan('notifications') + ' ' + chalk.yellow('<cmd>') + ' ' + 'Manage push notifications');
console.log(' ' + chalk.cyan('transactions') + ' ' + chalk.yellow('[options]') + ' ' + 'View recent transaction history');
console.log();

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

console.log(chalk.bold('Notifications Examples:'));
console.log(' npx atxp notifications enable # Enable push notifications (auto-configured)');
console.log();

console.log(chalk.bold('Memory Examples:'));
console.log(' npx atxp memory push --path ~/.openclaw/workspace-abc/');
console.log(' npx atxp memory pull --path ~/.openclaw/workspace-abc/');
Expand Down
7 changes: 6 additions & 1 deletion packages/atxp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { videoCommand } from './commands/video.js';
import { xCommand } from './commands/x.js';
import { emailCommand } from './commands/email.js';
import { phoneCommand, type PhoneOptions } from './commands/phone.js';

Check warning on line 16 in packages/atxp/src/index.ts

View workflow job for this annotation

GitHub Actions / test

'PhoneOptions' is defined but never used
import { balanceCommand } from './commands/balance.js';
import { depositCommand } from './commands/deposit.js';
import { paasCommand } from './commands/paas/index.js';
Expand All @@ -21,8 +21,9 @@
import { whoamiCommand } from './commands/whoami.js';

import { memoryCommand, type MemoryOptions } from './commands/memory.js';
import { contactsCommand, type ContactsOptions } from './commands/contacts.js';

Check warning on line 24 in packages/atxp/src/index.ts

View workflow job for this annotation

GitHub Actions / test

'ContactsOptions' is defined but never used
import { transactionsCommand } from './commands/transactions.js';
import { notificationsCommand } from './commands/webhook.js';

interface DemoOptions {
port: number;
Expand Down Expand Up @@ -120,7 +121,7 @@

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

case 'notifications':
await notificationsCommand(subCommand || '', process.argv[4]);
break;

case 'transactions':
await transactionsCommand();
break;
Expand Down
10 changes: 10 additions & 0 deletions skills/atxp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,16 @@ Local contacts database for resolving names to phone numbers and emails. Stored
| `npx atxp@latest contacts push` | Free | Back up contacts to server |
| `npx atxp@latest contacts pull` | Free | Restore contacts from server |

### Notifications

Enable push notifications so your agent receives a POST to its `/hooks/wake` endpoint when events happen (e.g., inbound email), instead of polling.

| Command | Cost | Description |
|---------|------|-------------|
| `npx atxp@latest notifications enable` | Free | Enable push notifications (auto-configured) |

Setup is zero-config for OpenClaw instances — the webhook URL and auth token are auto-discovered. Just run `notifications enable`.

## MCP Servers

For programmatic access, ATXP exposes MCP-compatible tool servers:
Expand Down
Loading