# 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`  | 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

```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 |
| `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}
}
```

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

## 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)
```

> **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
})
```

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