Layr8 Elixir SDK

Elixir client for the Layr8 decentralized identity-native messaging network.

Full documentation at docs.layr8.io/build/elixir-sdk

Installation

Add to your mix.exs dependencies:

def deps do
[{:layr8, github: "layr8/elixir_sdk"}]
end

Requires Elixir ~> 1.15.

Quick Start

{:ok, client} = Layr8.Client.start_link(%{
node_url: "wss://node.example.com/plugin_socket/websocket",
api_key: System.fetch_env!("LAYR8_API_KEY")
})
: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: msg.body
}}
end)
:ok = Layr8.Client.connect(client)

Configuration

Fields can be provided explicitly or resolved from environment variables:

FieldEnv VariableRequiredDescription
node_urlLAYR8_NODE_URLYesWebSocket URL of the cloud-node
api_keyLAYR8_API_KEYYesAuthentication key
agent_didLAYR8_AGENT_DIDYesAgent DID — your agent's network identity

HTTP(S) URLs are automatically normalized (https:// to wss://, http:// to ws://).

Message Handlers

Register handlers before calling connect/1. Each handler receives a Layr8.Message and returns one of:

: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: %{"ok" => true}}}
end)

Reply messages auto-populate id, from, to, and thread_id from the inbound message.

Wildcard Handler

Register a catch-all handler for messages that don't match any specific type:

:ok = Layr8.Client.handle_all(client, fn msg ->
Logger.info("Unhandled message type: #{msg.type}")
:pass
end)

Dispatch priority: specific handler > catch-all > auto-pass.

Sending Messages

# Fire-and-wait (default: waits for server ack)
:ok = Layr8.Client.send(client, %Layr8.Message{
type: "https://example.com/proto/1.0/request",
to: ["did:example:bob"],
body: %{"text" => "hello"}
})
# Fire-and-forget
:ok = Layr8.Client.send(client, msg, fire_and_forget: true)

Request/Response

Send a message and block until a correlated response arrives (matched by thid):

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

Configuration

Configuration can be provided explicitly or via environment variables:

FieldEnv VariableRequiredDescription
node_urlLAYR8_NODE_URLYesWebSocket URL of the cloud-node
api_keyLAYR8_API_KEYYesAuthentication key
agent_didLAYR8_AGENT_DIDYesAgent DID — your agent's network identity
attach_grantsLAYR8_ATTACH_GRANTSNoAttach Verifiable Grants to outbound messages. Default true
grant_cache_msLAYR8_GRANT_CACHE_MSNoHow long held grants are cached. Default 60_000
grant_read_timeout_msLAYR8_GRANT_READ_TIMEOUT_MSNoDeadline on the credential read. Default 2_000
rest_timeout_msLAYR8_REST_TIMEOUT_MSNoDeadline on every other REST call. Default 30_000; 0 for none

HTTP(S) URLs are automatically normalized to WebSocket scheme:

# All from environment variables
{:ok, client} = Layr8.Client.start_link(%{})
# Explicit values override env vars
{:ok, client} = Layr8.Client.start_link(%{
node_url: "wss://node.example.com/plugin_socket/websocket",
api_key: "my-api-key",
agent_did: "did:key:z6Mk..."
})

Protocol Registration

The SDK automatically derives protocol base URIs from registered handler message types and sends them to the cloud-node on connect. For example, handling "https://example.com/proto/1.0/request" registers the protocol "https://example.com/proto/1.0".

Note: The cloud-node requires at least one protocol on join. Unlike the Node and Go SDKs, the Elixir SDK does not auto-add the problem report protocol. Sender-only clients that don't register any handlers will fail to connect. Register at least one handler before connecting.

Message Handlers

Handlers are registered before connect/1 and called when inbound DIDComm messages arrive.

Return Values

Return valueEffect
{:reply, message}Send a response to the sender
:noreplyNo response; message consumed

Manual Acknowledgment

By default messages are auto-acknowledged before the handler runs. For manual control:

Layr8.Client.handle(client, "https://example.com/proto/1.0/request", fn msg ->
# Do your work, then ack manually (coming in a future version)
:noreply
end, manual_ack: true)

Request/Response Pattern

Use Layr8.Client.request/3 to send a message and wait for a correlated response. Responses are matched by thid (thread ID).

Options: :timeout (default 30s), :parent_thread (sets pthid).

case Layr8.Client.request(client, msg, timeout: 10_000) do
{:ok, response} ->
IO.inspect(response.body)
# Raises on error:
# - Layr8.ProblemReportError — remote agent sent a problem report
# - Layr8.NotConnectedError — not connected
# - Layr8.Error — timeout or other error
end

Verifiable Grants

The cloud-node requires a Verifiable Grant for anything its policy does not allow outright. The SDK attaches the grants covering each outbound message automatically — on send/3, on request/3, and on a handler's reply — so there is nothing to wire up. Turn it off with attach_grants: false.

Selection mirrors the policy and deliberately errs wide: everything that plausibly applies goes on the wire, because over-attaching is free (the policy allows on the first passing grant) while withholding one costs a working call and fails silently. Validity and revocation are the node's decision, not this side's.

# A grant you were just given is invisible until the cache lapses (60s).
# If you have just been told you were granted something, say so:
:ok = Layr8.Client.refresh_grants(client)

When a message goes out with nothing attached

