agent-ecosystem/docs/research/messenger-connectors-uncertainty-pass-32.md
2026-04-29 10:55:07 +03:00

23 KiB

Messenger Connectors - Uncertainty Pass 32

Date: 2026-04-29 Scope: agent reply capture, outbound Telegram delivery, message visibility policy, duplicate prevention, and provider delivery ambiguity

Executive Delta

The next lowest-confidence boundary is the final leg:

agent/team message
-> local app feed
-> outbound eligibility decision
-> Telegram sendMessage
-> provider message id
-> future reply-to route

This is where two severe bugs can happen:

1. Privacy leak:
   internal thoughts, tool summaries, teammate protocol XML, retry prompts, or slash output
   get sent to Telegram as if they were user-facing replies.

2. Duplicate provider send:
   Telegram receives a sendMessage request, but our process times out before seeing the result.
   Automatic retry can send the same user-visible reply twice.

The fix is a dedicated outbound projection layer:

MessengerOutboundProjectionGate
  decides if a local message is eligible for external provider delivery

MessengerProviderDeliveryLedger
  records provider send intent, in-flight state, success, ambiguity, and terminal failure

ProviderMessageLink
  records Telegram message id after success so reply-to routing works later

Do not use the renderer feed or sentMessages.json as the outbound provider queue. They are useful inputs, but not the delivery protocol.

Source Facts Rechecked

Telegram official facts checked on 2026-04-29:

  • Bot API methods return a JSON object with ok; successful calls put the method result in result.
  • sendMessage sends text and returns the sent Message on success.
  • sendMessage supports message_thread_id for forum/private-chat topics.
  • sendMessage supports reply_parameters for replying to a specific message.
  • When using webhook inline responses to call Bot API methods, Telegram says it is not possible to know whether the method succeeded or to get its result.
  • ResponseParameters.retry_after tells how many seconds to wait after flood control.
  • Telegram FAQ recommends avoiding more than one message per second in a single chat; otherwise 429 errors can happen.
  • Telegram FAQ says bots should not rely on webhook inline response if they need to know the result of the method.
  • Bot API docs and FAQ do not expose a client-supplied idempotency key for sendMessage.

Sources:

Local code facts:

  • TeamSentMessagesStore persists sentMessages.json, but it caps history at 200 messages and is optimized as a local UI/persistence store, not a provider delivery ledger.
  • TeamSentMessagesStore preserves message fields such as from, to, source, leadSessionId, conversationId, and replyToConversationId.
  • TeamDataService.extractLeadSessionTextsFromJsonl creates lead-session text rows with source: 'lead_session' and usually no to.
  • leadSessionMessageExtractor creates slash command result rows with source: 'lead_session' and messageKind: 'slash_command_result'.
  • TeamProvisioningService captures native SendMessage tool calls. recipient === 'user' is persisted to sentMessages.json; other recipients are persisted to inbox.
  • relayLeadInboxMessages captures plain lead output for inbox relay, strips agent-only blocks, then persists a lead_process message to user.
  • stripAgentBlocks removes info_for_agent, legacy agent blocks, and OpenCode runtime delivery blocks.
  • inboxNoise detects internal JSON noise and teammate-message XML protocol artifacts.
  • RuntimeDeliveryService already has strong local idempotency ideas: journal begin, payload hash conflict detection, destination verification, committed state, failed retryable state, and reconciler.
  • Existing runtime delivery works for local destinations because it can verify local files/stores. Telegram provider sends are different because success may be unknowable after network timeout.

Implication:

The current app has good ingredients,
but messenger outbound needs a separate provider delivery ledger
with stricter "external visibility" rules than the UI feed.

1. Outbound Eligibility Is A Security Boundary

The local feed contains multiple categories:

user_sent
lead_process
lead_session
runtime_delivery
inbox
system_notification
cross_team
cross_team_sent
slash_command_result
tool summaries
command output
internal protocol blocks
noise JSON

Only a small subset should be allowed to leave the app through Telegram.

Minimal provider-send eligibility:

