# 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, github: "layr8/elixir_sdk"}]
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` | No | Agent DID (ephemeral if omitted)|
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)
```
<<<<<<< HEAD
## 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` | No | Agent DID (ephemeral if omitted) |
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
```
## 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")
{: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)
```
## Connection Lifecycle
The cloud-node assigns an ephemeral DID on connect when no `agent_did` is
configured. Retrieve it 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
})
```
## 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)
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