The node's denial names the grant it could not find, which reads as "your grant is misconfigured" when the truth is "no credential was ever put on the wire". Only the sender knows which one it was. Wire :on_grant_miss and the next such incident is one log line:

{:ok, client} = Layr8.Client.start_link(%{
on_grant_miss: fn info -> Logger.warning("grant miss: #{inspect(info)}") end
})

It fires in three cases, distinguished by the key present:

KeyMeaning
:denial_codeThe node denied a message we sent with nothing attached
:cappedMore grants covered the message than fit on it (%{covering: n, attached: 16})
:errorThe grants could not be read — every send after this is flying blind

It deliberately does not fire merely because a message went out unattached: most traffic (discovery, trust-ping, problem reports) needs no grant, and a diagnostic that fires constantly is one nobody reads when it matters.

Attaching one by hand

media_type is the only field the node's credential extractor filters on, by exact string equality, and it drops everything else silently — producing a denial byte-for-byte identical to the one for attaching nothing. Attach the credential bare; a Verifiable Presentation (application/vp+jwt) is dropped on that rule. See Layr8.Attachment.

%Layr8.Attachment{
id: "urn:uuid:…",
media_type: "application/vc+jwt",
data: %{"jws" => compact_jws}
}

MCP (tool calling) over DIDComm

Layr8 services expose an MCP surface as DIDComm request/reply. Layr8.Mcp removes the boilerplate — the protocol subscription, the type mapping (tools/call#{base}/tools-call), the JSON-RPC envelope, and unwrapping result.

mcp/2 must be called beforeconnect/1, like handle/3: it registers the protocol subscription the node needs in order to deliver replies.

{:ok, binding} = Layr8.Client.mcp(client) # default base: mcp/1.0
:ok = Layr8.Client.connect(client)
loom = Layr8.Mcp.peer(binding, loom_did)
{:ok, _info} = Layr8.Mcp.initialize(loom)
{:ok, tools} = Layr8.Mcp.list_tools(loom)
{:ok, result} = Layr8.Mcp.call_tool(loom, "create_workflow", %{"name" => "onboarding"})

Every call returns a tagged tuple rather than raising — a tool call failing is an ordinary outcome:

ResultMeaning
{:error, {:mcp_error, code, message, data}}The peer answered with a JSON-RPC error
{:error, {:problem_report, code, comment}}DIDComm-level failure, including authorization denials
{:error, :timeout}No reply within the deadline

W3C Verifiable Credentials

Credential operations use the REST API (work without a WebSocket connection):

{:ok, jwt} = Layr8.Client.sign_credential(client, %{
"credentialSubject" => %{"id" => "did:example:bob", "name" => "Bob"}
}, issuer_did: "did:example:alice", format: "compact_jwt")
{:ok, verified} = Layr8.Client.verify_credential(client, jwt)
{:ok, stored} = Layr8.Client.store_credential(client, jwt)
{:ok, creds} = Layr8.Client.list_credentials(client)
{:ok, cred} = Layr8.Client.get_credential(client, stored["id"])

W3C Verifiable Presentations

{:ok, vp_jwt} = Layr8.Client.sign_presentation(client, [vc_jwt],
nonce: "challenge-123", format: "compact_jwt")
{:ok, verified} = Layr8.Client.verify_presentation(client, vp_jwt)

A presentation is not how you authorize a message. The node keeps only attachments whose media_type is exactly application/vc+jwt and drops a vp+jwt silently. Attach the credential bare — or let the SDK do it, which it does by default. See Verifiable Grants.

Connection Lifecycle

agent_did is required — it's the DID your agent connects as and the address other agents use to reach it. Set it via config or the LAYR8_AGENT_DID env var; read it back at runtime with Layr8.Client.did/1.

The channel auto-reconnects with exponential backoff (1s to 30s). Subscribe to lifecycle events:

{:ok, client} = Layr8.Client.start_link(%{
on_disconnect: fn reason -> Logger.warning("Disconnected: #{inspect(reason)}") end,
on_reconnect: fn -> Logger.info("Reconnected") end
})

Error Handling

All errors are exceptions under the Layr8 namespace:

ExceptionRaised when
Layr8.ErrorGeneral SDK error (missing config, send failure)
Layr8.ConnectionErrorWebSocket connection fails
Layr8.NotConnectedErrorsend/3 or request/3 called before connect/1
Layr8.AlreadyConnectedErrorhandle/4 called after connect/1
Layr8.ClientClosedErrorconnect/1 called after close/1
Layr8.ProblemReportErrorRemote agent sends a DIDComm problem report

Examples

See examples/echo_agent.ex for a standalone echo agent.

Development

mix deps.get
mix test
mix check # format + compile warnings + test
mix docs # generate ExDoc documentation

Architecture

Layr8.Client (GenServer)
Layr8.Config -- config resolution and URL normalization
Layr8.Handler -- message type -> handler registry
Layr8.Message -- DIDComm v2 message struct + marshal/parse
Layr8.Attachment -- DIDComm v2 attachment struct
Layr8.Channel -- Phoenix Channel WebSocket transport (GenServer + WebSockex)
Layr8.REST -- HTTP client for REST API (Req)
Layr8.Credentials -- W3C Verifiable Credential operations
Layr8.Presentations -- W3C Verifiable Presentation operations

License

MIT