Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

livt - Living Text

Collaborate on board. Make it living in text.

Outcome

Collaborative outcomes evolve alongside the product as living text.

Opportunities

  • Stale Discovery: Discovery-phase artifacts are not persisted after synchronous collaboration sessions
  • Discovery-Development Gap: Persisted discovery artifacts are not leveraged in the development process

Solutions

  • Provide a CLI tool that captures collaborative outcomes as text files (YAML, Markdown)
  • Track consistency across artifacts via ID-based references with automated checks
  • Treat Formulation artifacts (Gherkin scenarios) as generated output, not the source of truth — that lives in the Discovery artifacts

Live Demo

livt dogfoods itself: its own discovery artifacts — stories, story maps, example mappings, and ubiquitous language — are published with livt build as a live demo. It shows exactly what the guides below describe.

The demo reads in Japanese, which is what lang: ja in its livt.yaml does: this repository’s artifacts are written in Japanese, so the site is framed to match them. livt itself defaults to English — the screenshots in these guides show that default.

Installation

livt ships as a single static binary. Every release is built by the release workflow on GitHub Actions, and every artifact is:

  • listed in checksums.txt (SHA-256)
  • signed with a GitHub artifact attestation (Sigstore build provenance) tying it to the exact source commit and workflow run
  • built from the module as served by the Go module proxy and verified against the Go checksum database, so the binaries can be reproduced from the public source

Pick the method that matches how much of that verification you want automated.

The mise github backend downloads the release binary and verifies its build provenance attestation by default — a tampered or foreign-built asset fails to install.

mise use "github:boykush/livt@<version>"

Or in mise.toml:

[settings]
lockfile = true # pin checksum + provenance in mise.lock

[tools]
"github:boykush/livt" = "<version>"

With lockfile = true, the asset checksum and attestation provenance are recorded in mise.lock, so later installs — on CI or a teammate’s machine — must match bit-for-bit.

Manual download

Download the archive for your platform and checksums.txt from GitHub Releases, then verify before running:

# Provenance: proves the artifact was built by this repository's release
# workflow on GitHub Actions (requires the GitHub CLI)
gh attestation verify livt_<version>_<os>_<arch>.tar.gz --repo boykush/livt

# Integrity: check the SHA-256 checksum (macOS: shasum -a 256 -c)
sha256sum --check --ignore-missing checksums.txt

tar -xzf livt_<version>_<os>_<arch>.tar.gz
install livt ~/.local/bin/ # or anywhere on your PATH

Build from source

Source installs are verified by the Go checksum database: everyone gets byte-identical source for a given version, and a silently re-pushed tag is rejected. Pin a release version rather than latest so installs stay reproducible.

go install github.com/boykush/livt@<version>

Or with the mise go backend (requires Go on your PATH):

mise use "go:github.com/boykush/livt@<version>"

Getting Started

Installation

Install with mise, which verifies the release binary’s build provenance attestation by default:

mise use "github:boykush/livt@<version>"

See Installation for all methods and how release artifacts are verified.

Quick Start

  1. Create the required directories:
mkdir -p stories discoveries/usm discoveries/example-mappings ubiquitous
  1. Create your first story in stories/my-first-story.md:
---
name: My first story
---

As a user
I want to do something
So that I get value
  1. Build and serve:
livt serve
  1. Open http://localhost:3000 in your browser. Every page has a sidebar to switch between Example Mappings, Story Maps, Stories, and Ubiquitous Language. Open Stories to find the story you just created:

The site is rendered in English by default. To read it in Japanese instead, add livt.yaml beside your stories/ directory:

lang: ja

livt serve watches the file, so the open page reloads in the new language. Only the labels livt renders change — your stories, rules and terms are shown as you wrote them. See Configuration.

Opportunities

An opportunity is something the product could take on: a user problem together with the business benefit of solving it, held as one unit of consideration. It is what a story map is for — you decide whether to pursue an opportunity before you map the journey that serves it.

Opportunities are Markdown files with YAML frontmatter, stored in the opportunities/ directory. Their canvases are YAML files stored in discoveries/opportunity-canvases/.

Format

---
name: Opportunity display name
---

Whose problem this is, and what the business gets from solving it.

The name field is a short label — it is what the filter chips and the navigation show. The body is the opportunity itself: the sentence saying whose problem it is and what solving it is worth. This is the same split a story makes between its name and its narrative, and it is there for the same reason — a label fits on a card, a statement does not.

Name the opportunity, not the subject area it sits in. “Collaborative discovery” names a topic; “stale discovery” names something you could decide to fix. The topic already has a home — it is what the story map is called, and a map and its opportunity are free to carry different names precisely because they answer different questions: the map says which journey was drawn, the opportunity says why anyone drew it.

