Skip to content

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.

Open the board