hermes#
is a type-safe XRPC client for atproto.
Hermes provides three components:
- hermes - Core library for making XRPC calls
- hermes-cli - Code generator for atproto lexicons
- hermes_ppx - PPX extension for ergonomic API calls
table of contents#
quick start#
open Hermes_lexicons (* generate lexicons using hermes-cli! *)
open Lwt.Syntax
let () = Lwt_main.run begin
(* Create an unauthenticated client *)
let client = Hermes.make_client ~service:"https://public.api.bsky.app" () in
(* Make a query using the generated module *)
let* profile = App_bsky_actor_getProfile.call ~actor:"bsky.app" client in
print_endline profile.display_name;
Lwt.return_unit
end
complete example#
open Hermes_lexicons (* generate lexicons using hermes-cli! *)
open Lwt.Syntax
let main () =
(* Set up credential manager with persistence *)
let manager = Hermes.make_credential_manager ~service:"https://pegasus.example" () in
Hermes.on_session_update manager (fun session ->
let json = Hermes.session_to_yojson session in
Yojson.Safe.to_file "session.json" json;
Lwt.return_unit
);
(* Log in or resume session *)
let* client =
if Sys.file_exists "session.json" then
let json = Yojson.Safe.from_file "session.json" in
match Hermes.session_of_yojson json with
| Ok session -> Hermes.resume manager ~session ()
| Error _ -> failwith "Invalid session file"
else
Hermes.login manager
~identifier:"you.bsky.social"
~password:"your-app-password"
()
in
(* Fetch your profile *)
let session = Hermes.get_session client |> Option.get in
let* profile =
[%xrpc get "app.bsky.actor.getProfile"]
~actor:session.did
client
in
Printf.printf "Logged in as %s\n" profile.handle;
(* Create a post *)
let* _ =
[%xrpc post "com.atproto.repo.createRecord"]
~repo:session.did
~collection:"app.bsky.feed.post"
~record:(`Assoc [
("$type", `String "app.bsky.feed.post");
("text", `String "Hello from Hermes!");
("createdAt", `String (Ptime.to_rfc3339 (Ptime_clock.now ())));
])
client
in
print_endline "Post created!";
Lwt.return_unit
let () = Lwt_main.run (main ())
installation#
Add to your dune-project:
(depends
hermes
hermes_ppx)
hermes
session management#
(* Unauthenticated client for public endpoints *)
let client = Hermes.make_client ~service:"https://public.api.bsky.app" ()
(* Authenticated client with credential manager *)
let manager = Hermes.make_credential_manager ~service:"https://bsky.social" ()
let%lwt client = Hermes.login manager
~identifier:"user.bsky.social"
~password:"app-password-here"
()
(* Get current session for persistence *)
let session = Hermes.get_session client
(* Save session to JSON *)
let json = Hermes.session_to_yojson session
(* Resume from saved session *)
let%lwt client = Hermes.resume manager ~session ()
(* Auto-save session to disk *)
let () = Hermes.on_session_update manager (fun session ->
save_to_disk (Hermes.session_to_yojson session);
Lwt.return_unit
)
(* Listen for session expiration *)
let () = Hermes.on_session_expired manager (fun () ->
print_endline "session expired, log in again!";
Lwt.return_unit
)
making XRPC calls#
(* GET request *)
let%lwt result = Hermes.query client
"app.bsky.actor.getProfile"
(`Assoc [("actor", `String "bsky.app")])
decode_profile
(* GET request returning raw bytes *)
let%lwt (data, content_type) = Hermes.query_bytes client
"com.atproto.sync.getBlob"
(`Assoc [("did", `String did); ("cid", `String cid)])
(* POST request *)
let%lwt result = Hermes.procedure client
"com.atproto.repo.createRecord"
(`Assoc []) (* query params *)
(Some input_json)
decode_response
(* POST request with raw bytes as input *)
let%lwt response = Hermes.procedure_bytes client
"com.atproto.repo.importRepo"
(`Assoc [])
(Some car_data)
~content_type:"application/vnd.ipld.car"
(* upload bytes, get a blob back *)
let%lwt blob = Hermes.procedure_blob client
"com.atproto.repo.uploadBlob"
(`Assoc [])
image_bytes
~content_type:"image/jpeg"
decode_blob
error handling#
try%lwt
let%lwt _ = some_xrpc_call client in
Lwt.return_unit
with Hermes.Xrpc_error { status; error; message } ->
Printf.printf "Error %d: %s (%s)\n"
status error (Option.value message ~default:"no message");
Lwt.return_unit
hermes-cli (codegen)
generates type-safe OCaml modules from atproto lexicon files.
usage#
# Generate from a lexicons directory
hermes-cli generate ./lexicons -o ./lib/generated
# Generate from multiple inputs
hermes-cli generate ./lexicons/com/atproto ./lexicons/app/bsky/feed/*.json -o ./lib/generated
# With custom root module name
hermes-cli generate ./lexicons -o ./lib/generated -m Bsky_api
options#
| Option | Short | Description |
|---|---|---|
INPUT... |
Lexicon files or directories (recursive) | |
--output |
-o |
Output directory for generated OCaml |
--module-name |
-m |
Root module name (default: Lexicons) |
generated code structure#
For a lexicon like app.bsky.actor.getProfile, the generator creates:
lib/generated/
├── dune
├── lexicons.ml # Re-exports all modules
└── app/
└── bsky/
└── actor/
└── getProfile.ml
Each endpoint module contains:
module GetProfile = struct
type params = {
actor: string;
} [@@deriving yojson]
type output = {
did: string;
handle: string;
display_name: string option;
(* ... *)
} [@@deriving yojson]
let nsid = "app.bsky.actor.getProfile"
let call ~actor (client : Hermes.client) : output Lwt.t =
let params = { actor } in
Hermes.query client nsid (params_to_yojson params) output_of_yojson
end
type mappings#
| Lexicon Type | OCaml Type |
|---|---|
boolean |
bool |
integer |
int |
string |
string |
bytes |
string |
blob |
Hermes.blob |
cid-link |
Cid.t |
array |
list |
object |
record type |
union |
variant type |
unknown |
Yojson.Safe.t |
bytes encoding#
Endpoints with non-JSON encoding are automatically detected and handled:
-
Queries with bytes output (e.g.,
com.atproto.sync.getBlobwithencoding: "*/*"):- Output type is
string * string(data, content_type) - Generated code uses
Hermes.query_bytes
- Output type is
-
Procedures with bytes input:
- Input is
?input:string(optional raw bytes) - Generated code uses
Hermes.procedure_bytes
- Input is
union types#
Unions generate variant types with a discriminator:
type relationship_union =
| Relationship of relationship
| NotFoundActor of not_found_actor
| Unknown of Yojson.Safe.t (* for open unions *)
hermes_ppx (PPX extension)
Transforms [%xrpc ...] into generated module calls.
setup#
(library
(name my_app)
(libraries hermes hermes_ppx lwt)
(preprocess (pps hermes_ppx)))
usage
let get_followers ~actor ~limit client =
[%xrpc get "app.bsky.graph.getFollowers"]
~actor
?limit
client
let create_post ~text client =
let session = Hermes.get_session client |> Option.get in
[%xrpc post "com.atproto.repo.createRecord"]
~repo:session.did
~collection:"app.bsky.feed.post"
~record:(`Assoc [
("$type", `String "app.bsky.feed.post");
("text", `String text);
("createdAt", `String (Ptime.to_rfc3339 (Ptime_clock.now ())));
])
client