SimplifyBAML
Structured LLM output generation for Elixir
SimplifyBAML is an Elixir wrapper around the BAML (Basically A Markup Language) runtime, providing type-safe schema definitions, automatic prompt generation, and streaming support with integration to ReqLLM.
Features
- ✅ Type-Safe Schemas - Define types using Elixir macros with compile-time validation
- ✅ Automatic Prompt Generation - Schemas are automatically converted to LLM-readable prompts
- ✅ Lenient Parsing - Handles real-world LLM responses (markdown, type coercion, enum normalization)
- ✅ Streaming Support - Three patterns from simple to schema-aware
- ✅ Multi-Provider - Works with 45+ LLM providers via ReqLLM (OpenAI, Anthropic, Groq, etc.)
- ✅ Backend Focus - Perfect for APIs, background jobs, and data processing pipelines
- ✅ Hybrid Architecture - Rust NIFs for performance, Elixir for ergonomics
Installation
Add to your mix.exs:
def deps do
[
{:simplify_baml, "~> 0.1.0"},
{:req_llm, "~> 1.0.0-rc.6"}
]
endYou'll need Rust installed for compiling the NIFs. Install from rustup.rs.
Quick Start
1. Define Schemas
defmodule Person do
use SimplifyBaml.Schema
schema "Person" do
field :name, :string, required: true, description: "Full name"
field :age, :integer, required: true, description: "Age in years"
field :occupation, :string, description: "Job title"
end
end2. Define BAML Functions
defmodule MyApp.BAML do
use SimplifyBaml.Function
defbaml extract_person(text: :string) :: Person do
model "anthropic:claude-3-sonnet-20240229"
temperature 0.7
template """
Extract the person's information from: {{ text }}
"""
end
end3. Execute
# Synchronous
{:ok, person} = MyApp.BAML.extract_person(%{text: "John is 30 years old"})
#=> {:ok, %{"name" => "John", "age" => 30, "occupation" => nil}}
# Streaming
{:ok, stream} = MyApp.BAML.extract_person_stream(%{text: "John is 30"})
stream
|> SimplifyBaml.Streaming.with_partial_parsing()
|> Enum.each(fn
{:partial, value} -> IO.inspect(value, label: "Partial")
{:complete, value} -> IO.inspect(value, label: "Complete")
end)Configuration
ReqLLM API keys can be configured via:
Environment Variables
export OPENAI_API_KEY="sk-..."
export ANTHROPIC_API_KEY="sk-ant-..."Application Config
# config/config.exs
config :req_llm,
openai_api_key: System.get_env("OPENAI_API_KEY"),
anthropic_api_key: System.get_env("ANTHROPIC_API_KEY")Runtime
ReqLLM.put_key(:openai, "sk-...")See ReqLLM documentation for more details.
Advanced Usage
Enums
defmodule Status do
use SimplifyBaml.Schema
enum "Status" do
description "Employment status"
values [:employed, :unemployed, :self_employed, :retired]
end
end
defmodule Employee do
use SimplifyBaml.Schema
schema "Employee" do
field :name, :string, required: true
field :status, Status, required: true
end
endLists
defmodule Person do
use SimplifyBaml.Schema
schema "Person" do
field :name, :string, required: true
field :hobbies, [:string], description: "List of hobbies"
end
endNested Schemas
defmodule Address do
use SimplifyBaml.Schema
schema "Address" do
field :street, :string, required: true
field :city, :string, required: true
end
end
defmodule Person do
use SimplifyBaml.Schema
schema "Person" do
field :name, :string, required: true
field :address, Address
end
endStreaming Patterns
Pattern 1: Simple Accumulation
Wait for the complete response before parsing:
{:ok, stream} = MyApp.BAML.extract_person_stream(%{text: text})
{:complete, full_text} = SimplifyBaml.Streaming.accumulate(stream)
{:ok, result} = SimplifyBaml.Streaming.parse_final(full_text, ir, "Person")When to use: Simple use cases, small responses, CLI tools.
Pattern 2: Partial Parsing (Recommended)
Parse incomplete JSON progressively as chunks arrive:
{:ok, stream} = MyApp.BAML.extract_person_stream(%{text: text})
stream
|> SimplifyBaml.Streaming.with_partial_parsing()
|> Enum.each(fn
{:partial, value} ->
# Value may be incomplete, structure can grow
broadcast_update(value)
{:complete, value} ->
# Final complete value
save_to_database(value)
end)When to use: Backend processing, APIs, monitoring, logs.
Pattern 3: Schema-Aware (Best for UIs)
Always return the full schema structure with fields filled progressively:
{:ok, stream} = MyApp.BAML.extract_person_stream(%{text: text})
stream
|> SimplifyBaml.Streaming.with_schema_updates()
|> Enum.each(fn {:update, value, state} ->
# Value ALWAYS has full structure
# Fields are nil until filled
# State: :pending | :partial | :complete
Phoenix.PubSub.broadcast!(
MyApp.PubSub,
"user:#{user_id}",
{:person_update, value, state}
)
end)When to use: Phoenix LiveView, WebSockets, real-time UIs.
Benefits:
- No layout shifts in UI
- Can show loading states per field
- Predictable component structure
- Better accessibility (screen readers)
Phoenix LiveView Integration
Perfect for real-time UIs with schema-aware streaming:
defmodule MyAppWeb.PersonLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, person: nil, streaming_state: :idle)}
end
def handle_event("extract", %{"text" => text}, socket) do
# Start streaming
task = Task.async(fn ->
{:ok, stream} = MyApp.BAML.extract_person_stream(%{text: text})
stream
|> SimplifyBaml.Streaming.with_schema_updates()
|> Enum.each(fn {:update, value, state} ->
send(self(), {:person_update, value, state})
end)
end)
{:noreply, assign(socket, streaming_state: :streaming, task: task)}
end
def handle_info({:person_update, person, state}, socket) do
{:noreply, assign(socket, person: person, streaming_state: state)}
end
endTemplate:
<div class="person-card">
<%= if @streaming_state == :streaming %>
<div class="loading-indicator">Extracting...</div>
<% end %>
<div class="field">
<label>Name:</label>
<span><%= @person["name"] || "..." %></span>
</div>
<div class="field">
<label>Age:</label>
<span><%= @person["age"] || "..." %></span>
</div>
<button disabled={@streaming_state != :complete}>
Submit
</button>
</div>Architecture
SimplifyBAML uses a hybrid Rust/Elixir architecture for the best of both worlds:
Rust Layer (NIFs via Rustler)
- Schema generation (IR → human-readable prompts)
- Response parsing with lenient JSON extraction
- Type coercion (string "30" → integer 30)
- Partial JSON parsing for streaming
- Enum validation with case-insensitive matching
Elixir Layer
- HTTP communication via ReqLLM
- Ergonomic macros for schema/function definitions
- Streaming helpers with backpressure
- Template rendering
- Developer experience
Flow:
Elixir Schema → Rust IR → Schema String → ReqLLM → LLM Response → Rust Parser → Elixir ResultExamples
Run the included examples:
# Set your API key
export ANTHROPIC_API_KEY="sk-ant-..."
# Basic usage
mix run examples/basic_usage.exs
# With macros (enums, nested schemas)
mix run examples/with_macros.exs
# Streaming (all 3 patterns)
mix run examples/streaming.exsTesting
mix testDocumentation
Generate HexDocs:
mix docs
open doc/index.htmlComparison to Other Libraries
vs. Instructor/Outlines
SimplifyBAML is similar but:
- Native Elixir API (not a Python port)
- Streaming-first design
- Schema-aware streaming for better UX
- Multi-provider support via ReqLLM
vs. Langchain
SimplifyBAML focuses on structured output only:
- No chains, agents, or RAG
- Simpler API surface
- Better performance (Rust parsing)
- Type safety at compile time
vs. Manual JSON Parsing
SimplifyBAML handles the edge cases:
- Markdown code blocks
- Type coercion
- Incomplete JSON (streaming)
- Enum normalization
- Nested structures
Roadmap
- Support for streaming lists
- JSON Schema validation
- Custom validators
- Retry policies
- Cost tracking integration
- OpenTelemetry tracing
Contributing
Contributions welcome! Please:
- Fork the repo
- Create a feature branch
- Add tests
- Submit a PR
License
MIT
Credits
- Original BAML runtime: BoundaryML/baml
- ReqLLM: agentjido/req_llm
- Inspired by Instructor and Outlines