Building AI Agent Approval Workflows: Emoji Shortcuts, Telegram Bots & Mobile-First Permissions

Intermediate17m readFull-stack developers

Building AI Agent Approval Workflows: Emoji Shortcuts, Telegram Bots & Mobile-First Permissions A practical guide to the part of agent design nobody demos: the moment your autonomous agent stops and asks "can I run this?" We cover three...

Primary Focus

ai agents

AI Tools Covered

agentstelegrammobile

What You'll Learn

  • The "5.24" That Isn't a Version Number
  • The Approval Features That Are Real
  • The Claims You Should Treat as Unverified
  • An Approval Is a Pending Promise
  • Resolve a Decision From One Emoji
  • Why Emoji Is a Shortcut, Not a Gate

Guide Curriculum

What Actually Ships vs. What the Demo Promises

Learn key concepts

3 lessons
  • The "5.24" That Isn't a Version Number1m
  • The Approval Features That Are Real1m
  • The Claims You Should Treat as Unverified1m

Emoji Shortcuts — Reaction-Based Approvals

Learn key concepts

3 lessons
  • An Approval Is a Pending Promise2m
  • Resolve a Decision From One Emoji2m
  • Why Emoji Is a Shortcut, Not a Gate1m

Telegram Bot Integration — Inline-Keyboard Approval Buttons

Learn key concepts

3 lessons
  • The Telegram Primitives You Need1m
  • A Complete Approval Bot2m
  • Lock Down Who Can Approve1m

Mobile-First Permission UX — Risk Tiers, Async Auth, Fail-Closed

Learn key concepts

3 lessons
  • Not Every Action Deserves the Same Friction2m
  • Async, Fail-Closed Gate2m
  • The Audit Trail Is the Product1m

Preview: First Lesson

What Actually Ships vs. What the Demo Promises

The "5.24" That Isn't a Version Number

Objectives

  • Separate verified OpenClaw approval features from the video's aspirational claims.
  • Understand why every claim in an "update just dropped" video should be read as should, not does.
  • Establish the vocabulary used across the rest of this guide.

The source video is titled "OpenClaw 5.24 Update Just Dropped" and walks through a pre-release beta on GitHub. Before you copy any command from a video like this, check the primary source. OpenClaw does not use a 5.24 version scheme. The project switched to date-based release trains — YYYY.M.PATCH — and the current beta line as of this writing is v2026.6.5-beta.5, published June 8, 2026. The release notes literally say: "switch release trains to YYYY.M.PATCH monthly patch numbering."

So "5.24" is the video creator's own shorthand, not a tag you will find on the OpenClaw releases page. This matters because the rest of the video inherits the same loose relationship with the source. The narrator is admirably honest about it — he repeats the word "should" on nearly every feature and says outright: "I always apply should instead of does." That is your cue to verify before you build.

Free Access

Start learning with this comprehensive guide

This guide includes:

4 modules with 12 lessons
17m estimated reading time

About the Author

H
✨ Vibe Coder
@hiram-clark

Hiram Clark is the founder and managing editor of vybecoding.ai and sets editorial direction for the guides and news published here. Articles are drafted with AI assistance and edited before publication. He works hands-on with the AI development tools, workflows, and infrastructure covered on the site.

Full Guide Content

Complete lesson text — start the interactive course above for exercises and progress tracking.

Module 1What Actually Ships vs. What the Demo Promises

1.1The "5.24" That Isn't a Version Number

Objectives
  • Separate verified OpenClaw approval features from the video's aspirational claims.
  • Understand why every claim in an "update just dropped" video should be read as should, not does.
  • Establish the vocabulary used across the rest of this guide.

The source video is titled "OpenClaw 5.24 Update Just Dropped" and walks through a pre-release beta on GitHub. Before you copy any command from a video like this, check the primary source. OpenClaw does not use a 5.24 version scheme. The project switched to date-based release trains — YYYY.M.PATCH — and the current beta line as of this writing is v2026.6.5-beta.5, published June 8, 2026. The release notes literally say: "switch release trains to YYYY.M.PATCH monthly patch numbering."

So "5.24" is the video creator's own shorthand, not a tag you will find on the OpenClaw releases page. This matters because the rest of the video inherits the same loose relationship with the source. The narrator is admirably honest about it — he repeats the word "should" on nearly every feature and says outright: "I always apply should instead of does." That is your cue to verify before you build.

1.2The Approval Features That Are Real

