Skip to content

Brewlang v0 specification

This page defines Brewlang v0 for anyone writing an implementation, an editor mode or a converter. The guide explains the same language for recipe writers. The reference implementation is the brewlang TypeScript package.

Brewlang is at v0: the syntax can still change. Changes are listed in the brewlang repository’s history until a versioning policy is set for v1.

Brewlang describes recipes made of pours and gestures over time: pour-over (V60, Kalita, Melitta, Chemex, Origami, UFO, Phin), immersion (AeroPress, French press, siphon) and hybrid valve brewers (Switch, Clever, Pulsar). Out of scope: espresso and moka pot, driven by pressure and heat rather than pours; cold brew, which needs durations in hours.

A recipe is a UTF-8 text file with the .brew extension. Lines end with LF or CRLF. Each line holds one element; blank lines are allowed anywhere.

The grammar gives the form; the semantic rules give the meaning. WS is one or more spaces or tabs between tokens.

file = [ frontmatter ] { blank NL } header { line } ;
frontmatter = "---" NL { yaml_line NL } "---" NL ;
header = brewer WS dose [ WS water ] [ WS temp ] [ WS comment ] NL ;
line = ( grind | pour_step | action_step | temp_change | target | comment | blank ) NL ;
grind = "grind" WS grind_size [ WS comment ] ;
pour_step = [ time WS ] pour_water { WS pour_mod } [ WS comment ] ;
action_step = [ time WS ] action [ WS duration ] [ WS comment ] ;
temp_change = temp [ WS comment ] ; (* alone on its line, no time *)
target = "target" WS time [ WS comment ] ;
brewer = "@" ident ;
action = "/" ident ;
dose = amount ( "g" | "oz" ) ;
water = amount water_unit ;
pour_water = [ "+" ] amount water_unit ; (* no '+': total on the scale; '+': water to add *)
temp = amount ( "°C" | "°F" ) ;
water_unit = "g" | "ml" | "oz" | "floz" ;
pour_mod = duration | qualifier ; (* in any order, at most one duration *)
qualifier = "bloom" | "spiral" | "center" | "pulse" ;
grind_size = "extra-fine" | "fine" | "medium-fine" | "medium"
| "medium-coarse" | "coarse" | "extra-coarse" ;
duration = "~" ( number ( "s" | "m" ) | time ) ; (* ~15s, ~4m, ~3:30 *)
time = digit { digit } ":" digit digit ; (* seconds under 60 *)
amount = number [ "-" number ] ; (* a value or a range *)
number = digit { digit } [ "." digit { digit } ] ;
ident = letter { letter | digit | "-" } ;
comment = "--" { any_char } ;

Every token identifies itself: n:nn is always a time, a number is always followed by its unit with no space, and a ratio is never written. Keywords (grind, target), brewer names and units are matched as written, except brewer names, which are matched ignoring case.

Each rule reports a diagnostic with a line and a column. Errors make the recipe invalid; warnings point out a probable mistake; suggestions point out a possible improvement. An implementation runs the semantic rules only on a recipe without syntax errors.

  • Error: more than one grind line.
  • Error: a grind line after the first step. Comments may come before it.
  • Error: a timed step that starts before the previous timed step. Equal times are allowed and run in the written order.
  • Untimed steps before the first timed step are preparation, done in the written order before starting the timer. Untimed steps after it go at the user’s pace. A recipe without any time runs step by step, and the timing rules do not apply.
  • Suggestion: an action at 0:00 before the first pour. It is a preparation; its time can go.
  • The running total starts at 0. A pour without + sets it; a pour with + adds to it.
  • Error: a pour without + that is not above the running total.
  • Error: a pour with + that adds nothing.
  • Error: the header announces a total water different from the running total after the last pour. Ranges compare bound by bound. Without pours, the header water is the water to use.
  • Suggestion: a recipe mixing both forms of pour, on the first pour whose form differs.
  • Suggestion: the first pour is not marked bloom, is at most 3 times the dose in the same unit, and another pour follows.
  • Error: a timed pour whose duration goes past the start of the next timed step.
  • Error: a water amount in a different unit from the first one (header included).
  • Error: a temperature in a different unit from the first one.
  • Error at parse time: an unknown unit. A misspelled temperature (94C, 94°c) suggests the correct one; spoons and cups are unknown units.
  • Error at parse time: an unknown qualifier, with a correction when it is one letter away from a known one (blom → bloom). A known action name written as a qualifier (swirl) suggests writing it as an action on its own line.
  • Error: more than one technique (spiral, center, pulse) on a pour. bloom may appear on any number of pours.
  • Any action name is accepted.
  • Warning: an unknown name within one edit of a known action, or two edits for names longer than 5 letters (insert, delete, replace, or swap two neighbors). The message proposes the known name.
  • Error: a core action used with a known brewer of a type it does not support. An unknown brewer has no restriction.
  • Aliases are equivalent to their action: /plunge is /press.

The registry gives some brewers a starting state by convention.

  • Valve brewers: @Switch and @Pulsar start open, @Clever starts closed. Warning: an /open or /close that does not change the valve. When it is the first valve action, the message explains the starting state.
  • AeroPress: starts upright. Warning: /invert when already inverted, /flip when upright, /press while inverted.
  • The current temperature is the header’s, until a temperature line changes it.
  • Warning: a temperature line with the current value.
  • Warning: a temperature line followed by another one before any step.
  • Warning: a temperature line before the first pour or action; its value belongs in the header.
  • Error: more than one target.
  • Warning: a target earlier than the start of the last timed step.
  • A missing target is never deduced.

Free key: value lines; no key is required. Without title, tools use the file name. filter is free text. Tools reading the metadata as data use only simple key: value lines.

The brewers, their types, their starting states, the core actions and the types each one supports are listed in the vocabulary. The registry has one entry per brewing method, not per product.

These operations are part of the reference implementation. Another implementation that offers them must give the same results.

  • Formatting rewrites a recipe in its canonical form without changing its meaning.
  • Scaling multiplies the dose, the header water and every pour by one factor. Times, durations, temperatures and the grind are unchanged, with a warning. Amounts round to 1 g or ml and to 0.1 oz or floz; + pours are derived from the rounded running total.
  • Conversion changes units within a kind: g ↔ oz for the dose and water weights, ml ↔ floz for water volumes, °C ↔ °F. Never weight ↔ volume. Rounding as for scaling, temperatures to 1 degree.
  • JSON output follows the JSON format and its schema.

The repository’s tests/canonical/ folder holds the recipes every implementation must agree on:

Folder Content
valid/ Recipes with no diagnostic
invalid/, warnings/, suggestions/ Each .brew with its expected diagnostics (severity, message, line, column) in a .json next to it
scaling/ The scaling in the .json, and the scaled recipe in a .expected.brew
json/ The expected JSON output in the .json

Messages are part of the interface: they always say what to do, not only what is wrong.