···11+## v0.1.0
22+33+Initial release.
44+55+- Zulip bot for broadcasting daily changelog updates
66+- Claude integration for natural language interaction
77+- XDG-compliant configuration loading
88+- Two operation modes: daemon and one-shot broadcast
99+- Commands: help, status, broadcast
+15
LICENSE.md
···11+ISC License
22+33+Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>
44+55+Permission to use, copy, modify, and distribute this software for any
66+purpose with or without fee is hereby granted, provided that the above
77+copyright notice and this permission notice appear in all copies.
88+99+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
1010+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1111+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1212+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1313+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1414+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1515+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+62
README.md
···11+# Poe - Zulip Bot for Monorepo Changes
22+33+Poe is a Zulip bot that broadcasts daily changelog updates from a monorepo to a configured channel. It integrates with Claude to interpret messages and can help extend its own functionality.
44+55+## Key Features
66+77+- **Daily Broadcasts**: Automatically post changelog updates to Zulip
88+- **Claude Integration**: Natural language interaction for queries and self-modification
99+- **XDG Configuration**: Standard config file locations with fallbacks
1010+- **Multiple Commands**: Run as daemon or one-shot broadcast
1111+1212+## Usage
1313+1414+### Running the Bot
1515+1616+```bash
1717+# Run as a long-running bot service
1818+poe run
1919+2020+# With explicit config file
2121+poe run -c /path/to/poe.toml
2222+2323+# Broadcast changes once and exit
2424+poe broadcast
2525+```
2626+2727+### Configuration
2828+2929+Poe searches for configuration in order:
3030+1. Explicit path via `-c` / `--config`
3131+2. XDG config: `~/.config/poe/config.toml`
3232+3. Current directory: `poe.toml`
3333+4. Built-in defaults
3434+3535+Config file format (TOML):
3636+3737+```toml
3838+channel = "general" # Zulip channel to broadcast to
3939+topic = "Daily Changes" # Topic for broadcasts
4040+changes_file = "DAILY-CHANGES.md"
4141+monorepo_path = "."
4242+```
4343+4444+Zulip credentials are loaded from `~/.config/zulip-bot/<name>/config` or environment variables.
4545+4646+### Bot Commands
4747+4848+When running as a bot, Poe responds to:
4949+- `help` or `?` - Show help message
5050+- `status` - Show current configuration
5151+- `broadcast`, `post changes`, `post`, `changes` - Broadcast daily changes
5252+- Any other message - Passed to Claude for interpretation
5353+5454+## Installation
5555+5656+```
5757+opam install poe
5858+```
5959+6060+## License
6161+6262+ISC
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let setup_logging style_renderer level =
77+ Fmt_tty.setup_std_outputs ?style_renderer ();
88+ Logs.set_level level;
99+ Logs.set_reporter (Logs_fmt.reporter ())
1010+1111+let run_bot bot_name config_file =
1212+ Eio_main.run @@ fun env ->
1313+ Eio.Switch.run @@ fun sw ->
1414+ let fs = Eio.Stdenv.fs env in
1515+ let process_mgr = Eio.Stdenv.process_mgr env in
1616+ let clock = Eio.Stdenv.clock env in
1717+1818+ (* Load poe config: explicit path > XDG > current dir > defaults *)
1919+ let poe_config =
2020+ match config_file with
2121+ | Some path -> Poe.Config.load ~fs path
2222+ | None -> (
2323+ match Poe.Config.load_xdg_opt ~fs with
2424+ | Some c -> c
2525+ | None -> (
2626+ match Poe.Config.load_opt ~fs "poe.toml" with
2727+ | Some c -> c
2828+ | None -> Poe.Config.default))
2929+ in
3030+3131+ (* Load zulip bot config *)
3232+ let zulip_config = Zulip_bot.Config.load_or_env ~fs bot_name in
3333+3434+ (* Create handler environment *)
3535+ let handler_env : _ Poe.Handler.env =
3636+ { sw; process_mgr; clock; fs }
3737+ in
3838+3939+ (* Create and run the bot *)
4040+ let handler = Poe.Handler.make_handler handler_env poe_config in
4141+ Logs.info (fun m ->
4242+ m "Starting Poe bot, broadcasting to %s/%s" poe_config.channel
4343+ poe_config.topic);
4444+ Zulip_bot.Bot.run ~sw ~env ~config:zulip_config ~handler
4545+4646+let run_cmd =
4747+ let open Cmdliner in
4848+ let bot_name =
4949+ Arg.(
5050+ value
5151+ & opt string "poe"
5252+ & info [ "n"; "name" ] ~docv:"NAME"
5353+ ~doc:"Bot name for Zulip configuration lookup.")
5454+ in
5555+ let config_file =
5656+ Arg.(
5757+ value
5858+ & opt (some string) None
5959+ & info [ "c"; "config" ] ~docv:"FILE"
6060+ ~doc:"Path to poe.toml configuration file.")
6161+ in
6262+ let run style_renderer level bot_name config_file =
6363+ setup_logging style_renderer level;
6464+ run_bot bot_name config_file
6565+ in
6666+ let doc = "Run the Poe Zulip bot" in
6767+ let info = Cmd.info "run" ~doc in
6868+ Cmd.v info
6969+ Term.(
7070+ const run $ Fmt_cli.style_renderer () $ Logs_cli.level () $ bot_name
7171+ $ config_file)
7272+7373+let broadcast_cmd =
7474+ let open Cmdliner in
7575+ let config_file =
7676+ Arg.(
7777+ value
7878+ & opt (some string) None
7979+ & info [ "c"; "config" ] ~docv:"FILE"
8080+ ~doc:"Path to poe.toml configuration file.")
8181+ in
8282+ let bot_name =
8383+ Arg.(
8484+ value
8585+ & opt string "poe"
8686+ & info [ "n"; "name" ] ~docv:"NAME"
8787+ ~doc:"Bot name for Zulip configuration lookup.")
8888+ in
8989+ let broadcast style_renderer level config_file bot_name =
9090+ setup_logging style_renderer level;
9191+ Eio_main.run @@ fun env ->
9292+ Eio.Switch.run @@ fun sw ->
9393+ let fs = Eio.Stdenv.fs env in
9494+9595+ (* Load poe config: explicit path > XDG > current dir > defaults *)
9696+ let poe_config =
9797+ match config_file with
9898+ | Some path -> Poe.Config.load ~fs path
9999+ | None -> (
100100+ match Poe.Config.load_xdg_opt ~fs with
101101+ | Some c -> c
102102+ | None -> (
103103+ match Poe.Config.load_opt ~fs "poe.toml" with
104104+ | Some c -> c
105105+ | None -> Poe.Config.default))
106106+ in
107107+108108+ let zulip_config = Zulip_bot.Config.load_or_env ~fs bot_name in
109109+ let client = Zulip_bot.Bot.create_client ~sw ~env ~config:zulip_config in
110110+111111+ match Poe.Handler.read_changes_file ~fs poe_config with
112112+ | None ->
113113+ Logs.err (fun m ->
114114+ m "Could not read changes file: %s" poe_config.changes_file)
115115+ | Some content ->
116116+ let msg =
117117+ Zulip.Message.create ~type_:`Channel ~to_:[ poe_config.channel ]
118118+ ~topic:poe_config.topic ~content ()
119119+ in
120120+ let resp = Zulip.Messages.send client msg in
121121+ Logs.info (fun m ->
122122+ m "Broadcast sent, message ID: %d" (Zulip.Message_response.id resp))
123123+ in
124124+ let doc = "Broadcast daily changes to Zulip (one-shot)" in
125125+ let info = Cmd.info "broadcast" ~doc in
126126+ Cmd.v info
127127+ Term.(
128128+ const broadcast $ Fmt_cli.style_renderer () $ Logs_cli.level ()
129129+ $ config_file $ bot_name)
130130+131131+let main_cmd =
132132+ let open Cmdliner in
133133+ let doc = "Poe - Zulip bot for monorepo changes with Claude integration" in
134134+ let info =
135135+ Cmd.info "poe" ~version:"0.1.0" ~doc
136136+ ~man:
137137+ [
138138+ `S Manpage.s_description;
139139+ `P
140140+ "Poe is a Zulip bot that broadcasts daily changelog updates from a \
141141+ monorepo to a configured channel. It integrates with Claude to \
142142+ interpret messages and can help extend its own functionality.";
143143+ `S Manpage.s_commands;
144144+ `P "$(b,run) - Run the bot as a long-running service";
145145+ `P "$(b,broadcast) - Send daily changes once and exit";
146146+ `S "CONFIGURATION";
147147+ `P
148148+ "Poe configuration is searched in order:";
149149+ `I ("1.", "Explicit path via $(b,-c) / $(b,--config)");
150150+ `I ("2.", "XDG config: $(b,~/.config/poe/config.toml)");
151151+ `I ("3.", "Current directory: $(b,poe.toml)");
152152+ `I ("4.", "Built-in defaults");
153153+ `P "Config file format (TOML):";
154154+ `Pre
155155+ "channel = \"general\" # Zulip channel to broadcast to\n\
156156+ topic = \"Daily Changes\" # Topic for broadcasts\n\
157157+ changes_file = \"DAILY-CHANGES.md\"\n\
158158+ monorepo_path = \".\"";
159159+ `P
160160+ "Zulip credentials are loaded from \
161161+ $(b,~/.config/zulip-bot/<name>/config) or environment variables.";
162162+ ]
163163+ in
164164+ Cmd.group info [ run_cmd; broadcast_cmd ]
165165+166166+let () =
167167+ Fmt_tty.setup_std_outputs ();
168168+ Logs.set_reporter (Logs_fmt.reporter ());
169169+ exit (Cmdliner.Cmd.eval main_cmd)
+14
config.toml.example
···11+# Poe Bot Configuration
22+# Copy this file to ~/.config/poe/config.toml
33+44+# The Zulip channel/stream to broadcast daily changes to
55+channel = "general"
66+77+# The topic for broadcast messages
88+topic = "Daily Changes"
99+1010+# Path to the daily changes file (relative to monorepo_path)
1111+changes_file = "DAILY-CHANGES.md"
1212+1313+# Path to the monorepo root (where the changes file is located)
1414+monorepo_path = "."
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let src = Logs.Src.create "poe" ~doc:"Poe Zulip bot"
77+88+module Log = (val Logs.src_log src : Logs.LOG)
99+1010+type 'a env = {
1111+ sw : Eio.Switch.t;
1212+ process_mgr : 'a Eio.Process.mgr;
1313+ clock : float Eio.Time.clock_ty Eio.Resource.t;
1414+ fs : Eio.Fs.dir_ty Eio.Path.t;
1515+}
1616+1717+let read_changes_file ~fs config =
1818+ let open Eio.Path in
1919+ let path = fs / config.Config.monorepo_path / config.Config.changes_file in
2020+ try Some (load path) with _ -> None
2121+2222+let broadcast_changes ~fs ~storage:_ ~identity:_ config _msg =
2323+ match read_changes_file ~fs config with
2424+ | None ->
2525+ Zulip_bot.Response.reply
2626+ (Printf.sprintf "Could not read changes file: %s"
2727+ config.Config.changes_file)
2828+ | Some content ->
2929+ Zulip_bot.Response.stream ~stream:config.Config.channel
3030+ ~topic:config.Config.topic ~content
3131+3232+let create_claude_client env =
3333+ let options =
3434+ Claude.Options.default
3535+ |> Claude.Options.with_model `Sonnet_4_5
3636+ |> Claude.Options.with_permission_mode Claude.Permissions.Mode.Bypass_permissions
3737+ |> Claude.Options.with_allowed_tools [ "Read"; "Glob"; "Grep" ]
3838+ |> Claude.Options.with_append_system_prompt
3939+ {|You are Poe, a helpful Zulip bot that manages a monorepo.
4040+You have access to your own source code in the poe/ directory.
4141+When asked to add features to yourself, you can read and understand your implementation.
4242+Be concise in your responses as they will be posted to Zulip.
4343+When suggesting code changes, format them clearly with markdown code blocks.|}
4444+ in
4545+ Claude.Client.create ~options ~sw:env.sw ~process_mgr:env.process_mgr
4646+ ~clock:env.clock ()
4747+4848+let ask_claude env prompt =
4949+ let client = create_claude_client env in
5050+ Claude.Client.query client prompt;
5151+ let responses = Claude.Client.receive_all client in
5252+ let text =
5353+ List.filter_map
5454+ (function
5555+ | Claude.Response.Text t -> Some (Claude.Response.Text.content t)
5656+ | _ -> None)
5757+ responses
5858+ in
5959+ String.concat "" text
6060+6161+let handle_help () =
6262+ Zulip_bot.Response.reply
6363+ {|**Poe Bot Commands:**
6464+6565+- `broadcast` or `post changes` - Broadcast the daily changes to the configured channel
6666+- `help` - Show this help message
6767+- `status` - Show bot configuration status
6868+- Any other message will be interpreted by Claude to help you understand or modify the bot
6969+7070+**Configuration:**
7171+The bot reads its configuration from `poe.toml` with the following fields:
7272+- `channel` - The Zulip channel to broadcast to
7373+- `topic` - The topic for broadcast messages
7474+- `changes_file` - Path to the daily changes file
7575+- `monorepo_path` - Path to the monorepo root|}
7676+7777+let handle_status config =
7878+ Zulip_bot.Response.reply
7979+ (Printf.sprintf
8080+ {|**Poe Bot Status:**
8181+8282+- Channel: `%s`
8383+- Topic: `%s`
8484+- Changes file: `%s`
8585+- Monorepo path: `%s`|}
8686+ config.Config.channel config.Config.topic config.Config.changes_file
8787+ config.Config.monorepo_path)
8888+8989+let handle_claude_query env msg =
9090+ let content = Zulip_bot.Message.content msg in
9191+ Log.info (fun m -> m "Asking Claude: %s" content);
9292+ let prompt =
9393+ Printf.sprintf
9494+ {|The user sent this message to the Poe Zulip bot:
9595+9696+%s
9797+9898+Please help them. If they're asking about adding features to the bot, read the bot's source code in the poe/ directory first.
9999+If they're asking about the monorepo or daily changes, help them understand the content.
100100+Keep your response concise and suitable for a Zulip message.|}
101101+ content
102102+ in
103103+ let response = ask_claude env prompt in
104104+ Log.info (fun m -> m "Claude response: %s" response);
105105+ Zulip_bot.Response.reply response
106106+107107+let make_handler env config =
108108+ fun ~storage ~identity msg ->
109109+ let bot_email = identity.Zulip_bot.Bot.email in
110110+ let sender_email = Zulip_bot.Message.sender_email msg in
111111+ if sender_email = bot_email then Zulip_bot.Response.silent
112112+ else
113113+ let content =
114114+ Zulip_bot.Message.strip_mention msg ~user_email:bot_email
115115+ |> String.trim |> String.lowercase_ascii
116116+ in
117117+ Log.info (fun m -> m "Received message: %s" content);
118118+ match content with
119119+ | "help" | "?" -> handle_help ()
120120+ | "status" -> handle_status config
121121+ | "broadcast" | "post changes" | "post" | "changes" ->
122122+ broadcast_changes ~fs:env.fs ~storage ~identity config msg
123123+ | _ -> handle_claude_query env msg
+33
lib/handler.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Poe bot message handler. *)
77+88+val src : Logs.Src.t
99+(** Log source for the handler. *)
1010+1111+type 'a env = {
1212+ sw : Eio.Switch.t;
1313+ process_mgr : 'a Eio.Process.mgr;
1414+ clock : float Eio.Time.clock_ty Eio.Resource.t;
1515+ fs : Eio.Fs.dir_ty Eio.Path.t;
1616+}
1717+(** Environment required for the handler. *)
1818+1919+val make_handler : _ env -> Config.t -> Zulip_bot.Bot.handler
2020+(** [make_handler env config] creates a bot handler with the given
2121+ environment and configuration.
2222+2323+ The handler responds to:
2424+ - "help" or "?" - Shows help message
2525+ - "status" - Shows current configuration
2626+ - "broadcast", "post changes", "post", "changes" - Broadcasts daily changes
2727+ - Any other message - Passed to Claude for interpretation *)
2828+2929+val ask_claude : _ env -> string -> string
3030+(** [ask_claude env prompt] sends a prompt to Claude and returns the response. *)
3131+3232+val read_changes_file : fs:_ Eio.Path.t -> Config.t -> string option
3333+(** [read_changes_file ~fs config] reads the daily changes file. *)
+9
lib/poe.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Poe - A Zulip bot for broadcasting monorepo changes with Claude integration. *)
77+88+module Config = Config
99+module Handler = Handler
+40
poe.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis:
44+ "Zulip bot for broadcasting monorepo changes with Claude integration"
55+description: """
66+Poe is a Zulip bot that broadcasts daily changelog updates from the monorepo
77+ to a configured channel. It integrates with Claude to interpret messages
88+ and can help extend its own functionality."""
99+maintainer: ["Anil Madhavapeddy <anil@recoil.org>"]
1010+authors: ["Anil Madhavapeddy"]
1111+license: "ISC"
1212+homepage: "https://tangled.org/@anil.recoil.org/poe"
1313+bug-reports: "https://tangled.org/@anil.recoil.org/poe/issues"
1414+depends: [
1515+ "ocaml" {>= "5.2.0"}
1616+ "dune" {>= "3.20" & >= "3.20"}
1717+ "eio_main" {>= "1.2"}
1818+ "zulip" {>= "0.1.0"}
1919+ "claude" {>= "0.1.0"}
2020+ "tomlt" {>= "0.1.0"}
2121+ "xdge" {>= "0.1.0"}
2222+ "logs" {>= "0.7.0"}
2323+ "cmdliner" {>= "1.3.0"}
2424+ "odoc" {with-doc}
2525+]
2626+build: [
2727+ ["dune" "subst"] {dev}
2828+ [
2929+ "dune"
3030+ "build"
3131+ "-p"
3232+ name
3333+ "-j"
3434+ jobs
3535+ "@install"
3636+ "@runtest" {with-test}
3737+ "@doc" {with-doc}
3838+ ]
3939+]
4040+x-maintenance-intent: ["(latest)"]