April 19, 2026 β€’ Version: latest

Adding Tap-to-Copy Approve Commands to Telegram Exec Approval Messages

How to implement ready-to-copy /approve command buttons in Telegram exec approval notifications to eliminate manual UUID copying on mobile.

πŸ” Symptoms

Current Telegram Exec Approval Message

When an exec approval is triggered, the Telegram bot sends:

πŸ”’ Exec approval required
ID: 25395703-a97b-4bc0-8f20-52701089a058
Command: uptime
Triggered by: [email protected]
Timestamp: 2024-01-15T10:30:00Z

Reply with: /approve <id> allow-once|allow-always|deny

User Experience Problems

  • Manual UUID copying β€” Users must long-press to select the UUID, which on mobile devices often results in incorrect selection due to UUID length and hyphen placement
  • Command construction β€” After copying, users must manually type the command structure: /approve + space + paste UUID + space + action
  • High error rate β€” One mistyped character causes command rejection, requiring the entire process restart
  • Mobile friction β€” The workflow requires 8-12 manual interactions versus a single tap

Observed Error Response

When a user sends an incorrectly formatted approve command:

Invalid approval ID format. Expected full UUID.
Use: /approve <uuid> allow-once|allow-always|deny

Platform Comparison

PlatformApproval UXMechanism
DiscordOne-click inline buttonsdiscord.js ButtonComponents
SlackInteractive buttonsBlock Kit interactive buttons
TelegramManual text entryNo native button support in this context

🧠 Root Cause

Technical Architecture Analysis

The Telegram exec approval workflow involves multiple components:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Exec Tool      │────▢│  Approval Queue  │────▢│  Telegram       β”‚
β”‚  (agent/core)   β”‚     β”‚  (approval svc)  β”‚     β”‚  Notifier       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β”‚
                               β–Ό
                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                        β”‚  approval ID     β”‚
                        β”‚  (full UUID)     β”‚
                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Root Cause Factors

1. Telegram Message Formatting Limitation

Telegram supports markdownv2 and HTML parse modes, but does not support inline keyboard buttons when the bot receives messages (only when the bot sends proactive messages). The ReplyKeyboardMarkup approach is not suitable for inline copy-paste workflows.

2. Code Message Template Deficiency

In packages/notifier-telegram/src/lib/format-message.ts (or equivalent), the approval message template constructs the human-readable text but omits the ready-to-use command blocks:

// Current implementation (simplified)
const formatApprovalMessage = (approval) => {
  return [
    'πŸ”’ Exec approval required',
    `ID: ${approval.id}`,
    `Command: ${approval.command}`,
    '',
    'Reply with: /approve <id> allow-once|allow-always|deny'
  ].join('\n');
};

The template requires users to manually extract and reconstruct the command.

3. Short ID Non-Support

The /approve command handler only accepts full UUIDs, not short prefixes:

// Command handler validates full UUID
const APPROVAL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

if (!APPROVAL_ID_PATTERN.test(approvalId)) {
  throw new Error('Invalid approval ID format. Expected full UUID.');
}

This design decision prevents users from using the visible short prefix in the message.

4. Failed Alternative: WebSocket Event Subscription

An alternative approach was attempted using the gateway WebSocket connection:

// Attempted external notifier approach
gateway.ws.on('exec.approval.requested', (event) => {
  // Send Telegram message with copyable commands
  await telegram.send({
    text: buildApprovalMessage(event),
    parse_mode: 'MarkdownV2'
  });
});

// Result: Event never received by external clients
// Root cause: exec\.approval\.requested events not broadcast to
// clients with operator.approvals scope (possible bug in event routing)

The scoped event was not propagating to external subscribers, eliminating this approach.

πŸ› οΈ Step-by-Step Fix

Phase 1: Modify the Message Formatter

File: packages/notifier-telegram/src/lib/format-message.ts

Before:

export function formatExecApprovalMessage(
  approval: ExecApproval
): string {
  const lines = [
    'πŸ”’ Exec approval required',
    `ID: ${approval.id}`,
    `Command: ${approval.command}`,
    `Triggered by: ${approval.triggeredBy}`,
    `Timestamp: ${approval.timestamp}`,
    '',
    'Reply with: /approve  allow-once|allow-always|deny'
  ];
  
  return lines.join('\n');
}

After:

export function formatExecApprovalMessage(
  approval: ExecApproval
): string {
  // Escape special characters for Telegram MarkdownV2
  const escapeMarkdownV2 = (text: string): string => {
    const specialChars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
    return specialChars.reduce((str, char) => str.replace(char, `\\${char}`), text);
  };

  const escapedId = escapeMarkdownV2(approval.id);
  const escapedCommand = escapeMarkdownV2(approval.command);
  const escapedUser = escapeMarkdownV2(approval.triggeredBy);
  
  const lines = [
    'πŸ”’ *Exec approval required*',
    '',
    `\\- \\*ID\\:* \`${escapedId}\``,
    `\\- \\*Command\\:* \`${escapedCommand}\``,
    `\\- \\*Triggered by\\:* ${escapedUser}`,
    `\\- \\*Timestamp\\:* ${approval.timestamp}`,
    '',
    '*Tap to copy and send one of:*{ESCAPED_BREAK_POINT}',
    '',
    'βœ… Tap to approve once:',
    `\`/approve ${escapedId} allow\\-once\``,
    '',
    '♾️ Tap to approve always:',
    `\`/approve ${escapedId} allow\\-always\``,
    '',
    'β›” Tap to deny:',
    `\`/approve ${escapedId} deny\``,
  ];
  
  return lines.join('\n');
}

