doc-hub · story mapping
The .storymap format
A story map is a text file. It belongs in the repository of the product it describes, where it diffs, reviews and merges like everything else there — which is the whole reason the board keeps nothing of its own.
An example
// Story map exported by doc-sm.
// Comments and blank lines in an imported file are not preserved: the board
// is the source, this file is a render of it.
storymap "Doc-Hub Onboarding" {
product "client-onboarding"
space "CLONB"
delivery "Sprint 24" sprint #CLONB-S24
delivery "Sprint 25" sprint #CLONB-S25
delivery "MVP" release #CLONB-R1
activity "Discover documentation" #CLONB-1 ~in-progress {
persona "Business analyst"
persona "Product manager"
persona "Support engineer"
step "Search the catalog" #CLONB-10 ~in-progress {
story "Full-text search" @"Sprint 24" #CLONB-42 ~in-progress {
as "Business analyst"
want "to search every product at once"
so "I can answer a question without knowing which product owns it"
}
story "Filter by domain" @"Sprint 25" #CLONB-43 ~ready {
as "Product manager"
want "to narrow the catalogue to one domain"
so "I review only the products my portfolio covers"
note "Domain comes from the registry entry, not a\
free-text field that anyone can mistype."
}
story "Saved searches" {
as "Support engineer"
want "to keep the searches I run every week"
so "I stop retyping the same query"
}
}
step "Open a product" #CLONB-11 ~analysing
}
activity "Judge what I am reading" #CLONB-2 ~analysing {
persona "Support engineer"
persona "Documentation owner"
step "Check how current it is" #CLONB-12 ~in-progress {
story "Show the age of the docs" @"Sprint 24" #CLONB-51 ~done {
as "Support engineer"
want "to see when a page was last updated"
so "I can judge whether to trust it"
}
story "Flag anything over 90 days" @"Sprint 25" ~analysing {
as "Documentation owner"
want "stale pages called out for me"
so "I fix them before somebody is misled"
}
}
step "Check who owns it" #CLONB-13 ~ready {
story "Name the owning squad" @"Sprint 24" {
as "Support engineer"
want "to see which squad owns a product"
so "I can route an incident without guessing"
}
story "Link the squad's channel" @MVP {
as "Support engineer"
want "to reach the owning squad in one click"
so "I can ask while the incident is still open"
}
}
}
activity "Register a new product" #CLONB-3 ~ready {
persona "Registrar"
persona "Product owner"
step "Fill the registration form" #CLONB-14 ~ready {
story "Validate the repository URL" @"Sprint 24" {
as "Registrar"
want "a bad repository link refused at entry"
so "the catalogue never points at nothing"
}
story "Reject a duplicate slug" @"Sprint 24" {
as "Registrar"
want "a shortname that is already taken refused"
so "two products can never share an address"
}
}
step "Confirm it landed" #CLONB-15 ~analysing {
story "Show the entry in the catalog" @"Sprint 25" {
as "Product owner"
want "to see my product listed straight after registering"
so "I know the registration actually took"
}
story "Mail the owner a receipt" @MVP {
as "Product owner"
want "a written record of what I registered"
so "I can correct it if it is wrong"
}
}
}
}
Two cards in it are carrying a point. story "Saved searches" has no@, so it sits below the line. step "Open a product" has no body at all — a step that has been named and has no stories yet.
What the format decides for you
- Braces, not indentation
- Whitespace is a formatting choice and never syntax. A file that has been through a chat window, an editor with different tab settings, or a copy-paste still parses.
- One product, by shortname
- A map names the product it is about using the registry shortname, not the display name. The name is editable in the CMS; the shortname is the identity, so it is what survives a rename. Declaring it twice is an error, because two declarations mean a bad merge.
- One ticketing space
- Where tickets are raised — a Jira project key, or whatever the tracker calls one. Stated with `space`, and left out when it is simply the product shortname, which is the common case. Picking a product on a board that has none sets it.
- Three rows, three levels
- An activity is a capability, a step is an epic, a story is a story — the three levels every tracker has. Each carries the same #ticket and ~status, and only a story takes a @delivery: the other two span every band, so when the work happens is settled one level down.
- Tickets come from the ticketing system
- A story carries the ticket id the ticketing system issued, written whole after a #. doc-sm never invents one. A story with no # is simply not linked yet, which is where every story starts.
- Status defaults to Open
- One of ~open, ~analysing, ~ready, ~in-progress, ~done, ~closed. The default is Open and is never written to the file. You can set it here, but once a story is linked the ticketing system is the truth and this is a cached copy of it.
- A sprint is a kind of delivery, not a different thing
- Both kinds are the same structure and behave identically; the word is for reading. Four sprints and a release says something five equal bands do not — that four of them are steps towards the fifth. The same three words mean the same thing in doc-em, so a band carried between the two boards keeps its meaning.
- A band has a ticket of its own
- A sprint is a real object in the tracker and so is a release — one has a number, the other a version. `delivery "Sprint 24" sprint #CLONB-S24` records which, and it is editable on the board here because doc-sm *issues* tickets through its publish flow: a board that could not store what came back would be refusing its own output.
- `release "MVP"` still opens, and converts on export
- That was the only spelling before deliveries existed, and `.storymap` files live in product repositories where nobody watches for a grammar change. So it is still read, and means `delivery "MVP" release`. Nothing writes it any more: one trip through the board converts a file, which makes the old spelling a migration path rather than a dialect the format keeps.
- Declaration order is timeline order
- Bands are read top to bottom in the order they are declared, and a `delivery` line adds one: `delivery "Sprint 24" sprint`, `delivery "MVP" release`. There is no index and no date — an index drifts out of step with the file, and a date is the one thing here that would go stale on its own. The tracker holds the calendar; this holds the sequence.
- No @delivery means below the line
- A story with no band is known and not committed to. Absence is the encoding — there is no keyword for it to get wrong.
- Band titles must be unique
- A story refers to a band by its title, so two bands cannot share one. This is what keeps card identifiers out of the file entirely. A band’s own `#ticket` is not such an identifier: it names the band in another system and nothing resolves against it.
- Empty cards are real
- A step with no stories, or an activity with no steps, keeps its place. Both are ordinary states mid-workshop, and dropping them on export would delete a column.
- Each activity lists its cast
- Personas are listed inside the activity they belong to, one per line — an activity is where "who is doing this?" actually gets asked. A story may name a persona its own activity lists, and no other; if it names somebody the activity never mentioned, one of the two is wrong.
- Every story states its need
- as / want / so — the formal story language, modelled in three fields rather than written as prose. The persona is a reference so it cannot drift; the other two are the story's own words. All three are optional, because a workshop that has agreed the persona but not the outcome is an ordinary state.
- Notes wrap at 50 characters
- A note is prose, so its text is broken into lines of at most 50 characters. A trailing backslash carries the string onto the next line, and that split is the break — one pair of quotes for the whole note, and the file stays inside the same measure the text does.
- Comments do not survive the board
- A // comment is read and discarded. Import a commented file, export it again, and the comments are gone — the board is the source, the file is a render of it.
The grammar
File = StoryMap , EOF ;
StoryMap = 'storymap' , String ,
[ '{' , { Product | Space | Delivery | Activity | Note } , '}' ] ;
Product = 'product' , String ; (* at most one *)
Space = 'space' , String ; (* at most one; defaults to the product *)
Delivery = 'delivery' , String , ( 'sprint' | 'release' ) , [ TicketRef ] ,
[ '{' , { Note } , '}' ] ; (* order is timeline order *)
| 'release' , String , [ '{' , { Note } , '}' ] ; (* older spelling, still read *)
Activity = 'activity' , String , { TicketRef | StatusRef } ,
[ '{' , { Persona | Step | Note } , '}' ] ;
Persona = 'persona' , String ; (* the activity's cast; unique within it *)
Step = 'step' , String , { TicketRef | StatusRef } ,
[ '{' , { Story | Note } , '}' ] ;
Story = 'story' , String , { DeliveryRef | TicketRef | StatusRef } ,
[ '{' , { As | Want | So | Note } , '}' ] ;
As = 'as' , String ; (* a persona this activity lists *)
Want = 'want' , String ; (* one clause, one line, however long *)
So = 'so' , String ;
Note = 'note' , String ;
DeliveryRef = '@' , ( Ident | String ) ; (* at most one, any order *)
TicketRef = '#' , ( Ident | String ) ; (* at most one *)
StatusRef = '~' , Ident ; (* at most one *)
String = '"' , { Char | Escape | Splice } , '"' ;
Escape = '\' , ( '"' | '\' | 'n' | 't' ) ;
Splice = '\' , newline , { space } ; (* carries the string on; is a break *)
Ident = ( Letter | '_' ) , { Letter | Digit | '_' | '-' } ;
Comment = '//' , { Char } ; (* discarded *)Every name is a quoted string, so a title can never collide with a keyword. Write@MVP when the release name is one word, @"Q3 2026" when it is not — the board picks the right form for you on export.