message.to == "user"
message.from is a known active team member or lead
message.source is user-visible by policy
message.text remains non-empty after sanitization
message is linked to a provider route or an explicit publish action
message has not already been sent to that provider route
route is active
topic is active
outbound policy allows this member/source/kind

Hard excludes:

message.from == "user"
message.from == "system"
message.to != "user"
messageKind == "slash_command" unless explicitly mirrored as a user command echo
messageKind == "slash_command_result" unless explicitly requested
isInboxNoiseMessage(text)
isThoughtProtocolNoise(text)
stripAgentBlocks(text) is empty
only teammate-message XML blocks
tool-only rows with no human answer
debug diagnostics
runtime retry prompt text
permission_request JSON

The important rule:

If a message is visible in the local app, that does not automatically mean it is safe to send to Telegram.

2. What Counts As A User-Facing Agent Reply

For the Telegram topic product, user-facing means:

Lead or teammate intentionally answered the external user.

Good candidates:

  • SendMessage(to="user") captured from lead or teammate runtime.
  • Runtime delivery envelope whose destination is user_sent_messages.
  • A visible reply proof with relayOfMessageId linked to a messenger inbound turn.
  • A manual user action in our UI like "send this to Telegram".

Risky candidates:

  • Lead session thoughts without to.
  • Plain assistant text captured from stdout during a relay batch.
  • Slash command output.
  • Task/comment notifications.
  • Cross-team internal coordination.
  • Teammate-to-teammate messages.

Recommended MVP:

Auto-send to Telegram only messages that have an explicit destination to external user.
Do not auto-send generic lead thoughts.

This means:

lead_process with to=user -> eligible if linked to route
runtime delivery to user -> eligible if linked to route
lead_session without to -> not eligible
slash_command_result -> not eligible by default
cross_team_sent -> not eligible unless to=user and explicit external link exists

3. User Wants Teammate Messages Too

The user's desired behavior:

Messages from other teammates to the user should appear in Telegram too,
signed by each teammate.

This is real and understandable. The safe model:

If any team member sends a message to "user" in a route-linked conversation,
send it into the team topic with a member prefix.

Example Telegram rendering:

[Frontend] Alice
I found the failing test. The auth callback returns before token refresh completes.
[Frontend] Lead
Alice is checking the failing test. I will update you when she has a patch.

Do not send teammate-internal chatter:

Alice -> Lead: "Can you clarify the expected API?"
Lead -> Bob: "Please review Alice's patch"
Bob -> Alice: "Approved"

unless the destination is explicitly user.

Therefore the outbound projection should key off destination, not role:

to=user + route link + eligible source -> send to Telegram
to=lead/teammate/cross-team -> do not send

Do not send every to=user message to Telegram. The user may have multiple channels:

local UI only
Telegram official bot
Telegram own bot
future WhatsApp
future Discord

Outbound needs an explicit route link:

type MessengerOutboundContext =
  | {
      kind: 'reply_to_provider_turn';
      routeId: string;
      inboundProviderMessageKey: string;
      internalInboundMessageId: string;
    }
  | {
      kind: 'manual_publish';
      routeId: string;
      requestedBy: 'user';
      localMessageId: string;
    };

For auto-send MVP, require reply_to_provider_turn.

Manual publish can come later. Without route link, local app replies remain local app replies.

5. Provider Delivery Is Not Local Delivery

Existing RuntimeDeliveryService can retry local destinations because it can verify them:

write deterministic local message id
verify file/store contains destination message id
mark committed

Telegram is different:

POST sendMessage
network timeout before response
unknown whether Telegram created the message
no Bot API client idempotency key
cannot verify by deterministic local id

Therefore provider delivery states need an ambiguity state:

type MessengerProviderDeliveryStatus =
  | 'pending'
  | 'send_in_flight'
  | 'sent'
  | 'send_ambiguous'
  | 'rate_limited'
  | 'failed_retryable_before_send'
  | 'failed_terminal'
  | 'cancelled';

Critical rule:

Never automatically retry send_in_flight after a transport timeout
unless the provider adapter can prove the previous attempt did not reach Telegram.

Most HTTP timeout cases cannot prove that.

6. Provider Delivery Ledger

Suggested ledger:

