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:

: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:

# 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_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
})

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)

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

License

MIT