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, "~> 0.3.0"}]
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 |
parent_did |
— | No | The identity whose authority this DID borrows — see Borrowing a parent's authority |
mediator |
LAYR8_MEDIATOR_DID |
No | Mediator to enrol with and collect from on every (re)connect |
mediator_live |
LAYR8_MEDIATOR_LIVE |
No | false leaves live delivery off after collection |
didcomm_url |
LAYR8_DIDCOMM_URL |
No | Where collected ciphertext is re-injected (default <rest url>/didcomm) |
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 |
parent_did |
— | No | The identity whose authority this DID borrows — see Borrowing a parent's authority |
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}
}
Attachments you supply are never displaced. The single exception is identity credentials, which are appended to rather than displacing the wallet's selection: they answer a different question.
Reading an attachment: lastmod_time, and a header that was not read
Layr8.Attachment's lastmod_time is non_neg_integer() | String.t() | nil,
and this SDK does not interpret it.
DIDComm v2 states no type for the field. Its Attachments section says only
"OPTIONAL. A hint about when the content in this attachment was last modified",
while the same document pins created_time and expires_time to "UTC Epoch
Seconds (seconds since 1970-01-01T00:00:00Z) as an integer". The authors knew
how to spell "epoch integer" and did not spell it here, so a receiver is not
entitled to demand one. This SDK writes epoch seconds and reads whatever
arrives — an integer or a timestamp string — unchanged.
The typespec used to say non_neg_integer() | nil while parse/1 converted
nothing. Match on what arrived:
case att.lastmod_time do
nil -> nil # the sender sent no hint
secs when is_integer(secs) -> DateTime.from_unix!(secs)
iso when is_binary(iso) -> elem(DateTime.from_iso8601(iso), 1)
end
Layr8.Message's attachments has three states, because "this message
carried none" and "nobody could read the header" are different answers, and a
reader that folds them reports a measurement that was never taken:
| the message carried | attachments |
attachments_unread |
|---|---|---|
no attachments header |
[] |
nil |
| a header this SDK read | the attachments | nil |
| a header it could not read | nil |
why |
Layr8.Message.parse/1 returns {:ok, msg} in all three cases. An
authorization denial must not disappear because a hint travelling beside it was
malformed — being refused and being ignored are different events, and a caller
waiting on Layr8.Client.request/4 sees the difference as a denial versus a
timeout.
Identity credentials
A grant says what the sender may do. An identity credential says who
the sender is — that it works for a particular company, holds a licence, is
over eighteen. The cloud-node keeps them apart on one test,
credentialSubject.scope: with a scope it is a grant; without one it is an
identity credential and lands in the policy input a grant's senderCredentials
requirement reads.
Both ride in the same attachments list with the same
media_type: "application/vc+jwt". Layr8.Identity.attachment!/1 builds the
envelope:
{:ok, creds} = Layr8.Client.list_credentials(client)
employment = Enum.find(creds, &(&1["id"] == chosen_id))
Layr8.Client.send_message(client, %Layr8.Message{
to: [peer],
type: "https://layr8.io/protocols/mcp/1.0/tools-call",
body: %{"params" => %{"name" => "place_order"}},
attachments: [Layr8.Identity.attachment!(employment["credential_jwt"])]
})
Layr8.Identity.attachment/1 is the same thing returning
{:ok, attachment} | {:error, reason}.
Attaching one does not cost the message its grants — the wallet's selection is appended after yours.
You choose, always
The SDK will not pick identity credentials for you, and this is deliberate. The requirement you are trying to satisfy lives in the grant held by the recipient; it never reaches you before the call. An SDK selecting automatically would therefore have no criterion to select by, and exactly one implementable behaviour: attach everything you hold. Which claims about you or your organisation a counterparty gets to see is your decision, made per message — not a library default.
Errors
| Argument | Result |
|---|---|
| Not a compact JWS (three non-empty segments) | {:error, :not_compact_jws}. The node can verify nothing else. |
A credential with a non-empty credentialSubject.scope |
{:error, :credential_is_grant} — that is a grant. Attached this way it would be routed as one, satisfy no senderCredentials requirement, and produce a denial identical to attaching nothing. Let the wallet handle grants. |
An expired or revoked identity credential is admitted by the node today: validity is not checked on this input. Do not treat arrival as proof of currency.
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 before connect/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 |
Mediation (offline delivery)
The cloud-node does not queue for an agent that is offline; a layr8/mediator
in the Space does. Give the client the mediator's DID and it enrols, declares
the mediator on its node, collects whatever was queued while it was away, and
keeps live delivery on — on every connect and reconnect, in the background:
{:ok, client} = Layr8.Client.start_link(%{
node_url: "wss://node.example.com/plugin_socket/websocket",
api_key: System.fetch_env!("LAYR8_API_KEY"),
agent_did: "did:web:node.example.com:agents:me",
mediator: "did:web:node.example.com:agents:mediator" # or LAYR8_MEDIATOR_DID
})
Collected messages arrive through your ordinary handlers: the mediator holds
the original ciphertext, and the client posts each one back to its own node's
/didcomm, so it is unpacked, sender-bound and authorized exactly like a
first arrival. Nothing in the SDK decrypts.
Every step is also available by hand — Layr8.Mediation.enroll/3,
declare/3, pickup/3, live/4, status/3, undeclare/2 — and none of
them raises. mediator_live: false collects but leaves live delivery off;
didcomm_url: overrides where ciphertext is re-injected (default
<rest url>/didcomm).
The agent needs protocol grants on the mediator for
coordinate-mediation/3.0 and messagepickup/3.0. Forwards to the mediator
need none.
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")
# The node requires "id" and "issuer". When the credential omits them, the SDK
# sends "id" => "urn:uuid:<random UUID v4>" and "issuer" => the signing issuer DID.
{: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
})
Borrowing a parent's authority
A join can name the identity whose authority its DID borrows. Set :parent_did
and leave :agent_did empty, and the client joins as <parent_did>:<segment> —
twelve characters of Crockford base32, generated once when the configuration is
resolved, so a reconnect returns under the same DID.
{:ok, client} = Layr8.Client.start_link(%{
node_url: "wss://node.example.com/plugin_socket/websocket",
api_key: api_key,
parent_did: "did:web:acme.example:users:alice"
})
The node signs one credential for this DID per grant that parent holds and
returns them in the join reply. There is nothing to select — everything the
parent holds is delegated — and when :attach_grants is on they are attached to
outbound messages automatically.
The DID must be named beneath its parent, exactly one further segment. Pass
your own :agent_did that is not, and Layr8.Config.resolve!/1 raises
Layr8.Error rather than writing a join the node would refuse; the same rule
applies to a did_spec handed to Layr8.Client.join_did/2, which returns
{:error, %Layr8.Error{}}. Layr8.ChildDid.did_namespace_of/1 returns the one
API-key entry that admits every DID which may borrow from that parent.
Only a temporary identity may borrow. A join that names a parent is sent
with storage: "ephemeral" unless the caller's own did_spec says otherwise:
the node refuses persistent + parentDid with
e.join.plugin.child.storage-not-ephemeral. The parent itself must be a
persistent identity hosted by that node.
Read the status before the credentials.
Layr8.Client.delegated_credentials/1 returns a Layr8.Delegated.Reading, not
a list, and its answers are different things:
| Reading | supports_ephemeral_delegation/1 |
Meaning |
|---|---|---|
nil |
true |
This join named no parent |
%Reading{status: :complete, credentials: []} |
true |
The parent's wallet was read and it holds no grants |
%Reading{status: :complete, credentials: [_ | _]} |
true |
Read, and here is all of it |
%Reading{status: :partial, credentials: [_ | _]} |
true |
Read, and some of it could not be delegated |
%Reading{status: :unread, credentials: []} |
true |
The wallet could not be read; the [] measures nothing |
nil |
false |
The node predates delegation — it never looked |
Reaching for .credentials without reading .status turns four of those rows
into the second, and the second is the only one that is a measurement.
The credential exists nowhere but the join reply. The node stores nothing
about it, so GET /api/v1/credentials will never return it and no endpoint will
hand it back; rejoin to be issued a new one. It is not individually revocable —
authority is withdrawn by revoking or expiring the parent's grant.
A refused join names its reason: e.join.plugin.parent.not-persistent,
e.join.plugin.parent.not-found, e.join.plugin.parent.not-hosted-here,
e.join.plugin.child.not-beneath-parent,
e.join.plugin.child.storage-not-ephemeral,
e.join.plugin.child.already-persistent. The node's reason names the code and
reaches the caller as the :reason of the error connect/2 returns — this SDK
does not swallow or rewrite it.
Hosting more than one DID on one connection
connect/1 joins one DID. join_did/3 hosts additional DIDs on the same
WebSocket — one Phoenix topic each, one set of handlers each — which is how an
agent that speaks for many identities (a workflow per DID, an account per DID)
avoids one connection per identity:
:ok = Layr8.Client.connect(client)
{:ok, handle} = Layr8.Client.join_did(client, workflow_did,
protocols: ["https://example.com/workflow/1.0"],
handlers: %{
"https://example.com/workflow/1.0/request" => fn msg ->
{:reply, %Layr8.Message{type: "https://example.com/workflow/1.0/response", body: %{}}}
end
},
# Persistent so the DID survives a restart; `controller` is the DID whose
# grants apply to it — helix enforces `issuer == resource_controller`, so a
# controller that is not the owner makes every grant on it fail.
did_spec: %{"storage" => "persistent", "controller" => owner_did}
)
:ok = Layr8.DidHandle.send(handle, %Layr8.Message{to: [peer], type: type, body: %{}})
{:ok, reply} = Layr8.DidHandle.request(handle, %Layr8.Message{to: [peer], type: type, body: %{}})
:ok = Layr8.DidHandle.leave(handle)
- Inbound messages 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 - Everything it sends carries
from = did, and the Verifiable Grants attached are that DID's :protocolsdefaults to the protocols derived from:handlers; the problem-report protocol is appended so the node can deliver denials addressed to this DID (connect/1does not do this — see.claude/CLAUDE.md)- Joined DIDs are re-joined automatically after a reconnect. A caller is never told that a DID went away, so leaving that to the caller would stop delivery with nothing to observe
Layr8.Client.joined_dids/1lists them;leave_did/2drops one;close/1leaves them all
This is the Elixir counterpart of the node-sdk's joinDid / DidHandle.
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);
one socket, one topic per hosted DID
Layr8.DidHandle -- send/request as one additional DID joined with join_did/3
Layr8.REST -- HTTP client for REST API (Req)
Layr8.Credentials -- W3C Verifiable Credential operations
Layr8.Presentations -- W3C Verifiable Presentation operations
Links
License
MIT