My agentic slop goes here. Not intended for anyone else!

I would like to build high quality OCaml bindings to the Zulip REST API, documented at https://zulip.com/api/rest. As another reference, the Python zulip library from pip is well maintained.

My target is to use the OCaml EIO direct-style library, with an idiomatic as possible API that implements it. For JSON parsing, using the jsonm library is right. For HTTPS, use cohttp-eio with the tls-eio library. You have access to an OCaml LSP via MCP which provides type hints and other language server features after you complete a dune build.

OCaml Zulip Library Design#

Based on analysis of:

Overview#

The library follows OCaml best practices with abstract types (type t) per module, comprehensive constructors/accessors, and proper pretty printers. Each core concept gets its own module with a clean interface.

Module Structure#

Authentication (Zulip.Auth)#

type t (* abstract *)

val create : server_url:string -> email:string -> api_key:string -> t
val from_zuliprc : ?path:string -> unit -> (t, Error.t) result
val server_url : t -> string
val email : t -> string
val to_basic_auth_header : t -> string
val pp : Format.formatter -> t -> unit

Error Handling (Zulip.Error)#

type code = 
  | Invalid_api_key
  | Request_variable_missing
  | Bad_request
  | User_deactivated
  | Realm_deactivated
  | Rate_limit_hit
  | Other of string

type t (* abstract *)

val create : code:code -> msg:string -> ?extra:(string * Jsonm.value) list -> unit -> t
val code : t -> code
val message : t -> string
val extra : t -> (string * Jsonm.value) list
val pp : Format.formatter -> t -> unit
val of_json : Jsonm.value -> t option

Message Types (Zulip.Message_type)#

type t = [ `Direct | `Channel ]

val to_string : t -> string
val of_string : string -> t option
val pp : Format.formatter -> t -> unit

Message (Zulip.Message)#

type t (* abstract *)

val create : 
  type_:Message_type.t -> 
  to_:string list -> 
  content:string -> 
  ?topic:string -> 
  ?queue_id:string -> 
  ?local_id:string -> 
  ?read_by_sender:bool -> 
  unit -> t

val type_ : t -> Message_type.t
val to_ : t -> string list
val content : t -> string
val topic : t -> string option
val queue_id : t -> string option
val local_id : t -> string option
val read_by_sender : t -> bool
val to_json : t -> Jsonm.value
val pp : Format.formatter -> t -> unit

Message Response (Zulip.Message_response)#

type t (* abstract *)

val id : t -> int
val automatic_new_visibility_policy : t -> string option
val of_json : Jsonm.value -> (t, Error.t) result
val pp : Format.formatter -> t -> unit

Client (Zulip.Client)#

type t (* abstract *)

val create : #Eio.Env.t -> Auth.t -> t
val with_client : #Eio.Env.t -> Auth.t -> (t -> 'a) -> 'a

