ADR-0162accepted

Gateway Reactions, Replies & Social Channel Configuration

  • Status: accepted
  • Date: 2026-02-28
  • Deciders: Joel, Panda
  • Relates to: ADR-0144 (hexagonal architecture), ADR-0120 (Discord threads), ADR-0160 (Telegram streaming)

Context

The gateway agent responds to messages across Telegram, Discord, Slack, and iMessage but has no ability to react to messages โ€” only reply with text. Reactions are a natural, lightweight acknowledgment mechanism that every chat platform supports (except iMessage via our current imsg-rpc daemon). The agent should use reactions contextually: ๐Ÿ‘€ on receipt, ๐Ÿ‘ for simple acks, ๐Ÿ”ฅ for excitement, ๐Ÿค” for processing, etc.

Additionally, channel configuration is scattered across environment variables (TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN, SLACK_ALLOWED_USER_ID, etc.) with no unified schema, no validation, and no documentation. Talon introduced .joelclaw/talon/services.toml as precedent for file-based config with hot-reload. Social channels should follow the same pattern.

Decision

1. Reaction System

Structured response convention. The gateway agent includes reaction directives in its response text using a <<react:EMOJI>> prefix. The outbound router strips the directive and executes the reaction on the source channel before routing the text.

<<react:๐Ÿ‘>>Got it, deploying now.
<<react:๐Ÿ”ฅ>>

Rules:

  • Multiple <<react:...>> directives allowed (first one wins per message, rest ignored)
  • Empty text after stripping is valid โ€” reaction-only responses (no text sent)
  • Directive must be at the start of the response text
  • Unknown/unsupported emoji silently ignored per-channel
  • iMessage: no-op (tapback not available via imsg-rpc)

Per-channel API mapping:

ChannelAPINotes
Telegrambot.api.setMessageReaction(chatId, messageId, [{type:"emoji", emoji}])Requires messageId from inbound context
Discordmessage.react(emoji)Unicode emoji or custom guild emoji
Slackclient.reactions.add({channel, timestamp, name})Slack emoji name without colons
iMessageno-optapback not exposed via imsg-rpc

1b. Reply-to-Message System

Targeted replies. The gateway agent can reply to a specific inbound message using a <<reply:MESSAGE_ID>> directive. The outbound router strips the directive and passes the message ID as replyTo context to the channel adapter.

<<reply:5872>>That's the right approach.
<<react:๐Ÿ‘>><<reply:5872>>Confirmed.

The reply infrastructure already exists in the Telegram adapter (reply_parameters), Discord (thread-based), and Slack (thread_ts). Whatโ€™s missing is the agentโ€™s ability to target a specific message.

Context injection. When a message arrives, the inbound metadata already carries the platform message ID (e.g. telegramMessageId). The command queue injects this into the prompt context so the agent knows which message ID to reference:

[msg:5872] Hey, did the deploy finish?

Per-channel reply support:

ChannelMechanismNotes
Telegramreply_parameters: { message_id }Native quote-reply, shows referenced message
DiscordAlready thread-basedReplies are implicit within threads
Slackthread_tsReply in thread
iMessageNot supportedNo reply-to via imsg-rpc

Rules:

  • <<reply:ID>> is optional โ€” omitting it sends a normal message (current behavior)
  • Can combine with <<react:EMOJI>> โ€” both directives stripped before text routing
  • Invalid/stale message IDs silently ignored (Telegram returns error, we catch and send without reply)
  • Agent should reply when the conversation has multiple messages in flight and context matters

System prompt addition. The gateway agentโ€™s system prompt is updated to encourage contextual reactions and replies:

  • ๐Ÿ‘€ on receipt of messages that will take time to process
  • ๐Ÿ‘ for simple acknowledgments where no text reply is needed
  • ๐Ÿ”ฅ for genuinely cool/impressive things shared
  • ๐Ÿค” when the request needs thought
  • โœ… when a task is confirmed complete
  • Use sparingly โ€” not every message needs a reaction

