Packages

remit.md SDK for Elixir - universal payment protocol for AI agents

Retired package: Renamed - DEPRECATED: Use pay-cli (cargo install pay-cli) or pay-sdk (pip install pay-sdk). See https://pay-skill.com/docs

Current section

Files

Jump to
remit lib remit_md a2a.ex
Raw

lib/remit_md/a2a.ex

defmodule RemitMd.A2A do
@moduledoc """
A2A / AP2 - agent card discovery and A2A JSON-RPC task client.
## Example
{:ok, card} = RemitMd.A2A.discover("https://remit.md")
IO.puts(card.name) # => "remit.md"
IO.puts(card.url) # => "https://remit.md/a2a"
Spec: https://google.github.io/A2A/specification/
AP2: https://ap2-protocol.org/
"""
@type extension :: %{
uri: String.t(),
description: String.t(),
required: boolean()
}
@type capabilities :: %{
streaming: boolean(),
push_notifications: boolean(),
state_transition_history: boolean(),
extensions: [extension()]
}
@type skill :: %{
id: String.t(),
name: String.t(),
description: String.t(),
tags: [String.t()]
}
@type agent_card :: %{
protocol_version: String.t(),
name: String.t(),
description: String.t(),
url: String.t(),
version: String.t(),
documentation_url: String.t(),
capabilities: capabilities(),
skills: [skill()],
x402: map()
}
@doc """
Fetch and parse the A2A agent card from `base_url/.well-known/agent-card.json`.
Returns `{:ok, agent_card}` on success or `{:error, reason}` on failure.
"""
@spec discover(String.t()) :: {:ok, agent_card()} | {:error, term()}
def discover(base_url) do
url = String.trim_trailing(base_url, "/") <> "/.well-known/agent-card.json"
uri = String.to_charlist(url)
:inets.start()
:ssl.start()
case :httpc.request(:get, {uri, [{~c"accept", ~c"application/json"}]}, [], []) do
{:ok, {{_, 200, _}, _headers, body}} ->
json = IO.iodata_to_binary(body)
case Jason.decode(json) do
{:ok, data} -> {:ok, parse_card(data)}
{:error, reason} -> {:error, {:parse_error, reason}}
end
{:ok, {{_, status, _}, _, _}} ->
{:error, {:http_error, status}}
{:error, reason} ->
{:error, {:network_error, reason}}
end
end
# ─── Private ─────────────────────────────────────────────────────────────────
defp parse_card(data) do
caps = data["capabilities"] || %{}
extensions =
(caps["extensions"] || [])
|> Enum.map(fn e ->
%{
uri: e["uri"] || "",
description: e["description"] || "",
required: e["required"] || false
}
end)
capabilities = %{
streaming: caps["streaming"] || false,
push_notifications: caps["pushNotifications"] || false,
state_transition_history: caps["stateTransitionHistory"] || false,
extensions: extensions
}
skills =
(data["skills"] || [])
|> Enum.map(fn s ->
%{
id: s["id"] || "",
name: s["name"] || "",
description: s["description"] || "",
tags: s["tags"] || []
}
end)
%{
protocol_version: data["protocolVersion"] || "0.6",
name: data["name"] || "",
description: data["description"] || "",
url: data["url"] || "",
version: data["version"] || "",
documentation_url: data["documentationUrl"] || "",
capabilities: capabilities,
skills: skills,
x402: data["x402"] || %{}
}
end
end
# ─── A2A Task types ────────────────────────────────────────────────────────────
defmodule RemitMd.A2A.TaskStatus do
@moduledoc "Status of an A2A task."
defstruct [:state, :message]
@type t :: %__MODULE__{
state: String.t(),
message: String.t() | nil
}
@doc false
def from_map(nil), do: %__MODULE__{state: "unknown", message: nil}
def from_map(m) when is_map(m) do
msg = case Map.get(m, "message") do
%{"text" => text} -> text
text when is_binary(text) -> text
_ -> nil
end
%__MODULE__{
state: Map.get(m, "state", "unknown"),
message: msg
}
end
end
defmodule RemitMd.A2A.ArtifactPart do
@moduledoc "A part of an A2A artifact."
defstruct [:kind, :data]
@type t :: %__MODULE__{
kind: String.t(),
data: map() | nil
}
@doc false
def from_map(m) do
%__MODULE__{
kind: Map.get(m, "kind", ""),
data: Map.get(m, "data")
}
end
end
defmodule RemitMd.A2A.Artifact do
@moduledoc "An A2A task artifact."
defstruct [:name, :parts]
@type t :: %__MODULE__{
name: String.t() | nil,
parts: [RemitMd.A2A.ArtifactPart.t()]
}
@doc false
def from_map(m) do
parts = (Map.get(m, "parts") || []) |> Enum.map(&RemitMd.A2A.ArtifactPart.from_map/1)
%__MODULE__{
name: Map.get(m, "name"),
parts: parts
}
end
end
defmodule RemitMd.A2A.Task do
@moduledoc "An A2A task."
defstruct [:id, :status, :artifacts]
@type t :: %__MODULE__{
id: String.t(),
status: RemitMd.A2A.TaskStatus.t(),
artifacts: [RemitMd.A2A.Artifact.t()]
}
@doc false
def from_map(m) do
artifacts = (Map.get(m, "artifacts") || []) |> Enum.map(&RemitMd.A2A.Artifact.from_map/1)
%__MODULE__{
id: Map.get(m, "id", ""),
status: RemitMd.A2A.TaskStatus.from_map(Map.get(m, "status")),
artifacts: artifacts
}
end
@doc "Extract txHash from task artifacts, if present."
@spec get_tx_hash(t()) :: String.t() | nil
def get_tx_hash(%__MODULE__{artifacts: artifacts}) do
Enum.find_value(artifacts, fn artifact ->
Enum.find_value(artifact.parts, fn part ->
case part.data do
%{"txHash" => tx} when is_binary(tx) -> tx
_ -> nil
end
end)
end)
end
end
# ─── IntentMandate ──────────────────────────────────────────────────────────────
defmodule RemitMd.A2A.IntentMandate do
@moduledoc "An intent mandate for A2A payments."
defstruct [:mandate_id, :expires_at, :issuer, :allowance]
@type t :: %__MODULE__{
mandate_id: String.t(),
expires_at: String.t(),
issuer: String.t(),
allowance: %{max_amount: String.t(), currency: String.t()}
}
@doc "Create a new IntentMandate."
def new(opts) do
%__MODULE__{
mandate_id: Keyword.fetch!(opts, :mandate_id),
expires_at: Keyword.fetch!(opts, :expires_at),
issuer: Keyword.fetch!(opts, :issuer),
allowance: %{
max_amount: Keyword.fetch!(opts, :max_amount),
currency: Keyword.get(opts, :currency, "USDC")
}
}
end
@doc false
def to_map(%__MODULE__{} = m) do
%{
"mandateId" => m.mandate_id,
"expiresAt" => m.expires_at,
"issuer" => m.issuer,
"allowance" => %{
"maxAmount" => m.allowance.max_amount,
"currency" => m.allowance.currency
}
}
end
end
# ─── A2A Client ─────────────────────────────────────────────────────────────────
defmodule RemitMd.A2A.Client do
@moduledoc """
A2A JSON-RPC client - send payments and manage tasks via the A2A protocol.
## Example
{:ok, card} = RemitMd.A2A.discover("https://remit.md")
signer = RemitMd.PrivateKeySigner.new("0x...")
client = RemitMd.A2A.Client.from_card(card, signer)
{:ok, task} = RemitMd.A2A.Client.send(client, to: "0xRecipient", amount: 10.0)
IO.puts(task.status.state)
"""
alias RemitMd.Http
@chain_ids %{
"base" => 8453,
"base-sepolia" => 84532,
"base_sepolia" => 84532
}
@enforce_keys [:http, :path]
defstruct [:http, :path]
@type t :: %__MODULE__{
http: Http.t(),
path: String.t()
}
@doc """
Create a client from an A2AClientOptions-style keyword list.
## Options
- `:endpoint` - full A2A endpoint URL (required)
- `:signer` - signer struct (required)
- `:chain_id` - numeric chain ID (required)
- `:verifying_contract` - router contract address (default: `""`)
"""
@spec new(keyword()) :: t()
def new(opts) do
endpoint = Keyword.fetch!(opts, :endpoint)
signer = Keyword.fetch!(opts, :signer)
chain_id = Keyword.fetch!(opts, :chain_id)
verifying_contract = Keyword.get(opts, :verifying_contract, "")
parsed = URI.parse(endpoint)
base_url = "#{parsed.scheme}://#{parsed.authority}"
path = parsed.path || "/a2a"
# Build Http transport manually
chain = Enum.find_value(@chain_ids, "base", fn {k, v} -> if v == chain_id, do: k end)
http = Http.new(
signer: signer,
chain: chain,
api_url: base_url,
router_address: verifying_contract
)
%__MODULE__{http: http, path: path}
end
@doc """
Convenience constructor from an agent card and a signer.
## Options
- `:chain` - chain name (default: `"base"`)
- `:verifying_contract` - router contract address (default: `""`)
"""
@spec from_card(map(), term(), keyword()) :: t()
def from_card(card, signer, opts \\ []) do
chain = Keyword.get(opts, :chain, "base")
chain_id = Map.get(@chain_ids, chain, 8453)
new(
endpoint: card.url || card[:url],
signer: signer,
chain_id: chain_id,
verifying_contract: Keyword.get(opts, :verifying_contract, "")
)
end
@doc """
Send a direct USDC payment via `message/send`.
## Options
- `:to` - recipient address (required)
- `:amount` - USDC amount (required)
- `:memo` - payment memo (default: `""`)
- `:mandate` - `%RemitMd.A2A.IntentMandate{}` (optional)
Returns `{:ok, %RemitMd.A2A.Task{}}` or `{:error, reason}`.
"""
@spec send(t(), keyword()) :: {:ok, RemitMd.A2A.Task.t()} | {:error, term()}
def send(%__MODULE__{} = client, opts) do
to = Keyword.fetch!(opts, :to)
amount = Keyword.fetch!(opts, :amount)
memo = Keyword.get(opts, :memo, "")
mandate = Keyword.get(opts, :mandate)
nonce = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
message_id = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
amount_str = if is_float(amount), do: :erlang.float_to_binary(amount, decimals: 2), else: to_string(amount)
message = %{
"messageId" => message_id,
"role" => "user",
"parts" => [
%{
"kind" => "data",
"data" => %{
"model" => "direct",
"to" => to,
"amount" => amount_str,
"memo" => memo,
"nonce" => nonce
}
}
]
}
message =
if mandate do
Map.put(message, "metadata", %{"mandate" => RemitMd.A2A.IntentMandate.to_map(mandate)})
else
message
end
rpc(client, "message/send", %{"message" => message}, message_id)
end
@doc "Fetch the current state of an A2A task by ID."
@spec get_task(t(), String.t()) :: {:ok, RemitMd.A2A.Task.t()} | {:error, term()}
def get_task(%__MODULE__{} = client, task_id) do
rpc(client, "tasks/get", %{"id" => task_id}, String.slice(task_id, 0, 16))
end
@doc "Cancel an in-progress A2A task."
@spec cancel_task(t(), String.t()) :: {:ok, RemitMd.A2A.Task.t()} | {:error, term()}
def cancel_task(%__MODULE__{} = client, task_id) do
rpc(client, "tasks/cancel", %{"id" => task_id}, String.slice(task_id, 0, 16))
end
# ─── Private ──────────────────────────────────────────────────────────────
defp rpc(%__MODULE__{http: http, path: path}, method, params, call_id) do
body = %{
"jsonrpc" => "2.0",
"id" => call_id,
"method" => method,
"params" => params
}
try do
data = Http.post(http, path, body)
case data do
%{"error" => %{"message" => msg}} ->
{:error, "A2A error: #{msg}"}
%{"error" => err} ->
{:error, "A2A error: #{inspect(err)}"}
%{"result" => result} ->
{:ok, RemitMd.A2A.Task.from_map(result)}
_ ->
# Response may be the task directly (no result wrapper)
{:ok, RemitMd.A2A.Task.from_map(data)}
end
rescue
e in RemitMd.Error -> {:error, e}
end
end
end