Layr8.Client (layr8 v0.2.11)

Copy Markdown View Source

Main entry point for interacting with the Layr8 platform.

Lifecycle

# 1. Start the client
{:ok, client} = Layr8.Client.start_link(%{
  node_url: "wss://node.example.com/plugin_socket/websocket",
  api_key: "my-api-key"
})

# 2. Register message handlers BEFORE connect
:ok = Layr8.Client.handle(client, "https://example.com/proto/1.0/request", fn msg ->
  {:reply, %Layr8.Message{type: "https://example.com/proto/1.0/response", body: %{text: "pong"}}}
end)

# 3. Connect to the cloud-node
:ok = Layr8.Client.connect(client)

# 4. Send messages
:ok = Layr8.Client.send(client, %Layr8.Message{
  type: "https://example.com/proto/1.0/request",
  to: ["did:example:bob"],
  body: %{text: "ping"}
})

# 5. Request/response
{:ok, response} = Layr8.Client.request(client, %Layr8.Message{
  type: "https://example.com/proto/1.0/request",
  to: ["did:example:bob"],
  body: %{text: "ping"}
})

# 6. Shut down
:ok = Layr8.Client.close(client)

Hosting more than one DID

connect/1 joins one DID — the one from config, or the one the node assigns. join_did/3 hosts additional DIDs on the same WebSocket, each with its own handlers and its own protocol subscription:

{:ok, handle} = Layr8.Client.join_did(client, workflow_did,
  protocols: ["https://example.com/proto/1.0"],
  handlers: %{"https://example.com/proto/1.0/request" => fn msg -> ... end},
  did_spec: %{"storage" => "persistent", "controller" => owner_did}
)

:ok = Layr8.DidHandle.send(handle, %Layr8.Message{to: [peer], type: t, body: %{}})

Messages arriving for a joined DID go to that DID's handlers first and to the client-global ones (handle/3, handle_all/2) as the fallback; replies it sends carry from = did, and the grants attached are that DID's.

Joined DIDs are re-joined automatically after a reconnect — see Layr8.Channel.

Events

Subscribe to lifecycle events by passing callbacks in start_link/1 opts:

  • :on_disconnect(reason :: term() -> any())
  • :on_reconnect(() -> any())

Credentials & Presentations

Credential and presentation operations use the REST API (no WebSocket required):

{:ok, jwt} = Layr8.Client.sign_credential(client, credential, issuer_did: "did:example:alice")

Summary

Functions

Returns a specification to start this module under a supervisor.

Gracefully shuts down the client.

Establishes the WebSocket connection and joins the Phoenix channel.

Returns the agent's DID — either provided in config or assigned by the node on connect.

Retrieves a stored credential by ID.

Registers a handler for a DIDComm message type.

Registers a catch-all handler invoked when no specific handler matches.

Joins an additional DID on this client's existing WebSocket connection and returns a Layr8.DidHandle for it.

Lists the DIDs joined with join_did/3 (the connected DID is did/1).

Leaves a DID joined with join_did/3 and forgets its handlers. The connection and every other DID stay up. No-op for a DID not joined.

Lists stored credentials for a holder. Defaults: holder = did/1.

Sets up MCP (Model Context Protocol) over DIDComm on a protocol base and returns a binding whose Layr8.Mcp.peer/2 yields a caller.

