BAML (BoundaryML): Writing LLM Prompts as Clean, Typed Functions

# BAML (BoundaryML): Nulis Prompt LLM Jadi Fungsi Ber-Type yang Rapi Temen-temen, kalau kamu udah pernah ngoding aplikasi yang manggil LLM, pasti kamu tau rasanya. Kode kamu penuh sama string prompt...

By Ruby Abdullah · · tutorial
bamlllmpythonstructured-outputprompt-engineering

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 ANTHROPICAPIKEY="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 GPT4 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 named ExtractResume that takes an input resumetext of type string and returns a Resume object. The contract is crystal clear.
  • client GPT4 indicates this function uses the GPT4 client 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 the Resume class. 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

resumetext = """

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 with name and level of 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.outputformat }}

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(resumetext=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.getfinalresponse()

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!

Related Articles

DSPy: Stop Hand-Tuning Prompts, Let the Compiler Optimize Them

DSPy: Berhenti Ngoprek Prompt Manual, Biarkan Compiler yang Optimasi Halo temen-temen, kali ini aku mau ngenalin satu li...

Inspect AI: The LLM Evaluation Framework from the UK AI Safety Institute

Inspect AI: Framework Evaluasi LLM dari UK AI Safety Institute yang Wajib Kamu Coba Temen-temen, kalau kamu udah mulai s...

Complete Braintrust Tutorial: Evaluate, Test, and Improve Your LLM Applications

Tutorial Lengkap Braintrust: Evaluasi, Testing, dan Improve Aplikasi LLM Halo temen-temen, di tutorial kali ini aku mau ...

OpenRouter: One API for Hundreds of LLMs (OpenAI, Anthropic, Google, Meta, Mistral)

OpenRouter: Satu API untuk Ratusan LLM (OpenAI, Anthropic, Google, Meta, Mistral) Halo temen-temen, ketemu lagi sama aku...