Writing features
Gherkin, with no step definitions and no glue code. The steps are natural
language; the agent figures out how to perform them. Write either kind of
feature file: standard .feature, or .saffron, Saffron's superset of
Gherkin that adds step sets. Both run the same way; the difference, and
which to pick, is on the ".saffron and .feature files" page.
Feature: Login
Background:
Given I open the application
And I accept cookies if prompted
@smoke
Scenario: Successful login
When I enter "standard_user" in the username field
And I enter "secret" in the password field
And I click the "Log in" button
Then I should see the products page
Scenario Outline: Failed login shows an error
When I enter "<username>" in the username field
And I enter "<password>" in the password field
And I click the "Log in" button
Then I should see the error message "<message>"
Examples:
| username | password | message |
| locked | secret | This user has been locked. |
| standard | wrong | Wrong username or password |
Tips for cache-friendly features
Given/Whensteps are actions: the agent may adapt them when the UI drifts.Thensteps are assertions: under the default policy they can never be adapted, so make them state exactly what must be true. (Opt-inadaptable-midrelaxes mid-scenario checkpoints only; the finalThenblock is always strict.) A text check ("the error message should be shown") passes only on text the page shows: text inside a hidden element (display: none,visibility: hidden), a hidden part of a visible one, a hidden option of a dropdown, or text inside a closed<details>does not count. Text in adisplay: contentswrapper, which the page draws without a box of its own, counts as shown, and so does a<textarea>'s current text. When the check fails on a value the page holds but does not show, the error says where it sits:Timed out waiting for text "Total: 42 NOK": the page holds it, but not shown (inside a closed <details>), or in an element withdisplay: none, thehiddenattribute,hidden="until-found",visibility: hiddenorcontent-visibility: hidden, naming the element ((<div>)). A total that is gone and a total hidden by a panel that did not open read apart, in the run, the report and the agent's proof alike.Dynamic values work: "I record the displayed price as "X"" becomes a
captureText, and ""X" should differ from "Y"" becomes a live comparison at replay time. Replay first waits for the page's fetch and XHR requests to be answered (up to 3 seconds), then takes a captured text once it has held for half a second (a quarter of a short timeout), so a total the page asks its server for, or draws again just after the last change, is not read stale. A "should differ" check, a count, a "not shown" check, a "URL does not contain" check and a response check wait the same way and pass only on a state that has held for that moment: a placeholder drawn during a refresh, a list still growing or a row gone for an instant decides nothing (only the requests the last action started are waited for, never a long-poll the page holds open). A "not shown" check looks inside the app's own iframes too, never another origin's, such as a chat widget's. Arithmetic works on captured amounts too: "the basket total should be "before" plus "breakfast"" records anexpectAmountcheck of"before" + "breakfast"(also-, and*by a number, as in"night" * 2). It reads the one number an element shows, with either decimal style (NOK 1 234,50,$1,234.56), negative as-$5.00,5,00-,–5,00or($5.00), and compares to the cent, so a total that changed by the wrong amount fails. It fails, too, when the total is shown in another currency than the captures, and captures in two currencies are refused. A currency is read as a code beside the number (NOK 150,150 eur) or a symbol; a symbol counts as each code it can stand for, so$5andUSD 5are one currency,$5andNOK 5two, and€is EUR. A "should differ" check between two amounts compares the numbers, soNOK 1 295drawn again asNOK 1,295.00has not changed. Two values of different kinds fail at once and say why:SEK 1,295againstNOK 1,347differ by currency alone, and a whole button label (Book now for NOK 1,295) against a price by its label. Capture both from the same kind of element, the amount alone.saffron diffand the report show what each capture read in the proof replay beside its step (captured "before" = NOK 1,295), secrets masked and credentials left out.A step that says an element is selectable, available, enabled or bookable records an enabled check (
expectEnabled), and one that says disabled, unavailable or greyed out a disabled check (expectDisabled): the element must be shown and in that state. A visibility check alone passes on a date shown but not selectable. Counts with labels in parentheses, such asGuests (2 pax),Cart (3 pcs)andTasks (4 open), stay positive in amount checks, also where the label happens to be a currency code in lower case (Pens (2 pen)). Currency symbols, uppercase currency codes andkridentify accounting parentheses, so($3),(3 EUR)and(3 kr)remain negative, as does a lower-case code beside decimals ((usd 5.00)). Unlabelled decimal amounts in parentheses, such as(5,00), also remain negative. The last.or,is the decimal point unless three digits follow it after a whole part other than 0 (1,234is 1234,0,125is 0.125); grouping it cannot read for sure (1.2.3) is refused. A written amount in another format than its Examples cell (12.50for12,50) follows the column the step names. Each captured name stands alone in its quotes:"night"*2is refused rather than read as 2. Capture amount-only text: an element showing two numbers ("2 nights NOK 240") is refused rather than guessed. Captures and comparisons against a live element read displayed text, ignore hidden descendants, and normalize whitespace. Both require a non-empty displayed value: a hidden or blank element does not prove the value changed. Use an explicit empty-text assertion to check that a value was cleared. Capture names are identifiers: table and doc-string parameterization leaves them unchanged, even when they contain a recorded data value. Names containing resolved environment secrets are stored as stable opaque identifiers instead, with capture references updated together. If two names cannot be safely distinguished, recording stops and asks for distinct names without secrets. Captures and live comparisons search child frames when no frame selector was recorded; an explicit frame selector stays scoped to it. A live comparison against the same recorded target retains the element selected by its capture, including its frame. Hiding or removing that element before the check starts cannot make another frame satisfy it. The same identity is retained when the healing guard checks the original assertion, including when proposal verification is disabled. Named regex groups are preserved in this identity, even when a step has a doc string or another parameter with the same name. Otherwise the comparison selects the first displayed non-empty value in locator order. While polling, an equal or hidden value does not make it try another frame or fallback. A replaced DOM node can be found again by its unique ID or test ID in the original frame, provided the original locator also had exactly one match. A unique test ID takes priority over an app-generated ID, so changing that DOM ID alone does not break recovery. Duplicate or unidentified targets remain tied to their original DOM element. Scope the target explicitly when you intend to compare a different element or frame.Data tables work: prefer the 2-column key/value shape:
When I enter the following credentials | username | premium_user | | password | secret2 |Recorded actions reference
<table:username>etc., so editing the table values later replays at zero tokens; only changing the keys triggers a re-record.Multi-row record tables work too: header row + one row per record:
When I add the following guests | firstName | email | city | | Alice | alice@test.com | Oslo | | Bob | bob@test.com | Bergen |Cells record as
<table:1:firstName>,<table:2:email>, … so editing any cell value replays free. Adding/removing a row or renaming a header column is a structural change and honestly re-records. (A 2-column table is always treated as key/value, never as records.)Doc strings (
"""blocks) work: the content records as<docstring>, so rewording a note, message, or payload replays at zero tokens. If a later assertion checks that same content ("the saved note should be shown"), it follows your edits too.Secrets never go in files: write
{env:VAR}and export the variable (or put it in a git-ignored.env, real env wins;.envis read as a shell reads it:export KEY=valuecounts,# commentafter an unquoted value is no part of it, and a double-quoted value takes\"and\\escapes):When I enter "{env:ADMIN_USER}" in the username field And I enter "{env:ADMIN_PASSWORD}" in the password fieldCaches, proposals, reports, history and traces only ever contain the token (a value shorter than three characters is too short to mask, so never use one as a secret; in a recording, a value shorter than six characters is masked where it stands alone or where the step joins it to other text, as in
"qa{env:ID}", sodevin/devicesis left as it is); values resolve at replay, fallback CSS selectors included (td.{env:NAME}), and a missing variable fails fast by name. A{env:VAR}written as a value in a data file (§16) is masked the same way. A recording can only use the variables its scenario names: replay fails any other{env:...}by name, so a recording someone commits or a CI bundle you import cannot read the rest of your environment, andaccept --with-feature-editskips an edit that would make the scenario name a variable it does not name yet. Replay also opens only http(s) pages (andabout:blank), and uploads only files inside the project, with relative paths counted from the project root. The agent never sees the value, not even while recording (how, with a diagram: §8): it is given the token, types the token, and Saffron's browser tools put the value into the page. Whatever the page then shows, logs, sends or downloads reaches the agent as the token too: snapshots, the code a tool echoes, console messages, network requests and bodies,browser_evaluateresults and Saffron's own replies. Screenshots never reach the agent. This holds for every provider (Claude, Codex, Antigravity, Cursor), since each one reaches the browser only through Saffron.What it cannot cover:
- A value shorter than three characters is never masked: never use one as a secret.
- The value is masked in its common spellings (as written, percent-encoded once or twice, JSON- and HTML-escaped, quoted in code). A page that shows it transformed, for example reversed, base64-encoded (as in a Basic authorization header) or hashed, shows the agent that form, and one that shows only part of it (a field that cuts it at its maximum length) shows that part.
- Masking is by value, so the same characters elsewhere on the page are
masked as well: a password
adminpassalso hides it inside/adminpass-reset. A value under six characters is masked only where it stands alone (devinon dev today, never in/devices), or where the scenario, or a data value it uses, writes it glued to other text (qa{env:PIN}is shown back as written). Pick secrets that are not everyday words. - An Outline whose recording is tied to its row keeps, for an Examples
cell read through
{env:...}, a salted and slow fingerprint of the value, so a changed variable is noticed: never the value itself. A value of a few digits (a 4-digit PIN) can still be found from it by trying every one, so keep such a secret out of a row-bound Outline's Examples, or in a scenario of its own. - A value of six characters or more is also masked in another case
(
ADMIN@SHOP.TESTforadmin@shop.test). A check the agent records on that text expects the value as written, so it fails on replay: check something else there, or let the step say the text in its own case. - A value goes into the page only through what the browser types, picks,
opens or uploads:
browser_evaluatecode with a token in it is refused, so the agent cannot have the page compute a transformed value. Code that reads a field the value was typed into can still return it transformed; the agent is told never to try. - A secret read from somewhere other than an
{env:...}the scenario names (typed into a step as plain text, or shown by the app without being one of the scenario's variables) is not a secret to Saffron.
Test data does not have to live in the feature file: put it in
data/*.jsonand write{data:users.admin.email}(§16).Waiting on the backend works: say it in the step and the recording carries a network matcher instead of a brittle sleep:
When I place the order and wait for the order API to return 201 And I wait until the job status API reports "READY" Then the order request should have returned 201The wait blocks replay until a matching response (URL pattern, method, status, optional body regex) is observed; the
Thenform is an API contract assertion and is never healed. Waits match responses from the triggering step onward, so "click and wait" in one step never races. Response assertions check requests started since the latest browser interaction (including filling, hovering and clicking), navigation, or opening or closing a tab in that session. Waits, captures and consecutive assertions keep this window open. An earlier request cannot satisfy the check, even if its response arrives late. Redirects stay in their original request's window. During healing, agent interactions advance the same window in that browser session, so the guard checks the latest attempt. A multi-field form advances the window before each field, matching its replayed actions; a successful first field cannot hide a failed last field. Typing with submission advances the window separately for typing and Enter, so a successful autosave cannot hide a failed submission. Saffron validates each compound call before its first edit. If a later field or submission fails, completed edits stay in the recording while the error reaches the agent, which can retry the failed part. Observations and recording tools leave it open. Check a request before the next interaction: recordings that previously audited an earlier action must move that assertion next to its trigger and be re-recorded. A body regex checks the complete response text after it has been read; an unreadable or pending body cannot pass as an empty string. Logged body checks capture JSON, text and XML responses. URL and status checks do not wait for the body. Large response bodies are retained in full for matching and therefore use more memory during the scenario. JSON fields can also be checked directly, without a body regex:Scenario: Order response contains the created order Given I am on the orders page When I place the order Then the POST response from "/api/order" should have status 201 and JSON field "status" equal to the string "CREATED" And that response's JSON field "id" should be present And I should see "Order placed"Say which type you expect: number
2differs from string"2", booleantruediffers from string"true", and null differs from a missing field. You can assert that a field is present or absent, including when its value is null. For nested fields and array entries, say which field and entry you mean. For example, "the first item's sku is the string ABC" records the path/items/0/sku.The agent records these on
saffron_assert kind=response(orsaffron_wait_apifor a wait) usingjson, a list of field checks:[ { "path": "/order/status", "equals": "CREATED" }, { "path": "/order/quantity", "equals": "<quantity>", "type": "number" }, { "path": "/order/paid", "equals": "true", "type": "boolean" }, { "path": "/order/note", "equals": "null", "type": "null" }, { "path": "/items/0/sku", "equals": "{data:order.sku}" }, { "path": "/order/id", "exists": true }, { "path": "/error", "exists": false }, { "path": "/items", "item": [{ "path": "/sku", "equals": "<sku>" }, { "path": "/qty", "equals": "2", "type": "number" }] } ]This illustrates the recorded fields; write the scenario and let Saffron record it, rather than editing cache JSON.
equalsis text in the recording, withtypeselecting string (the default), number, boolean or null. Values resolve Examples, data, env, unique and date tokens at replay. A numeric or boolean expectation that does not resolve to that type fails with guidance. Paths use JSON Pointer:/order/statusselects a nested field,/items/0selects the first array entry,~1names a literal slash,~0names a literal tilde, and an empty path selects the entire JSON value. Equality checks support scalar values; use field checks for objects and arrays. A path is kept as recorded: an Examples or table value in it never follows another row, since/items/0may hold a row's0only by chance. An Outline row whose value differs there is refused with guidance, or recorded again, instead of checking another field.An entry of a list is checked with
item: the path points at the list (empty for a body that is the list), and the checks inside, their paths read from one entry (/sku; empty for the entry itself), must all hold on the same entry, wherever it stands. "The order should list 2 of ABC" records anitemcheck, never/items/0/sku, so another order of the list cannot break it. A position is recorded only when the step names one ("the first item"). A recording with anitemcheck is at schema versionReplay waits for the complete body. Missing fields, invalid JSON and wrong values have distinct errors, naming the field and the expected and actual types or values with secrets masked. A response check's status, body pattern (if supplied) and JSON fields must all match before it passes. The existing action window still applies, so check the response before the next browser interaction. JSON checks replay without AI, and a Then failure cannot be healed. Recordings with JSON checks use format version 5; older runners refuse them and ask for an update. Recordings without JSON checks keep their existing format version. Playwright export currently refuses scenarios containing JSON field checks, rather than exporting tests that silently skip those assertions.
Relative dates work: "select a check-in date 1 day from today" records as a
{date+1}template and stays valid every day. A date you write out stays that date:When I enter 2026-10-10, a date in a table, a doc string, a{data:...}value or an{env:...}variable is entered as written on every run, never moved to "tomorrow". Only a date the agent picked for you moves with the calendar. That holds for a date the app spells out too: a calendar button namedFriday, October 30, 2026,30.10.2026or3 Nov 2026is recorded as the token that writes it ({date+3:dddd, MMMM D, YYYY}), within 31 days back and 400 ahead. So is a range of days the page shows, with its year (20–22 Oct 2026,Oct 30 - Nov 1, 2026), up to 62 days long: it becomes a range token ({date+7–+9:D MMM YYYY}), so a step that names a stay by intent ("the summary shows check-in in 7 days for 2 nights") checks the days the page shows on every later run, on the element that shows them, never with a pattern any date would pass. A range this does not read (one that writes the month twice,Oct 20 - Oct 22, 2026, one from before today to after it, one longer than 62 days) stays as recorded, whole: its end is never rewritten on its own. A spelling two near days share (11/12/2026is November 12 or 11 December) stays as recorded, and a day the scenario's own token wrote keeps that token. In a Scenario Outline whose date column holds a token ({date+3}on one row,{date+10}on the next), such a day, or one the page works out from it (a checkout two days on), follows the row's day on every later run. One recording cannot check a row with another date there: that row fails, saying why, and is never recorded over the first. Give such rows a Scenario of their own. A date the scenario writes itself (2026-10-30), in whatever spelling the page shows it, stays that date.Locators that would not find their element on a later load or day are caught when the agent finishes: a generated id in a selector, a frame or a test id (
#__privateStripeFrame55311,:r1f:, a hash), a UUID in a name, or a month heading (October 2026).saffron_doneasks the agent once to find the element again with a target that stays. What is left gets avolatile locator:note on the proposal, andaccept --allleaves it to be accepted by file.A date the app displays in its own words is a token too, so the day a step picks and the day a later step checks come from one clock, with no setup script computing them:
When I choose {date+7} as check-in and {date+9} as check-out Then the summary shows "{date+7 - +9:MMM D, YYYY}" And the German receipt shows "{date+7:D. MMM:de-DE} - {date+9:D. MMM:de-DE}"On 17 October 2026 these read
2026-10-24,Oct 24 - 26, 2026and24. Okt. - 26. Okt.; the next day, a day later. A format writesYYYY(2026),YY(26),MMMM(October),MMM(Oct),MM(10),M,DD(04),D(4),dddd(Saturday) andddd(Sat); anything else is written as it stands. Month and weekday names are in the locale the token names after a second colon ({date+7:D MMMM:fr-FR}is "24 octobre"), else indateLocalefromsaffron.config.json, else US English, and the way that locale writes them beside the format's other fields: German "Okt." with a day, "Okt" without. Numbers are always 0-9.A range is two offsets, and what you write between them is written between the days:
{date+7 - +9:MMM D, YYYY},{date+7–+9:...},{date+7 to +9:...}. What both days share is written once:Oct 24 - 26, 2026in one month,Oct 30 - Nov 1, 2026across one,Dec 31, 2026 - Jan 2, 2027across a year, and German24.-26.10.2026from{date+7-+9:DD.MM.YYYY}. The second offset always carries its sign ({date+0 - +2}for today to the day after tomorrow). To write both days in full, use two tokens, as the German receipt above does.Days are counted on this machine's calendar, or on the one
timezoneinsaffron.config.jsonnames ("Europe/Berlin"), which the browser runs in too, so the page and the tokens agree on what today is, also around midnight and when clocks change. Every token of one replayed scenario resolves on the clock it started with: a stay picked at 23:59 is still the stay checked at 00:01. While recording, the agent is given each date resolved, and the recording keeps the token wherever that day appears: as the whole value or within one, a range with its year in any step, and a day with its month's name but no year ({date+9:D MMM}, "6 Nov") in any step too. A numeric day without a year ({date+9:DD.MM}) only in the step that writes it: "06.11" can be a price elsewhere. A day without a year left as text near today is named by the clock check (§5). A day a step writes as plain{date+1}that the app shows in its own format (21/10/2026,21.10.2026,Oct 21, 2026,21 October 2026) becomes the token in that format, so it moves with the calendar too. In an Outline whose date column the app shows that way, the day stays as recorded. A token Saffron cannot read ({date+7 - 9}) or a locale this Node.js does not know stops the run before any browser opens, naming it, and the editor marks it while you type.Conditional steps ("accept cookies if prompted") are fine, the agent records them as no-ops when nothing appears.
Clicks and selections replay as they were made: a double click, a right click and a click with Shift, Control, Alt or Meta held stay what they were, a multi-select keeps every option chosen, and clearing a selection clears it. Typed text keeps its line breaks and tabs. A recording that uses any of these is written as version 2 of the recording format (§5), so an older runner refuses it instead of replaying a plain click.
Dialogs, uploads, drag-and-drop, and iframes all record live and replay: confirm() dialogs record as an armed accept/dismiss placed before the trigger, uploads take a file path (relative paths resolve from the project root), drag records source and destination targets, and elements inside iframes are scoped automatically, an assertion that misses an iframe boundary still replays, because positive lookups fall back to searching child frames. The scope is recorded whole: an iframe inside another one keeps both, and one of several alike iframes (previews, cards) keeps its position, so replay acts in the same iframe the agent did. If the agent reaches an element in a way the recorder cannot read (an iframe picked by something other than a selector or a position), the recording fails and names that interaction instead of leaving it out or recording it outside its frame. Scenario steps never need to say how to find an iframe. Hand-writing a cache entry (
recordedBy: "manual") remains a supported path; the zero-AI replay verifies it like any recording.Downloads record too, and a
Thenstep can check what the file says:Scenario: Add the stay to the calendar Given I am on the booking confirmation page When I add the stay to my Apple Calendar Then the calendar file should contain "DTSTART;VALUE=DATE:{date+7:YYYYMMDD}" And the calendar event should carry the booking's identifierThe click that downloads the file records with the download armed before it (
waitForDownload), as a dialog is. Replay waits for that download, fails the click when none starts within the action timeout, and, when the click started several (a batch export), waits for as many and checks the latest, as the recording did, and keeps the file beside the scenario's other evidence, in.saffron/artifacts/<feature>/<scenario>/downloads/, replaced each run. The file is kept as your app sent it: like a screenshot, it is not masked, so a secret your app writes into it is in it. The check (expectDownload) reads the scenario's latest download as text: text it must contain, a regular expression it must match, or both. A regular expression is case-sensitive, and^and$match at each line.{date...},{data:...},{env:...}and Examples values resolve in both, so the check above names next week's day on every run. Line breaks read as one\nwhatever the file uses, and in a calendar or contact file (.ics,.vcf) a folded line reads whole. Only UTF-8 text files up to 5 MB are checked; a larger or binary file (a PDF, an image) fails the check and says why. A link's target or download file name needs no download: check the link'shrefordownloadattribute. Like everyThenstep, the check is never healed. While recording, Saffron checks it against the file the agent's click downloaded, and shows the agent the start of the file when it does not hold. A recording with a download needs a saffron-ai release after 1.0.0; an older runner refuses it by name.Tabs are plain prose too. A click that opens a new tab is followed automatically; to work across tabs, say so: "When I switch back to the first tab", "And I close the preview tab", "When I open a new tab at the admin page". The agent's tab operations record as tab actions and replay in the same order. Name the tab in the step so the recording is unambiguous.
Two people in one scenario? Start a step with
As "name"and it runs in that person's own browser session: a browser context with its own cookies, storage, login and tabs.Scenario: The seller sees a new order Given As "seller" I am on the orders page When As "buyer" I order the blue lamp Then As "seller" I see an order for the blue lamp And the order is marked newThe session carries over to the steps after it until another
As "..."names one, soAnd the order is marked newis the seller's. Steps before the firstAs "..."run in the default session, andAs "default"goes back to it. A session keeps its page while the scenario works in another: the seller is still on the orders page when the scenario comes back to them. Names are case-sensitive (As "Buyer"is notAs "buyer"), and the name can come from an Examples column (As "<role>" ...). A step that reads as a session step and names none (As "" ..., two spaces afterAs) stops the run with its file and line instead of running in the session before it;As "Jane", I log inor a loneAs "admin"are ordinary steps. Step reuse keeps each person's own recording: a step recorded for the buyer never seeds the seller's, which might type what only the buyer may. That holds for a step that carries the session over from an earlierAs "..."too:I enter my addressrecorded afterAs "buyer"seeds only steps that run as the buyer. A session an Examples column names (As "<who>") is a different person on each row, so its steps seed no other step. The editors' completion and step vocabulary offerI open the cart, not one person'sAs "buyer" I open the cart, so picking a step never moves it to someone else's session. Upgrading from 0.9.x: a step whose text already began withAs "..."and a space (Given As "admin" I log in) now runs in that browser session, which starts fromstorageStateunlesssessionsgives it its own (§8); reword it, or set the session up. To start a session signed in as its own person, give it its own storage state (§8). One agent records all the sessions, its browser tools following the session of the step it is on; replay opens each session the first time a step needs it. Recordings store nothing new: a step's session follows from the step texts, so moving a step to another session changes a text and makes the recording stale, as any edit does.Repeated step groups? Rename the file to
.saffron(a superset of Gherkin: nothing else changes) and define a step set once:StepSet: Add the standard guests When I add the following guests | firstName | email | city | | Alice | alice@test.com | Oslo | | Bob | bob@test.com | Bergen | Then the guest count should be 2 Scenario: Note after the standard guests Given I am on the guestbook page StepSet Add the standard guests When I leave a note ...Colon defines, no colon invokes. A call takes no data table or doc string of its own: put them on the set's steps (a call with one is an error naming its line). The set's steps are inlined at parse time, so they cache and seed like ordinary steps; editing the set makes every invoking scenario re-record (mostly seeded), while table-value edits inside a set replay free as usual. Tip: start a set with a guard step and end it with an exit assertion so every invocation checks itself.
Sets are project-wide: define once, invoke from any
.saffronfile. Put application-wide flows (login, cookie banner) in a sets-only library file, by conventionfeatures/shared.steps.saffron(it still needs aFeature:header; it yields no runnable scenarios). Keep sets used by one flow in that flow's own file, and promote them to the library when a second feature needs them.For the full
.saffronexperience install the Saffron VS Code extension (sibling reposaffron-vscode): highlighting, step completion with recorded/divergent badges, StepSet go-to-definition, and near-duplicate wording warnings. Without it,npx saffron steps --snippetsgives native snippet completion and{ "files.associations": { "*.saffron": "feature" } }gives highlighting. Before writing new steps, check what already exists:npx saffron steps <word>: reusing an exact wording replays at zero tokens. If an AI assistant writes your feature files, serve it the vocabulary:claude mcp add saffron -- npx saffron mcp. JetBrains or Neovim?npx saffron lspserves the same completion, navigation and diagnostics to any LSP-capable editor: JetBrains setup (incl. Community editions, via the free LSP4IJ plugin) is in the guide. And to draft a whole file from prose:npx saffron author notes.txt. Full guide: Step Sets & Vocabulary.