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 inresult. sendMessagesends text and returns the sentMessageon success.sendMessagesupportsmessage_thread_idfor forum/private-chat topics.sendMessagesupportsreply_parametersfor 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_aftertells 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:
- https://core.telegram.org/bots/api#making-requests
- https://core.telegram.org/bots/api#making-requests-when-getting-updates
- https://core.telegram.org/bots/api#sendmessage
- https://core.telegram.org/bots/api#replyparameters
- https://core.telegram.org/bots/api#responseparameters
- https://core.telegram.org/bots/faq
Local code facts:
TeamSentMessagesStorepersistssentMessages.json, but it caps history at 200 messages and is optimized as a local UI/persistence store, not a provider delivery ledger.TeamSentMessagesStorepreserves message fields such asfrom,to,source,leadSessionId,conversationId, andreplyToConversationId.TeamDataService.extractLeadSessionTextsFromJsonlcreates lead-session text rows withsource: 'lead_session'and usually noto.leadSessionMessageExtractorcreates slash command result rows withsource: 'lead_session'andmessageKind: 'slash_command_result'.TeamProvisioningServicecaptures nativeSendMessagetool calls.recipient === 'user'is persisted tosentMessages.json; other recipients are persisted to inbox.relayLeadInboxMessagescaptures plain lead output for inbox relay, strips agent-only blocks, then persists alead_processmessage to user.stripAgentBlocksremovesinfo_for_agent, legacy agent blocks, and OpenCode runtime delivery blocks.inboxNoisedetects internal JSON noise and teammate-message XML protocol artifacts.RuntimeDeliveryServicealready 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
relayOfMessageIdlinked 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
4. Route Link Requirement
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=userreplies 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
ProviderMessageLinkafter 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=usermessage 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.jsonis capped at 200;- it is not route-specific;
- not all
to=usermessages 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=userlead reply linked to provider route is eligible.to=userteammate reply linked to provider route is eligible.to=userlocal-only reply without route link is not eligible.to=leadteammate message is not eligible.- lead session thought without
tois 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_parameterswhen 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.jsonrows into provider delivery state without accidental sends; - how to model manual "send again anyway" for
send_ambiguouswithout hiding duplicate risk; - whether
sendMessageDraftcan 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
ProviderMessageLinkunder privacy delete/export requirements.