The opportunity key is derived from the filename (without .md), and must be kebab-case. Key uniqueness is enforced by the filesystem.

Example

opportunities/collaborative-discovery.md:

---
name: Stale discovery
repos:
  - boykush/livt
---

Discovery outcomes sit in the board tool and go stale. The raw record is rough, and
nothing versions it or checks it for consistency. Transcribing it, refining it into a
fair copy, and keeping it updated as rules change saves re-running the same argument
and lets the outcome stand as the specification.

Any frontmatter field beyond name is kept and shown as metadata, and a field whose value is a URL renders as a link — the same treatment story frontmatter gets.

The opportunity canvas

The Opportunity Canvas holds an opportunity on a single sheet. It is a discovery session’s outcome, so it lives in discoveries/ beside the story maps and example mappings, and it joins its opportunity by filename:

opportunities/collaborative-discovery.md                        # the opportunity
discoveries/opportunity-canvases/collaborative-discovery.yaml   # its canvas

This is the same filename join an example mapping makes with its story, and it carries the same meaning: either file can exist without the other. An opportunity with no canvas has not been thought through yet; a canvas with no opportunity file still renders.

Format

canvas:
  solution-ideas:
    - A specific product, feature, or enhancement idea
  problems:
    - A problem users have today
  users-and-customers:
    - Who has that problem
  solutions-today:
    - How they address it now
  business-challenges:
    - What those problems cost the business
  user-value:
    - What users will do with the solution
  user-metrics:
    - What you could measure to show they did it
  adoption-strategy:
    - How they discover and adopt it
  business-impact:
    - Which business metrics move
  budget:
    - What you would spend to find out

ubiquitous:
  - term-key

Every box is a list, because a box on a canvas holds sticky notes rather than a paragraph. Keeping them apart is what lets the board render one card per idea, the way it sat in the room.

Every key is optional. A box left out renders as an empty box on the sheet rather than disappearing from it — a blank box is the visible record of a question the opportunity has not answered yet, and that is worth seeing.

ubiquitous is optional and works exactly as it does on the other boards: each entry is a ubiquitous language term key, rendered as a pink sticky below the sheet.

The ten boxes

YAML keyBoxZone
solution-ideas1. Solution IdeasSolution
problems2. ProblemsVerifiable facts
users-and-customers3. Users and CustomersVerifiable facts
solutions-today4. Solutions TodayVerifiable facts
business-challenges5. Business ChallengesVerifiable facts
user-value6. What Will Users Do To Get Value?Assumptions about value
user-metrics7. User MetricsAssumptions about value
adoption-strategy8. Adoption StrategyAssumptions about value
business-impact9. Business ImpactAssumptions about value
budget10. BudgetSolution

The numbers are the order Jeff Patton recommends filling the boxes in. The zones are how the sheet is laid out, which is a different thing: the facts on the left, the solution down the middle, the assumptions about value on the right. Reading the sheet left to right walks back from the idea to the problem it solves, then forward to the value it would create.

The split is the point of the canvas — what you can go and check sits apart from what you are only assuming until the thing ships.

Linking a story map

A story map serves an opportunity when its filename key matches:

opportunities/collaborative-discovery.md          # the opportunity
discoveries/usm/collaborative-discovery.yaml      # the journey mapped for it

No field connects them; the filename does, as everywhere else in livt. When the two match:

  • The story map board names its opportunity, and links to it
  • Every story on that map carries the opportunity’s name on its chip, and the chip links to the opportunity’s page
  • The Stories, Example Mappings, and Tasks lists filter on that name

A story map whose key names no opportunity file keeps working exactly as it did before opportunities were files of their own: the map stands in as its own opportunity, named by the map. Nothing has to be migrated.

Where opportunities sit

Opportunity  ──▶  Story Map  ──▶  Story  ──▶  Example Mapping
   why, and         the journey     what one     how it must
   whether at all   that serves it  person does  behave

An opportunity is deliberately allowed to sit alone. One with no story map is one that has not been taken on — still a candidate, or decided against. livt records no status for this: whether the map exists is the record, the same way a rule’s automation is recorded rather than inferred.

Stories

Stories are Markdown files with YAML frontmatter, stored in the stories/ directory. The stories/ directory is the committed story registry: once a story candidate is committed for detailed discovery, its key is owned by stories/{story-key}.md.

Format

---
name: Story display name
---

Story body in Markdown.

The name field in frontmatter is required. The story key is derived from the filename (without .md). Story keys must be kebab-case, using lowercase letters, numbers, and hyphens. Key uniqueness is enforced by the filesystem: two committed stories cannot use the same stories/{story-key}.md path.

Example

stories/confirm-story-map.md:

---
name: Confirm story map
---

