a2a-ocaml

License: MIT A2A OCaml

A type-safe A2A (Agent2Agent) Protocol v1.0 client for OCaml 5, built on Eio and cohttp-eio. It speaks the JSON-RPC and HTTP+JSON (REST) bindings, and is backward compatible with v0.3 agents: the version and binding are negotiated from the Agent Card, and your code stays the same.

match A2a_client.send_text client "Summarize this company's latest filings" with
| Ok (Message m) -> print_endline (A2a_client.Message.text m)
| Ok (Task t) -> Printf.printf "task %s is %s\n" t.id (A2a_client.Task_state.to_string t.status.state)
| Error e -> prerr_endline (A2a_client.Error.to_string e)

Features

Installation

Install from the opam repository:

opam update
opam install a2a-client

To try the development version, pin it from GitHub: opam pin add a2a-client https://github.com/TaisonTsukada/a2a-ocaml.git. To install from a local checkout, run opam install . in the repository root.

Then add the library to your dune file:

(executable
 (name main)
 (libraries a2a-client eio_main))

Requirements: OCaml ≥ 5.1, eio ≥ 1.0, cohttp-eio ≥ 6.2.1, yojson ≥ 3.0.

Quick start

let () =
  Eio_main.run @@ fun env ->
  let clock = Eio.Stdenv.mono_clock env in
  let transport = A2a_client.Transport.cohttp (Eio.Stdenv.net env) in
  match A2a_client.connect ~clock ~transport (Uri.of_string "http://localhost:9999") with
  | Error e -> prerr_endline (A2a_client.Error.to_string e)
  | Ok client -> (
      match A2a_client.send_text client "Hello" with
      | Ok (Message m) -> print_endline (A2a_client.Message.text m)
      | Ok (Task t) -> print_endline t.id
      | Error e -> prerr_endline (A2a_client.Error.to_string e))

connect reads the Agent Card from <base>/.well-known/agent-card.json and chooses the protocol version and binding (see Protocol versions and bindings). If you already know the endpoint, use A2a_client.create ~clock ~transport endpoint instead.

Transport.cohttp opens a connection per request. For a long-lived process, use Transport.pooled (see Connection reuse).

Usage

Sending messages

send_text is a shortcut for the common case. send_message accepts any Message.t:

open A2a_client

let message =
  Message.user
    [ Part.text "Describe this image";
      Part.url ~media_type:"image/png" (Uri.of_string "https://example.com/chart.png");
      Part.data (`Assoc [ ("locale", `String "en-US") ]) ]

let response =
  send_message client message
    ~configuration:(Send_message_configuration.make ~accepted_output_modes:[ "text/plain" ] ())

The agent replies with either a Message or a Task, and the task may still be running.

Task lifecycle

send_message_and_wait sends a message and then polls GetTask until the task is terminal (completed, failed, canceled or rejected) or interrupted (input-required or auth-required):

match send_message_and_wait client ~timeout:300. (Message.user_text "Research ACME Corp") with
| Ok (Outcome.Message m) -> (* direct answer, no task *) ...
| Ok (Outcome.Completed task) -> List.iter (fun a -> print_endline (Artifact.text a)) task.artifacts
| Ok (Outcome.Failed task | Outcome.Rejected task | Outcome.Canceled task) -> ...
| Ok (Outcome.Input_required task) ->
    (* continue the same task; taskId and contextId are carried over *)
    send_message client (Task.follow_up_text task "The one headquartered in Tokyo")
| Ok (Outcome.Auth_required task) -> ...
| Ok (Outcome.In_progress _) -> assert false (* never returned by send_message_and_wait *)
| Error (Error.Poll_timeout { last_task }) -> (* still running after 300 s *) ...
| Error e -> ...

Listing tasks

list_tasks returns one page of the tasks visible to the client, most recently updated first, with optional filters:

let rec all_working ?page_token acc =
  match list_tasks client ~status:Working ~context_id:"ctx-1" ~page_size:50 ?page_token () with
  | Error e -> Error e
  | Ok { tasks; next_page_token = None; _ } -> Ok (acc @ tasks)
  | Ok { tasks; next_page_token = Some page_token; _ } -> all_working ~page_token (acc @ tasks)