Note: In MarkdownV2, line breaks within code blocks prevent copy-paste. Each command must be on its own line. The backtick-wrapped commands ensure Telegram renders them as tappable code blocks.

Phase 2: Update Telegram Send Options

File: packages/notifier-telegram/src/lib/send-message.ts

Before:

const sendMessage = async (chatId: string, text: string) => {
  await telegramBot.sendMessage(chatId, text, {
    parse_mode: 'Markdown'
  });
};

After:

const sendMessage = async (chatId: string, text: string) => {
  // Replace placeholder with actual line break (MarkdownV2 compatible)
  const processedText = text.replace(
    /\{ESCAPED_BREAK_POINT\}/g,
    '\\- \\- \\-'
  );
  
  await telegramBot.sendMessage(chatId, processedText, {
    parse_mode: 'MarkdownV2',
    disable_web_page_preview: true
  });
};

Phase 3: Type Definition Update

File: packages/notifier-telegram/src/types/approval.ts

export interface ExecApproval {
  id: string;              // Full UUID (required for /approve command)
  command: string;         // The exec command being approved
  triggeredBy: string;     // Operator/username who triggered
  timestamp: string;       // ISO 8601 timestamp
  status?: 'pending' | 'approved' | 'denied';
  expiresAt?: string;      // Optional expiration
}

Phase 4: Verify UUID Availability

Ensure the approval ID passed to the formatter is always the full UUID:

File: packages/notifier-telegram/src/lib/handle-approval.ts

import { validateUUID } from '@openclaw/shared-utils';

// Ensure we're always working with full UUIDs
const ensureFullUUID = (id: string): string => {
  if (!validateUUID(id)) {
    throw new Error(
      `Invalid approval ID: ${id}. Short IDs cannot be used with /approve command.`
    );
  }
  return id;
};

export async function handleApprovalRequest(approval: RawApproval) {
  const fullUUID = ensureFullUUID(approval.approvalId);
  
  const message = formatExecApprovalMessage({
    id: fullUUID,
    command: approval.command,
    triggeredBy: approval.triggeredBy,
    timestamp: approval.timestamp,
  });
  
  await sendApprovalNotification(approval.chatId, message);
}

πŸ§ͺ Verification

Test Case 1: Message Renders with Copyable Commands

CLI Test Command:

# Start the bot and trigger an exec approval
openclaw exec --agent=prod-01 "uptime" --require-approval

# In a separate Telegram chat with the bot, observe the message

Expected Telegram Output:

πŸ”’ *Exec approval required*

\- *ID:* `25395703-a97b-4bc0-8f20-52701089a058`
\- *Command:* `uptime`
\- *Triggered by:* [email protected]
\- *Timestamp:* 2024-01-15T10:30:00Z

*Tap to copy and send one of:*
\- \- \-

βœ… Tap to approve once:
`/approve 25395703-a97b-4bc0-8f20-52701089a058 allow-once`

♾️ Tap to approve always:
`/approve 25395703-a97b-4bc0-8f20-52701089a058 allow-always`

β›” Tap to deny:
`/approve 25395703-a97b-4bc0-8f20-52701089a058 deny`

Verification Steps:

  1. Verify the message contains three distinct code blocks (backtick-wrapped)
  2. Tap each code block β€” Telegram should show "Copy" option
  3. Copy and send each command to the bot
  4. Confirm each command is accepted without "Invalid ID format" error

Test Case 2: Command Execution After Approval

CLI Test Command:

# After sending allow-once approval via Telegram
openclaw exec --agent=prod-01 "uptime" --require-approval

# User in Telegram taps the first command block, copies, sends
# Expected: Bot responds with approval confirmation

Expected Bot Response:

βœ… Approved exec request (one\\-time)
Command: uptime
Agent: prod\\-01
Status: Executing...
10:30:05 up 23 days, 4:12, 2 users, load average: 0.15, 0.10, 0.08

Test Case 3: Verify Short ID Rejection

Test Command:

# Manually send a short ID to verify the bot rejects it
/s approve 25395703 allow-once

Expected Response:

❌ Invalid approval ID format.

The /approve command requires the full UUID.
Short IDs or partial IDs are not supported.

Use: /approve <uuid> allow-once|allow-always|deny

πŸ’‘ Tip: Tap the command in the approval message to copy it directly.

Unit Test Verification