As a team member
I want to view the story map as a board
So that I can visually confirm the discovery outcomes maintained in text

This story has key confirm-story-map, which is used to reference it from story maps and example mappings.

Commit from a story map

Committing a story candidate from a story map into the registry is handled by the /story-commit skill (from the discovery-facilitator plugin), rather than a CLI command. Given the story map and the candidate, it creates stories/{story-key}.md first and then writes the same key back to the matching candidate in the story map.

Story Maps

Story maps are YAML files stored in discoveries/usm/. They define the structure of a User Story Map with activities, user tasks, stories, and release slices. The steps: field holds the user tasks — the field keeps livt’s own name, the boards use Patton’s.

A map’s filename names the opportunity it serves: a map whose key matches an opportunities/{key}.md is the journey mapped for that opportunity, and every story on it carries that opportunity’s chip. A map whose key matches no opportunity stands in as its own, named by the map — see Linking a story map. The map’s name: stays its own display name either way, and is what addresses it.

Format

name: Map Name

activities:
  - key: activity-key
    name: Activity Name
    steps:
      - key: step-key
        name: Step Name
        stories:
          - key: story-key
            name: Story Card Name
            release: release-id
          - name: Lightweight Story Card

releases:
  - id: release-id
    name: Release Name

ubiquitous:
  - term-key

Ubiquitous Language

  • ubiquitous is optional: each entry is a ubiquitous language term key
  • Referenced terms render as pink stickies below the board, linking to ubiquitous.html#{term-key}
  • A key with no matching term file renders as a plain pink card

Releases

  • Each release defines a horizontal divider on the board
  • Each release has an id that story cards reference with release
  • Stories with a release appear above that release’s divider
  • A release without name defaults to “Release N” based on position
  • Stories not in any release appear below all dividers
  • Story cards without key appear as plain cards and can still belong to a release
  • A story can reference only one release

Example

discoveries/usm/collaborative-discovery.yaml:

name: Collaborative Discovery

activities:
  - key: story-mapping
    name: Story Mapping
    steps:
      - key: discover-stories
        name: Discover stories
        stories:
          - key: confirm-story-context
            name: Confirm story context
            release: walking-skeleton
          - key: confirm-story-map
            name: Confirm story map
            release: walking-skeleton
          - name: Draft session outcomes
      - key: slice-releases
        name: Slice into releases
        stories:
          - key: split-release-scope
            name: Split release scope
            release: release-2

  - key: discovery
    name: Discovery
    steps:
      - key: discover-rules
        name: Discover rules
        stories:
          - key: confirm-discovery-outcomes
            name: Confirm discovery outcomes
            release: walking-skeleton

releases:
  - id: walking-skeleton
    name: Walking Skeleton
  - id: release-2

Example Mappings

Example mappings are YAML files stored in discoveries/example-mappings/. They capture the rules, examples, and questions discovered during an Example Mapping session for a story.

Format

story: story-key

rules:
  - id: R-01
    name: Rule description
    examples:
      - id: EX-01
        name: Example description
    issues:
      - https://github.com/owner/repo/issues/1
    automated: true
  - id: R-02
    name: Rule the spec no longer asks for
    retired: true
    superseded_by:
      - livt://mapping/story-key/rule/R-03

questions:
  - id: Q-01
    text: Question text

ubiquitous:
  - term-key
  • story is optional (links to the corresponding story detail page)
  • IDs must be unique within their rule or question list
  • ubiquitous is optional: each entry is a ubiquitous language term key, rendered as a pink sticky linking to ubiquitous.html#{term-key}. A key with no matching term file renders as a plain pink card.
  • issues is optional: the rule’s automation Issue URLs on implementation repos (Issue URLs only). The livt repository records the links; their state lives at the URL target. A rule without issues is unlinked.
  • automated is optional: records the judgment that the rule is actually automated by tests, which is independent of Issues being filed or closed. Absent means not automated. Set it when the rule’s automation lands; unset it when the rule changes.
  • retired is optional and applies to a rule, an example, or a question: it records that the item is no longer part of the spec. Absent means live.
  • superseded_by is optional and goes with retired: the livt URIs of whatever took the item’s place. Absent means nothing did.

Retiring an item

An item that no longer holds is marked retired: true — never deleted, and never commented out:

  • Deleting frees the ID. With R-01/R-02/R-03 on file, deleting R-03 makes R-02 the highest, so the next rule takes R-03 back. A livt://mapping/{story-key}/rule/R-03 reference already quoted in an Issue or a test comment then resolves to a different rule instead of failing — the quietest way for a reference to break. Retired items keep their IDs taken: new IDs are numbered from the max including them.
  • Commenting out loses the record. A comment is not part of the YAML structure, so any tool that rewrites the file drops it. retired: true is a field and survives.