2. Social Channel Configuration

File-based config at ~/.joelclaw/channels.toml with schema validation at startup.

# ~/.joelclaw/channels.toml
# Social channel configuration for the joelclaw gateway.
# Gateway validates on startup and logs warnings for invalid config.
# Changes require gateway restart (no hot-reload โ€” channels bind SDK clients).
 
[telegram]
enabled = true
bot_token_secret = "telegram_bot_token"    # agent-secrets key
user_id = 7718912466
reactions = true
 
[discord]
enabled = true
bot_token_secret = "discord_bot_token"     # agent-secrets key
allowed_user_id = "257596554986823681"
reactions = true
 
[slack]
enabled = true
bot_token_secret = "slack_bot_token"       # agent-secrets key
app_token_secret = "slack_app_token"       # agent-secrets key
allowed_user_id = "U01BCPFPG0D"
default_channel_id = "C04NM8AHJ6E"
reaction_ack_emoji = "eyes"
reactions = true
 
[imessage]
enabled = true
socket_path = "/tmp/imsg.sock"
reactions = false                          # tapback not supported via imsg-rpc

Design decisions:

  • TOML โ€” consistent with Talonโ€™s services.toml; human-readable, typed, no trailing comma drama
  • Secret references, not values โ€” bot_token_secret points to an agent-secrets key name; gateway resolves at startup via secrets_lease. Tokens never appear in config files.
  • enabled flag โ€” channels can be toggled without removing config
  • reactions flag โ€” per-channel opt-in for the reaction system
  • No hot-reload โ€” channel SDKs (grammy, discord.js, @slack/bolt) bind connections at startup; hot-reload would require teardown/reconnect logic thatโ€™s not worth the complexity. Restart the gateway instead.
  • Schema validation โ€” gateway validates config at startup using a TypeScript schema (Effect Schema or Zod). Invalid config โ†’ log error + skip that channel (donโ€™t crash).
  • Fallback to env vars โ€” if channels.toml doesnโ€™t exist, gateway falls back to current process.env behavior for backwards compatibility during migration.

3. Channel Config Skill

A channel-config skill documents the channels.toml schema, valid options per channel, how secrets are resolved, and troubleshooting. Canonical source in skills/channel-config/SKILL.md, symlinked as usual.

Consequences

  • Gateway agent gains reaction capability across 3 of 4 channels
  • Channel config moves from scattered env vars to a single validated file
  • Secrets stay in agent-secrets, never in config files
  • Config is documented as a skill โ€” agents can read and modify it
  • iMessage reactions remain unsupported until imsg-rpc gains tapback support
  • Existing env var config continues to work during migration period

Implementation Order

  1. Add react() to Channel interface + implement per-channel
  2. Parse <<react:EMOJI>> and <<reply:ID>> directives in outbound router
  3. Inject inbound message ID into prompt context ([msg:ID] prefix)
  4. Update gateway system prompt with reaction + reply guidance
  5. Create channels.toml schema + loader with env var fallback
  6. Migrate daemon.ts channel startup to use config loader
  7. Create telegram skill (Telegram-specific capabilities, API patterns, troubleshooting)
  8. Create channel-config skill (channels.toml schema, secrets, per-channel options)
  9. Remove env var fallback after verification period

Execution Status (2026-03-04)

A loop execution attempt for ADR-0162 was started twice (loop-mm6gkutx-m9epj7, loop-mm6is72z-phixid) and both stalled at story 1 with CHAIN_BROKEN (judgeโ†’plan event lost, zero claims).

Both loops were explicitly cancelled on 2026-03-04 to clear the active loop backlog before starting ADR-0207 work. Decision status remains accepted; implementation is still pending and must be relaunched as a fresh loop.

Tech Debt

  • Extract channel adapters from packages/gateway/ into standalone packages (noted in streaming work, deferred until APIs stabilize)
  • iMessage tapback support pending imsg-rpc enhancement