Forgets the cached Verifiable Grants for did (default: this agent's), so the next message re-reads them.

Sends a DIDComm message and waits for a correlated response (matched by thread ID).

request/3 without the raises: returns {:ok, Message.t()} or {:error, reason}:timeout, :not_connected, or {:problem_report, code, comment}.

Sends a DIDComm message.

Signs a W3C Verifiable Credential. Defaults: issuer = did/1, format = "compact_jwt".

Signs a W3C Verifiable Presentation. Defaults: holder = did/1, format = "compact_jwt".

Starts the Layr8 Client GenServer.

Stores a signed credential JWT for a holder. Defaults: holder = did/1.

Verifies a signed credential. Defaults: verifier = did/1.

Verifies a signed presentation. Defaults: verifier = did/1.

Types

start_opts()

@type start_opts() :: %{
  optional(:node_url) => String.t(),
  optional(:api_key) => String.t(),
  optional(:agent_did) => String.t(),
  optional(:on_disconnect) => (term() -> any()),
  optional(:on_reconnect) => (-> any()),
  optional(:on_grant_miss) => (map() -> any()),
  optional(:did_spec) => map()
}

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

close(pid)

@spec close(pid()) :: :ok

Gracefully shuts down the client.

connect(pid)

@spec connect(pid()) :: :ok

Establishes the WebSocket connection and joins the Phoenix channel.

Blocks until the join is acknowledged. Returns :ok on success or raises on error.

did(pid)

@spec did(pid()) :: String.t()

Returns the agent's DID — either provided in config or assigned by the node on connect.

get_credential(pid, id)

@spec get_credential(pid(), String.t()) :: {:ok, map()} | {:error, term()}

Retrieves a stored credential by ID.

handle(pid, msg_type, fun)

@spec handle(pid(), String.t(), Layr8.Handler.handler_fn()) :: :ok

Registers a handler for a DIDComm message type.

Must be called before connect/1. Raises Layr8.AlreadyConnectedError if called after.

Handler Signature

fn msg -> {:reply, %Layr8.Message{...}} | :noreply | :pass | {:error, reason} end

handle_all(pid, fun)

@spec handle_all(pid(), Layr8.Handler.handler_fn()) :: :ok

Registers a catch-all handler invoked when no specific handler matches.

Must be called before connect/1. Raises Layr8.AlreadyConnectedError if called after.

Handler Signature

fn msg -> {:reply, %Layr8.Message{...}} | :noreply | :pass | {:error, reason} end

join_did(pid, did, opts \\ [])

@spec join_did(pid(), String.t(), keyword()) ::
  {:ok, Layr8.DidHandle.t()} | {:error, term()}

Joins an additional DID on this client's existing WebSocket connection and returns a Layr8.DidHandle for it.

Must be called after connect/1 — it rides the connection that call established. The counterpart of the node-sdk's joinDid (src/client.ts), and the same shape mcp-pod uses to host one Instance DID per connected account on a single socket.

Options

  • :protocols — this DID's payload_types subscription. Defaults to the protocols derived from :handlers. The problem-report protocol is appended unless already present (or a catch-all is registered), so the node can deliver denials addressed to this DID
  • :handlers%{msg_type => handler_fn} that fire for messages received on THIS DID; the client-global registry is the fallback
  • :handle_all — catch-all handler for this DID
  • :did_spec — merged over the SDK defaults, e.g. %{"storage" => "persistent", "controller" => owner_did}. controller matters: helix enforces issuer == resource_controller on grants, so a DID whose controller is the node makes every grant issued by its owner fail

Returns {:error, %Layr8.NotConnectedError{}} before connect/1, {:error, :already_joined} for a DID already hosted, and {:error, :primary_did} for the DID connect/1 joined — that one is reachable through send/3 and request/3 directly.

joined_dids(pid)

@spec joined_dids(pid()) :: [String.t()]

Lists the DIDs joined with join_did/3 (the connected DID is did/1).

leave_did(pid, did)

@spec leave_did(pid(), String.t()) :: :ok

Leaves a DID joined with join_did/3 and forgets its handlers. The connection and every other DID stay up. No-op for a DID not joined.

list_credentials(pid, opts \\ [])

@spec list_credentials(
  pid(),
  keyword()
) :: {:ok, [map()]} | {:error, term()}

Lists stored credentials for a holder. Defaults: holder = did/1.

mcp(pid, base \\ nil)

@spec mcp(pid(), String.t()) :: {:ok, Layr8.Mcp.Binding.t()} | {:error, term()}

Sets up MCP (Model Context Protocol) over DIDComm on a protocol base and returns a binding whose Layr8.Mcp.peer/2 yields a caller.

Must be called before connect/1 (like handle/3): it registers the protocol subscription the cloud-node needs in order to deliver #{base}/* replies. Idempotent per base — calling it twice returns a second binding over the same subscription rather than raising the way a duplicate handle/3 would. Compose freely with your own handle/3 registrations.

See Layr8.Mcp for the call surface.

refresh_grants(pid, did \\ nil)

@spec refresh_grants(pid(), String.t() | nil) :: :ok

Forgets the cached Verifiable Grants for did (default: this agent's), so the next message re-reads them.

The cache TTL is the whole freshness story: a grant minted seconds ago is invisible until it lapses. An agent that has just been TOLD it was granted something — by a request/approve flow, or by a person on the other end of a chat — should not have to wait out a timer it cannot see.

request(pid, msg, opts \\ [])

@spec request(pid(), Layr8.Message.t() | map(), keyword()) :: {:ok, Layr8.Message.t()}

Sends a DIDComm message and waits for a correlated response (matched by thread ID).

Returns {:ok, Layr8.Message.t()} or raises Layr8.ProblemReportError / Layr8.NotConnectedError.

Options

  • :timeout — milliseconds to wait for a reply (default 30s)
  • :parent_thread — set pthid for nested thread correlation

request_from(pid, did, msg, opts \\ [])

@spec request_from(pid(), String.t(), Layr8.Message.t() | map(), keyword()) ::
  {:ok, Layr8.Message.t()}

request/3 from a DID joined with join_did/3. Prefer Layr8.DidHandle.request/3.

request_result(pid, msg, opts \\ [])

@spec request_result(pid(), Layr8.Message.t() | map(), keyword()) ::
  {:ok, Layr8.Message.t()} | {:error, term()}

request/3 without the raises: returns {:ok, Message.t()} or {:error, reason}:timeout, :not_connected, or {:problem_report, code, comment}.

Same request path, same options. This exists because a remote call failing is an ordinary outcome for some callers (Layr8.Mcp routes on it rather than rescuing), while for others an unanswered request is exceptional — the SDK should not force either one to convert.

request_result_from(pid, did, msg, opts \\ [])

@spec request_result_from(pid(), String.t(), Layr8.Message.t() | map(), keyword()) ::
  {:ok, Layr8.Message.t()} | {:error, term()}

request_result/3 from a DID joined with join_did/3. Prefer Layr8.DidHandle.request_result/3.

send(pid, msg, opts \\ [])

@spec send(pid(), Layr8.Message.t() | map(), keyword()) :: :ok

Sends a DIDComm message.

By default waits for server acknowledgment. Pass fire_and_forget: true to skip.

Returns :ok or raises Layr8.NotConnectedError.

send_from(pid, did, msg, opts \\ [])

@spec send_from(pid(), String.t(), Layr8.Message.t() | map(), keyword()) :: :ok

send/3 from a DID joined with join_did/3. Prefer Layr8.DidHandle.send/3.

sign_credential(pid, credential, opts \\ [])

@spec sign_credential(pid(), map(), keyword()) :: {:ok, String.t()} | {:error, term()}

Signs a W3C Verifiable Credential. Defaults: issuer = did/1, format = "compact_jwt".

sign_presentation(pid, credentials, opts \\ [])

@spec sign_presentation(pid(), [String.t()], keyword()) ::
  {:ok, String.t()} | {:error, term()}

Signs a W3C Verifiable Presentation. Defaults: holder = did/1, format = "compact_jwt".

start_link(opts \\ %{})

@spec start_link(start_opts()) :: GenServer.on_start()

Starts the Layr8 Client GenServer.

Accepts the same keys as Layr8.Config.resolve!/1 plus:

  • :on_disconnect — called with the disconnect reason
  • :on_reconnect — called after successful reconnection
  • :on_grant_miss — called when a message went out with NO covering Verifiable Grant and the node then denied it, when the covering set had to be capped, or when the grants could not be read at all. See Layr8.Wallet: the sender is the only party that knows nothing was attached, and the node's denial names the grant it could not find, which sends people to check a grant that is fine.

Raises Layr8.Error if required config fields are missing.

store_credential(pid, jwt, opts \\ [])

@spec store_credential(pid(), String.t(), keyword()) ::
  {:ok, map()} | {:error, term()}

Stores a signed credential JWT for a holder. Defaults: holder = did/1.

verify_credential(pid, signed, opts \\ [])

@spec verify_credential(pid(), String.t(), keyword()) ::
  {:ok, map()} | {:error, term()}

Verifies a signed credential. Defaults: verifier = did/1.

verify_presentation(pid, signed, opts \\ [])

@spec verify_presentation(pid(), String.t(), keyword()) ::
  {:ok, map()} | {:error, term()}

Verifies a signed presentation. Defaults: verifier = did/1.