A retired item stays readable in the file and still resolves by its livt URI, carrying retired: true so the reader can tell. It leaves the board and the Tasks page: a retired question is not an open question, and a retired rule is not waiting for a test.

Saying where the spec went

Retirement alone tells a reference it has stopped, not where to go. superseded_by adds that half — the livt URIs of whatever took the item’s place:

rules:
  - id: R-02
    name: Rule the spec no longer asks for
    retired: true
    superseded_by:
      - livt://mapping/story-key/rule/R-05
      - livt://mapping/another-story/rule/R-01

questions:
  - id: Q-01
    text: Question that turned into a rule
    retired: true
    superseded_by:
      - livt://mapping/story-key/rule/R-05
  • It is a list, so a rule that split into two names both.
  • It holds livt URIs, not bare ids. A successor can live in another mapping — the rule moved to the story that actually owns it — and R-05 on its own names nothing, since ids restart in every mapping.
  • A settled question points at the rule that settled it. The answer lands as a rule; the Question card never carries one.
  • Nothing replaced it? Leave superseded_by off. Plenty of retirements are just the business no longer asking.

Only the pointer is structured. Why the item was retired belongs to the commit that retired it, where it is written once and cannot drift — a second copy in the YAML would. Tooling reads the pointer back as URIs and stops there: the successor is one read away for whoever needs it, and inlining its text would spend a consumer’s context on a hop most of them never take.

Visual Layout

The board renders cards in the Example Mapping format:

  • Yellow card: Story (top)
  • Blue cards: Rules (row below story)
  • Green cards: Examples (stacked under their rule)
  • Red cards: Questions (separate column)
  • Pink cards: Ubiquitous language terms (referenced via ubiquitous, below the board)

Example

discoveries/example-mappings/confirm-discovery-outcomes.yaml:

rules:
  - id: R-01
    name: An example mapping can be rendered as a sticky view with only a story reference
    examples:
      - id: EX-01
        name: A YAML with only a story reference displays a single yellow Story card

  - id: R-02
    name: Cards are laid out following the Example Mapping format
    examples:
      - id: EX-01
        name: Rules are displayed as blue cards in a row below the Story card
      - id: EX-02
        name: Examples are displayed as green cards stacked under their Rule
      - id: EX-03
        name: Questions are displayed as red cards in a separate column

questions: []

Ubiquitous Language

Ubiquitous language terms are Markdown files with YAML frontmatter, stored in the ubiquitous/ directory. Each file is a single term, and livt renders them as a table you can browse like a database.

Format

---
name: Term display name
---

Definition in Markdown.

The name field in frontmatter is the display term. The term key is derived from the filename (without .md) and must be kebab-case, using lowercase letters, numbers, and hyphens. The body is the term’s definition.

Example

ubiquitous/story-map.md:

---
name: Story Map
---

A board to overview activities, steps, and stories alongside release slices.

Contexts

A term can be scoped to a context by putting it in a directory:

ubiquitous/
  story-map.md                # holds across contexts
  billing/
    invoice.md                # what "invoice" means in billing
  shipping/
    invoice.md                # what "invoice" means in shipping

A context is optional. Cut one when a word means different things to different parts of the domain — the DDD bounded context — and leave a term at the root when its meaning holds everywhere. Most livt repositories start with every term at the root and grow contexts only where the language actually diverges.

The directory is what makes a term unique, so invoice above is three separate terms, not one with three labels. Each has its own definition, its own row, and its own address. A term reference is therefore {ctx}/{term-key} for a scoped term and {term-key} for a context-free one, and the two never resolve to each other: a bare invoice names the root term even when billing/invoice exists.

Contexts are one directory deep. ubiquitous/billing/eu/invoice.md is not a term livt can address, and is left out of the glossary.

Visual Layout

livt build renders every term as a row on a single page at ubiquitous.html, with Term, Key, and Definition columns. A scoped term shows its context beneath its key. Each row carries an id={term-ref} anchor, so a term is linkable as ubiquitous.html#{term-key} or ubiquitous.html#{ctx}/{term-key}.

When a livt repository cuts at least one context, the page carries a filter bar above the table. Selecting a context narrows the table to that context’s terms, and the selection is mirrored in the ?context= query parameter so a filtered view is shareable. Context-free terms are not part of any single context, so they drop out while a filter is on rather than being repeated under every chip.

Referencing terms from boards

Story maps and example mappings can declare the terms they use with a top-level ubiquitous list of term references:

ubiquitous:
  - story-map
  - billing/invoice

Referenced terms render as pink stickies below the board, each linking to its glossary row, with the context shown on the sticky when it has one. A reference with no matching file renders as a plain pink card, so references degrade gracefully. See Story Maps and Example Mappings.

