Messenger agent
Scenario. You want your agent in the chat your users already have open.
DM it or mention it, and it shows typing, posts a one-line status per tool
call, then streams its answer into a single message. Ask for a picture and
the picture lands in the chat before the model says a word. Every chat gets
its own loop and its own history, and the same program runs on Telegram,
Discord or Slack behind a --messenger flag.
The agentic loop already does the hard part: wait for input at clean turn boundaries, batch bursts, run tools, keep history. This recipe swaps its two ends. The inbox is fed by addressed messages from the chat instead of stdin, and the answer goes back as one progressively edited message instead of stdout.
The Design Move
The loop never learns which platform it is on. It yields the generic
Messenger tag, and the chat it answers in is ambient:
conversation(inbox, options).pipe(inConversation(ref), Effect.forkScoped)inConversation(ref) is set once, where a conversation’s fiber starts.
Every post, typing and stream below it lands in that chat without a
chat id threaded through the loop. The image tool shows what that buys: it
runs inside Toolkit.run, deep under the loop, and posts the picture itself.
run: ({ prompt }) => Effect.gen(function* () { const messenger = yield* Messenger const { images } = yield* ImageGenerator.generate({ prompt, model }) yield* Effect.forEach(images, ({ image }) => messenger.post(media(image))) return "Sent." }),The model only hears “Sent.”, the tool never sees a chat id, and switching platforms changes nothing here.
Tools are configuration
--search exa and --image fal:fal-ai/flux/schnell each produce one value
holding the tool and the layer that serves it:
const image = Option.map(flagValue("image", argv), (spec) => { const drawing = parseModelSpec(spec, "openai") return { tool: imageTool(drawing.model), layer: imageGeneratorLayer(drawing) }})const configured = Arr.getSomes([search, image])The toolkit is Toolkit.fromArray over the present tools and the layers are
spread into Layer.mergeAll, so a tool and its provider cannot drift apart.
Leave a flag out and the model is never offered that tool.
react is built per conversation instead: it needs no provider, but it
needs the id of this conversation’s last message, which a shared toolkit
cannot hold.
const lastMessage = yield * Ref.make(Option.none<MessageId>())const tools = Toolkit.fromArray([...Object.values(options.toolkit), reactTool(lastMessage)])Per turn
Each iteration holds the typing indicator and a delivery fiber in its own scope:
yield * messenger.typingconst deltas = yield * Queue.unbounded<string, Cause.Done>()const delivery = yield * Effect.forkScoped(messenger.stream(Stream.fromQueue(deltas)))Text deltas go into the queue and appear in the chat as the model writes; a
ToolCallStart becomes a short status post. When the turn completes, the
queue is ended and the delivery joined, so the final edit lands before the
loop moves on. A turn that only called tools streams no text and posts
nothing. When the iteration ends, its scope releases the typing indicator.
Input comes from Inbox.drainBurst: block for the first message, then keep
taking while the next arrives within the settle window, so three quick lines
become one user turn.
Router
yield * Stream.runForEach(messenger.events, (event) => Match.value(event).pipe( Match.when({ _tag: "Message", addressed: true }, (message) => Effect.flatMap(inboxFor(message.conversation), (inbox) => Queue.offer(inbox, { text: message.text, id: message.id }), ), ), Match.when({ _tag: "Reaction" }, (reaction) => Effect.flatMap( openInbox(reaction.conversation), Option.match({ onNone: () => Effect.void, onSome: (inbox) => Queue.offer(inbox, { text: `[reacted ${reaction.emoji}]`, id: reaction.message }), }), ), ), Match.when({ _tag: "Command", name: "start" }, (command) => messenger.post(text(greeting)).pipe(inConversation(command.conversation)), ), Match.orElse(() => Effect.void), ), )The first addressed message in a chat creates its inbox and forks its loop;
unaddressed group chatter never reaches a model. inboxFor starts a
conversation, openInbox only joins one, so a reaction is a turn in a chat
already talking and nothing in a quiet channel. It arrives as
[reacted 🤔], an ordinary line of input, so the loop never learns
reactions exist. A conversation that dies logs its cause; the router and
every other chat keep going.
Formatting is the prompt’s job
Text is sent as written, so the platform’s markup is the prompt’s business.
--messenger picks the layer and the markup together, and the persona’s
formatting sentence, greeting and status line follow the markup:
const platforms = { telegram: Effect.map(Config.redacted("TELEGRAM_BOT_TOKEN"), (token) => ({ layer: telegramLayer({ token }), markup: "html", })), discord: Effect.map(Config.redacted("DISCORD_BOT_TOKEN"), (token) => ({ layer: discordLayer({ token }), markup: "markdown", })), slack: Effect.map( Effect.all({ botToken: Config.redacted("SLACK_BOT_TOKEN"), appToken: Config.redacted("SLACK_APP_TOKEN"), }), (tokens) => ({ layer: slackLayer(tokens), markup: "markdown" }), ),}Only the chosen platform’s tokens are read.
Run it
Set the bot up on the platform’s page first: Telegram, Discord or Slack. Then:
# TelegramTELEGRAM_BOT_TOKEN=123:abc OPENAI_API_KEY=... EXA_API_KEY=... FAL_API_KEY=... \ pnpm tsx recipes/messenger-agent/run.ts --search exa --image fal:fal-ai/flux/schnell
# DiscordDISCORD_BOT_TOKEN=... OPENAI_API_KEY=... EXA_API_KEY=... \ pnpm tsx recipes/messenger-agent/run.ts --messenger discord --search exa
# SlackSLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... OPENAI_API_KEY=... EXA_API_KEY=... \ pnpm tsx recipes/messenger-agent/run.ts --messenger slack --search exa--model provider:model and --base-url pick the model. --search exa | perplexity | tavily and --image provider:model switch the tools on; both
are optional. DM the bot and ask it something, or ask it to draw.
What to expect per platform:
- Telegram:
/startgreets. For group mentions, turn privacy mode off in BotFather or make the bot an admin. - Discord: no commands; the first DM or mention starts a conversation.
Mention the bot from the
@autocomplete under MEMBERS, not the role of the same name.--read-allrequests the Message Content intent so a plain reply reaches it without a mention. - Slack:
/startgreets. Mention the bot in a channel and it answers in a thread; inside that thread, mention it again for a follow-up.
LOG_LEVEL=Debug logs every inbound event with its conversation and
addressed flag, plus each turn and tool call, which is where to look when
a chat stays silent.
The full source lives next to this README at
recipe.ts.