next_page_token is None on the last page. The other filters are history_length, status_timestamp_after and include_artifacts. Protocol 0.3 has no ListTasks; there the call fails locally with Unsupported_capability.

Push notifications

An agent can POST task updates to a webhook while no stream is open. Register the webhook with the message:

let push =
  Push_notification_config.make ~token:"check-me"
    ~authentication:(Authentication_info.make ~credentials:secret "Bearer")
    (Uri.of_string "https://my-service.example.com/a2a/webhook")

let configuration = Send_message_configuration.make ~push_notification_config:push ~return_immediately:true ()

or for an existing task:

create_task_push_notification_config client (Task_push_notification_config.make ~task_id push)
get_task_push_notification_config client ~task_id config_id
list_task_push_notification_configs client task_id
delete_task_push_notification_config client ~task_id config_id

The agent sends token back in the X-A2A-Notification-Token header so the webhook can check where a notification came from. When the client was built from an Agent Card that does not declare capabilities.pushNotifications, these calls fail locally with Unsupported_capability. Receiving the notifications is up to your HTTP server.

Extended Agent Card

An agent may show more (skills, interfaces) to authenticated clients:

match get_extended_agent_card client () with
| Ok card -> let client = with_agent_card client card in ...
| Error e -> ...

The request carries the client's auth. with_agent_card returns the same client checking capabilities against the new card. If the client's card does not declare capabilities.extendedAgentCard (supportsAuthenticatedExtendedCard in 0.3), the call fails locally with Unsupported_capability.

Streaming

send_streaming_message client (Message.user_text "Write a report")
  ~on_event:(function
    | Stream_response.Task t -> Printf.printf "task %s\n%!" t.id
    | Message m -> print_endline (Message.text m)
    | Status_update e -> Printf.printf "[%s]\n%!" (Task_state.to_string e.status.state)
    | Artifact_update e -> print_string (Artifact.text e.artifact))
(* : (Outcome.t, Error.t) result, with all updates folded into the final task *)
Reading events one at a time

open_streaming_message (and open_task_subscription) return an Event_stream.t that you read with Event_stream.next. It returns once the agent has accepted the request and sent its first event, so a relay can decide its own status code before writing anything:

Eio.Switch.run @@ fun sw ->
match open_streaming_message client ~sw ~accumulate:false (Message.user_text "Build the UI") with
| Error e -> respond_502 (Error.to_string e)   (* refused, HTTP error, or agent error *)
| Ok stream ->
    let rec relay () =
      match Event_stream.next stream with
      | Ok (Some ev) -> write_sse (Codec.stream_response_to_json ev); relay ()
      | Ok None -> ()                            (* ended normally *)
      | Error e -> log_error e                   (* e.g. Stream_ended *)
    in
    relay ()
Resuming broken streams

With a Reconnect policy, a stream that breaks before the task settles is re-attached with SubscribeToTask:

connect ~clock ~transport base
  ~stream_idle_timeout:60.
  ~reconnect:(Reconnect.make ~max_attempts:5 ~delay:1. ~max_delay:30. ())

Protocol versions and bindings

The client speaks A2A 1.0 over JSON-RPC and HTTP+JSON (REST), and A2A 0.3 over JSON-RPC. The version and binding only change the wire format; requests, results and errors use the same OCaml types, so the code above works with all of them.

connect (and of_agent_card) picks the first interface in the Agent Card for protocol 1.x whose binding is JSONRPC or HTTP+JSON; the card's order is the agent's preference. If the card has none, it picks the first JSONRPC interface for 0.3.x. Cards in the v0.3 layout (url, preferredTransport, additionalInterfaces, protocolVersion) are read as well. Other versions and bindings (gRPC) are never used; the call fails with No_compatible_interface.

let client = connect ~clock ~transport base |> Result.get_ok in
match A2a_client.version client with
| V1_0 -> print_endline "talking A2A 1.0"
| V0_3 -> print_endline "talking A2A 0.3"