Commands

livt serve

Build artifacts and start a local server.

While the server is running, livt watches every directory the build reads (opportunities, discoveries/opportunity-canvases, discoveries/example-mappings, stories, discoveries/usm, and ubiquitous) along with livt.yaml. When a file changes, livt rebuilds and reloads the page in the browser automatically, so you can preview refinements while editing.

livt serve [flags]
FlagShortDefaultDescription
--port-p3000Port to listen on
--out-odistOutput directory

livt build

Build static HTML from artifacts without starting a server.

livt build [flags]
FlagShortDefaultDescription
--out-odistOutput directory

Both commands read livt.yaml from the directory they run in, which is where the site’s language is set.

livt mcp

Run an MCP (Model Context Protocol) server that exposes the livt repository (story maps, stories, example mappings, and the ubiquitous language). An implementation repo’s coding agent can then fetch the spec for a story or rule without reading livt’s source.

The livt repository usually lives in a separate checkout from the consumer, so point at it with --root or the LIVT_ROOT environment variable. The flag takes precedence; both default to the current directory.

livt mcp [flags]
FlagDefaultDescription
--root$LIVT_ROOT, then .Path to the root of the livt repository
--http(off; stdio)Serve over Streamable HTTP at this address (e.g. localhost:5488) instead of stdio; the MCP endpoint is <addr>/mcp

Transports

By default the server runs over stdio, spawned per consumer — the client launches livt mcp as a subprocess. stdio is the recommended form: the binding to the livt repository is read at spawn time from the consumer’s own environment, so each workspace resolves against what it declares, and a session never outlives the declaration it was started with.

Reach for --http when reachability is the problem — a client that cannot spawn processes, or several repos sharing one machine-wide server without each holding a checkout of the livt repository. It serves Streamable HTTP from one long-running process:

livt mcp --http localhost:5488

Each consumer points its MCP client at http://localhost:5488/mcp. The server is stateless and read-only, so one process backs many clients; keep git pull current on its checkout and the served spec (and spec_version) updates live. The binding trades the other way from stdio: the server fixes its repository once, at start, for every client — not per workspace. For distributing this client configuration to implementation repos, see the livt-mcp plugin. This mode assumes local use with no authentication — the server is meant to bind to localhost, not a public network.

Tools

ToolArgumentsReturns
list_opportunitiesEvery opportunity with its key, name, and statement. Each entry links to its opportunity resource (uri), to its canvas (canvas_uri) when one has been filled in, and to the story maps mapped for it. A missing canvas or story map is the record that the opportunity has not been taken that far.
list_storiesopportunity (optional) — an opportunity name, matched exactly against the name a story’s opportunity chip carries; keeps only the stories on that map, and an unknown name yields an empty listEvery story with its key and name. Each entry links to its story resource (uri); stories that have an example mapping also include example_mapping_uri, and stories on a story map carry opportunities — one map name plus story map resource URI per map they sit on.
list_story_mapsEvery story map with its name and its story map resource URI (uri).
list_termsEvery ubiquitous language term with its key, display name, and term resource URI (uri) — including terms no board references. A term scoped to a context also carries ctx, which is part of what identifies it.

Resources

The spec itself is exposed as resources, addressable by URI (opportunity → story map → story → mapping → rule → example, with the canvas, questions, and ubiquitous terms linked alongside):

URIReturns
livt://opportunity/{opportunity_key}An opportunity: its name, its statement (whose problem, and what the business gets from solving it), and its frontmatter meta. Carries canvas_uri when a canvas has been filled in, and story_maps — the maps whose key matches, as map name plus story map resource URI.
livt://opportunity-canvas/{opportunity_key}The Opportunity Canvas filled in for an opportunity, as its ten boxes — each with its key, printed number, heading, the prompt it asks, and its items. Every box is returned, unanswered ones with an empty items: a blank box records a question the opportunity has not answered.
livt://story-map/{map_name}A story map: activities, steps, story cards, and releases. Committed story cards link to their story resource. {map_name} is the map’s display name (percent-encoded) — the same identifier the build output uses for story-map/{name}.html.
livt://story/{story_key}The story’s name, body, and frontmatter meta (e.g. issue), plus example_mapping_uri when a mapping exists and opportunities — the story maps the story sits on, as map name plus story map resource URI.
livt://mapping/{story_key}The story’s example mapping (rules, examples, questions, ubiquitous terms). Each rule, example, and question carries its own uri, and ubiquitous_terms resolves each referenced term to its resource URI. Retired entries are listed too, flagged — the mapping is the structural record their ids are numbered from.
livt://mapping/{story_key}/rule/{rule_id}A single rule and its examples, plus its recorded automation: issues (automation Issue URLs) and automated (whether the rule is automated by tests). Rules inside livt://mapping/{story_key} carry the same fields.
livt://mapping/{story_key}/rule/{rule_id}/example/{example_id}A single example of a rule. Example ids are numbered within their rule, so the address carries {rule_id}EX-01 alone does not identify an example.
livt://mapping/{story_key}/question/{question_id}A single question. Questions hang off the mapping rather than off a rule, so the address stops at {story_key}.
livt://ubiquitous/{term_key}A ubiquitous language term’s name and definition. This shape addresses a term whose meaning holds across contexts.
livt://ubiquitous/{ctx}/{term_key}A term scoped to one context, carrying ctx alongside its key. A context is optional and part of the address, so the same {term_key} can name one term at the root and another inside a context; the two never resolve to each other.

