Gut
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}
Installation
Add gut and req_llm to mix.exs:
def deps do
[
{:gut, "~> 0.1.0"},
{:req_llm, "~> 1.24"}
]
end
Choose one of these options to configure the adapter:
Option 1: Jev evaluation
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")}
Option 2: Other LLM models
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.
Option 3: Local models with Ollama
Start Ollama and pull a model:
ollama pull llama3.2
Then 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.
Option 4: Custom adapter
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.BumblebeeAdapter
Usage
Use 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}
end
Use 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
end
An 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}
end
Gut.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.
Subjects
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]
end
Testing
Use Gut.Test in config/test.exs to run tests without LLM requests:
config :gut, adapter: Gut.Test
It 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) == :technical
Stubs 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.
Telemetry
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.
Development
Gut requires Elixir 1.18 or later.
mix deps.get
pre-commit install
Run the checks:
mix format --check-formatted
mix compile --warnings-as-errors
mix credo --strict
mix test