Here is what the OpenClaw beta release notes actually document about approvals — verified against the GitHub releases, not the video:

  • Telegram exec approval allowlists. Release v2026.6.2-beta.1 notes: "keep Telegram DM exec approval allowlists working with ask:off." This is a real, shipped approval mechanism: OpenClaw can require human sign-off before executing a command, gated per-channel, and ask:off disables the prompt for allowlisted callers.
  • Admin-gated writeback. The same release: "require admin rights for Telegram target writeback." Approvals are tied to channel permissions, not just to whoever sends a message.
  • Native approval cards on Google Chat. The v2026.6.5 line: "add native approval card actions and click handling so Google Chat approvals use platform-native cards instead of generic message flow." Platform-native approval UI is the direction the project is moving.

1.3The Claims You Should Treat as Unverified

These appear in the video but are not in the verified release notes, so treat them as roadmap, not reality:

  • "Approve from iMessage with a single thumbs-up emoji." The video says WhatsApp already had it and iMessage is catching up. The release notes for the beta line do not document an iMessage thumbs-up flow. It may exist behind a flag or land later — but do not architect around it until you confirm it.
  • Meeting-notes plugin, real-time voice steering, and wake-name gating. Interesting, but unrelated to approvals and unverified here.

The good news: the pattern the video is selling — approve sensitive agent actions with a single tap from whatever app you already have open — is sound, well-understood, and buildable today on primitives that are fully documented. The next three modules build exactly that, independent of any one vendor's beta.

Module 2Emoji Shortcuts — Reaction-Based Approvals

2.1An Approval Is a Pending Promise

Objectives
  • Model an approval as a pending decision keyed to a message.
  • Resolve that decision from a single emoji reaction.
  • Understand why an emoji reaction is a UX shortcut, not a security boundary.

Strip away the messaging layer and an approval is just a deferred decision. Your agent reaches a gated action, creates a pending record, sends a notification, and waits — it does not block a thread; it parks the action and resolves it when a human answers. Every channel (emoji, button, push) is just a different front-end onto the same record.

The minimal shape of that record:

// lib/approvals/types.ts
export interface PendingApproval {
  id: string;                 // stable id, also embedded in the notification
  action: string;             // e.g. "shell.exec", "email.send", "db.delete"
  payload: Record;
  channel: "emoji" | "telegram" | "push";
  requestedAt: number;        // epoch ms
  expiresAt: number;          // fail-closed deadline
  status: "pending" | "approved" | "denied" | "expired";
  decidedBy?: string;         // who answered
  decidedAt?: number;
}

The two fields people forget are expiresAt and decidedBy. The first makes the system fail closed — an unanswered request denies itself rather than hanging forever. The second gives you an audit trail, which Module 4 treats as non-negotiable.

2.2Resolve a Decision From One Emoji

A reaction-based approval maps a specific emoji to approve and another to deny. OpenClaw's own Telegram exec-approval allowlist is the production version of this idea; the durable, vendor-neutral version is a webhook that receives a reaction event and resolves the matching pending approval.

This handler is framework-agnostic — point it at any chat platform's reaction webhook (Slack reaction_added, a Telegram reaction update, an iMessage bridge):

// lib/approvals/emojiHandler.ts
import { logger } from "@/lib/logger";
import { resolveApproval, findApprovalByMessageRef } from "./store";

// Map reaction emoji -> decision. Keep this tiny and explicit.
const DECISION_BY_EMOJI: Record = {
  "👍": "approved",
  "✅": "approved",
  "👎": "denied",
  "❌": "denied",
};

interface ReactionEvent {
  messageRef: string;   // the notification message the reaction was added to
  emoji: string;
  userId: string;
}

export async function handleReaction(event: ReactionEvent): Promise {
  const decision = DECISION_BY_EMOJI[event.emoji];
  if (!decision) return; // ignore unrelated reactions silently

  const approval = await findApprovalByMessageRef(event.messageRef);
  if (!approval) {
    logger.warn("Reaction on unknown approval message", { ref: event.messageRef });
    return;
  }

  if (approval.status !== "pending") {
    logger.info("Reaction on already-decided approval", { id: approval.id });
    return; // idempotent: first reaction wins, later ones are no-ops
  }

  if (Date.now() > approval.expiresAt) {
    await resolveApproval(approval.id, "expired", event.userId);
    return; // fail closed
  }

  await resolveApproval(approval.id, decision, event.userId);
  logger.info("Approval resolved via emoji", {
    id: approval.id,
    decision,
    by: event.userId,
  });
}

2.3Why Emoji Is a Shortcut, Not a Gate

