How We Added Confirmation Before an Agent Sends a Message — Agent Lab Journal
Agent Lab Journal
Guides
Glossary
Case study · Agent safety
How We Added Confirmation Before an Agent Sends a Message
Intermediate
7 min read
Safe approval loop
A message prepared by an agent must not automatically become a message sent in its owner’s name. We separated drafting from delivery and placed an explicit, verifiable human decision between them.
The problem was not text generation
Our agent could assemble a useful Telegram reply from the conversation context. The dangerous part came afterward: the same execution path also had access to the send operation. A mistaken recipient, an outdated draft, a misunderstood instruction, or a malicious fragment in the source material could therefore turn directly into an external action.
This is an approval loop problem. Drafting is reversible; sending is not. Once a message reaches another person, deleting it may not undo screenshots, notifications, automated processing, or reputational damage.
The safety requirement was simple to state:
No agent-generated message may be sent until the owner has reviewed the exact text, the exact destination, and explicitly approved that specific version.
The important words are “exact” and “specific.” A generic “yes” from an old conversation must not authorize a later draft, and approval for one recipient must not be reusable for another.
The concrete case
The workflow began when an incoming Telegram message required a response. The agent received the conversation identifier and enough recent context to propose a reply. Previously, the handler could call the Telegram send method immediately after generation.
We changed the flow into four distinct states:
Drafted: the agent produces text but has no permission to deliver it.
Pending approval: the owner sees the recipient, draft, and approval controls.
Approved or rejected: the owner makes an explicit decision.
Sent or cancelled: a separate delivery component executes only an approved, unchanged request.
This separation establishes a human-in-the-loop boundary around the consequential action. It also follows the principle of least privilege: the drafting component can create pending records, while only the delivery component can use the messaging credential.
The approval record
A pending approval must be durable. Keeping it only in model context or process memory makes restarts unsafe and makes it difficult to determine what was actually approved.
We used a record with fields equivalent to the following:
{
"id": "generated-random-id",
"status": "pending",
"channel": "telegram",
"recipient_id": "resolved-chat-id",
"recipient_label": "displayed-to-owner",
"message_text": "Exact proposed message",
"message_hash": "sha256-of-canonical-payload",
"created_at": "server-timestamp",
"expires_at": "server-timestamp",
"created_by": "agent",
"approved_by": null,
"approved_at": null,
"sent_at": null,
"provider_message_id": null
}
The message hash binds approval to both content and destination. Hash a canonical payload rather than the text alone:
payload = channel + "\n" + recipient_id + "\n" + message_text
message_hash = SHA256(payload)
If the recipient or text changes, the hash changes and the old approval is invalid. In that case the record returns to the pending state and must be reviewed again.
Practical implementation
1. Remove sending from the agent’s toolset
The strongest control is architectural. Do not merely prompt the agent to ask first. A prompt is behavioral guidance, not an authorization boundary.
Expose a draft operation:
{
"name": "create_message_draft",
"description": "Create a pending message for owner review. Does not send.",
"parameters": {
"recipient_id": "string",
"message_text": "string"
}
}
Keep the actual provider token outside the agent runtime. The draft service needs database access, but no Telegram send credential.
2. Show what will happen
The approval screen or owner notification should display:
the channel and unambiguous recipient;
the complete message without hidden truncation;
attachments, formatting, reply target, and link previews when applicable;
when the draft was created and when it expires;
separate Approve, Edit, and Reject actions.
Do not make approval the default when the owner presses Enter, closes a dialog, or fails to respond. Silence means the draft remains pending until it expires.
3. Authenticate the decision
An approval callback must be tied to the owner’s verified account. Treat callback data as untrusted input, even if it originated in your own interface.
Use a random, single-use token or a signed value containing the approval ID, intended owner, and expiration. On receipt, the server must verify the owner identity, signature, expiry, current record status, and message hash.
POST /approvals/{approval_id}/approve
Authorization: Bearer <owner-session>
If-Match: "<message-hash>"
The server—not the client—must decide whether the request is valid.
4. Make the state transition atomic
Two approval clicks or concurrent workers must not produce two sends. Use an atomic conditional update:
UPDATE message_approvals
SET status = 'approved',
approved_by = :owner_id,
approved_at = CURRENT_TIMESTAMP
WHERE id = :approval_id
AND status = 'pending'
AND message_hash = :expected_hash
AND expires_at > CURRENT_TIMESTAMP;
Continue only if exactly one row changed. This is an idempotency safeguard at the approval boundary.
5. Deliver through a separate worker
The delivery worker selects approved records, verifies the hash again, and claims one record before contacting Telegram:
pending -> approved -> sending -> sent
\-> failed
Use a unique idempotency key derived from the approval ID where the provider supports it. If it does not, store the provider response immediately and design retry behavior conservatively. After an uncertain timeout, do not blindly resend: the first request may have succeeded.
Suggested policy configuration
Keeping the rules in configuration makes the boundary visible and reviewable:
outbound_messages:
default_mode: require_approval
approvers:
- owner
approval_ttl_minutes: 30
approval_binds:
- channel
- recipient_id
- message_text
- attachments
edit_invalidates_approval: true
reject_is_terminal: true
auto_send:
enabled: false
audit_log:
enabled: true
redact_credentials: true
We recommend starting with no automatic-send exceptions. If a future workflow genuinely needs them, define a narrow allowlist based on action type and destination—not on the model’s confidence. Confidence is not authorization.
Verification checklist
We verified the control by checking state transitions and permissions rather than judging whether generated prose looked reasonable. A repeatable test plan should cover:
A new draft cannot call the provider send endpoint.
An unauthenticated user cannot approve a draft.
A different authenticated user cannot approve the owner’s draft.
Changing one character, attachment, channel, or recipient invalidates approval.
An expired draft cannot move to approved.
Two simultaneous approval requests result in one successful transition.
Repeated delivery jobs do not intentionally send the same approved record twice.
A rejected draft can never be sent.
A service restart preserves pending, approved, and sent states correctly.
Logs record the decision without exposing credentials or unnecessary private content.
A useful manual test is to pause between preview and approval, edit the database record through the normal application path, and confirm that the stored hash no longer matches. The delivery worker should refuse the request and require fresh approval.
Failure cases we designed for
The owner approves the wrong chat
A display name alone is ambiguous. Show stable context such as the channel, chat type, and a recognizable recipient label. Bind the internal recipient identifier into the hash.
The draft changes after preview
Any edit creates a new version and resets the status to pending. Never modify an approved record in place.
The approval button is clicked twice
The second request should return the existing state without scheduling another delivery. Conditional updates and a unique delivery record prevent duplicate work.
The provider times out
A timeout is an unknown result, not proof of failure. Mark it separately, retain the request details, and reconcile before retrying when possible.
The owner never responds
The approval expires. It must not become approved through a timeout handler, scheduled task, or fallback branch.
Prompt injection asks the agent to bypass review
The request fails because the agent has no sending credential and no approval transition capability. This is why the permission boundary matters more than wording in the system prompt.
Limitations
An approval loop reduces unauthorized sending, but it does not guarantee that the owner will notice every factual, legal, privacy, or tone problem. The preview must provide enough context for a meaningful decision, and high-risk messages may need additional review.
The pattern also introduces latency and interface work. It may be unsuitable for emergency automation where delay is itself dangerous. Such systems need a separately designed policy with tightly scoped actions, predefined destinations, monitoring, and an explicit risk decision.
Finally, exactly-once delivery cannot always be guaranteed when an external provider accepts a request but loses the response. The local state machine can prevent ordinary duplicates, but ambiguous network failures still require reconciliation or a provider-supported idempotency mechanism.
The resulting safety boundary
The final design made one rule enforceable: the agent proposes; the owner authorizes; a separate worker acts. Approval is authenticated, expires, applies to one immutable payload, and cannot be replayed.
This pattern is useful beyond Telegram. The same boundary applies to email, calendar invitations, support replies, issue comments, purchases, document sharing, and any other action performed in a person’s name.
Continue with the implementation patterns in Agent Lab Journal guides, or review the linked concepts in the glossary.
© Agent Lab Journal
Original article: https://agentlabjournal.online/en/agent-telegram-approval-case.html
Top comments (0)