AGENTS.md
View SourceQuick-reference for AI coding agents (Claude, Cursor, Copilot, Codex, etc.)
working with the Nous Elixir AI agent framework. This file is for agents
that want to use the library, not for agents maintaining the library
itself (see CONTRIBUTING.md and docs/ for that). Conforms to
https://agents.md.
What Nous is
Multi-provider LLM framework for Elixir/OTP. Provides:
- One-shot LLM calls (
Nous.generate_text/2,3,Nous.stream_text/2,3) - Stateful agents with tool-calling, memory, plugins (
Nous.new/2,Nous.run/2,3) - Pluggable providers — OpenAI, Anthropic, Gemini, Vertex AI, Groq, Mistral,
OpenRouter, Together, Ollama, LM Studio, vLLM, SGLang, LlamaCpp, and a
generic
custom:adapter for any OpenAI-compatible endpoint - Tool system — file ops, bash, web fetch + search, plus easy custom tools
- Pluggable HTTP backend (Req default, hackney alternative)
- Streaming (Req default; opt into the hackney
:async, :oncepull-mode backend for strict backpressure)
Minimal API surface (start here)
# Drop-in: one-shot text generation
{:ok, text} = Nous.generate_text("openai:gpt-4o", "Explain GenServer in one sentence.")
# Streaming
{:ok, stream} = Nous.stream_text("anthropic:claude-sonnet-4-5", "Write a haiku")
Enum.each(stream, &IO.write/1)
# Stateful agent with tools
agent =
Nous.new("openai:gpt-4o",
tools: [Nous.Tools.FileRead, Nous.Tools.FileGrep],
system_prompt: "You are a code reviewer."
)
{:ok, result} = Nous.run(agent, "Find all TODOs in lib/")
# result.text, result.messages, result.usage
# Streaming agent run (text deltas only, no tool execution)
{:ok, stream} = Nous.run_stream(agent, "Summarize this repo")
# Streaming + tool execution in the same call (Nous 0.15.3+)
{:ok, result} = Nous.run(agent, "Search and summarize",
stream: true,
callbacks: %{
on_llm_new_delta: fn _, t -> IO.write(t) end,
on_llm_new_thinking_delta: fn _, t -> IO.write(["[thinking] ", t]) end
}
)That's 90% of what most apps need. Everything else is configuration.
Provider quick-pick (model strings)
Format is "<provider>:<model_id>". Pick one:
| If you want… | Use |
|---|---|
| Best general-purpose, high quality | openai:gpt-4o or anthropic:claude-sonnet-4-5-20250929 |
| Cheap and fast | groq:llama-3.1-70b-versatile or gemini:gemini-2.0-flash |
| Local / no API key | lmstudio:<your-loaded-model> (default port 1234) |
| Local high-throughput inference | vllm:<huggingface-id> (default port 8000) |
| Local with structured generation | sglang:<model> (default port 30000) |
| Anything else with an OpenAI-compatible API | custom:<model> + :base_url opt |
Auth picks up the env var by convention: OPENAI_API_KEY, ANTHROPIC_API_KEY,
GROQ_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY, etc. Local providers
don't need a key. Override per-call with api_key: opt.
Key opts you'll actually use
Nous.new("openai:gpt-4o",
# LLM behavior
system_prompt: "...",
temperature: 0.7,
max_tokens: 2_000,
receive_timeout: 60_000, # ms; 120_000 for local models
# Tools (modules implementing Nous.Tool.Behaviour)
tools: [Nous.Tools.Bash, MyApp.MyTool],
# Memory backend (optional)
memory: %{store: Nous.Memory.Store.ETS, opts: []},
# Plugins (optional, composable)
plugins: [Nous.Plugins.SubAgent, Nous.Plugins.HumanInTheLoop],
# Resilience
fallback: ["anthropic:claude-sonnet-4-5", "groq:llama-3.1-70b-versatile"],
# Vendor-specific body params (vLLM/SGLang/LM Studio/llama.cpp)
extra_body: %{top_k: 50, repetition_penalty: 1.1}
)Built-in tools
In Nous.Tools.*. The five most useful:
Nous.Tools.Bash— execute shell commands (requires approval handler in production)Nous.Tools.FileRead/FileWrite/FileEdit— workspace-sandboxed file opsNous.Tools.FileGlob/FileGrep— find files / search contentNous.Tools.WebFetch— fetch + extract text from a URL (SSRF-protected)Nous.Tools.TavilySearch/BraveSearch— web search
File tools enforce a workspace root. Default is cwd. Override per-agent:
Nous.new("openai:gpt-4o",
tools: [Nous.Tools.FileRead],
deps: %{workspace_root: "/path/to/project"}
)Building a custom tool
defmodule MyApp.WeatherTool do
@behaviour Nous.Tool.Behaviour
@impl Nous.Tool.Behaviour
def metadata do
%{
name: "get_weather",
description: "Get current weather for a city",
parameters: %{
"type" => "object",
"properties" => %{
"city" => %{"type" => "string", "description" => "City name"}
},
"required" => ["city"]
}
}
end
# Context comes FIRST, args second.
@impl Nous.Tool.Behaviour
def execute(_ctx, %{"city" => city}) do
{:ok, "Weather in #{city}: 72°F, sunny"}
end
endPass the module in the tools: list (tools: [MyApp.WeatherTool]) — bare
behaviour modules are converted via Nous.Tool.from_module/1 automatically.
The _ctx arg gives access to deps, the workspace root, and the approval
handler. Use Nous.Tool.Validator for input validation — it runs automatically
when validate_args: true (the default).
HTTP backend (don't change unless you need to)
Default backend is Nous.HTTP.Backend.Req — Req on top of Finch. It's
faster under parallel batching than the alternative. Override only if:
- You need HTTP/3 →
NOUS_HTTP_BACKEND=hackney - You want one HTTP family across streaming + non-streaming → same
Pool config (hackney pool, used by the Hackney backend):
config :nous, :hackney_pool,
max_connections: 200,
timeout: 1_500 # idle keepalive ms (hackney 4 caps at 2_000)Streaming defaults to Nous.HTTP.StreamBackend.Req (push-based with a
best-effort mailbox-watching backpressure guard). For STRICT pull-based
backpressure (:async, :once — a slow consumer can't OOM under a fast LLM),
opt into the Hackney stream backend via config :nous, :http_stream_backend, Nous.HTTP.StreamBackend.Hackney, NOUS_HTTP_STREAM_BACKEND=hackney, or the
per-call stream_backend: option. See docs/benchmarks/http_backend.md.
Critical rules (security & correctness)
These are project-wide and non-negotiable. If you write code that breaks these, it will be rejected.
- Never
String.to_atom/1on untrusted input. UseString.to_existing_atom/1with rescue, or pattern-match on a whitelist of literal strings. The atom table is finite and a prompt-injection input can OOM the BEAM. - Tools requiring approval are rejected without an
:approval_handler.Bash,FileWrite,FileEditneed one wired inRunContextor they refuse to run. Don't disable this. - File tools enforce a workspace root. Don't bypass
PathGuard. Pass paths within the workspace; the guard rejects..traversal, absolute paths outside, and symlink escapes. - HTTP from agents goes through
UrlGuard. Don't make rawReq.get/1calls from a tool to a user-controlled URL — useNous.Tools.WebFetchor callUrlGuard.validate/2first. Blocks RFC1918, loopback, link-local, cloud-metadata IPs. PromptTemplaterejects<% ... %>blocks — only<%= @var %>substitution is allowed. Don't try to enable EEx evaluation on LLM-touched templates; it's an RCE vector.- Sub-agent deps don't auto-forward. If you spawn a sub-agent via
Nous.Plugins.SubAgent, declare which deps it sees with:sub_agent_shared_deps, [:key1, :key2]. The default[]is correct for security.
Common workflows
Streaming to LiveView
# In your LiveView mount or handle_event:
{:ok, stream} = Nous.stream_text("anthropic:claude-sonnet-4-5", prompt)
stream
|> Stream.each(fn chunk ->
send(self(), {:llm_chunk, chunk})
end)
|> Stream.run()For strict backpressure under LiveView fan-out (so the stream paces itself to match diff/push throughput with no mailbox accumulation), opt into the Hackney stream backend (see the HTTP backend section); the default Req stream backend uses a best-effort mailbox-watching guard.
Tool-using agent loop
agent =
Nous.new("openai:gpt-4o",
tools: [Nous.Tools.FileGrep, Nous.Tools.FileRead, Nous.Tools.Bash],
max_iterations: 10
)
{:ok, result} = Nous.run(agent, "Find the bug in lib/foo.ex and explain it")
# result.messages contains the full transcript including tool calls
# result.usage gives token counts per providerProvider failover
agent =
Nous.new("openai:gpt-4o",
fallback: [
"anthropic:claude-sonnet-4-5-20250929",
"groq:llama-3.1-70b-versatile"
]
)Falls through on transport errors, 5xx, and rate-limit (429) responses.
Local dev with LM Studio
# 1. Start LM Studio, load a model, start the server (default port 1234).
# 2. In Elixir:
{:ok, text} = Nous.generate_text("lmstudio:<exact-model-name-shown-in-lmstudio>",
"Hello!")
# Or override the URL:
agent = Nous.new("lmstudio:my-model", base_url: "http://gpu-host:1234/v1")Testing your code that uses Nous
# Use the test helpers in Nous.Tool.Testing for tool unit tests.
# For end-to-end agent tests, the recommended pattern is to use Bypass to
# stub the LLM HTTP endpoint:
setup do
bypass = Bypass.open()
base = "http://localhost:#{bypass.port}/v1"
{:ok, bypass: bypass, base: base}
end
test "agent calls the model", %{bypass: bypass, base: base} do
Bypass.expect_once(bypass, "POST", "/v1/chat/completions", fn conn ->
conn
|> Plug.Conn.put_resp_header("content-type", "application/json")
|> Plug.Conn.resp(200, ~s({"choices":[{"message":{"content":"hi!"}}]}))
end)
agent = Nous.new("custom:test-model", base_url: base, api_key: "test")
assert {:ok, %{text: "hi!"}} = Nous.run(agent, "hello")
endDon't mock Req/hackney directly — Bypass is the supported test seam.
What NOT to use
The public API is Nous.* and Nous.Tools.*. These are NOT public:
Nous.HTTP.Backend.*— internal; useHTTP.post/4's:backendopt insteadNous.Providers.HTTP— internal helper for provider authorsNous.AgentRunner,Nous.AgentServer— internal supervision; useNous.run/3- Nous.Application, Nous.Persistence.ETS.TableOwner — internal supervision tree
- Anything under
Nous.Workflow.Engine.*— internal; the public API isNous.Workflow - Anything marked
@moduledoc false— hidden on purpose; will change without notice
Stick to the documented modules and your code will survive minor version bumps.
Where to look for more
- Hex docs: https://hexdocs.pm/nous
- Getting started:
docs/getting-started.md - Production guides:
docs/guides/(skills, hooks, LiveView integration, best practices, tool development, troubleshooting, evaluation, structured output, workflows, memory, context, knowledge base) - Examples:
examples/ - CHANGELOG: behavioral changes per release; read the "Behavioral / breaking changes" sections before upgrading.