Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
180 changes: 180 additions & 0 deletions packages/atxp/src/commands/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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';

interface EnableResponse {
instance?: { webhookUrl?: string; hooksToken?: string };
webhook?: { id?: string; url?: string; eventTypes?: string[]; secret?: string; enabled?: boolean };
error?: string;
}

/**
* 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.
*
* Trust boundary: This intentionally injects a system-level prompt into the
* agent's memory. The webhook endpoint MUST validate the Authorization header
* before acting on the message content. The hardcoded text is controlled by us
* (not user input) and tells the agent how to broadcast notifications.
*/
async function sendHeartbeatInstruction(webhookUrl: string, hooksToken: string): Promise<void> {
try {
const res = 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.',
}),
});
if (!res.ok) {
console.log(chalk.gray(`Note: Setup instruction returned HTTP ${res.status} — agent may not have received it.`));
} else {
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 {
const flyId = process.env.FLY_MACHINE_ID;
if (flyId) return flyId;

// Fly sets hostname to the machine ID, but nested shells (e.g. the agent's
// process) may not inherit FLY_MACHINE_ID. Only use hostname if it looks
// like a Fly machine ID (hex string, typically 14 chars).
const hostname = os.hostname();
if (hostname && /^[0-9a-f]{10,}$/.test(hostname)) return hostname;

return 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 Fly 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().catch(() => ({})) as EnableResponse;
if (!res.ok) {
console.error(chalk.red(`Error: ${data.error || res.statusText}`));
process.exit(1);
}

const { instance, webhook } = data;
if (!instance?.webhookUrl || !instance?.hooksToken || !webhook) {
console.error(chalk.red('Error: Unexpected response from notifications service.'));
process.exit(1);
}

// 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?.join(', ') || ''));
if (webhook.secret) {
console.log(' ' + chalk.bold('Secret:') + ' ' + chalk.yellow(webhook.secret));
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): Promise<void> {
if (process.argv.includes('--help') || process.argv.includes('-h') || !subCommand) {
showNotificationsHelp();
return;
}

switch (subCommand) {
case 'enable':
await enableNotifications();
break;

default:
showNotificationsHelp();
break;
}
}
76 changes: 51 additions & 25 deletions packages/atxp/src/commands/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,49 @@ 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;
}

/**
* Fetch account info from the accounts API.
* Returns the account data on success, or null on failure.
* Callers needing HTTP status details can use fetchAccountInfo() instead.
*/
export async function getAccountInfo(): Promise<AccountInfo | null> {
const result = await fetchAccountInfo();
return result.data ?? null;
}

/**
* Fetch account info with full error context.
* Returns { data } on success, { status } on HTTP error, or {} on network/parse failure.
*/
export async function fetchAccountInfo(): Promise<{ data?: AccountInfo; status?: number }> {
const connection = getConnection();
if (!connection) return {};
const token = getConnectionToken(connection);
if (!token) return {};
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 { status: response.status };
return { data: await response.json() as AccountInfo };
} catch {
return {};
}
}

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

Expand All @@ -39,45 +82,28 @@ 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 [accountResult, phoneNumber] = await Promise.all([
fetchAccountInfo(),
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) {
const data = accountResult.data;
if (!data) {
if (accountResult.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 if (accountResult.status) {
console.error(chalk.red(`Error: Could not fetch account info (HTTP ${accountResult.status}).`));
} else {
console.error(chalk.red(`Error: ${response.status} ${response.statusText}`));
console.error(chalk.red('Error: Could not fetch account info (network error).'));
}
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('enable') + ' ' + 'Enable 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
11 changes: 8 additions & 3 deletions packages/atxp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@ import { musicCommand } from './commands/music.js';
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';
import { phoneCommand } from './commands/phone.js';
import { balanceCommand } from './commands/balance.js';
import { depositCommand } from './commands/deposit.js';
import { paasCommand } from './commands/paas/index.js';
import { agentCommand } from './commands/agent.js';
import { whoamiCommand } from './commands/whoami.js';

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

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

// 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 @@ async function main() {
await contactsCommand(subCommand || '', contactsOptions, process.argv[4]);
break;

case 'notifications':
await notificationsCommand(subCommand || '');
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