mlld Elixir SDK
Elixir wrapper for mlld using a persistent NDJSON JSON-RPC transport over mlld live --stdio.
This SDK intentionally matches the behavior and option model used by the Go, Python, Ruby, and Rust SDKs in sdk/, while adding BEAM-native features for supervision, pooling, and telemetry.
Status
-
Phase 1 parity: implemented (
Client,Handle, typed results, async control, timeout/cancel, transport restart) -
Phase 2 native features: implemented (
GenServerintegration, named registration, pool, telemetry, Phoenix bridge) - Phase 3 future readiness: option/model guidance documented; runtime support depends on upstream mlld features
Table Of Contents
- Requirements
- Installation
- Quick Start
- API At A Glance
- Core Types
- Option Reference
- Request Lifecycle And Transport Model
- Async Handles
- State Updates And Cancellation
- Supervision And Named Clients
-
Connection Pool (
Mlld.Pool) - Telemetry
- Phoenix Channel Bridge
- Error Model
- Behavioral Parity With Other SDKs
- Testing
- Operational Notes
- Release Process
- Future Feature Readiness (VFS / Checkpoint / Resume / Fork)
1. Requirements
- Elixir 1.15+
- Erlang/OTP compatible with your Elixir version
- Node.js runtime (mlld CLI runtime dependency)
mlldexecutable available in PATH, or command override via client options
2. Installation
From this repo checkout:
cd sdk/elixir
mix deps.getFor local development against this repository's CLI build:
-
Build CLI at repo root (
dist/cli.cjsmust exist) -
Configure client as
command: "node", command_args: ["/abs/path/to/dist/cli.cjs"]
3. Quick Start
alias Mlld.Client
{:ok, client} =
Client.start_link(
command: "mlld",
timeout: 30_000
)
# String script execution
{:ok, output} =
Client.process(
client,
"/show \"Hello World\"\n",
mode: :strict
)
IO.puts(output)
# File execution with payload/state/dynamic modules
{:ok, result} =
Client.execute(
client,
"./agent.mld",
%{"text" => "hello"},
state: %{"count" => 0},
dynamic_modules: %{
"@config" => %{"mode" => "demo"}
},
timeout: 10_000
)
IO.puts(result.output)
IO.inspect(result.state_writes)
Client.stop(client)4. API At A Glance
Mlld.Client
start_link(opts)stop(client)process(client, script, opts)process_async(client, script, opts)execute(client, filepath, payload, opts)execute_async(client, filepath, payload, opts)analyze(client, filepath)process_task(client, script, opts)execute_task(client, filepath, payload, opts)cancel_request(client, request_id)update_state(client, request_id, path, value, opts)
Mlld.Handle
request_id(handle)cancel(handle)update_state(handle, path, value, opts)wait(handle)result(handle)task(handle)
Module-level convenience (Mlld)
Mlld.process(script, opts)Mlld.process_async(script, opts)Mlld.execute(filepath, payload, opts)Mlld.execute_async(filepath, payload, opts)Mlld.analyze(filepath)Mlld.close()
5. Core Types
Returned structs are aligned with other SDK wrappers:
Mlld.ExecuteResultoutputstate_writes([Mlld.StateWrite])exportseffects([Mlld.Effect])denials([Mlld.GuardDenial])metrics(Mlld.Metrics | nil)
Mlld.AnalyzeResultfilepathvaliderrors([Mlld.AnalysisError])executables([Mlld.Executable])exportsimports([Mlld.Import])guards([Mlld.Guard])needs(Mlld.Needs | nil)
Mlld.StateWritepathvaluetimestamp
Mlld.Error(exception struct, returned in{:error, ...}tuples)messagecodereturn_codedetails
6. Option Reference
Client options (start_link/1)
:name- process registration (:atom,{:global, term},{:via, module, term}):command- command executable ("mlld"default):command_args- prepended args beforelive --stdio:timeout- default request timeout in milliseconds (default30_000):working_dir- working directory for script execution:completed_limit- in-memory completed request cache size (default1024)
Local repo CLI example:
{:ok, client} =
Mlld.Client.start_link(
command: "node",
command_args: ["/absolute/path/to/dist/cli.cjs"],
timeout: 20_000
)
Process options (process/3, process_async/3)
:file_path:payload:state:dynamic_modules:dynamic_module_source:mode(:strict,:markdown, or string):allow_absolute_paths:timeout(request-specific ms override)
Execute options (execute/4, execute_async/4)
:state:dynamic_modules:dynamic_module_source:mode:allow_absolute_paths:timeout
Update-state options (update_state/5, Handle.update_state/4)
:timeout- timeout for eachstate:updaterequest
Behavior:
-
Retries on
REQUEST_NOT_FOUNDevery 25ms until deadline - Deadline = resolved timeout or 2000ms fallback
- Matches retry semantics in Go/Python/Ruby/Rust wrappers
7. Request Lifecycle And Transport Model
Internals are intentionally consistent with other SDK implementations:
-
Client keeps one persistent subprocess via
mlld live --stdio -
Each call is encoded as JSON-RPC line with integer
id - Multiple requests are in-flight concurrently (multiplexed)
- NDJSON envelopes are decoded from stdout
eventmessages are routed byidand accumulated for handle resultresultmessage resolves request and unblocks waiters-
Transport death fails all pending requests with
TRANSPORT_ERROR - Next request lazily restarts transport automatically
8. Async Handles
process_async/3 and execute_async/4 return Mlld.Handle.
{:ok, handle} =
Mlld.Client.process_async(
client,
"loop(99999, 50ms) until @state.exit [\n continue\n]\nshow \"done\"",
state: %{"exit" => false},
timeout: 10_000
)
# Control in-flight request
:ok = Mlld.Handle.update_state(handle, "exit", true)
# Block until done
{:ok, output} = Mlld.Handle.result(handle)Task interop patterns:
# Task-returning helpers
task = Mlld.Client.execute_task(client, "pipeline.mld", payload, timeout: 20_000)
{:ok, execute_result} = Task.await(task, :infinity)
# Handle -> underlying task
{:ok, handle} = Mlld.Client.execute_async(client, "pipeline.mld", payload)
task = Mlld.Handle.task(handle)
{:ok, execute_result} = Task.await(task, :infinity)9. State Updates And Cancellation
Cancel an in-flight request
:ok = Mlld.Handle.cancel(handle)
# or
:ok = Mlld.Client.cancel_request(client, request_id)Update in-flight state
:ok = Mlld.Handle.update_state(handle, "exit", true)If request is already complete, update returns:
{:error, %Mlld.Error{code: "REQUEST_NOT_FOUND"}}10. Supervision And Named Clients
Mlld.Client is a GenServer worker with child spec support.
children = [
{Mlld.Client,
name: :main_agent,
command: "mlld",
timeout: 60_000}
]
{:ok, _pid} = Supervisor.start_link(children, strategy: :one_for_one)
{:ok, output} = Mlld.Client.process(:main_agent, "/show \"hello\"")Named process discovery works with standard OTP registration forms.
11. Connection Pool (Mlld.Pool)
Pool provides checkout/checkin and convenience execute/process/analyze helpers.
children = [
{Mlld.Pool,
name: :agent_pool,
size: 20,
overflow: 5,
command: "mlld",
timeout: 30_000}
]
{:ok, _pid} = Supervisor.start_link(children, strategy: :one_for_one)
{:ok, result} = Mlld.Pool.execute(:agent_pool, "pipeline.mld", %{"topic" => "safety"})Manual checkout:
{:ok, client} = Mlld.Pool.checkout(:agent_pool)
{:ok, output} = Mlld.Client.process(client, "/show \"pooled\"")
:ok = Mlld.Pool.checkin(:agent_pool, client)Pool notes:
-
Base clients (
size) are long-lived - Overflow clients are temporary and stopped on checkin
- Owner process monitoring returns clients when owners exit
12. Telemetry
The SDK emits :telemetry events with prefix [:mlld, ...].
Core events:
[:mlld, :process, :start][:mlld, :process, :stop][:mlld, :process, :exception][:mlld, :execute, :start][:mlld, :execute, :stop][:mlld, :execute, :exception][:mlld, :analyze, :start][:mlld, :analyze, :stop][:mlld, :analyze, :exception][:mlld, :transport, :restart]
Attach handler example:
:telemetry.attach(
"mlld-logger",
[
[:mlld, :process, :stop],
[:mlld, :execute, :stop],
[:mlld, :transport, :restart]
],
fn event, measurements, metadata, _config ->
IO.inspect({event, measurements, metadata}, label: "mlld telemetry")
end,
nil
)13. Phoenix Channel Bridge
MlldPhoenix.ChannelBridge provides optional event/result forwarding to channel pushes without introducing a hard compile-time dependency on Phoenix.
# inside a Phoenix channel module
def handle_in("execute", %{"filepath" => path, "payload" => payload}, socket) do
{:ok, _handle} =
Mlld.Phoenix.stream_execute(
socket,
path,
payload,
event_topic: "agent:event",
result_topic: "agent:result"
)
{:noreply, socket}
endIf Phoenix is not loaded at runtime:
{:error, :phoenix_not_available}14. Error Model
All APIs return tuples:
-
success:
{:ok, value} -
failure:
{:error, %Mlld.Error{...}}
Common error codes:
TRANSPORT_ERRORTIMEOUTREQUEST_NOT_FOUNDINVALID_REQUEST-
runtime codes propagated from mlld (
RUNTIME_ERROR, etc.)
Pattern matching example:
case Mlld.Client.execute(client, "agent.mld", payload) do
{:ok, result} ->
IO.puts(result.output)
{:error, %Mlld.Error{code: "TIMEOUT"}} ->
IO.puts("execution timed out")
{:error, %Mlld.Error{code: code, message: message}} ->
IO.puts("#{code}: #{message}")
end15. Behavioral Parity With Other SDKs
Parity guarantees in this implementation:
-
Live transport command shape:
command + command_args + ["live", "--stdio"] - Request IDs are integers and multiplexed in one live client
state:writeevents are merged with finalstateWritesin execute resultsExecuteResult.denialscollects structured guard/policy label-flow denials observed during execution-
Timeout behavior cancels request and returns
TIMEOUT update_stateretriesREQUEST_NOT_FOUNDwith short backoff- Transport closure fails pending operations and triggers lazy restart
This makes behavior consistent across Go/Python/Ruby/Rust/Elixir wrappers.
16. Testing
Run in sdk/elixir/:
mix testIntegration tests expect:
nodeon PATHdist/cli.cjsbuilt at repo root (../../dist/cli.cjsfromsdk/elixir)
Integration coverage mirrors other SDKs:
- execute roundtrip with dynamic modules and state writes
-
long-running loop stopped via
update_state REQUEST_NOT_FOUNDwhen updating after completion
17. Operational Notes
-
Keep one long-lived client per process domain (or use
Mlld.Pool) - Use request-level timeouts on untrusted/long tasks
- Prefer named clients for supervision-driven apps
- Collect telemetry to observe latency and transport restarts
-
Use
command: "node", command_args: [cli_path]in mono-repo development
18. Release Process
Release instructions are maintained in sdk/elixir/RELEASE.md.
Quick path:
cd sdk/elixir
mix format --check-formatted
mix test
mix hex.build
mix hex.publish --dry-run
mix hex.publishThen tag from repo root:
git tag elixir-sdk-v<version>
git push origin main --tags19. Future Feature Readiness
The SDK API shape is prepared for upcoming mlld capabilities described in project specs:
-
Virtual filesystem support (
fileSystem-style option passthrough) - Checkpoint/resume/fork option passthrough
- checkpoint/hook event forwarding to telemetry
- supervised restart + checkpoint reuse patterns for resilient agents
As upstream CLI flags/protocol fields land, additions should remain backward-compatible with the current client/handle/result model.