A retired rule, example, or question keeps resolving by its URI and carries retired: true, so a reference to it reads as retired rather than failing (or, worse, landing on whatever reused its id). Live items omit the field. When something took the item’s place, superseded_by lists it as livt URIs, so the reference reads on instead of stopping; a retirement that replaced nothing omits that field too.

Read them with resources/read; all appear in resources/templates/list. The server advertises templates only — there is no concrete resource list and no change notification (subscribe); every read is served fresh from disk.

Every tool and resource payload also includes a spec_version field – the short git revision of the livt repository – so consumers can tell which version of the spec they are reading and detect drift.

Citing the livt repository

The handshake carries instructions, so every session tells the consuming agent how to reference the livt repository in what it produces: copy the uri from the result verbatim, and never write a bare id. Ids are unique only within one mapping file, so R-02 exists in every mapping and identifies nothing on its own.

// livt://mapping/place-order-with-saved-card/rule/R-13/example/EX-01

That applies wherever a reference leaves the livt repository – a test comment, an issue body, a commit message, a PR description. A published living-document URL is a convenience link for humans, not the citation form: it depends on where the site is deployed, and the livt URI does not.

A URI cited this way is read back with livt resolve, which needs no MCP client – so the citation stays followable for CI, an editor, or a reader who only has a checkout.

livt resolve

Resolve a livt URI against the livt repository, without running an MCP client. A rule, example, or question cited in a test comment, an issue body, or a commit message can then be read back by whoever needs it — CI, an editor, or an agent that is not connected to livt over MCP.

livt resolve <uri> [flags]
FlagDefaultDescription
--root$LIVT_ROOT, then .Path to the root of the livt repository
--formatjsonOutput form: json or url
--base-urlRoot of the deployed site; required by --format url

Every URI shape the MCP server exposes as a resource resolves here: mappings, rules, examples, questions, stories, story maps, and ubiquitous language terms.

Output forms

--format json (the default) prints the same payload an MCP resources/read of that URI serves, spec_version included — the two surfaces resolve through one code path, so a consumer sees one shape whichever way it asked:

livt resolve livt://mapping/trace-test-to-rule/rule/R-04

--format url prints the item’s page on the deployed site, using the same URI-to-page derivation the site build anchors its stickies to:

$ livt resolve livt://mapping/trace-test-to-rule/rule/R-04 \
    --format url --base-url https://boykush.github.io/livt
https://boykush.github.io/livt/mapping/trace-test-to-rule.html#rule-R-04

The livt repository is still read in this form, even though the page path derives from the URI alone: a link to a page that was never built is worse than an error.

When a URI does not resolve

Both cases exit non-zero and explain themselves on stderr, because they need different fixes:

Malformed URIThe string is not a livt URI at all. The error lists every shape one can take. Fix the URI.
Nothing to resolveThe URI is well formed but the livt repository holds no such item, e.g. rule "R-99" not found in story "trace-test-to-rule". Fix the reference, or add the item.

livt version

Print the version of livt.

livt version

Configuration

livt.yaml at the root of the livt repository configures the site build:

lang: ja

The file is optional and every field falls back to a default, so a repository without one builds exactly as it did before there was one.

FieldDefaultValuesDescription
langenen, jaLanguage of the site chrome

Both livt build and livt serve read it from the directory the command runs in — the same place stories/ and discoveries/ are resolved from. livt serve watches it like any other input, so an edit rebuilds and reloads the page you have open.

A value livt does not support is an error rather than a silent fall back to the default, since the alternative is a site built in a language nobody asked for:

$ livt build
Error: livt.yaml: unknown lang "de" (supported: en, ja)

lang

lang is the language of everything livt renders of itself: the sidebar, page headings and titles, board legends, filter chips, table headers, empty states, the lang attribute of every page, and the headings and prompts of the Opportunity Canvas — the questions printed on the sheet, which are livt’s words rather than yours.