(* Only accept 1.0: *)
connect ~protocol_version:V1_0 ~clock ~transport base   (* a preference: filters the card *)

(* Only use JSON-RPC: *)
connect ~bindings:[ Jsonrpc ] ~clock ~transport base

(* Known REST endpoint, no Agent Card: *)
create ~protocol:(V1_0 Http_json) ~clock ~transport (Uri.of_string "https://agent.example.com/a2a/rest")

(* Known v0.3 endpoint, no Agent Card: *)
create ~protocol:V0_3 ~clock ~transport (Uri.of_string "https://agent.example.com/a2a")

The card picks the interface, but the URL it advertises is not always reachable: an agent in a container often builds its card from its bind address (http://127.0.0.1:8080/a2a). ~rewrite_endpoint maps that URL to the one to use; rebase_endpoint ~onto keeps its path and takes the scheme, host and port of the address you reached the card at:

let base = Uri.of_string "http://agent.default.svc:8080" in
connect ~rewrite_endpoint:(rebase_endpoint ~onto:base) ~clock ~transport base

With HTTP+JSON, operations map to resource paths under the endpoint (POST /message:send, GET /tasks/{id}, GET /tasks?status=…, POST /tasks/{id}:cancel, …), prefixed with /{tenant} when a tenant is set. Bodies use application/a2a+json. Errors are google.rpc.Status objects; the reason of their google.rpc.ErrorInfo detail (TASK_NOT_FOUND, …) gives the error code.

With protocol 0.3:

Configuration and authentication

A2a_client.create ~clock ~transport
  ~auth:(Auth.bearer token)                   (* or Auth.header "X-API-Key" key, Auth.dynamic f *)
  ~headers:[ ("X-Request-Source", "my-service") ]
  ~extensions:[ "https://example.com/ext/v1" ]  (* sent as A2A-Extensions *)
  ~protocol:(V1_0 Jsonrpc)                    (* or V1_0 Http_json, V0_3; default V1_0 Jsonrpc *)
  ~tenant:"acme"
  ~timeout:30.                                (* unary calls and stream headers; default 30 s *)
  ~stream_idle_timeout:120.                   (* max silence on a stream; default none *)
  ~max_response_size:(16 * 1024 * 1024)       (* per body or SSE event; default 16 MiB *)
  ~reconnect:(Reconnect.make ())              (* resume broken streams; default Reconnect.none *)
  endpoint

HTTPS

The library does not depend on a TLS implementation. Pass a TLS wrapper to Transport.cohttp; it has the same type as the one Cohttp_eio.Client.make takes. With tls-eio and ca-certs:

let () = Mirage_crypto_rng_unix.use_default ()  (* TLS needs a seeded RNG *)

let https =
  let authenticator = Ca_certs.authenticator () |> Result.get_ok in
  let config = Tls.Config.client ~authenticator () |> Result.get_ok in
  fun uri raw ->
    let host = Uri.host uri |> Option.map (fun h -> Domain_name.(host_exn (of_string_exn h))) in
    Tls_eio.client_of_flow ?host config raw

let transport = A2a_client.Transport.cohttp ~https (Eio.Stdenv.net env)

Transport.pooled takes the same ~https argument. If you use an https:// URL without a wrapper, the call returns a Transport_error. The error message never contains the URL.

Connection reuse

Transport.pooled keeps connections open between requests (HTTP/1.1 keep-alive):

Eio.Switch.run @@ fun sw ->
let transport = A2a_client.Transport.pooled ~sw ~clock (Eio.Stdenv.net env) in
...

Embedding in an existing service

Testing code that uses the client

The a2a-client.testing library is a scripted agent behind a Transport.t. Replies are typed values, encoded in the wire format of the agent's protocol version and binding, so tests need neither a network nor hand-written JSON-RPC or SSE:

module T = A2a_client_testing

let agent =
  T.agent ~clock (fun req ->
      match T.rpc_method req with
      | Some "SendStreamingMessage" ->
          T.Reply.stream
            [ T.Step.event (Task task); T.Step.event (Status_update working); T.Step.drop ]
      | _ -> T.Reply.error Method_not_found "unexpected")
in
let client = A2a_client.create ~clock ~transport:(T.transport agent) endpoint in
...
T.requests agent   (* what the client sent *)

Replies cover results (send_message_response, task), agent errors (error), any HTTP response (http ~status:500 "boom"), refused connections (refuse) and delays (after). Streams are lists of steps: events, in-band errors, raw bytes, pauses, a dropped connection (drop) or silence (hang). Pass ~protocol to T.agent to answer as a 0.3 or REST agent.

Logging

The library logs to the Logs source A2a_client.src (named "a2a-client"). Log lines contain method names, request ids, URLs without userinfo or query, and errors. They never contain headers, credentials or message bodies.

Errors

Every operation returns (_, A2a_client.Error.t) result. The only exception that can escape is Eio cancellation.

Constructor

Meaning

Rpc_error { code; message; details }

The agent returned an A2A error: a JSON-RPC error, or with REST a google.rpc.Status. code is a variant such as Task_not_found, Task_not_cancelable or Version_not_supported, or one of the standard JSON-RPC codes; a REST error with an unknown reason is Other status. Error.error_info extracts a google.rpc.ErrorInfo detail

Http_error { status; body }

Non-2xx HTTP status that is not an A2A error. body is an excerpt of up to 1 KiB

Transport_error msg

Connection, DNS, reset or TLS failure

Timeout what

timeout or stream_idle_timeout elapsed

Invalid_response msg

Bad JSON, a JSON-RPC envelope violation, a schema violation (for example at $.result.task.status.state: unknown TaskState "done") or an oversized body

Stream_ended { last_task }

The stream closed before the task settled

Poll_timeout { last_task }

Polling gave up

No_compatible_interface, Unsupported_capability

Checked locally. No_compatible_interface: the Agent Card has no usable interface. Unsupported_capability: the card does not declare a capability the call needs, or the protocol version lacks the operation

Protocol compatibility

Protocol

A2A 1.0: specification v1.0.1, a2a.proto (lf.a2a.v1)

Backward compatibility

A2A 0.3: specification v0.3.0, JSON schema

Bindings

1.0: JSON-RPC 2.0 and HTTP+JSON (REST). 0.3: JSON-RPC 2.0. Both stream with Server-Sent Events

Wire format

1.0: ProtoJSON (camelCase fields, TASK_STATE_* / ROLE_* enum names, oneofs discriminated by member name). 0.3: kind-tagged objects and lowercase enums (working, input-required, user)

Older versions

v0.2 and earlier are not supported. Agents that only offer them get No_compatible_interface; the client never falls back to a version it does not implement (spec §3.6.3)

These choices follow the official SDKs:

Module overview

Module

Purpose

A2a_client

Client operations, discovery and polling. Also re-exports the data model

Types

Protocol data model

Codec

JSON conversion for the 1.0 wire format; Agent Cards in either layout

Codec_v0_3

JSON conversion for the 0.3 wire format

Protocol_version

V1_0 / V0_3

Protocol_binding

Jsonrpc / Http_json

Protocol

A supported version and binding pair: V1_0 Jsonrpc, V1_0 Http_json, V0_3

Transport

HTTP abstraction, and the cohttp-eio implementations (cohttp, pooled)

Lifecycle

Outcome classification and folding stream events into a task

Error

Error type and A2A error codes

A2a_client_testing

Scripted agent for tests (library a2a-client.testing)

The API reference is generated from the .mli files with dune build @doc.

Examples

examples/a2a_cli.ml is a small command-line client:

dune exec examples/a2a_cli.exe -- http://localhost:9999 send   "Hello"
dune exec examples/a2a_cli.exe -- http://localhost:9999 wait   "Research this company"
dune exec examples/a2a_cli.exe -- http://localhost:9999 stream "Write a report"
dune exec examples/a2a_cli.exe -- http://localhost:9999 get    TASK_ID
A2A_TOKEN=... dune exec examples/a2a_cli.exe -- http://localhost:9999 cancel TASK_ID
dune exec examples/a2a_cli.exe -- http://localhost:9999 list   CONTEXT_ID   # or - for all

Limitations

Not supported yet:

License

MIT