An emoji reaction is fast, but it inherits the trust model of the channel it rides on. Anyone who can react to the message can decide the action. That is fine for low-risk actions ("post the draft tweet") and dangerous for high-risk ones ("wire the refund"). The fix is not to abandon emoji — it is to tier the actions, which is Module 4. Treat the emoji handler above as the convenient path for low-risk approvals and require a stronger channel for anything financially or destructively material.

Module 3Telegram Bot Integration — Inline-Keyboard Approval Buttons

3.1The Telegram Primitives You Need

Objectives
  • Send an approval request as a Telegram message with Approve/Deny buttons.
  • Receive and answer the resulting callback query correctly.
  • Edit the message after a decision so it can't be double-clicked.

Telegram's inline keyboards are the cleanest mobile approval surface that does not require building an app. The relevant primitives, quoted from the Telegram Bot API:

  • InlineKeyboardButton — a button with a text label and a callback_data string (the payload sent back when pressed). Pressing a callback button "doesn't send messages to the chat"; it delivers data straight to your bot.
  • CallbackQuery — the update your bot receives when a button is pressed. Key fields: id (String), from (User), message (Message, optional), data (String, optional — your callback_data), and chat_instance (String).
  • answerCallbackQuery — the method you must call to dismiss the button's spinner. Parameters: callback_query_id (String, required), text (String, optional notification), show_alert (Boolean, optional).

The non-obvious rule: Telegram shows a loading spinner on the pressed button until you call answerCallbackQuery. Forget it and the UI looks frozen even though your handler ran.

3.2A Complete Approval Bot

This is a runnable Node.js bot (no framework, just fetch against the Bot API). It sends an approval request and resolves it from the button press. Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in your environment.

// scripts/telegram-approval-bot.ts
import { logger } from "@/lib/logger";

const TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
const API = `https://api.telegram.org/bot${TOKEN}`;

async function tg(method: string, body: Record) {
  const res = await fetch(`${API}/${method}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${method} failed: ${res.status}`);
  return res.json();
}

// 1. Send an approval request with two callback buttons.
export async function requestApproval(
  chatId: string,
  approvalId: string,
  summary: string,
) {
  return tg("sendMessage", {
    chat_id: chatId,
    text: `🔐 *Approval needed*\n${summary}`,
    parse_mode: "Markdown",
    reply_markup: {
      inline_keyboard: [
        [
          { text: "✅ Approve", callback_data: `approve:${approvalId}` },
          { text: "❌ Deny", callback_data: `deny:${approvalId}` },
        ],
      ],
    },
  });
}

// 2. Handle the callback when the user taps a button.
// Named types beat inline anonymous shapes — reuse and readability.
interface TelegramCallbackUpdate {
  callback_query?: {
    id: string;
    from: { id: number };
    data?: string;
    message?: { chat: { id: number }; message_id: number };
  };
}

// Only these Telegram user IDs may decide (Lesson 9 allowlist, inlined).
const APPROVER_IDS = new Set([
  Number(process.env.TELEGRAM_APPROVER_ID),
]);

export async function handleCallback(update: TelegramCallbackUpdate) {
  const cb = update.callback_query;
  if (!cb?.data) return;

  // Reject anyone not on the allowlist BEFORE acting on their tap.
  if (!APPROVER_IDS.has(cb.from.id)) {
    await tg("answerCallbackQuery", {
      callback_query_id: cb.id,
      text: "Not authorized to approve.",
      show_alert: true,
    });
    return;
  }

  const [decision, approvalId] = cb.data.split(":");
  const approved = decision === "approve";

  // Resolve your pending approval here (see Module 2 store).
  logger.info("Telegram approval decision", { approvalId, approved });

  // MUST answer to clear the button spinner.
  await tg("answerCallbackQuery", {
    callback_query_id: cb.id,
    text: approved ? "Approved ✅" : "Denied ❌",
  });

  // Edit the message so the buttons can't be pressed twice.
  if (cb.message) {
    await tg("editMessageText", {
      chat_id: cb.message.chat.id,
      message_id: cb.message.message_id,
      text: `${approved ? "✅ Approved" : "❌ Denied"} — decision recorded.`,
    });
  }
}

3.3Lock Down Who Can Approve

A bot token is a bearer credential — anyone who can message the bot can press its buttons unless you gate it. Two defenses, both of which OpenClaw's release notes echo ("require admin rights for Telegram target writeback," "exec approval allowlists"):

  1. Allowlist the approver. Check callback_query.from.id against a known set of admin user IDs before honoring the decision. Reject everyone else and answer with show_alert: true so they see it was refused.
  2. Sign the callback_data. callback_data is capped at 64 bytes and is attacker-visible. Don't trust it as the source of truth — look the approval up by id in your own store and verify it is still pending before acting. The approve: string is a pointer, not a permission.

