Coffee and paper
Bag labels
Bag labels designed in Typst, printed over IPP, with a public page per roast behind the QR code.
Roastwarden prints labels for bags of roasted coffee on standard laser sticker sheets. A label is filled from the roast, its coffee and your settings: the coffee's name and origin, the roast level and purpose, the roast date and when it is best from, the tasting notes, the weight and a QR code. Each label is laid out by a design, a small Typst file, so you can use one of the built-in designs or write your own. Print the PDF yourself, or let the server send it straight to a network printer.
Labels is an optional module: turn it on under Settings → Modules (the Roastery preset has it). It adds Labels to the top bar (Label Studio) and Labels… to each roast in History.
Labels for a roast
-
Open the roast's labels: History → the roast → Labels…, the Labels link on the Roast page's after-roast summary once the roast is weighed, or a lot under Roasted coffee on Beans.
-
Pick the Label sheet and where to Start at label on a part-used sheet: click a label in the preview. Grey labels are left blank. The sheet remembers where the last print left off.
-
Fill in the label:
Field What it does Design The coffee's own label ("Its own: …") if it has one, else plain, or any other design. Its settings show under it. Copies How many labels. Net weight The roast's roasted weight unless you type one (a 250 g bag: 250). In the bag Whole bean or Ground, with the grind ("espresso", "filter", your own word). A ground bag gets an earlier best-until date. Message A line for a gift ("for Sam, with love"). A design that prints its own "To" calls it To and wants just the name. Brand The coffee's brand, else the default one (below, "Label Studio"). Tasting notes Leave empty for the roast's cupping notes, else the coffee's. On the label Untick what should not print. QR code opens This roast in Roastwarden (needs your login), Its share page (anyone with the bag, below), The coffee at its supplier, or No QR code. -
Use for
keeps the design, its settings and the brand as that coffee's own label, so its next roasts start with it. -
Download PDF, or Print on … when a printer is set up. Print a PDF at Actual size (not "Fit to page") with the paper type "Labels", then press Printed it so the sheet's next free label moves on. Downloading the same labels twice is one run, not two.
The preview is the server's own rendering of the first sheet, the same layout as the PDF: what you see is what prints.
When the roast has roasted coffee left, a tick box can take the bags off it as packed when the labels are made (roasted coffee).
Label sheets
Built in: Avery 5163 (4 × 2 in, 10 a sheet, the common bag label), 6874 (3.75 × 3 in, 6 a sheet, room for notes and a QR code) and 5294 (2.5 in round, 12 a sheet). Buy sheets sold for laser printers: inkjet stock can smear toner or let adhesive ooze in the fuser. Most office lasers are black only, so the built-in designs are black; black on kraft paper is the usual coffee look.
Other stock goes in the label_sheets setting, a JSON list in inches (a sheet with a built-in
id replaces it). Generic "compatible" sheets can differ from Avery's layout, so measure yours:
echo '[{"id":"OL125","name":"OnlineLabels OL125","page_w":8.5,"page_h":11,"w":4,"h":2,
"cols":2,"rows":5,"left":0.16,"top":0.5,"pitch_x":4.14,"pitch_y":2}]' \
| roast settings set label_sheets=-
top and left are the page margins to the first label, pitch_x and pitch_y from one
label's edge to the next; add "round": true for round labels.
What a label says
-
The name: the coffee's label name when it has one (a short name for a long one, "Kenya Gatugi"), else its name.
-
The notes, the first found of: the notes you type; the coffee's Label notes (Beans → the bean → Edit, a few words such as "Chocolate · coconut · apple", at most 120 characters; with none, the form suggests some from the supplier's notes); the roast's cupping notes; the supplier's notes, cut after a whole sentence.
-
Best from: the roast date plus a rest, set in Settings → Roasting:
Rest Default Espresso, light 10 days Espresso, medium or darker 7 days Filter, light 7 days Filter, medium or darker 4 days Omni roasts read as filter; a roast with no level reads as light. A ground bag's best-until is the pack date plus "Ground, at its best for" (14 days), or the lot's own date if that is earlier.
-
The weight in grams and ounces ("250 g · 8.8 oz"), rounded down, never more than is in the bag.
-
The roaster's name: "Roaster name on labels" in Settings → Roasting, or a brand.
Dates are in your time zone: the browser's, or the time_zone setting.
Built-in designs
| Design | Look |
|---|---|
| plain | Everything at a glance in the dashboard's typeface, the QR code on the right; a centred stack on round labels. The type grows to fill a roomy label. |
| gift | A serif name, the message in a script face, a double frame; the QR code opens the share page by default. |
| script | The name in a script face over a drawn flourish. Settings: a script title instead of the name ("Sam's Blend"), a small top line, the flourish. |
| ascii | A typewriter ticket with a drawing in characters: art is cup, bean, pot, cherry, none or your own lines. |
| cherry | Line art of a coffee branch with cherries, a leaf or a roasted bean; ink sets a colour for a colour printer. |
| shop | For coffee sold under your name: the brand on top, the coffee in the middle, and in the bottom 30 % what a bag that is sold needs (what it is, the net weight, "Roasted and packed by" and your place). |
Label Studio
Labels in the top bar. Its tabs:
| Tab | What it is for |
|---|---|
| Designs | Every design on sample data, at the shape of the sheet picked. Open one to edit it, or New design from the template or another design. |
| A print run: one sheet of labels for one roast or several (a gift run of three coffees), each roast with its own design, message and copies. | |
| History | What was printed and when: download a run's PDF again, Printed it, Reprint (the labels exactly as they were, all or some), and the sheet's next free label (new sheet after loading a fresh one). |
| Printers | Where labels print (below). |
| Coffees | Each coffee's own label: design, settings, brand. |
| Assets | Images (SVG, PNG, JPEG, up to 2 MB) and fonts (TTF, OTF, up to 8 MB) a design can use. |
| Brands | Brand kits: your roaster's name, tagline, place, website, logo, colours and fonts, previewed on the shop design. Make default uses one when a coffee has none. |
Printing to a printer
Roastwarden can send labels straight to a network printer over IPP, the protocol AirPrint and IPP Everywhere printers speak. Without one, download the PDF and print it yourself.
Add a printer: Label Studio → Printers → Add a printer, with its address. Most
printers: ipp://its-address:631/ipp/print; the printer's own web page or a configuration
report shows it. Find printers on the network looks for 4 s, but only on the server's own
network segment: in Docker that needs host networking (running it), and a printer
on another subnet is never found. Typing the address always works.
When it is added, and each time the Printers page opens, the printer is asked what it can do and how it is (a door open, no paper). Per printer you can set:
| Setting | Choices |
|---|---|
| Tray | The one holding the sheet's size (the default), or a named tray. |
| Colour | Black unless a label has colour, Always black, Colour. |
| Send labels as | Whatever it takes best (PDF, else raster), PDF, PWG Raster, Apple URF. Printers without PDF get a raster of the same layout. |
One printer is the default (Make default); a print can name another, and a tray and colour, under the Labels page's Printer.
An ipps printer makes its own certificate, so no authority vouches for it. Read its
certificate shows its SHA-256 fingerprint: compare it with the one on the printer's own page,
tick "trust it", and Roastwarden talks only to a printer showing that same certificate. Some
printers' TLS is too old to be accepted; use ipp:// for those.
At the printer: load the label sheet before printing, in the tray the print asks for (the one holding paper of the sheet's size: on many lasers the hand-fed slot), face up as the tray's picture shows. A print sent to an empty tray waits; feed the sheet and confirm on the printer. A stopped printer (door open, out of paper) is refused before anything is sent. Roastwarden follows the job and says what the printer did: printed, still printing, waiting for paper, or aborted. A job that waited for paper still ends up "printed" in History.
Approval: a print asked for with an API token (an agent, a script) always waits for your approval, whatever the token's settings: it uses a sheet of labels, and someone must load it. The approval says what to load ("load a 5163 sheet; labels 1–3 used"). From the dashboard signed in with the owner key, a print goes at once.
Calibration
Printers feed paper a little off. To line the labels up:
- Open Calibrate this sheet on a roast's Labels page, or Calibration for a sheet under Label Studio → Printers.
- Print the Calibration page on plain paper. It has an L 3 mm inside every label corner (a circle and its centre on round labels), a mm scale across each label edge, and a 100 mm rule to check the printer did not scale the page.
- Hold it over a sheet of labels against the light. Where each label's edge crosses its scale (a tick each mm, the long tick where the edge should be) is how far the marks must move.
- Enter it as Right, mm and Down, mm (positive is right and down) and Save offset. Print the page again to check.
On the Printers page you can enter both the first and the last label's marks. They must agree within 0.5 mm: if not, the printer is scaling the page (print at Actual size) or the sheet feeds askew, which no offset fixes. An offset is kept per printer and sheet, with the sheet's own offset as the fallback.
Most lasers cannot print in the outer 4 mm or so of the page; the built-in designs keep text well inside each label, and the calibration page leaves out a scale that would fall there.
The share page
A bag you give away has a QR code that should open something for the person holding it, not a
login. Each roast can have a public share page at <your address>/R/<code>:
- What it shows: your brand, the gift line if the link has one, the coffee's name, origin and producer, the roast level and purpose, the roast date and when it is at its best ("Resting: at its best in 3 days"), the tasting notes, variety, process and batch number, the roast curve with its events and times, a brew suggestion from the roast's purpose and level and your doses (Settings → Roasting), and your place and website.
- What it never shows: the roast's id, its cost, your stock, a street address.
- Making one: History → the roast → Share page → Make a share link, with an optional line for the person it is for. Labels with QR code opens: Its share page make the link when the roast has none (the gift design does by default). Another link makes a second one for someone else, with its own line.
- Revoke makes the page a "not found", the same as a code that never existed. Bags printed with it open nothing.
- Scans are counted (how many, and the last time), without addresses.
The code is 25 random characters: it cannot be guessed. The page loads no scripts and is kept
out of search engines. The address comes from the public_url setting (else the address the
request came to), so set it to the address people outside reach your server at. If your
server sits behind a login proxy, let /R/ through without the login (and rate limit it);
everything else can stay behind it.
Coffee that is sold: the retail check
Gifts need none of this. For coffee you sell in the US, a design page's For sale on choice
(or roast labels design check --retail pouch-12oz) reads the laid-out label against the FDA
rules (21 CFR 101.3, 101.5 and 101.7): a bold statement of identity ("Whole bean coffee"), the
net weight in ounces and grams in the bottom 30 % of the label, never rounded up, its letters
as tall as the bag's front needs, and the packer's name, city, state and ZIP. It also flags
words that are nutrition claims ("sugar free"). The type height comes from the bag's front,
not the sticker: pick the bag (sample-2oz, pouch-8oz, pouch-12oz, pouch-16oz,
tin-tie-8oz, your own in the label_bags setting, or "The label is the whole front"). The
shop design passes for a 12 oz pouch on 5163 and 6874. It is a reading of the rules, not
legal advice: it cannot know the bag's real size, contrast on your stock, that each bag holds
what it states, or state rules.
Writing a design
A design is one Typst file (Typst 0.15) that draws one label. Roastwarden places it at every label on the sheet, clipped to the label (a circle on round labels), with the sheet's offset: a design never deals with sheets. Edit one in Label Studio → Designs → a design: the Typst source on the left with completion and syntax errors as you type, and beside it the server's check of the label on sample data, redrawn as you type. Save makes a new version with a note; Compare, Open and Back to this work with older versions, so nothing is lost and a reprint can match what was printed. A built-in design is saved under a new id. Ctrl+S saves, Ctrl+Enter checks now.
The file
#import "roastwarden.typ": fit, clamp, join, with, ascii
#let design = ( // optional: a name, a description, settings
name: "Mine",
description: "What it is for",
params: (art: (default: "cup", choices: ("cup", "none"), help: "the drawing")),
)
#let label(l, w, h, round) = {
// l: the label's fields; w, h: the label's size; round: a round label
set text(font: "Atkinson Hyperlegible Next", top-edge: "ascender", bottom-edge: "descender")
fit(l.name, w - 0.3in, max: 16pt, weight: "bold")
if l.qr { place(right + bottom, image("qr.svg", width: 0.6in)) }
}
label(l, w, h, round) is the only thing a design must have. Every field of l is text, or
none when it is unknown or hidden, so handle none (join and with below do it for you).
The top-edge and bottom-edge line keeps stacked lines from crowding.
Settings (params) show as a form under the design. A setting's default sets its type: a
value typed as text becomes a number or true/false when the default is one. Colours stay text:
rgb(l.params.ink). The manifest may also set the QR code's default, qr: "share" (app,
share, supplier or none), and name the message field, message: "Dedication" (or
message: false for none).
The fields
| Field | What it holds |
|---|---|
name, full_name | The label name (else the coffee's name); the coffee's whole name |
origin, variety, process | From the coffee ("region, country · producer" for origin) |
level, purpose | "Light", "Espresso" |
roasted, best_from | Dates as printed; roasted_iso, best_from_iso ("2026-10-25") to print a date your own way; rest_days |
form, ground, grind | "Whole bean" or "Ground · espresso"; true for a ground bag; the grind |
best_until, best_until_iso | The bag's best-until date |
weight, net, weight_g | "250 g · 8.8 oz"; "Net wt 8.8 oz (250 g)" as a US label states it; the grams as a number |
batch | The batch number |
notes, message, roaster | Tasting notes, the gift line, your roaster's name |
brew | How to brew it, or none: method, dose_g, out_g, ratio ("1:2.5"), temp_c, time, grind, and line, the recipe in a sentence |
qr, url, qr_to, qr_modules | Whether a QR code is wanted (draw it with image("qr.svg")), the address, where it goes (share, app, link), its width in squares |
brand | The brand kit: name, tagline, place, website, logo (an asset's name), ink, accent, paper (#rrggbb), font_display, font_body, font_script; each none when unset, so a design must cope with no brand |
params | The design's settings, defaults filled in |
retail | Only in a retail check or print: bag, area_sq_in, min_net_mm, min_mm |
Hiding a field on the Labels page makes it none; hiding the weight hides all three weights,
hiding the dates hides their other forms too.
The helpers
From roastwarden.typ:
| Helper | What it does |
|---|---|
fit(text, width, max:, min:, ..style) | One line at the largest size that fits. |
wrap(text, width, max:, floor:, min:, lines: 2, round:, ..style) | One line down to floor, then up to lines lines of about equal length down to min; round: true keeps it inside a circle. |
clamp(text, width, lines:, height:, ..style) | At most N lines, or as many as fit in height; cuts after the last whole sentence when that keeps at least half of what fits, else at a word with "…". |
join((a, b, c), sep: " · ") | The parts that are there, joined. |
with("Roasted ", value) | The word and the value, or none when the value is none. |
qr-width(l, module:, min:, max:) | The QR code's width for squares of module (0.4 mm by default). |
qr-caption(l) | What the code opens in a few words ("Scan for this roast and how to brew it"). |
ascii(art, size:, font:) | Monospaced lines exactly as written. |
tall(text, height, caps:, min:, ..style) | The size at which the letters reach a height, measured as labelling rules do. |
hint(text) | A note of your own for the check. |
fit, wrap and clamp tell the check when they shrink, wrap, cannot fit or cut.
Fonts, images and your own files
Built in, all under the SIL Open Font License: Atkinson Hyperlegible Next, DM Serif Display,
Cormorant Garamond, Pinyon Script, and JetBrains Mono NL (the no-ligatures cut, so -> in
ASCII art stays two characters). Fonts added under Label Studio → Assets are in every render,
named by their family; images are read by name, image("logo.svg").
You can also keep designs and fonts as files in the labels folder beside the database
(/app/data/labels/ in the container): designs/<id>.typ (letters, digits, -, _) and
fonts/*.ttf or *.otf. They are read at each render, no restart, and a design there can use
images from its folder. roast labels --list shows the folder, the designs and every font
family.
Renders run in a sandbox: no network, no files beyond the design's own, at most 10 s and about
1 GB. Typst packages (@preview/…) are not available.
The check
The check lays out one label and reports, worst first:
| Issue | Meaning |
|---|---|
overflow | Text, an image or a shape past the label's edge (on a round label, past the circle). |
overlap | Text on text. |
too_long | A fit or wrap at its smallest and still too long. |
cut | A clamp dropped words, with how much of the label was still free. |
qr_small, qr_crowded | QR squares under 0.33 mm; text within two squares of the code. |
near_edge | Within 1.5 mm of the edge: a printer can land up to 1 mm off. |
tiny_text | Under 5.5 pt. |
typst | A warning, such as an unknown font falling back to another. |
param, empty | A setting the design does not declare; nothing printed. |
dropped, hint | Soft: the name, roast date or best-from has a value but did not print (hide the field if that is on purpose); your own hint. |
It also lists every line of text with its font, size and box in mm, and which fields printed.
A design that does not compile shows Typst's message and line (design.typ:12: unknown variable: nmae), underlined in the editor; the last good picture stays, dimmed. A design must
compile to be saved; its other issues do not stop a save.
From the command line: roast labels design check mine.typ --png checks a draft file and
writes the label as a picture, --diff plain says what changed against another design, and
roast labels design add mine mine.typ saves it. The Agent Skill carries a design guide with
patterns for ASCII art, calligraphy and drawings, so an AI agent can write and check designs
for you.
From the command line
roast labels latest makes the newest roast's labels as a PDF (--print sends them to the
printer), roast labels run makes a print run, roast labels printer … manages printers and
roast share <roast> makes a share link. Every option is in the roast CLI.