val request : 
  t -> 
  method_:[`GET | `POST | `PUT | `DELETE | `PATCH] ->
  path:string -> 
  ?params:(string * string) list ->
  ?body:string ->
  unit -> 
  (Jsonm.value, Error.t) result

Messages (Zulip.Messages)#

val send : Client.t -> Message.t -> (Message_response.t, Error.t) result
val edit : Client.t -> message_id:int -> ?content:string -> ?topic:string -> unit -> (unit, Error.t) result
val delete : Client.t -> message_id:int -> (unit, Error.t) result
val get : Client.t -> message_id:int -> (Jsonm.value, Error.t) result
val get_messages : 
  Client.t -> 
  ?anchor:string -> 
  ?num_before:int -> 
  ?num_after:int -> 
  ?narrow:string list -> 
  unit -> 
  (Jsonm.value, Error.t) result

Channel (Zulip.Channel)#

type t (* abstract *)

val create : 
  name:string -> 
  description:string -> 
  ?invite_only:bool -> 
  ?history_public_to_subscribers:bool -> 
  unit -> t

val name : t -> string
val description : t -> string
val invite_only : t -> bool
val history_public_to_subscribers : t -> bool
val to_json : t -> Jsonm.value
val of_json : Jsonm.value -> (t, Error.t) result
val pp : Format.formatter -> t -> unit

Channels (Zulip.Channels)#

val create_channel : Client.t -> Channel.t -> (unit, Error.t) result
val delete : Client.t -> name:string -> (unit, Error.t) result
val list : Client.t -> (Channel.t list, Error.t) result
val subscribe : Client.t -> channels:string list -> (unit, Error.t) result
val unsubscribe : Client.t -> channels:string list -> (unit, Error.t) result

User (Zulip.User)#

type t (* abstract *)

val create : 
  email:string -> 
  full_name:string -> 
  ?is_active:bool -> 
  ?is_admin:bool -> 
  ?is_bot:bool -> 
  unit -> t

val email : t -> string
val full_name : t -> string
val is_active : t -> bool
val is_admin : t -> bool
val is_bot : t -> bool
val to_json : t -> Jsonm.value
val of_json : Jsonm.value -> (t, Error.t) result
val pp : Format.formatter -> t -> unit

Users (Zulip.Users)#

val list : Client.t -> (User.t list, Error.t) result
val get : Client.t -> email:string -> (User.t, Error.t) result
val create_user : Client.t -> email:string -> full_name:string -> (unit, Error.t) result
val deactivate : Client.t -> email:string -> (unit, Error.t) result

Event Type (Zulip.Event_type)#

type t = 
  | Message
  | Subscription 
  | User_activity
  | Other of string

val to_string : t -> string
val of_string : string -> t
val pp : Format.formatter -> t -> unit

Event (Zulip.Event)#

type t (* abstract *)

val id : t -> int
val type_ : t -> Event_type.t
val data : t -> Jsonm.value
val of_json : Jsonm.value -> (t, Error.t) result
val pp : Format.formatter -> t -> unit

Event Queue (Zulip.Event_queue)#

type t (* abstract *)

val register : 
  Client.t -> 
  ?event_types:Event_type.t list -> 
  unit -> 
  (t, Error.t) result

val id : t -> string
val get_events : t -> Client.t -> ?last_event_id:int -> unit -> (Event.t list, Error.t) result
val delete : t -> Client.t -> (unit, Error.t) result
val pp : Format.formatter -> t -> unit

EIO Bot Framework Extension#

Based on analysis of the Python bot framework at:

Bot Handler (Zulip.Bot)#

module Storage : sig
  type t (* abstract *)
  
  val create : Client.t -> t
  val get : t -> key:string -> string option
  val put : t -> key:string -> value:string -> unit
  val contains : t -> key:string -> bool
end

module Identity : sig
  type t (* abstract *)
  
  val full_name : t -> string
  val email : t -> string
  val mention_name : t -> string
end

type handler = {
  handle_message : 
    client:Client.t ->
    message:Jsonm.value ->
    response:(Message.t -> unit) ->
    unit;
  
  usage : unit -> string;
  description : unit -> string;
}

type t (* abstract *)

val create : 
  Client.t -> 
  handler:handler ->
  ?storage:Storage.t ->
  unit -> t

val identity : t -> Identity.t
val storage : t -> Storage.t
val handle_message : t -> Jsonm.value -> unit
val send_reply : t -> original_message:Jsonm.value -> content:string -> unit
val send_message : t -> Message.t -> unit

Bot Server (Zulip.Bot_server)#

module Config : sig
  type bot_config = {
    email : string;
    api_key : string;
    token : string; (* webhook token *)
    server_url : string;
    module_name : string;
  }
  
  type t (* abstract *)
  
  val create : bot_configs:bot_config list -> ?host:string -> ?port:int -> unit -> t
  val from_file : string -> (t, Error.t) result
  val from_env : string -> (t, Error.t) result
  val host : t -> string
  val port : t -> int
  val bot_configs : t -> bot_config list
end

type t (* abstract *)

val create : #Eio.Env.t -> Config.t -> (t, Error.t) result

val run : t -> unit
(* Starts the server using EIO structured concurrency *)

val with_server : #Eio.Env.t -> Config.t -> (t -> 'a) -> ('a, Error.t) result
(* Resource-safe server management *)

Bot Registry (Zulip.Bot_registry)#

type bot_module = {
  name : string;
  handler : Bot.handler;
  create_instance : Client.t -> Bot.t;
}

type t (* abstract *)

val create : unit -> t
val register : t -> bot_module -> unit
val get_handler : t -> email:string -> Bot.t option
val list_bots : t -> string list

(* Dynamic module loading *)
val load_from_file : string -> (bot_module, Error.t) result
val load_from_directory : string -> (bot_module list, Error.t) result

Webhook Handler (Zulip.Webhook)#

type webhook_event = {
  bot_email : string;
  token : string;
  message : Jsonm.value;
  trigger : [`Direct_message | `Mention];
}

type response = {
  content : string option;
  message_type : Message_type.t option;
  to_ : string list option;
  topic : string option;
}

val parse_webhook : string -> (webhook_event, Error.t) result
val handle_webhook : Bot_registry.t -> webhook_event -> (response option, Error.t) result

Structured Concurrency Design#

The EIO-based server uses structured concurrency to manage multiple bots safely:

(* Example server implementation using EIO *)
let run_server env config =
  let registry = Bot_registry.create () in
  
  (* Load and register all configured bots concurrently *)
  Eio.Switch.run @@ fun sw ->
  
  (* Start each bot in its own fiber *)
  List.iter (fun bot_config ->
    Eio.Fiber.fork ~sw (fun () ->
      let auth = Auth.create 
        ~server_url:bot_config.server_url
        ~email:bot_config.email 
        ~api_key:bot_config.api_key in
      
      Client.with_client env auth @@ fun client ->
      
      (* Load bot module *)
      match Bot_registry.load_from_file bot_config.module_name with
      | Ok bot_module ->
          let bot = bot_module.create_instance client in
          Bot_registry.register registry bot_module;
          
          (* Keep bot alive and handle events *)
          Event_loop.run client bot
      | Error e ->
          Printf.eprintf "Failed to load bot %s: %s\n" 
            bot_config.email (Error.message e)
    )
  ) (Config.bot_configs config);
  
  (* Start HTTP server for webhooks *)
  let server_addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, Config.port config) in
  Eio.Net.run_server env#net server_addr ~on_error:raise @@ fun flow _addr ->
    
    (* Handle each webhook request concurrently *)
    Eio.Switch.run @@ fun req_sw ->
    Eio.Fiber.fork ~sw:req_sw (fun () ->
      handle_http_request registry flow
    )

Event Loop (Zulip.Event_loop)#

type t (* abstract *)

val create : Client.t -> Bot.t -> t

val run : Client.t -> Bot.t -> unit
(* Runs the event loop using real-time events API *)

val run_webhook_mode : Client.t -> Bot.t -> unit  
(* Runs in webhook mode, waiting for HTTP callbacks *)

(* For advanced use cases *)
val with_event_loop : 
  Client.t -> 
  Bot.t -> 
  (Event_queue.t -> unit) -> 
  unit

Key EIO Advantages#

  1. Structured Concurrency: Each bot runs in its own fiber with proper cleanup
  2. Resource Safety: Automatic cleanup of connections, event queues, and HTTP servers
  3. Backpressure: Natural flow control through EIO's cooperative scheduling
  4. Error Isolation: Bot failures don't crash the entire server
  5. Graceful Shutdown: Structured teardown of all resources

Design Principles#

  1. Abstract Types: Each major concept has its own module with abstract type t
  2. Constructors: Clear create functions with optional parameters
  3. Accessors: All fields accessible via dedicated functions
  4. Pretty Printing: Every type has a pp function for debugging
  5. JSON Conversion: Bidirectional JSON conversion where appropriate
  6. Error Handling: Consistent (_, Error.t) result return types

Authentication Strategy#

  • Support zuliprc files and direct credential passing
  • Abstract Auth.t prevents credential leakage
  • HTTP Basic Auth with proper encoding

EIO Integration#

  • All operations use EIO's direct-style async
  • Resource-safe client management with with_client
  • Proper cleanup of connections and event queues

Example Usage#

Simple Message Sending#

let () =
  Eio_main.run @@ fun env ->
  let auth = Zulip.Auth.create 
    ~server_url:"https://example.zulipchat.com"
    ~email:"bot@example.com" 
    ~api_key:"your-api-key" in
  
  Zulip.Client.with_client env auth @@ fun client ->
  
  let message = Zulip.Message.create 
    ~type_:`Channel
    ~to_:["general"]
    ~content:"Hello from OCaml!"
    ~topic:"Bots" 
    () in
  
  match Zulip.Messages.send client message with
  | Ok response -> 
      Printf.printf "Message sent with ID: %d\n" 
        (Zulip.Message_response.id response)
  | Error error -> 
      Printf.printf "Error: %s\n" 
        (Zulip.Error.message error)

Simple Bot#

let echo_handler = Zulip.Bot.{
  handle_message = (fun ~client ~message ~response ->
    let content = extract_content message in
    let echo_msg = Message.create 
      ~type_:`Direct 
      ~to_:[sender_email message]
      ~content:("Echo: " ^ content) () in
    response echo_msg
  );
  usage = (fun () -> "Echo bot - repeats your message");
  description = (fun () -> "A simple echo bot");
}

let () =
  Eio_main.run @@ fun env ->
  let auth = Auth.from_zuliprc () |> Result.get_ok in
  
  Client.with_client env auth @@ fun client ->
  let bot = Bot.create client ~handler:echo_handler () in
  Event_loop.run client bot

Multi-Bot Server#

let () =
  Eio_main.run @@ fun env ->
  let config = Bot_server.Config.from_file "bots.conf" |> Result.get_ok in
  
  Bot_server.with_server env config @@ fun server ->
  Bot_server.run server

Package Dependencies#

  • eio - Effects-based I/O
  • cohttp-eio - HTTP client with EIO support
  • tls-eio - TLS support for HTTPS
  • jsonm - Streaming JSON codec
  • uri - URI parsing and manipulation
  • base64 - Base64 encoding for authentication

Architecture Analysis: zulip_bot vs zulip_botserver#

Library Separation#

zulip_bot - Individual Bot Framework#

Purpose: Library for building and running a single bot instance

Key Components:

  • Bot_handler - Interface for bot logic with EIO environment access
  • Bot_runner - Manages lifecycle of one bot (real-time events or webhook mode)
  • Bot_config - Configuration for a single bot
  • Bot_storage - Simple in-memory storage for bot state

Usage Pattern:

(* Run a single bot directly *)
let my_bot = Bot_handler.create (module My_echo_bot) ~config ~storage ~identity in
let runner = Bot_runner.create ~client ~handler:my_bot in
Bot_runner.run_realtime runner  (* Bot connects to Zulip events API directly *)

zulip_botserver - Multi-Bot Server Infrastructure#

Purpose: HTTP server that manages multiple bots via webhooks

Key Components:

  • Bot_server - HTTP server receiving webhook events from Zulip
  • Bot_registry - Manages multiple bot instances
  • Server_config - Configuration for multiple bots + server settings
  • Webhook_handler - Parses incoming webhook requests and routes to appropriate bots

Usage Pattern:

(* Run a server hosting multiple bots *)
let registry = Bot_registry.create () in
Bot_registry.register registry echo_bot_module;
Bot_registry.register registry weather_bot_module;

let server = Bot_server.create ~env ~config ~registry in
Bot_server.run server  (* HTTP server waits for webhook calls *)

EIO Environment Requirements#

Why Bot Handlers Need Direct EIO Access#

Bot handlers require direct access to the EIO environment for legitimate I/O operations beyond HTTP requests to Zulip:

  1. Network Operations: Custom HTTP requests, API calls to external services
  2. File System Operations: Reading configuration files, CSV dictionaries, logs
  3. Resource Management: Proper cleanup via structured concurrency

Example: URL Checker Bot#

module Url_checker_bot : Zulip_bot.Bot_handler.Bot_handler = struct
  let handle_message ~config ~storage ~identity ~message ~env =
    match parse_command message with
    | "!check", url ->
        (* Direct EIO network access needed *)
        Eio.Switch.run @@ fun sw ->
        let client = Cohttp_eio.Client.make ~sw env#net in
        let response = Cohttp_eio.Client.head ~sw client (Uri.of_string url) in
        let status = Cohttp.Code.code_of_status response.status in
        Ok (Response.reply ~content:(format_status_message url status))
    | _ -> Ok Response.none
end

Example: CSV Dictionary Bot#

module Csv_dict_bot : Zulip_bot.Bot_handler.Bot_handler = struct
  let handle_message ~config ~storage ~identity ~message ~env =
    match parse_command message with
    | "!lookup", term ->
        (* Direct EIO file system access needed *)
        let csv_path = Bot_config.get_required config ~key:"csv_file" in
        let content = Eio.Path.load env#fs (Eio.Path.parse csv_path) in
        let matches = search_csv_content content term in
        Ok (Response.reply ~content:(format_matches matches))
    | _ -> Ok Response.none
end

Refined Bot Handler Interface#

Based on analysis, the current EIO environment plumbing is essential and should be cleaned up:

(** Clean bot handler interface with direct EIO access *)
module type Bot_handler = sig
  val initialize : Bot_config.t -> (unit, Zulip.Error.t) result
  val usage : unit -> string
  val description : unit -> string
  
  (** Handle message with full EIO environment access *)
  val handle_message : 
    config:Bot_config.t ->
    storage:Bot_storage.t ->
    identity:Identity.t ->
    message:Message_context.t ->
    env:#Eio.Env.t ->  (* Essential for custom I/O *)
    (Response.t, Zulip.Error.t) result
end

type t

(** Single creation interface *)
val create : 
  (module Bot_handler) -> 
  config:Bot_config.t -> 
  storage:Bot_storage.t -> 
  identity:Identity.t -> 
  t

(** Single message handler requiring EIO environment *)
val handle_message : t -> #Eio.Env.t -> Message_context.t -> (Response.t, Zulip.Error.t) result

Storage Strategy#

Bot storage can be simplified to in-memory key-value storage since it's server-side:

(* In zulip_bot - storage per bot instance *)
module Bot_storage = struct
  type t = (string, string) Hashtbl.t  (* Simple in-memory key-value *)
  
  let create () = Hashtbl.create 16
  let get t ~key = Hashtbl.find_opt t key  
  let put t ~key ~value = Hashtbl.replace t key value
  let contains t ~key = Hashtbl.mem t key
end

(* In zulip_botserver - storage shared across bots *)
module Server_storage = struct
  type t = (string * string, string) Hashtbl.t  (* (bot_email, key) -> value *)
  
  let create () = Hashtbl.create 64
  let get t ~bot_email ~key = Hashtbl.find_opt t (bot_email, key)
  let put t ~bot_email ~key ~value = Hashtbl.replace t (bot_email, key) value
end

Interface Cleanup Recommendations#

  1. Remove the problematic handle_message function with mock environment
  2. Keep handle_message_with_env but rename to handle_message
  3. Use #Eio.Env.t constraint for clean typing
  4. Document that bot handlers have full EIO access for custom I/O operations

This design maintains flexibility for real-world bot functionality while providing clean, type-safe interfaces.

Sources and References#

This design is based on comprehensive analysis of:

  1. Zulip REST API Documentation:

  2. Python Zulip Library:

The design adapts these Python patterns to idiomatic OCaml with abstract types, proper error handling, and EIO's structured concurrency for robust, type-safe Zulip integration.