Run in Livebook

Mix.install([
  {:nous, "~> 0.17"},
  {:kino, "~> 0.14"}
])

What you will build

Nous is an AI agent framework for Elixir. This notebook walks the four calls you need before anything else makes sense:

Nothing here writes to disk or shells out. Notebook 2 covers tools, permissions and approvals; notebook 3 covers the knowledge base.

Configure a provider

Your API key goes into a Kino.Input.password cell. It is never written into the notebook source, and this notebook never reads a key out of the environment of whoever wrote it.

If you are running a local model server (LM Studio on localhost:1234), pick lmstudio and leave the key blank.

provider_input =
  Kino.Input.select("Provider", [
    {:openai, "OpenAI"},
    {:anthropic, "Anthropic"},
    {:groq, "Groq"},
    {:lmstudio, "LM Studio (local, no key)"}
  ])

model_input = Kino.Input.text("Model name", default: "gpt-4o-mini")
key_input = Kino.Input.password("API key")

Kino.Layout.grid([provider_input, model_input, key_input], columns: 1)

Run the next cell every time you change one of the inputs above. It pushes the key into the application environment that Nous.Model reads per provider, and builds the "provider:model" string every Nous entry point takes.

# Provider -> the :nous application env key Nous.Model.parse/2 falls back to.
# lmstudio needs no credential at all.
key_env = %{
  openai: :openai_api_key,
  anthropic: :anthropic_api_key,
  groq: :groq_api_key,
  lmstudio: nil
}

provider = Kino.Input.read(provider_input)
model_name = model_input |> Kino.Input.read() |> String.trim()
api_key = key_input |> Kino.Input.read() |> String.trim()

case Map.fetch!(key_env, provider) do
  nil -> :ok
  env_key when api_key != "" -> Application.put_env(:nous, env_key, api_key)
  _env_key -> :ok
end

model_string = "#{provider}:#{model_name}"
ready = model_name != "" and (provider == :lmstudio or api_key != "")

Kino.Markdown.new(
  if ready do
    "Configured: **`#{model_string}`**. Every cell below will make real calls."
  else
    "**Not configured yet.** Fill in the model name and the API key above, then " <>
      "re-run this cell. The rest of the notebook will skip its network calls " <>
      "until you do."
  end
)

1. One-shot text — Nous.generate_text/3

The smallest thing Nous does. No agent struct, no message history, no tool loop — a model string, a prompt, and {:ok, text} back.

one_shot =
  if ready do
    Nous.generate_text(model_string, "In one sentence: what is the BEAM?",
      system: "You explain Elixir to working programmers. Be precise, not cute.",
      max_tokens: 200
    )
  else
    {:error, :not_configured}
  end

Kino.Markdown.new(
  case one_shot do
    {:ok, text} -> text
    {:error, reason} -> "Skipped or failed: `#{inspect(reason)}`"
  end
)

generate_text/3 takes :system, :temperature, :max_tokens, :top_p, :base_url, :api_key, :tools, :deps and :fallback. There is also Nous.generate_text!/3 if you would rather have the exception.

2. Your first agent — Nous.new/2

An agent is a plain struct. Building one performs no I/O, so you can construct it once and keep it in module state, an ETS table, or a LiveView assign.

agent =
  Nous.new(model_string,
    name: "intro_agent",
    instructions: """
    You are a concise Elixir tutor. Answer in at most three sentences.
    Prefer OTP vocabulary over generic programming vocabulary.
    """,
    model_settings: %{temperature: 0.2, max_tokens: 400}
  )

Kino.Tree.new(%{
  name: agent.name,
  provider: agent.model.provider,
  model: agent.model.model,
  tools: length(agent.tools),
  plugins: agent.plugins,
  end_strategy: agent.end_strategy,
  parallel_tool_calls: agent.parallel_tool_calls
})

Nous.run/2 (or /3 with options) drives the agent to completion: it calls the model, executes any tools the model asked for, feeds the results back, and repeats until the model stops calling tools or :max_iterations is hit.

run_result =
  if ready do
    Nous.run(agent, "Why does OTP prefer supervision over defensive error handling?")
  else
    {:error, :not_configured}
  end