Module 4Mobile-First Permission UX — Risk Tiers, Async Auth, Fail-Closed

4.1Not Every Action Deserves the Same Friction

Objectives
  • Tier actions by risk so trivial approvals stay one-tap and dangerous ones get friction.
  • Make approvals asynchronous and fail-closed.
  • Log every decision for audit.

The research consensus on mobile-first agent approvals (Auth0, Arahi, and others) is consistent: a smartphone push you can approve in one tap, with a round-trip under two seconds, is the ideal — for the right actions. The discipline is tiering. A common, well-documented pattern gates approval per agent, per tool, and per data scope, with the bar rising for "externally visible or financially material actions" — outbound emails, refunds or charges above a threshold, public posts, destructive database writes.

Encode the tiers as config, not as scattered if statements:

# config/approval-policy.yaml
# Tier determines BOTH whether approval is needed and how strong it must be.
tiers:
  auto:        # no human needed
    actions: ["read.*", "search.*", "draft.create"]
  one_tap:     # emoji or single button is fine (Modules 2-3)
    actions: ["email.send.internal", "calendar.create", "tweet.post"]
    channels: ["emoji", "telegram"]
    expires_in_seconds: 900
  step_up:     # requires a second factor before the tap counts
    actions: ["payment.refund", "db.delete", "email.send.external"]
    channels: ["push"]
    require_reauth: true
    expires_in_seconds: 300
default_tier: step_up   # unknown actions get the STRONGEST gate, not the weakest

The single most important line is default_tier: step_up. An action your policy has never seen must fall into the most restrictive tier, never the least. New tools are added all the time; a permissive default is how an agent quietly gains the ability to do something dangerous without anyone approving it.

4.2Async, Fail-Closed Gate

Asynchronous authorization is the security property that makes all of this safe: even if the agent is compromised, it cannot execute a gated action without an explicit human decision, and an unanswered request expires into a denial. Here is the gate that ties Modules 2–4 together:

// lib/approvals/gate.ts
import { logger } from "@/lib/logger";
import { createPending, waitForDecision } from "./store";
import { tierFor } from "./policy";

interface GateResult {
  allowed: boolean;
  reason: "approved" | "denied" | "expired" | "auto";
  decidedBy?: string;
}

export async function approvalGate(
  action: string,
  payload: Record,
): Promise {
  const tier = tierFor(action); // reads approval-policy.yaml

  if (tier.name === "auto") {
    return { allowed: true, reason: "auto" };
  }

  const approval = await createPending({
    action,
    payload,
    expiresAt: Date.now() + tier.expiresInSeconds * 1000,
    requireReauth: tier.requireReauth ?? false,
  });

  // Notify on the tier's channels (emoji / telegram / push) — fire and forget.
  // ⚠️ notifyApprovers is the ONE stub you must implement before this gate is
  // production-usable: wire it to Module 2's emoji handler and Module 3's bot.
  await notifyApprovers(approval, tier.channels);

  // Block the ACTION (not the process) until decided or expired.
  const decision = await waitForDecision(approval.id, approval.expiresAt);

  // Audit EVERY outcome, including expiry. Non-negotiable.
  logger.info("approval.decided", {
    id: approval.id,
    action,
    outcome: decision.status,
    decidedBy: decision.decidedBy,
  });

  return {
    allowed: decision.status === "approved",
    reason: decision.status,
    decidedBy: decision.decidedBy,
  };
}

// Stub — THIS IS YOUR INTEGRATION POINT. Replace the body with real dispatch:
// send the Telegram approval message (Module 3 `requestApproval`) and/or post
// the notification your emoji handler (Module 2) reacts to. The gate is not
// production-ready until this function actually reaches a human.
async function notifyApprovers(approval: { id: string }, channels: string[]) {
  logger.debug("notifying approvers", { id: approval.id, channels });
}

4.3The Audit Trail Is the Product

Every source on production human-in-the-loop systems lands on the same requirement: log every request and every decision, so you can later answer "why did the agent do that?" Notice the logger.info("approval.decided", ...) call fires on all outcomes — approved, denied, and expired — with who decided and what action it was. That log is not a debugging nicety; it is the evidence that your agent only acted with permission. If a decision isn't logged, for compliance purposes it didn't happen. Build the audit trail first and the approval UI second, never the reverse.

References