It does not touch the livt repository’s own prose. Story names and bodies, rules, examples, questions, and term definitions are rendered as written, whichever language they are written in. The two are independent on purpose — a team writing its stories in Japanese and reading the site in English is as ordinary an arrangement as the reverse, and the setting is about the frame, not the contents.

The setting reaches the site only. livt mcp and livt resolve answer in English whatever it says, canvas headings included: those payloads are a contract read by agents, and a field that changed language with a site setting would not be one. What they carry from the livt repository — names, rules, definitions — is verbatim there too.

Two languages are supported today, en and ja. A new one is a catalog in internal/i18n/; every catalog answers for every message, so a language is either complete or not offered.

File Structure

Input

project-root/
  livt.yaml                               # Site config (optional)
  opportunities/
    {opportunity-key}.md                  # Opportunity files
  stories/
    {story-key}.md                        # Story files
  discoveries/
    opportunity-canvases/
      {opportunity-key}.yaml              # Opportunity canvas files
    usm/
      {opportunity-key}.yaml              # Story map files
    example-mappings/
      {story-key}.yaml                    # Example mapping files
  ubiquitous/
    {term-key}.md                         # Terms holding across contexts
    {ctx}/
      {term-key}.md                       # Terms scoped to one context
  • livt.yaml configures the site build — see Configuration. Without it, every setting takes its default
  • Opportunity keys, like story keys, are derived from filenames (without extension) and must be kebab-case
  • An opportunity canvas filename must match an opportunity key to link them
  • A story map filename that matches an opportunity key marks the map as the journey mapped for that opportunity. A map whose key matches no opportunity stands in as its own, named by the map — which is how every livt repository behaved before opportunities were files
  • Story keys are derived from filenames (without extension)
  • Story keys must be kebab-case: lowercase letters, numbers, and hyphens
  • The stories/ directory is the committed story registry, and stories/{story-key}.md provides story key uniqueness
  • Example mapping filenames must match story keys to link them
  • Term keys are derived from filenames, and a term’s context from the directory holding it. The path is what makes a term unique, so the same key can sit at the root and under a context as two separate terms
  • A context is optional and one directory deep; terms nested deeper are not addressable and are left out of the glossary
  • A term is anchored as ubiquitous.html#{term-key}, or ubiquitous.html#{ctx}/{term-key} when it is scoped

Output

livt build generates the following structure:

dist/
  index.html                              # Example mappings overview (home)
  opportunities.html                      # Opportunities overview
  story-maps.html                         # Story maps overview
  stories.html                            # Story list
  ubiquitous.html                         # Ubiquitous language table
  tasks.html                              # Open questions and un-automated rules
  opportunity/
    {opportunity-key}.html                # Opportunity detail pages
  opportunity-canvas/
    {opportunity-key}.html                # Opportunity canvas sheets
  story/
    {story-key}.html                      # Story detail pages
  mapping/
    {story-key}.html                      # Example mapping boards
  story-map/
    {map-name}.html                       # Story map boards

Every page shares a left sidebar that links the five resource types (Example Mappings, Opportunities, Story Maps, Stories, Ubiquitous Language) and, below them, Tasks. The overview pages render each example mapping, opportunity canvas, and story map as a preview card.

tasks.html gathers what the livt repository leaves unfinished, so neither kind has to be hunted for board by board:

  • Open Questions — every questions entry across the example mappings. These close by a conversation, so they feed the next discovery session.
  • Un-automated Rules — every rule with no automated: true recorded. These close by a test, so they read as the list of behaviour still to build.

Retired items are on neither list, and off the boards as well: nothing can close them, so they would sit here forever.

Each item names the story it came from and links to its own sticky on that story’s mapping board. Both lists are filtered together by opportunity, and the selection is mirrored in the ?opportunity= query parameter so a filtered view is shareable.

Items carry no status beyond being listed. A rule records its automation issue URLs but not their open/closed state, and the build never queries the issue tracker, so an item leaves this page by being resolved — a question becoming a rule, a rule becoming automated — rather than by moving through states.

livt URI

A livt URI names one point in the livt repository — an opportunity, its canvas, a rule, an example, a question, a story, a story map, or a ubiquitous language term. It is the form to reach for whenever a reference has to survive outside the livt repository: a test comment, an issue body, a commit message.

livt://mapping/{story-key}/rule/{rule-id}

The MCP server addresses its resources by these URIs — see Commands for what each one returns — and the generated site anchors its stickies to them. Neither depends on where the site is deployed.

Why not a bare ID

R-02 on its own is not a reference. IDs are numbered within the file that holds them, so every example mapping in this repository defines an R-02, and every rule that has examples defines an EX-01. Even R-13 EX-01 narrows it to nothing — the reader still cannot tell which mapping was meant, and neither can a search. The story key is the part that makes it addressable:

