A Zulip bot agent to sit in our Black Sun. Ever evolving
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Post Claude agent activity to Zulip channel/DM during sessions

When Claude uses tools (Read, Glob, Grep, Edit, Write, Bash) or has
thinking/error events during a conversation, these are now posted to
the originating Zulip channel or DM with pretty-printed Zulip richtext
formatting:

- Tool uses shown with :gear: emoji and relevant parameters
- Thinking blocks shown with :thought_balloon: emoji (truncated)
- Errors shown with :warning: emoji

The agent activity is posted as a separate message before the final
response, giving users visibility into what Claude is doing.

Co-Authored-By: Claude (claude-opus-4-5) <noreply@anthropic.com>

+119 -5
+119 -5
lib/handler.ml
··· 69 69 Claude.Client.create ~options ~sw:env.sw ~process_mgr:env.process_mgr 70 70 ~clock:env.clock () 71 71 72 + (** Format tool use for Zulip richtext display *) 73 + let format_tool_use (tool : Claude.Response.Tool_use.t) = 74 + let name = Claude.Response.Tool_use.name tool in 75 + let input = Claude.Response.Tool_use.input tool in 76 + (* Extract key parameters for common tools *) 77 + let params = match name with 78 + | "Read" -> 79 + Claude.Tool_input.get_string input "file_path" 80 + |> Option.map (fun p -> Printf.sprintf "`%s`" p) 81 + |> Option.value ~default:"" 82 + | "Glob" -> 83 + Claude.Tool_input.get_string input "pattern" 84 + |> Option.map (fun p -> Printf.sprintf "pattern: `%s`" p) 85 + |> Option.value ~default:"" 86 + | "Grep" -> 87 + let pattern = Claude.Tool_input.get_string input "pattern" |> Option.value ~default:"" in 88 + let path = Claude.Tool_input.get_string input "path" |> Option.value ~default:"." in 89 + Printf.sprintf "`%s` in `%s`" pattern path 90 + | "Edit" -> 91 + Claude.Tool_input.get_string input "file_path" 92 + |> Option.map (fun p -> Printf.sprintf "`%s`" p) 93 + |> Option.value ~default:"" 94 + | "Write" -> 95 + Claude.Tool_input.get_string input "file_path" 96 + |> Option.map (fun p -> Printf.sprintf "`%s`" p) 97 + |> Option.value ~default:"" 98 + | "Bash" -> 99 + Claude.Tool_input.get_string input "command" 100 + |> Option.map (fun c -> 101 + let truncated = if String.length c > 60 then String.sub c 0 57 ^ "..." else c in 102 + Printf.sprintf "`%s`" truncated) 103 + |> Option.value ~default:"" 104 + | _ -> "" 105 + in 106 + if params = "" then 107 + Printf.sprintf "> :gear: **%s**" name 108 + else 109 + Printf.sprintf "> :gear: **%s** %s" name params 110 + 111 + (** Format thinking block for Zulip richtext display *) 112 + let format_thinking (thinking : Claude.Response.Thinking.t) = 113 + let content = Claude.Response.Thinking.content thinking in 114 + (* Truncate long thinking and format as quote *) 115 + let truncated = 116 + if String.length content > 200 then 117 + String.sub content 0 197 ^ "..." 118 + else content 119 + in 120 + Printf.sprintf "> :thought_balloon: *%s*" truncated 121 + 122 + (** Format error for Zulip richtext display *) 123 + let format_error (err : Claude.Response.Error.t) = 124 + let msg = Claude.Response.Error.message err in 125 + Printf.sprintf "> :warning: **Error:** %s" msg 126 + 127 + (** Post a message to the appropriate Zulip channel/DM based on scope *) 128 + let post_to_scope ~client ~(scope : Session.scope) content = 129 + let message = match scope with 130 + | Session.Channel { stream; topic } -> 131 + Zulip.Message.create ~type_:`Channel ~to_:[stream] ~topic ~content () 132 + | Session.Direct { user_email } -> 133 + Zulip.Message.create ~type_:`Direct ~to_:[user_email] ~content () 134 + in 135 + let _resp = Zulip.Messages.send client message in 136 + () 137 + 72 138 let ask_claude env prompt = 73 139 let client = create_claude_client env in 74 140 Claude.Client.query client prompt; ··· 82 148 in 83 149 String.concat "" text 84 150 85 - let ask_claude_with_session env ~storage msg user_content = 151 + (** Ask Claude with streaming responses posted to Zulip *) 152 + let ask_claude_with_session_streaming env ~zulip_client ~storage msg user_content = 86 153 let scope = Session.scope_of_message msg in 87 154 let now = Unix.gettimeofday () in 88 155 let session = Session.load storage ~scope ~now in ··· 105 172 context_section user_content 106 173 in 107 174 108 - let response = ask_claude env prompt in 175 + (* Create Claude client and start query *) 176 + let claude_client = create_claude_client env in 177 + Claude.Client.query claude_client prompt; 178 + 179 + (* Accumulate text and track agent messages *) 180 + let text_buffer = Buffer.create 1024 in 181 + let agent_messages = ref [] in 182 + 183 + (* Create streaming handler that posts agent messages to Zulip *) 184 + let handler = object 185 + inherit Claude.Handler.default 186 + 187 + method! on_text t = 188 + Buffer.add_string text_buffer (Claude.Response.Text.content t) 189 + 190 + method! on_tool_use t = 191 + let formatted = format_tool_use t in 192 + agent_messages := formatted :: !agent_messages; 193 + Log.debug (fun m -> m "Tool use: %s" (Claude.Response.Tool_use.name t)) 194 + 195 + method! on_thinking t = 196 + let formatted = format_thinking t in 197 + agent_messages := formatted :: !agent_messages; 198 + Log.debug (fun m -> m "Thinking: %s" (String.sub (Claude.Response.Thinking.content t) 0 (min 50 (String.length (Claude.Response.Thinking.content t))))) 199 + 200 + method! on_error t = 201 + let formatted = format_error t in 202 + agent_messages := formatted :: !agent_messages; 203 + Log.warn (fun m -> m "Claude error: %s" (Claude.Response.Error.message t)) 204 + 205 + method! on_complete c = 206 + let cost = Claude.Response.Complete.total_cost_usd c |> Option.value ~default:0.0 in 207 + let turns = Claude.Response.Complete.num_turns c in 208 + Log.info (fun m -> m "Claude complete: %d turns, $%.4f" turns cost) 209 + end in 210 + 211 + (* Run the streaming handler *) 212 + Claude.Client.run claude_client ~handler; 213 + 214 + (* Post agent messages summary if any occurred *) 215 + let agent_msgs = List.rev !agent_messages in 216 + if agent_msgs <> [] then begin 217 + let agent_summary = String.concat "\n" agent_msgs in 218 + let header = Printf.sprintf "**Agent activity:**\n%s" agent_summary in 219 + post_to_scope ~client:zulip_client ~scope header 220 + end; 221 + 222 + let response = Buffer.contents text_buffer in 109 223 110 224 (* Save the updated session with the response *) 111 225 let now = Unix.gettimeofday () in ··· 256 370 ~content:(Printf.sprintf "**Refresh triggered manually**\n\n%s" content) 257 371 end 258 372 259 - let handle_claude_query env ~storage msg = 373 + let handle_claude_query env ~zulip_client ~storage msg = 260 374 let content = Zulip_bot.Message.content msg in 261 375 Log.info (fun m -> m "Asking Claude: %s" content); 262 - let response = ask_claude_with_session env ~storage msg content in 376 + let response = ask_claude_with_session_streaming env ~zulip_client ~storage msg content in 263 377 Log.info (fun m -> m "Claude response: %s" response); 264 378 Zulip_bot.Response.reply response 265 379 ··· 315 429 else 316 430 Zulip_bot.Response.reply "Admin commands require authorization. Contact an admin to be added to the admin_emails list." 317 431 | Commands.Clear_session -> handle_clear_session ~storage msg 318 - | Commands.Unknown _ -> handle_claude_query env ~storage msg 432 + | Commands.Unknown _ -> handle_claude_query env ~zulip_client:client ~storage msg