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:
- Zulip REST API documentation: https://zulip.com/api/rest
- Python zulip library: https://github.com/zulip/python-zulip-api
- Zulip error handling: https://zulip.com/api/rest-error-handling
- Zulip send message API: https://zulip.com/api/send-message
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:
- https://github.com/zulip/python-zulip-api/blob/main/zulip_bots/zulip_bots/lib.py
- https://github.com/zulip/python-zulip-api/blob/main/zulip_botserver/zulip_botserver/server.py
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#
- Structured Concurrency: Each bot runs in its own fiber with proper cleanup
- Resource Safety: Automatic cleanup of connections, event queues, and HTTP servers
- Backpressure: Natural flow control through EIO's cooperative scheduling
- Error Isolation: Bot failures don't crash the entire server
- Graceful Shutdown: Structured teardown of all resources
Design Principles#
- Abstract Types: Each major concept has its own module with abstract
type t - Constructors: Clear
createfunctions with optional parameters - Accessors: All fields accessible via dedicated functions
- Pretty Printing: Every type has a
ppfunction for debugging - JSON Conversion: Bidirectional JSON conversion where appropriate
- Error Handling: Consistent
(_, Error.t) resultreturn types
Authentication Strategy#
- Support zuliprc files and direct credential passing
- Abstract
Auth.tprevents 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/Ocohttp-eio- HTTP client with EIO supporttls-eio- TLS support for HTTPSjsonm- Streaming JSON codecuri- URI parsing and manipulationbase64- 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 accessBot_runner- Manages lifecycle of one bot (real-time events or webhook mode)Bot_config- Configuration for a single botBot_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 ZulipBot_registry- Manages multiple bot instancesServer_config- Configuration for multiple bots + server settingsWebhook_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:
- Network Operations: Custom HTTP requests, API calls to external services
- File System Operations: Reading configuration files, CSV dictionaries, logs
- 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#
- Remove the problematic
handle_messagefunction with mock environment - Keep
handle_message_with_envbut rename tohandle_message - Use
#Eio.Env.tconstraint for clean typing - 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:
-
Zulip REST API Documentation:
- Main API: https://zulip.com/api/rest
- Error Handling: https://zulip.com/api/rest-error-handling
- Send Message: https://zulip.com/api/send-message
-
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.