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:
| Field | Env Variable | Required | Description |
|---|---|---|---|
node_url | LAYR8_NODE_URL | Yes | WebSocket URL of the cloud-node |
api_key | LAYR8_API_KEY | Yes | Authentication key |
agent_did | LAYR8_AGENT_DID | Yes | Agent 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:
{:reply, message}— send a reply and mark as handled:noreply— mark as handled with no reply:pass— decline to handle; the cloud-node may route elsewhere{:error, reason}— signal an error to the cloud-node
: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:
| Field | Env Variable | Required | Description |
|---|---|---|---|
node_url | LAYR8_NODE_URL | Yes | WebSocket URL of the cloud-node |
api_key | LAYR8_API_KEY | Yes | Authentication key |
agent_did | LAYR8_AGENT_DID | Yes | Agent DID — your agent's network identity |
attach_grants | LAYR8_ATTACH_GRANTS | No | Attach Verifiable Grants to outbound messages. Default true |
grant_cache_ms | LAYR8_GRANT_CACHE_MS | No | How long held grants are cached. Default 60_000 |
grant_read_timeout_ms | LAYR8_GRANT_READ_TIMEOUT_MS | No | Deadline on the credential read. Default 2_000 |
rest_timeout_ms | LAYR8_REST_TIMEOUT_MS | No | Deadline on every other REST call. Default 30_000; 0 for none |
HTTP(S) URLs are automatically normalized to WebSocket scheme:
https://→wss://http://→ws://
# 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 value | Effect |
|---|---|
{:reply, message} | Send a response to the sender |
:noreply | No 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:
| Key | Meaning |
|---|---|
:denial_code | The node denied a message we sent with nothing attached |
:capped | More grants covered the message than fit on it (%{covering: n, attached: 16}) |
:error | The 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:
| Result | Meaning |
|---|---|
{: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_typeis exactlyapplication/vc+jwtand drops avp+jwtsilently. 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:
| Exception | Raised when |
|---|---|
Layr8.Error | General SDK error (missing config, send failure) |
Layr8.ConnectionError | WebSocket connection fails |
Layr8.NotConnectedError | send/3 or request/3 called before connect/1 |
Layr8.AlreadyConnectedError | handle/4 called after connect/1 |
Layr8.ClientClosedError | connect/1 called after close/1 |
Layr8.ProblemReportError | Remote 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
Links
License
MIT