type MessengerProviderDeliveryRecord = {
  idempotencyKey: string;
  provider: 'telegram';
  botScope: 'official' | 'own_bot';
  routeId: string;
  providerChatIdHash: string;
  providerMessageThreadId: number | null;
  internalMessageId: string;
  internalPayloadHash: string;
  visibilityDecisionId: string;
  status: MessengerProviderDeliveryStatus;
  providerMessageId: number | null;
  replyToProviderMessageId: number | null;
  attempts: number;
  nextAttemptAt: string | null;
  ambiguousSince: string | null;
  lastErrorCode: string | null;
  lastErrorMessageRedacted: string | null;
  createdAt: string;
  updatedAt: string;
  sentAt: string | null;
};

Idempotency key should be deterministic:

sha256(provider + routeId + internalMessageId + normalizedTextHash + deliveryKind)

Payload hash prevents accidental reuse:

same idempotencyKey + different payloadHash -> conflict, terminal

When sent:

create ProviderMessageLink:
  providerMessageId -> internalMessageId

When send_ambiguous:

do not create ProviderMessageLink
show warning in connector status
allow manual "send again anyway" or "mark as sent" if future support flow exists

7. Send State Machine

Safe provider send state machine:

pending
-> send_in_flight
-> sent

pending
-> failed_retryable_before_send
-> pending

send_in_flight
-> rate_limited
-> pending at retry_after

send_in_flight
-> send_ambiguous

send_in_flight
-> failed_terminal

Retryable before-send examples:

  • route temporarily locked;
  • local rate limiter says wait;
  • backend/desktop connection unavailable before calling Telegram;
  • provider adapter rejected validation before network send.

Ambiguous examples:

  • request body was handed to HTTP client and connection timed out;
  • process crashed after starting sendMessage;
  • backend sent inline webhook response with method payload and needs the provider result;
  • connection reset after partial response;
  • app received malformed response after Telegram may have accepted request.

Terminal examples:

  • blocked by user;
  • chat not found;
  • topic missing and repair is required;
  • message text empty after sanitization;
  • payload too long and split policy disabled;
  • route disabled;
  • payload hash conflict.

8. Do Not Use Inline Webhook Response For Outbound Replies

Telegram allows calling a Bot API method by returning it in the webhook response. This is tempting for fast replies.

Do not use it for messenger outbound replies.

Reason:

Telegram says we cannot know if the inline method succeeded or get its result.
Without the returned Message, we cannot store providerMessageId.
Without providerMessageId, reply-to teammate routing becomes weaker.

Use normal Bot API calls for outbound messages:

POST /bot<TOKEN>/sendMessage
await result
persist provider message id
then mark sent

Inline webhook response is acceptable only for non-critical throwaway notices where no future reply routing is needed.

9. Rate Limiting

Telegram FAQ warns to avoid more than one message per second in a single chat.

For one-topic-per-team inside one private chat, the chat-level limiter matters more than topic-level limiter:

same Telegram private chat
many team topics
many team replies
one chat-level provider limit

Add provider route limiter:

global bot limiter
per chat limiter
per route/topic limiter

MVP values:

per chat: 1 message per second steady
per route/topic: 1 message per second steady
burst: small queue, for example 3 messages
queue overflow: collapse or mark delayed

Avoid splitting a single long answer into many Telegram messages unless necessary. If splitting is needed because text exceeds Telegram limit, send chunks under one ledger group and be careful:

part 1 sent
part 2 ambiguous
part 3 pending

Multi-part provider delivery needs a group ledger, so MVP should keep replies concise and reject/trim with clear policy before adding splitting.

10. Text Sanitization And Formatting

Outbound text pipeline:

raw local message
strip agent-only blocks
strip teammate protocol blocks if present
reject JSON noise
normalize whitespace
prefix with team/member context
enforce max length
send plain text or Telegram entities

Avoid parse modes in MVP:

send plain text
do not use MarkdownV2 until escaping is proven

Reason:

  • model output can contain arbitrary punctuation;
  • MarkdownV2 escaping is brittle;
  • malformed formatting can fail provider send;
  • provider failure after partial route logic increases ambiguity.