Kino.Markdown.new(
  case run_result do
    {:ok, result} -> result.output
    {:error, reason} -> "Skipped or failed: `#{inspect(reason)}`"
  end
)

The result is a map, not a struct. The keys you will actually use:

  • :output — the final answer.
  • :usage — a %Nous.Usage{}.
  • :iterations — how many model round-trips the tool loop took.
  • :all_messages / :new_messages — the conversation, for continuation.
  • :deps — dependencies as they stood at the end of the run.
case run_result do
  {:ok, result} ->
    result
    |> Map.take([:output, :iterations])
    |> Map.put(:message_count, length(result.all_messages))
    |> Map.put(:result_keys, result |> Map.keys() |> Enum.sort())
    |> Kino.Tree.new()

  {:error, _} ->
    Kino.Markdown.new("Run the previous cell with a provider configured first.")
end

3. Streaming — Nous.stream_text/3

stream_text/3 returns {:ok, stream} where the stream yields plain binaries. Rendering into a Kino.Frame gives you the usual typewriter effect.

frame = Kino.Frame.new()
Kino.render(frame)

stream_outcome =
  if ready do
    Nous.stream_text(model_string, "Write a four-line poem about supervision trees.",
      max_tokens: 200
    )
  else
    {:error, :not_configured}
  end

case stream_outcome do
  {:ok, stream} ->
    final =
      Enum.reduce(stream, "", fn chunk, acc ->
        text = acc <> chunk
        Kino.Frame.render(frame, Kino.Markdown.new(text))
        text
      end)

    Kino.Markdown.new("Streamed **#{String.length(final)}** characters.")

  {:error, reason} ->
    Kino.Markdown.new("Skipped or failed: `#{inspect(reason)}`")
end

For an agent — instructions, tools, plugins and all — the streaming equivalent is Nous.run_stream/3. It yields tagged tuples rather than binaries, so you can distinguish text from lifecycle events:

{:ok, stream} = Nous.run_stream(agent, "Tell me a story")

stream
|> Stream.each(fn
  {:text_delta, text} -> IO.write(text)
  {:complete, _result} -> IO.puts("\ndone")
  _other -> :ok
end)
|> Stream.run()

4. What did that cost? — result.usage

%Nous.Usage{} accumulates across every model call in a run, including the extra round-trips a tool loop adds. The two cache fields matter on providers with prompt caching (Anthropic in particular): a cache write is billed differently from a cache read, so they are tracked separately from :input_tokens rather than folded into it.

Note that :iterations lives on the result, not on the usage struct — usage counts tokens and calls, the result counts loop turns.

usage_rows =
  case run_result do
    {:ok, result} ->
      u = result.usage

      [
        %{field: "result.iterations", value: result.iterations},
        %{field: "usage.requests", value: u.requests},
        %{field: "usage.tool_calls", value: u.tool_calls},
        %{field: "usage.input_tokens", value: u.input_tokens},
        %{field: "usage.output_tokens", value: u.output_tokens},
        %{field: "usage.total_tokens", value: u.total_tokens},
        %{field: "usage.cache_creation_input_tokens", value: u.cache_creation_input_tokens},
        %{field: "usage.cache_read_input_tokens", value: u.cache_read_input_tokens}
      ]

    {:error, _} ->
      []
  end

if usage_rows == [] do
  Kino.Markdown.new("No run to measure yet — configure a provider and re-run section 2.")
else
  Kino.DataTable.new(usage_rows, keys: [:field, :value])
end

Nous.Usage.add/2 folds two usage structs together, which is how you total a batch of runs:

totals =
  case run_result do
    {:ok, result} -> Nous.Usage.add(Nous.Usage.new(), result.usage)
    {:error, _} -> Nous.Usage.new()
  end

Kino.Tree.new(Map.from_struct(totals))

Where to go next

  • Notebook 2 — Tools and agents. Write a tool with Nous.Tool.Behaviour, gate it with Nous.Permissions, and approve a file_write call by hand.
  • Notebook 3 — RAG and the knowledge base. Ingest documents, search them, and hand the populated store to an agent.
  • Nous.run_stream/3 for streaming agents, Nous.Agent for the full option list, and Nous.LLM for the provider-level API underneath generate_text/3.