# Run the notifier-telegram unit tests
cd packages/notifier-telegram
npm test -- --testPathPattern="format-message"

# Expected output:
# βœ“ formatExecApprovalMessage includes copyable commands
# βœ“ formatExecApprovalMessage escapes special characters
# βœ“ formatExecApprovalMessage handles long commands
# βœ“ formatExecApprovalMessage handles special characters in command

⚠️ Common Pitfalls

Pitfall 1: MarkdownV2 Escaping Oversights

Telegram’s MarkdownV2 parser is strict. Unescaped special characters break the entire message.

Common Mistakes:

// ❌ WRONG: Unescaped parentheses and hyphens
`/approve ${id} allow-once`

// βœ… CORRECT: Escaped special characters
`/approve ${escapedId} allow\\-once`

// ❌ WRONG: Unescaped dots in UUID context
`command: uptime`

// βœ… CORRECT: Escaped if using MarkdownV2 bold
`\\*Command\\:* \`uptime\``

Escape Reference Table:

CharacterEscapedPurpose
__Italic markers
**Bold markers
``Code blocks
((Link/format markers
))Link/format markers
--List markers
..Can break parsing
|

Pitfall 2: Command on Same Line as Text

❌ WRONG:

βœ… Tap to approve: `/approve ${id} allow-once`

βœ… CORRECT:

βœ… Tap to approve once:
`/approve ${id} allow-once`

Telegram requires the code block to be on its own line to enable the tap-to-copy functionality.

Pitfall 3: Using Legacy Markdown Parse Mode

❌ WRONG:

parse_mode: 'Markdown'  // Legacy, deprecated

βœ… CORRECT:

parse_mode: 'MarkdownV2'  // Current, required for proper escaping

Pitfall 4: Command Injection in Approval ID

Never directly embed unsanitized approval IDs in messages:

// ❌ DANGEROUS: Unsanitized ID could break parsing
`/approve ${approval.id} deny`

// βœ… SAFE: ID validated before message construction
const safeId = validateAndSanitizeUUID(approval.id);
`/approve ${safeId} deny`

Pitfall 5: Copy-Paste on Desktop vs Mobile

Desktop Telegram clients handle code block tap-to-copy differently than mobile:

PlatformBehaviorRecommendation
iOSLong-press shows “Copy”Works correctly
AndroidLong-press shows “Copy”Works correctly
Desktop macOSTriple-click selectsWorks correctly
Desktop WindowsTriple-click selectsWorks correctly

Always test on mobile devices as the primary use case.

Pitfall 6: Message Length Limits

Telegram messages are limited to 4096 characters. Long commands with full UUIDs may approach this limit:

const MAX_MESSAGE_LENGTH = 4096;

if (formattedMessage.length > MAX_MESSAGE_LENGTH) {
  // Truncate command display or use abbreviated format
  logger.warn('Approval message exceeds Telegram length limit', {
    approvalId: approval.id,
    messageLength: formattedMessage.length
  });
}

Pitfall 7: WebSocket Event Subscription (Historical)

When implementing alternative notification approaches:

// ❌ Attempting to subscribe to approval events via gateway WS
gateway.subscribe('exec.approval.requested', handler);

// Result: Event not received
// Reason: exec.* events are not broadcast to external scope subscribers
// Workaround: Use internal notifier pipeline instead

This was the failed alternative mentioned in the issue. The internal notifier pipeline remains the correct approach.

Error Reference Table

Error CodeDescriptionCauseResolution
APPROVAL_001“Invalid approval ID format. Expected full UUID.”Short ID or malformed UUID passed to /approveEnsure message includes full UUID in copyable format
APPROVAL_002“Approval request expired”Approval TTL exceeded before decisionImplement shorter TTL or refresh notifications
APPROVAL_003“Approval not found”UUID not in approval storeUUID may have been cleared; request new approval
APPROVAL_004“Unauthorized approver”User not in approval whitelistAdd user to operator.approvals scope
TG_001“Bot was blocked by the user”Telegram bot cannot deliver messageUser must unblock bot
TG_002“Parse error: invalid JSON”MarkdownV2 escaping errorReview character escaping
TG_003“Message is too long”Combined message exceeds 4096 charsTruncate or split message
WS_001“Event exec.approval.requested not received”External WS clients don’t receive scoped eventsUse internal notifier pipeline (see Pitfall 7)
  • #2147 β€” "Telegram inline keyboard support for approval actions" (closed, deferred β€” Telegram limitations)
  • #1893 β€” "exec.approval.requested event not broadcast to external WebSocket clients" (open β€” possible bug)
  • #1756 β€” "Short ID support for /approve command" (closed, won't fix β€” security concern)
  • #1522 β€” "Discord approval buttons UX inconsistency with other channels" (resolved)

Security Considerations

The /approve command requires full UUID to prevent:

  • Enumeration attacks on approval IDs
  • Brute-force guessing of approval IDs
  • Authorization bypass through short ID collision

The full UUID approach ensures that approval links cannot be predicted or harvested.

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.