Gut is a simple DSL for LLM decisions in Elixir. It picks an Elixir value for your subject and question. No agent framework required, just a few lines of code and your LLM of choice.
ticket = %{
subject: "Charged twice for the same order",
body: "Please refund the second charge."
}
Gut.feel(ticket, "Which team should handle this?",
billing: "Payments, invoices, and refunds",
technical: "Product defects and access problems",
other: "Anything else"
)
#=> {:ok, :billing}Add gut and req_llm to mix.exs:
def deps do
[
{:gut, "~> 0.1.0"},
{:req_llm, "~> 1.24"}
]
endChoose one of these options to configure the adapter:
Configure Gut.ReqLLM.Jev to use a Jev evaluation model:
config :gut,
adapter: {Gut.ReqLLM.Jev,
model: "typesafe:jev-latest",
api_key: System.fetch_env!("TYPESAFE_API_KEY")}Use Gut.ReqLLM with any ReqLLM-supported model:
config :gut,
adapter: {Gut.ReqLLM,
model: "anthropic:claude-haiku-4-5",
api_key: System.fetch_env!("ANTHROPIC_API_KEY")}With Gut.ReqLLM, options other than :model pass to ReqLLM.generate_text/3.
Start Ollama and pull a model:
ollama pull llama3.2Then use the same Gut.ReqLLM adapter. No API key is needed:
config :gut, adapter: {Gut.ReqLLM, model: "ollama:llama3.2"}Ollama must be running at its default address, http://localhost:11434. See the ReqLLM Ollama guide to use a different address.
An adapter connects Gut to a decision system. Gut passes it the subject, question, and choices. The adapter returns the ID of the selected choice. Implement Gut.Adapter to use your own model or service:
defmodule MyApp.BumblebeeAdapter do
@behaviour Gut.Adapter
def init([]), do: nil
def choose(subject, _question, choices, _state) do
%{predictions: predictions} = Nx.Serving.batched_run(MyApp.Classifier, subject)
%{label: label} = Enum.max_by(predictions, & &1.score)
case Enum.find(choices, fn {_id, description} -> description == label end) do
{id, _} -> {:ok, id}
nil -> {:error, %Gut.Error{reason: :invalid_answer, message: "Unknown label"}}
end
end
end
config :gut, adapter: MyApp.BumblebeeAdapterUse a list when the choices need no descriptions. For example, sort incoming mail:
case Gut.feel(%{from: email.from, subject: email.subject, body: email.body},
"Is this spam?", [true, false], allow_unsure: true) do
{:ok, true} -> Mailbox.move(email, :spam)
{:ok, false} -> Mailbox.move(email, :inbox)
{:ok, :unsure} -> Mailbox.flag_for_review(email)
{:error, error} -> {:error, error}
endUse keyword choices when the model needs descriptions. For example, route a ticket after loading it from the database:
with %Ticket{} = ticket <- Repo.get(Ticket, id),
{:ok, team} <- Gut.feel(%{subject: ticket.subject, body: ticket.body},
"Which team should handle this?",
billing: "Payments, invoices, and refunds",
technical: "Product defects and access problems",
other: "Anything else"
),
{:ok, ticket} <- Repo.update(Ecto.Changeset.change(ticket, team: Atom.to_string(team))) do
Support.notify_team(ticket)
else
nil -> {:error, :not_found}
error -> error
endAn integer range works for scores. For example, flag a conversation when the customer is frustrated:
case Gut.feel(messages, "How frustrated is the customer?", 1..5) do
{:ok, score} when score >= 4 -> Support.flag_for_review(conversation_id)
{:ok, _score} -> :ok
{:error, error} -> {:error, error}
endGut.feel/4 returns {:error, %Gut.Error{}} for provider and adapter failures. See Gut.Error for error details. Use the reason field for control flow:
:timeout:rate_limited:unauthorized:invalid_answer:adapter_error
cause contains the original error for logging.
Gut sends strings as plain text and encodes other values as JSON.
For structs, derive Gut.Subject to limit the fields sent to the model:
defmodule Ticket do
@derive {Gut.Subject, only: [:subject, :body]}
defstruct [:subject, :body, :internal_notes]
endUse Gut.Test in config/test.exs to run tests without LLM requests:
config :gut, adapter: Gut.TestIt selects the first choice by default. To select another choice, register a stub in each test:
# in an async test
Gut.Test.stub(fn _subject, question, _choices ->
if question == "Which team?", do: :technical, else: :billing
end)
assert MyApp.route(ticket) == :technicalStubs must return an offered choice. They belong to the test process. Tasks
started by that process can use them. Other processes need their own stub.
Tests can use async: true.
Gut emits these :telemetry events:
| Event | Measurements | Additional metadata |
|---|---|---|
[:gut, :feel, :start] |
system_time |
— |
[:gut, :feel, :stop] |
duration |
outcome (:ok or a Gut.Error reason) |
[:gut, :feel, :exception] |
duration |
kind (:error, :exit, or :throw) |
All events include adapter and model metadata. duration uses native time
units. Gut does not emit subjects, questions, choices, errors, or stack traces.
Validation and adapter initialization do not emit events.
Gut requires Elixir 1.18 or later.
mix deps.get
pre-commit installRun the checks:
mix format --check-formatted
mix compile --warnings-as-errors
mix credo --strict
mix test