Current section
Files
Jump to
Current section
Files
README.md
# Layr8 Elixir SDK
Elixir client for the [Layr8](https://layr8.io) decentralized identity-native messaging network.
Full documentation at [docs.layr8.io/build/elixir-sdk](https://docs.layr8.io/build/elixir-sdk)
## Installation
Add to your `mix.exs` dependencies:
```elixir
def deps do
[{:layr8, "~> 0.3.0"}]
end
```
Requires Elixir ~> 1.15.
## Quick Start
```elixir
{: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](#borrowing-a-parents-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
```elixir
: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:
```elixir
: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
```elixir
# 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`):
```elixir
{: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](#borrowing-a-parents-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://`
```elixir
# 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:
```elixir
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`).
```elixir
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.
```elixir
# 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:
```elixir
{: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`.
```elixir
%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](#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:
```elixir
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:
```elixir
{: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.
```elixir
{: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:
```elixir
{: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):
```elixir
{: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
```elixir
{: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](#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:
```elixir
{: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.
```elixir
{: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:
```elixir
: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
- `:protocols` defaults to the protocols derived from `:handlers`; the
problem-report protocol is appended so the node can deliver denials addressed
to this DID (`connect/1` does 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/1` lists them; `leave_did/2` drops one; `close/1`
leaves 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`](examples/echo_agent.ex) for a standalone echo agent.
## Development
```sh
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
- [Layr8 Documentation](https://docs.layr8.io)
- [Elixir SDK Docs](https://docs.layr8.io/build/elixir-sdk)
- [DIDComm v2 Spec](https://identity.foundation/didcomm-messaging/spec/)
- [GitHub](https://github.com/layr8/elixir_sdk)
## License
MIT