Use explicit Telegram entities later if rich formatting is necessary.

11. Reply-To Mapping

When sending a provider reply, use reply_parameters if we are replying to a known inbound provider message:

reply_to_provider_message_id = inbound Telegram message id
message_thread_id = team topic id

But do not depend only on Telegram reply UI.

Also store:

ProviderMessageLink(providerMessageId -> internalMessageId)

Then future user replies can route:

reply_to_message.message_id
-> ProviderMessageLink
-> internal from member
-> route to that teammate

If provider send succeeds but link persistence fails:

send was externally visible
do not retry send
mark provider link missing
schedule repair if possible

This should become sent_link_missing, or sent with diagnostics. It is not a send failure.

12. Local Store Is Not Enough

sentMessages.json is capped at 200 rows. This is fine for a UI feed but not for provider reply-to history.

Provider message links need their own retention policy:

keep links for active route history window
minimum 90 days or until route deletion by user
prune only with route-level retention
never prune solely because local sentMessages hit 200 rows

If links are pruned:

  • future replies to old Telegram messages route to lead;
  • UI should show "old reply target not available";
  • do not guess teammate from display prefix.

13. Deletion And Edits

MVP can ignore edits and deletions mostly, but not silently:

Inbound Telegram edited messages:

  • do not mutate already delivered internal turns in MVP;
  • create an edit event or ignore with diagnostics;
  • if edited before desktop acceptance, process latest only if ingress design supports it.

Outbound local message edits:

  • do not edit Telegram messages in MVP;
  • send corrections as new messages only on explicit action.

Telegram delete:

  • if provider message deleted, later reply-to links may break;
  • keep link but mark stale when detected by send/reply errors.

This avoids complicated bidirectional sync in v1.

14. Failure Matrix

Critical cases:

  • Local lead thought appears with no to.
    • Do not send.
  • Lead uses SendMessage(to="user") answering a Telegram-origin message.
    • Eligible, send to that route.
  • Teammate uses SendMessage(to="user") answering a Telegram-origin message.
    • Eligible, send to same team topic with teammate prefix.
  • Teammate sends to lead.
    • Not eligible.
  • Message contains only <info_for_agent>.
    • Strip to empty, not eligible.
  • Message contains teammate XML blocks.
    • Strip/block by protocol-noise policy.
  • Slash command output row appears.
    • Not eligible by default.
  • Provider route disabled after local reply was generated.
    • Mark terminal or cancelled, do not send.
  • Topic route repair-required.
    • Do not fallback to general chat.
  • Telegram returns 429 with retry_after.
    • Mark rate_limited, schedule retry after given time.
  • HTTP timeout after request sent.
    • Mark send_ambiguous, do not auto-retry.
  • HTTP timeout before request body leaves process.
    • If adapter can prove no send, mark failed_retryable_before_send.
  • Telegram returns success but local link write fails.
    • Do not retry provider send, repair link.
  • Duplicate local message event.
    • Ledger idempotency key returns existing provider status.
  • Same idempotency key with different text.
    • Payload conflict, terminal.
  • App restarts with send_in_flight.
    • Convert to send_ambiguous unless adapter has proof.
  • Provider message link pruned.
    • Future reply falls back to lead with stale target metadata.

15. Top 3 Options

Option 1 - Strict outbound projection gate + provider delivery ledger

🎯 9 🛡️ 9 🧠 7

Approx changed LOC: 2500-5500.

What it means:

  • build MessengerOutboundProjectionGate;
  • build MessengerProviderDeliveryLedger;
  • auto-send only explicit to=user replies linked to a provider route;
  • use Telegram normal API calls, not inline webhook response, for routable replies;
  • mark network unknowns as send_ambiguous, not retryable;
  • store ProviderMessageLink after success.

Why this is best:

  • prevents internal-message leakage;
  • avoids unsafe Telegram duplicates;
  • supports teammate messages to user;
  • gives reply-to routing a durable provider message id;
  • matches the feature architecture standard.

Risk:

  • more code;
  • some ambiguous sends need user-visible diagnostics;
  • initial behavior may feel conservative.