livt://mapping/trace-test-to-rule/rule/R-02/example/EX-01

Relative to a repository

The widening stops there: a livt URI never says which livt repository it belongs to. Its interpretation is uniform — one point in the livt repository at hand — the same shape http://localhost/ has, where every reader agrees on the meaning while the referent depends on where it is resolved. Which repository answers is supplied by the consumer’s own declaration (--root / LIVT_ROOT), so scope lives in the workspace, not in the name. That is what keeps a citation short, and what lets the same reference work in every checkout of the same repository.

The first segment is drawn from a closed set — opportunity, opportunity-canvas, mapping, story, story-map, ubiquitous — so a future form that names a repository in that position stays open without breaking any URI written today.

URI to page

livt build renders the livt repository as a static site, and this is where each URI lands in it. Paths are relative to the output root.

livt URIPage
livt://mapping/{story-key}mapping/{story-key}.html
livt://mapping/{story-key}/rule/{rule-id}mapping/{story-key}.html#rule-{rule-id}
livt://mapping/{story-key}/rule/{rule-id}/example/{example-id}mapping/{story-key}.html#rule-{rule-id}-example-{example-id}
livt://mapping/{story-key}/question/{question-id}mapping/{story-key}.html#question-{question-id}
livt://story/{story-key}story/{story-key}.html
livt://opportunity/{opportunity-key}opportunity/{opportunity-key}.html
livt://opportunity-canvas/{opportunity-key}opportunity-canvas/{opportunity-key}.html
livt://story-map/{map-name}story-map/{map-name}.html
livt://ubiquitous/{term-key}ubiquitous.html#{term-key}
livt://ubiquitous/{ctx}/{term-key}ubiquitous.html#{ctx}/{term-key}

An example’s anchor repeats its rule for the same reason its URI does: EX-01 recurs under every rule of a board, so #example-EX-01 would land on whichever one happened to be first. The sticky’s own badge still shows the local EX-01 — that is the ID the livt repository numbers — while the link behind it carries the rule.

A term takes a context the same way, and for the same reason: invoice can mean one thing in billing and another in shipping, so the context is part of the address rather than a label on it. The context is optional — a term whose meaning holds everywhere keeps the one-segment form — and the two shapes never resolve to each other.

An opportunity is addressed by key rather than by display name, unlike a story map. The key is what its filename and its canvas already join on, so it is the identifier the livt repository carries. The canvas sits beside the opportunity rather than under it, the way a mapping sits beside its story — the two are joined by key, and either can exist without the other.

A retired item has no sticky, so its URI lands on the board with nothing to scroll to. The URI still resolves: ask the tooling, which answers with the item and retired: true — plus superseded_by, the URIs of whatever took its place, when the retirement recorded any. Deriving a page is how a reference is shown, not how it is resolved.

Store the URI, render the URL

Nothing in the livt repository records where the site is deployed. The deployment URL is prefixed onto the paths above only while a page is being rendered, which is why the same livt repository can be served from a local livt serve, from GitHub Pages, and from an internal host at once:

livt://mapping/trace-test-to-rule/rule/R-02
  -> mapping/trace-test-to-rule.html#rule-R-02
  -> https://boykush.github.io/livt/demo/mapping/trace-test-to-rule.html#rule-R-02

A deployed URL is therefore a fine thing to paste into a chat and a poor thing to commit: it goes stale the moment the site moves, and it buries the identity it was meant to carry. Keep the URI; let the URL be derived.

Where each form comes from

  • A URL, to send to a person — take it from the board. Every sticky shows its own ID in the bottom-right corner; clicking it copies the deployed URL of that sticky, so what you paste already points at the exact card.
  • A livt URI, to leave in an artifact — take it from the tooling rather than the board. Every rule, example, and question the MCP server returns carries its own uri, so an agent writing a test can quote the point it is proving without composing the URI by hand.

Keeping the two apart is what stops the site from becoming a second source of identity. The board is for reading and sharing; the URI is what the livt repository actually knows about itself.

Reading one back

A URI left in a test comment is only worth as much as the ability to follow it, and that has to work for a reader who is not running an MCP client — CI, an editor, or someone with just a checkout. livt resolve turns any of the shapes above back into the point it names:

$ livt resolve livt://mapping/trace-test-to-rule/rule/R-02
{ "spec_version": "...", "rule": { "id": "R-02", ... } }

$ livt resolve livt://mapping/trace-test-to-rule/rule/R-02 \
    --format url --base-url https://boykush.github.io/livt
https://boykush.github.io/livt/mapping/trace-test-to-rule.html#rule-R-02

The URL form is derived from the table above rather than restated, so a link the CLI hands out and the anchor the build writes cannot drift apart.