Emergent's Wingman launch made the pattern legible: the winning agent interface in 2026 isn't a dashboard, it's the chat app your user already has open. You text the agent what you want; it works in the background; it checks with you before doing anything you can't undo. Here's that whole pattern in working code — the receive step, the act step, and the confirmation gate that turns "autonomous" into "trustworthy."
The three parts, in one breath: a webhook that receives the user's message, your agent loop that decides and acts, and a confirmation gate that stops before consequential actions and waits for an explicit yes. Telegram is the fastest way to see it work, so start there.
1. Telegram: token, webhook, reply#
Message @BotFather to create a bot and copy the token. Point Telegram at your server with one call — no SDK required:
curl "https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://your-app.com/tg"
Now every message the user sends arrives as a POST to /tg. Read the text, run your agent, reply with sendMessage:
// POST /tg — Telegram sends one "update" per message
app.post("/tg", async (req, res) => {
res.sendStatus(200); // ack fast; work async
const msg = req.body.message;
if (!msg?.text) return;
const chatId = msg.chat.id;
const decision = await agent.plan(msg.text); // your loop decides
if (decision.kind === "routine") {
await agent.run(decision); // reversible → just do it
await tg("sendMessage", { chat_id: chatId, text: decision.summary });
} else {
await proposeWithConfirm(chatId, decision); // consequential → gate it
}
});
const tg = (method, body) =>
fetch(`https://api.telegram.org/bot${TOKEN}/${method}`, {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}).then(r => r.json());
2. The confirmation gate — trust constraints, made real#
The entire safety story is this: classify every intended action, and never take a consequential one without an explicit yes. Routine and reversible (summarize, look up, draft) → just do it. Consequential (send an external message, spend money, delete data) → propose it, persist it, and wait.
Telegram hands you the UI for free with an inline keyboard — two buttons whose taps come back as a callback_query:
async function proposeWithConfirm(chatId, decision) {
const id = await pending.save(chatId, decision); // persist the pending action
await tg("sendMessage", {
chat_id: chatId,
text: `About to: ${decision.summary}\nProceed?`,
reply_markup: { inline_keyboard: [[
{ text: "✅ Approve", callback_data: `ok:${id}` },
{ text: "✖ Cancel", callback_data: `no:${id}` },
]]},
});
}
// the tap arrives as update.callback_query
app.post("/tg", async (req, res) => {
res.sendStatus(200);
const cq = req.body.callback_query;
if (!cq) return; /* ...fall through to the message handler above... */
const [verb, id] = cq.data.split(":");
const decision = await pending.take(id); // load + remove (one-shot)
if (verb === "ok" && decision) await agent.run(decision);
await tg("answerCallbackQuery", { callback_query_id: cq.id,
text: verb === "ok" ? "Done." : "Cancelled." });
});
A background agent is only as valuable as the actions you let it take unattended — and only as safe as the ones it refuses to take without asking. The gate is the product.
Two rules keep the gate honest. Persist the pending action (a row keyed by chat, not in-memory) so a restart doesn't execute a stale "yes." And make pending.take(id) one-shot — load and delete atomically — so a double-tap can't fire the action twice. We went deeper on the general judgment call in when should an AI agent ask for help.
3. WhatsApp: same shape, more paperwork#
WhatsApp runs on Meta's Cloud API. The shape is identical — receive, decide, confirm — but the setup is heavier: a Meta app, a verified webhook, and a phone-number ID. Verification is a GET your endpoint must answer by echoing the challenge:
// GET /wa — Meta's one-time webhook verification
app.get("/wa", (req, res) => {
if (req.query["hub.verify_token"] === VERIFY_TOKEN)
return res.send(req.query["hub.challenge"]); // echo it back verbatim
res.sendStatus(403);
});
Incoming messages POST to the same path under entry[].changes[].value.messages. You send by POSTing to the Graph API with a bearer token:
const wa = (payload) =>
fetch(`https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/messages`, {
method: "POST",
headers: { authorization: `Bearer ${WA_TOKEN}`, "content-type": "application/json" },
body: JSON.stringify({ messaging_product: "whatsapp", ...payload }),
}).then(r => r.json());
// confirmation via interactive reply buttons
await wa({ to, type: "interactive", interactive: {
type: "button",
body: { text: `About to: ${decision.summary}. Proceed?` },
action: { buttons: [
{ type: "reply", reply: { id: `ok:${id}`, title: "Approve" } },
{ type: "reply", reply: { id: `no:${id}`, title: "Cancel" } },
]},
}});
The button tap comes back as an interactive button_reply with your id — route it through the exact same pending.take(id) path as Telegram. One gate, two transports.
Which one to reach for#
Start on Telegram to validate the pattern — a token and one webhook call and you're live, with approve/deny buttons built in. Move to WhatsApp when your users are non-technical consumers who'll never install a bespoke app but already live in their messages; you pay for it in setup and per-conversation pricing, and you buy a ~2-billion-person front door. Either way, the durable part isn't the transport — it's the gate. Ship the confirmation model well and you have what Emergent is charging a $1.5B valuation to provide: an agent people will actually let work while they sleep. If email is more your users' native channel, the same receive-decide-confirm shape maps cleanly onto an email control channel with Resend.