Option 2 - Reuse sentMessages.json as outbound queue with simple dedupe

🎯 5 🛡️ 5 🧠 4

Approx changed LOC: 800-1800.

What it means:

  • watch sentMessages.json;
  • send any new to=user message to Telegram;
  • store last sent internal message ids.

Why it is tempting:

  • quick demo;
  • current system already writes user-directed lead messages there;
  • easy to observe from renderer.

Why it is risky:

  • sentMessages.json is capped at 200;
  • it is not route-specific;
  • not all to=user messages should go to Telegram;
  • provider send ambiguity is not represented;
  • reply-to provider ids need another store anyway.

Option 3 - Send all visible feed messages with broad filters

🎯 3 🛡️ 3 🧠 3

Approx changed LOC: 500-1400.

What it means:

  • use MessagesPanel/feed projection;
  • filter obvious noise;
  • push visible items to Telegram.

Why it is bad:

  • visibility in app is not external eligibility;
  • feed contains lead thoughts, slash results, diagnostics, and UI-specific projections;
  • dedupe is feed-oriented, not provider-send oriented;
  • provider reply-to routing remains fragile.

This should not be used beyond a throwaway prototype.

16. Decision Update

Recommended model:

Inbound Telegram turn creates route-linked internal message.
Agent/team responses become local messages as today.
MessengerOutboundProjectionGate observes durable local messages.
Only explicit user-directed, route-linked replies become provider send intents.
MessengerProviderDeliveryLedger handles Telegram send state.
ProviderMessageLink stores successful Telegram message ids.
Future reply-to routing uses ProviderMessageLink.

Minimal eligibility formula:

eligible =
  route.active
  && message.to == "user"
  && message.from is active member
  && origin/reply context links message to provider route
  && message not already delivered to provider
  && sanitized text non-empty
  && message kind/source allowed by policy

Important product behavior:

Teammate messages to user are sent to Telegram.
Teammate messages to lead or other teammates are not sent.
Lead thoughts without explicit to=user are not sent.

17. Tests To Write First

Domain tests:

  • to=user lead reply linked to provider route is eligible.
  • to=user teammate reply linked to provider route is eligible.
  • to=user local-only reply without route link is not eligible.
  • to=lead teammate message is not eligible.
  • lead session thought without to is not eligible.
  • slash command result is not eligible by default.
  • agent-only block strips to empty and is not eligible.
  • JSON noise is not eligible.
  • provider route disabled blocks eligibility.
  • same internal message maps to same provider idempotency key.
  • same idempotency key with changed payload is conflict.

Provider ledger tests:

  • pending -> send_in_flight -> sent creates provider link.
  • pre-send validation failure is retryable.
  • 429 response stores retry_after and schedules retry.
  • HTTP timeout after request started becomes send_ambiguous.
  • restart with send_in_flight becomes send_ambiguous.
  • duplicate local event returns existing sent/ambiguous state.
  • success with provider link write failure does not retry provider send.

Adapter tests:

  • Telegram send uses message_thread_id.
  • Telegram send uses reply_parameters when inbound provider message id is known.
  • Telegram send does not use webhook inline response for routable replies.
  • long text is rejected or handled by explicit split policy.
  • parse mode is omitted in MVP.

Renderer tests:

  • connector status shows ambiguous provider sends.
  • ambiguous send has manual resolution affordance.
  • user can see why a local reply was not sent to Telegram.
  • teammate prefix renders in Telegram projection preview.

18. Remaining Low-Confidence Areas

Still worth deeper research next:

  • exact local event source for teammate SendMessage(to="user") across all supported runtimes, not just OpenCode;
  • whether legacy Claude lead-session plain text should ever auto-send to Telegram or always require explicit SendMessage;
  • how to migrate old sentMessages.json rows into provider delivery state without accidental sends;
  • how to model manual "send again anyway" for send_ambiguous without hiding duplicate risk;
  • whether sendMessageDraft can safely show typing/progress in a topic without confusing delivery state;
  • exact Telegram error taxonomy for deleted private topic, blocked bot, and migrated chats in Bot API responses;
  • retention policy for ProviderMessageLink under privacy delete/export requirements.