BAML (BoundaryML): Writing LLM Prompts as Clean, Typed Functions
Hey folks, if you have ever built an application that calls an LLM, you know exactly what I mean. Your code is full of long prompt strings, formatted with messy f-strings, and then you parse the output with json.loads() while praying the model actually returns valid JSON. When the model misbehaves and adds some extra text before the JSON, your app crashes instantly. And do not even get me started on switching from OpenAI to Anthropic or a local model. You have to dig through code in a dozen places.
In this tutorial I want to introduce you to a tool that genuinely changed how I write LLM code. It is called BAML, built by the BoundaryML team. BAML stands for Basically A Made-up Language, and yes, it really is a small programming language purpose-built for one thing: writing LLM prompts as functions with clearly typed inputs and outputs. So instead of arbitrary strings, you get real functions with a contract.
I am going to take you from zero: installation, writing your first .baml function, generating the client, calling it from Python, testing, streaming, and swapping model providers. Take it easy, we will go step by step. Ready? Let us go.
Introduction: Why BAML Matters
Before we dive into code, I want you to understand the problem BAML solves. This matters because it explains why this tool is worth learning.
Imagine you want to build a feature that extracts information from a resume. You want the name, email, and a list of skills. The "usual" approach we often reach for looks something like this:
import openai
import json
prompt = f"""
Extract information from the following resume and return it as JSON
with fields name, email, and skills.
Resume:
{resumetext}
"""
response = openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}]
)
Pray this is valid JSON
data = json.loads(response.choices[0].message.content)
Looks fine, right? But there are many hidden problems here. First, the output is not guaranteed to be valid JSON. The model sometimes emits a ``` `json `` fence, or adds a sentence like "Here is the result:" that makes json.loads() fail. Second, you have no idea what the data structure will be. Is skills a list of strings or a list of objects? Your IDE cannot help with auto-complete because data is just a plain dict. Third, the prompt and the parsing logic are mixed into your application code, which makes it hard to maintain and test.
BAML comes in to solve all of this. The core idea is simple: you declare your LLM functions in dedicated .baml files, complete with their input and output types. Then the BAML CLI generates a type-safe client (Python or TypeScript). When you call that function, BAML handles the prompt, parses the output into a proper Python object, and even handles retries when it fails.
The key benefits of BAML that made me fall in love with it:
- Truly type-safe: the output is parsed into a Python object with clear types, so your IDE can auto-complete.
- A smart parser: BAML has a parser called SAP (Schema-Aligned Parsing) that can handle model output that is not 100 percent valid JSON. Got extra text? It still parses.
- Automatic streaming: you can stream partial output that stays structured.
- Easy provider switching: whether OpenAI, Anthropic, Gemini, or a local model via Ollama, just change the client config and the Python code stays untouched.
- A VSCode playground: there is an extension that lets you test prompts directly in the editor without running any Python at all.
Alright, enough theory. Now we practice.
Installation
To start using BAML you need two things: the baml-py Python package and the BAML CLI. Actually, when you install baml-py, the CLI comes bundled with it. So it is easy.
I recommend creating a virtual environment first to keep things tidy. Folks, this is a great habit for every Python project.
# Create a project folder
mkdir baml-demo
cd baml-demo
Create a virtual environment
python3 -m venv venv
source venv/bin/activate # on Windows: venv\Scripts\activate
Install baml-py
pip install baml-py
Once installed, you can check that the CLI runs:
baml-cli --version
If it prints a version number, you are good. Now let us initialize a BAML project. The baml-cli init command creates the standard folder structure for BAML:
baml-cli init
This command creates a bamlsrc/ folder in your project. Inside it there are already a few example files:
- clients.baml
: where you define your LLM clients (which provider, which model, where the API key comes from). - generators.baml
: configuration for generating the client code (target language, version, output path). - resume.baml
(or a similar example file): an example function so you have a reference.
The folder structure looks roughly like this:
baml-demo/
bamlsrc/
clients.baml
generators.baml
resume.baml
venv/
What you need to understand: all your BAML code lives in bamlsrc/. From there BAML generates Python code into a separate folder (usually bamlclient/) which you should NEVER edit by hand, because it gets overwritten every time you regenerate.
One more thing about API keys. BAML reads API keys from environment variables. So before running, set them first:
export OPENAIAPIKEY="sk-..."
or if using Anthropic:
export ANTHROPIC
APIKEY="sk-ant-..."
I usually put these in a .env file and load them with python-dotenv so I do not have to retype them every time I open a new terminal.
Basic Usage: Writing Your First BAML Function
Now the fun part. We are going to write our first BAML function to extract information from a resume, the case I mentioned at the start.
Step 1: Define the Client
Open bamlsrc/clients.baml. Here we define a "client" that connects BAML to the LLM provider. It looks roughly like this:
client GPT4 {
provider openai
options {
model "gpt-4o"
apikey env.OPENAIAPIKEY
}
}
Notice the syntax. client means we create a client named GPT4. provider openai indicates we use OpenAI. Inside options, we set model and apikey. The cool part is that env.OPENAIAPIKEY is how BAML reads an environment variable, so your API key is never hard-coded in the file.
Step 2: Define the Output Type (Class)
Now this is the heart of BAML. We define the output data structure using the class keyword. Open a new file, say bamlsrc/resume.baml, and write:
class Resume {
name string
email string
skills string[]
}
Easy to read, right? Resume has three fields: name of type string, email of type string, and skills of type array of string (marked with string[]). This will be our structured output. If you have ever written types in TypeScript or dataclasses in Python, it feels familiar.
Step 3: Define the LLM Function
Now we create the function. Still in the same file:
function ExtractResume(resumetext: string) -> Resume {
client GPT4
prompt #"
Extract information from the following resume.
{{ ctx.outputformat }}
Resume:
---
{{ resumetext }}
---
"#
}
Let us break it down piece by piece, folks:
- function ExtractResume(resumetext: string) -> Resume
declares a function namedExtractResumethat takes an inputresumetextof type string and returns aResumeobject. The contract is crystal clear. - client GPT4
indicates this function uses theGPT4client we made earlier. - prompt #"..."#
is the prompt. The#"..."#marks a multi-line string in BAML. - {{ resumetext }}
is a template variable. BAML uses Jinja-like syntax, so the function input can be interpolated into the prompt. - {{ ctx.outputformat }}
is the magic part. This is automatically replaced with output format instructions that BAML generates based on theResumeclass. So you do not need to manually write "return JSON with fields name, email, skills". BAML handles it.
Step 4: Generate the Client
Once the function is defined, we generate the Python code:
baml-cli generate
This command reads all the files in bamlsrc/ and generates a Python package in the bamlclient/ folder. Inside it is the type-safe code to call the ExtractResume function from Python. Remember, do not edit this folder by hand.
Step 5: Call It from Python
Now we use the function from Python. Create a main.py file:
from bamlclient import b
resume
text = """
Budi Santoso
budi.santoso@email.com
Experienced as a Backend Engineer.
Skills: Python, FastAPI, PostgreSQL, Docker
"""
resume = b.ExtractResume(resumetext=resumetext)
print(type(resume)) # client.types.Resume'>
print(resume.name) # Budi Santoso
print(resume.email) # budi.santoso@email.com
print(resume.skills) # ['Python', 'FastAPI', 'PostgreSQL', 'Docker']
Run it:
python main.py
Notice how clean this code is. No json.loads(), no messy f-string prompt, no manual parsing. The resume object you get is a real Python object with type-safe attributes. Your IDE will auto-complete resume.name, resume.email, and resume.skills. If you mistype resume.emial, the editor immediately complains. This is a game changer for productivity.
Advanced Usage
Now we level up. I will show you a few BAML features that make it different from just a plain prompt wrapper: testing, streaming, more complex types, retries, and provider switching.
Testing Directly in BAML
One of my favorite features: you can write test cases directly in the .baml file and run them through the VSCode playground without writing any Python at all. Add this to resume.baml:
test ResumeTest1 {
functions [ExtractResume]
args {
resumetext #"
Siti Aminah
siti.aminah@email.com
Skills: React, TypeScript, Tailwind CSS
"#
}
}
The test block declares a test named ResumeTest1, states which function it tests (ExtractResume), and provides the input arguments. If you install the BAML extension in VSCode, a small "Run" button appears above this test block. Click it, and you immediately see the results in the playground: the prompt sent to the model, the raw response from the model, and the final result already parsed into a Resume object. This makes prompt iteration incredibly fast because you do not need to rerun your Python app every time you change a word in the prompt.
More Complex Output Types
A real resume is of course richer than just three fields. We can create nested types and use enums. BAML supports this elegantly:
enum SkillLevel {
Beginner
Intermediate
Expert
}
class Skill {
name string
level SkillLevel
}
class WorkExperience {
company string
role string
years int
}
class DetailedResume {
name string
email string?
skills Skill[]
experiences WorkExperience[]
}
A few new things here that are important to know:
- enum SkillLevel
defines a fixed set of choices. The model is forced to pick one of these three values, so the output is consistent. - class Skill
is now an object withnameandlevelof enum type. - email string?
the question mark means this field is optional (can be null). This is useful when the data is sometimes missing. - skills Skill[]
is now an array of objects, not just an array of strings.
The function for this type looks roughly like:
function ExtractDetailedResume(resumetext: string) -> DetailedResume {
client GPT4
prompt #"
Analyze the following resume in detail. Determine the level of each skill.
{{ ctx.output
format }}
Resume:
---
{{ resumetext }}
---
"#
}
And in Python, you get a neatly nested object:
from bamlclient import b
result = b.ExtractDetailedResume(resumetext=resumetext)
for skill in result.skills:
print(f"{skill.name}: {skill.level}") # Python: Expert
for exp in result.experiences:
print(f"{exp.role} at {exp.company} ({exp.years} years)")
BAML automatically generates Python classes for Skill, WorkExperience, SkillLevel, and DetailedResume. Everything is type-safe. I really love how the parsing complexity is hidden from us.
Streaming Output
If you build a chat app or need to display results as fast as possible to the user, streaming is a must. BAML makes streaming structured output very easy. Every function automatically has a streaming version via b.stream:
from bamlclient import b
stream = b.stream.ExtractDetailedResume(resume
text=resumetext)
for partial in stream:
# partial is an incomplete DetailedResume object
# fields not yet finished will be None
print(partial.name)
Get the complete final result
final = stream.get
finalresponse()
print(final.skills)
The cool thing about BAML streaming is that it does not just stream raw tokens. It streams partial objects that are already structured. So while the model is still typing, you can already access partial.name as soon as that field is done, while other fields are still None. This is a perfect fit for a UI that wants to show data as it becomes available without waiting for everything to finish.
Retries and Fallbacks
In the real world, API calls sometimes fail: timeouts, rate limits, or server errors. BAML has built-in retry and fallback mechanisms that you configure at the client level. Add this to clients.baml:
retrypolicy Exponential {
maxretries 3
strategy {
type exponentialbackoff
delayms 200
multiplier 2
}
}
client GPT4 {
provider openai
retrypolicy Exponential
options {
model "gpt-4o"
apikey env.OPENAIAPIKEY
}
}
With retrypolicy Exponential, if a call fails, BAML automatically retries up to 3 times with increasing delays (200ms, 400ms, 800ms). You do not need to write your own try/except and retry loop in Python. All of this is declarative in the configuration.
You can also create a fallback to another client if one provider goes down:
client Resilient {
provider fallback
options {
strategy [GPT4, Claude]
}
}
The Resilient client will try GPT4 first, and if it fails entirely, switch to Claude. For production apps, this is a lifesaver.
Switching Model Providers
This is one of BAML's biggest strengths. Say you want to move from OpenAI to Anthropic. All you need to change is the client definition in clients.baml, and your Python code does NOT change at all.
Add an Anthropic client:
client Claude {
provider anthropic
options {
model "claude-3-5-sonnet-20241022"
apikey env.ANTHROPICAPIKEY
}
}
Then in the function, just change client GPT4 to client Claude:
function ExtractResume(resumetext: string) -> Resume {
client Claude
prompt #"
Extract information from the following resume.
{{ ctx.outputformat }}
Resume: {{ resumetext }}
"#
}
Regenerate (baml-cli generate), and your Python code runs as usual, only now it uses Claude. If you want to use a local model via Ollama, just create a client with a provider that points to the Ollama endpoint:
client LocalLlama {
provider openai-generic
options {
baseurl "http://localhost:11434/v1"
model "llama3.1"
}
}
Ollama exposes an API compatible with the OpenAI format, so we use provider openai-generic and point baseurl to the local Ollama server. Imagine how easy A/B testing between models becomes. Change one line, regenerate, done.
Best Practices
After using BAML in real projects for a while, here are some tips from me to make your experience smoother.
First, NEVER edit the bamlclient/ folder by hand. That folder is fully generated. Every time you run baml-cli generate, its contents are overwritten. If you edit it manually, your changes are lost. Treat that folder like a build folder, do not touch it.
Second, whether to put bamlclient/ in version control or not depends on your team. Some teams commit this folder so CI does not need to regenerate. Others put it in .gitignore and generate at build time. Both are valid. What matters is consistency. If you commit it, make sure to always regenerate after changing .baml files.
Third, use {{ ctx.outputformat }} as much as possible. The temptation to write manual format instructions in the prompt is there, but resist it. Let BAML generate the format instructions from your class. If you change the class, the instructions update automatically. If you write them manually, you will forget to update them and bugs appear.
Fourth, write test cases in .baml files from the start. Prompts are fragile. A small change in wording can change the model's behavior. By having test cases, you can quickly check whether the prompt still produces correct output after you change something. It is like unit tests for prompts.
Fifth, use specific types. Instead of a data string field that contains JSON text, better to make a real class. The more specific your output types, the better BAML's SAP parser handles messy model output, and the fewer bugs appear. Enums also help a lot to force the model to pick from a fixed set of choices.
Sixth, manage API keys via environment variables, always. Never hard-code API keys in .baml files. Use env.VARIABLENAME. For development, combine with a .env file and python-dotenv.
Seventh, split .baml files by domain. If your project is large, do not pile all functions into one file. Create resume.baml, email.baml, classification.baml, and so on. BAML reads all files in bamlsrc/ so you are free to organize.
Eighth, install the BAML VSCode extension. This is not optional in my opinion. The playground makes prompt iteration super fast. You can see the rendered prompt, the raw response, and the parsing result, all in real time without running your app. The syntax highlighting and auto-complete also help a lot when writing .baml.
Full Example: Sentiment Classification
To make it stick more, here is one full example with a different use case: sentiment classification of a product review. This is a pattern used very often in production.
In
bamlsrc/sentiment.baml:
enum Sentiment {
Positive
Neutral
Negative
}
class ReviewAnalysis {
sentiment Sentiment
confidence float
keywords string[]
summary string
}
function AnalyzeReview(review: string) -> ReviewAnalysis {
client GPT4
prompt #"
Analyze the following product review. Determine the sentiment,
confidence level (0 to 1), important keywords,
and a short summary.
{{ ctx.outputformat }}
Review:
---
{{ review }}
---
"#
}
test PositiveReview {
functions [AnalyzeReview]
args {
review "This product is amazing! The quality is great and shipping was fast."
}
}
And in Python:
from bamlclient import b
review = "The item is okay but shipping took forever, a bit disappointed."
analysis = b.AnalyzeReview(review=review)
print(f"Sentiment: {analysis.sentiment}") # Sentiment.Negative
print(f"Confidence: {analysis.confidence}") # 0.75
print(f"Keywords: {analysis.keywords}") # ['slow shipping', 'disappointed']
print(f"Summary: {analysis.summary}")
See, the pattern is always the same: define the output type, write the function with the prompt, generate, call from Python. Once you understand this pattern, you can build any LLM feature quickly and cleanly. This pattern consistency is what makes BAML pleasant to use across a team, because everyone writes LLM code with the same structure.
Conclusion
Alright folks, we have come quite far. Let me summarize what we have learned. BAML is a domain-specific language for writing LLM prompts as typed functions. Instead of messy prompt strings and fragile JSON parsing, you get functions with a clear input-output contract, output that is automatically parsed into type-safe Python objects, and tooling that makes life easier.
We covered how to install
baml-py and its CLI, initialize a project with baml-cli init, write output classes and functions in .baml files, generate the client with baml-cli generate`, call it from Python in a clean way, write tests directly in BAML, stream structured output, retries and fallbacks, and even swap model providers just by changing the client config.
In my opinion, BAML's greatest strength lies in two things. First, it separates the LLM prompt definitions from your application code, so it is cleaner and easier to maintain. Second, it makes LLM output predictable through the SAP parser and its type system, so you no longer pray that the JSON is valid. For anyone serious about building LLM-based applications, especially those heading to production, BAML is an investment well worth learning.
My advice, just start small. Take one LLM function in your project that currently uses manual JSON parsing, then try moving it to BAML. Feel the difference yourself. I am confident that once you experience the type-safety and the playground, you will find it hard to go back to the old way.
Happy trying, folks. If you have questions about BAML, do not hesitate to explore the official documentation at boundaryml.com. Happy coding and see you in the next tutorial!