NornsSdk
Elixir SDK for Norns. Define agents and tools, connect as a worker, or send messages as a client.
Install
{:norns_sdk, "~> 0.3"}
Quickstart
- Start Norns locally (
docker compose upin the Norns repo). - Set
NORNS_API_KEYandANTHROPIC_API_KEYin your environment. - Add the worker to your supervision tree.
- Send messages with
NornsSdk.Client.send_message/4.
Worker
defmodule MyTools.SearchDocs do
use NornsSdk.Tool,
name: "search_docs",
description: "Search product documentation"
@impl true
def input_schema do
%{
"type" => "object",
"properties" => %{"query" => %{"type" => "string"}},
"required" => ["query"]
}
end
@impl true
def execute(%{"query" => query}) do
results = MyApp.Docs.search(query)
{:ok, results}
end
end
agent = NornsSdk.Agent.new(
name: "support-bot",
model: "claude-sonnet-5",
system_prompt: "You are a customer support agent.",
tools: [MyTools.SearchDocs],
mode: :conversation
)
children = [
{NornsSdk.Worker,
url: "http://localhost:4000",
api_key: System.get_env("NORNS_API_KEY"),
llm_api_key: System.get_env("ANTHROPIC_API_KEY"),
agent: agent}
]
The worker connects via WebSocket, registers the agent and tools, and handles LLM + tool tasks from the orchestrator. Reconnects automatically.
Client
client = NornsSdk.Client.new("http://localhost:4000", api_key: "nrn_...")
# Fire and forget
{:ok, %{run_id: 42}} = NornsSdk.Client.send_message(client, "support-bot", "Hello!")
# Block until completion
{:ok, result} = NornsSdk.Client.send_message(client, "support-bot", "Hello!", wait: true)
IO.puts(result.output)
# With conversation key
{:ok, result} = NornsSdk.Client.send_message(client, "support-bot", "Follow up",
conversation_key: "slack:U01ABC", wait: true)
# Inspect runs — reads return typed structs (access fields with dot syntax)
{:ok, run} = NornsSdk.Client.get_run(client, 42)
run.status # "completed"
{:ok, events} = NornsSdk.Client.get_events(client, 42)
Human-in-the-loop
An agent can call the built-in ask_human tool to pause and ask a question.
The run parks with status "waiting" until someone answers, and survives a
restart while parked.
{:ok, result} = NornsSdk.Client.send_message(client, "support-bot", "Book me a table", wait: true)
if NornsSdk.MessageResult.waiting?(result) do
IO.puts(result.waiting_for.question) # "7pm or 8pm?"
:ok = NornsSdk.Client.reply(client, result.run_id, "7pm")
end
wait: true returns as soon as the agent parks — it's waiting on you, so it
won't progress on its own. Sending the agent another message with
send_message/4 answers the question too, which is usually what a chat or
Slack client wants; reply/3 targets one specific run.
Streaming
stream/4 sends a message and forwards the run's events to a subscriber process
as they happen. This is the idiomatic Elixir shape — events arrive in your
mailbox, so it drops straight into a GenServer or LiveView handle_info/2.
{:ok, _stream_pid} = NornsSdk.Client.stream(client, "support-bot", "Research quantum computing")
# Events arrive as {:norns_event, stream_pid, %NornsSdk.StreamEvent{}}.
# The terminal events are "completed" and "error".
receive do
{:norns_event, _pid, %NornsSdk.StreamEvent{type: "completed"} = ev} ->
IO.puts(ev.data["output"])
{:norns_event, _pid, %NornsSdk.StreamEvent{type: type, data: data}} ->
IO.inspect({type, data}, label: "event")
{:norns_closed, _pid, reason} ->
# Socket closed before a terminal event (disconnect / timeout).
IO.inspect(reason, label: "stream closed")
end
Pass subscriber: pid to deliver events to a process other than the caller.
Docs
License
MIT