diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,32 @@
 
 ## [Unreleased]
 
+## [0.2.0.0] - 2026-07-26
+
+### Added
+
+- `okf id next` and `okf id list` for allocating and listing profile-declared
+  document handles, plus document-ID fallback in `okf show` so a handle such as
+  `ADR-7` resolves to the concept that carries it.
+- Optional `BUNDLE` and `CONCEPT_ID` arguments for `okf show`, filled in with `fzf`
+  menus when omitted, plus the `okf help interactive` topic. Search roots default to
+  the current directory and can be overridden with `OKF_BUNDLE_ROOTS`.
+
+### Changed
+
+- Requires `okf-core >=0.2.0.0`, whose profile Dhall schema gained the required
+  `idField` and `idPrefix` record fields. Profile descriptors passed to
+  `okf validate --profile` must add those values or adopt the new
+  record-completion defaults.
+
+## [0.1.2.1] - 2026-07-20
+
+### Fixed
+
+- Ship the `help/*.md` topic sources in the sdist via `extra-source-files`. They
+  are embedded at compile time by `Okf.Cli.Help` (`file-embed`), so their absence
+  from the 0.1.2.0 Hackage tarball made that release fail to build from Hackage.
+
 ## [0.1.2.0] - 2026-07-14
 
 ### Added
diff --git a/help/agents.md b/help/agents.md
new file mode 100644
--- /dev/null
+++ b/help/agents.md
@@ -0,0 +1,46 @@
+AGENT SKILLS AND ASSIST
+
+okf can install reusable AI-agent "skills" and "subagents" from a shared git
+repository (okf-kit) and launch an interactive agent session that uses them.
+
+KIT COMMANDS
+
+  okf kit list                      List skills and subagents available in okf-kit.
+  okf kit install NAME              Install one to user scope.
+  okf kit install NAME --project    Install into this project (.okf/agents).
+  okf kit uninstall NAME            Remove an installed skill or subagent.
+  okf kit update [NAME]             Refresh okf-kit and reinstall installed items.
+  okf kit status                    Show what is installed and whether it is current.
+
+ASSIST
+
+  okf assist "PROMPT"               Launch an interactive Claude session with your
+                                    installed okf skills on its path, starting from
+                                    PROMPT.
+  okf assist --print-command "PROMPT"
+                                    Print the Claude command line without launching.
+
+CONFIGURATION
+
+  Settings live in okf-config.dhall (project) or ~/.config/okf/config.dhall
+  (global). Run 'okf config show' to see the effective values and 'okf config
+  init' to write a starter file.
+
+  Configurable fields:
+
+    kit.repoUrl
+    kit.providers
+    assist.provider
+    assist.model
+    assist.systemPrompt
+
+PUBLISHING YOUR OWN SKILL
+
+  1. Add skills/<name>/SKILL.md to the okf-kit repository.
+  2. Add a matching entry to its kit.json.
+  3. Commit and push.
+  4. Run 'okf kit update' to pull it.
+
+  Skills are plain directories with a SKILL.md file. Subagents are Markdown
+  persona files under agents/. Both are discovered by the interactive agent after
+  installation and an 'okf assist' launch.
diff --git a/help/config.md b/help/config.md
new file mode 100644
--- /dev/null
+++ b/help/config.md
@@ -0,0 +1,51 @@
+CONFIG
+
+okf config controls the optional agent-assistance features: where okf kit fetches
+skills and subagents from, which providers to install for, and how okf assist
+launches an interactive agent session.
+
+COMMANDS
+
+  okf config show                   Print the effective configuration and where
+                                    it was loaded from.
+  okf config path                   Print only the selected configuration source.
+  okf config init                   Write ./okf-config.dhall.
+  okf config init --global          Write ~/.config/okf/config.dhall.
+
+SEARCH ORDER
+
+  The first existing source wins:
+
+    1. OKF_CONFIG, when it points at an existing file
+    2. ./okf-config.dhall
+    3. ~/.config/okf/config.dhall
+    4. ~/.okf/config.dhall
+    5. built-in defaults
+
+FIELDS
+
+  kit.repoUrl                       Git URL used by okf kit.
+  kit.providers                     Providers to install kit items for.
+  assist.provider                   Provider launched by okf assist.
+  assist.model                      Optional model override for assist.
+  assist.systemPrompt               Optional extra system prompt for assist.
+
+  Providers are Dhall union values: Provider.Claude or Provider.Codex. Claude is
+  currently the supported assist provider; Codex support is reserved for a later
+  implementation.
+
+EXAMPLE
+
+  Run 'okf config init' to write this shape:
+
+    let Provider = < Claude | Codex >
+    in  { kit =
+            { repoUrl = "https://github.com/shinzui/okf-kit.git"
+            , providers = [ Provider.Claude ]
+            }
+        , assist =
+            { provider = Provider.Claude
+            , model = None Text
+            , systemPrompt = None Text
+            }
+        }
diff --git a/help/format.md b/help/format.md
new file mode 100644
--- /dev/null
+++ b/help/format.md
@@ -0,0 +1,75 @@
+OKF BUNDLE FORMAT
+
+An OKF bundle is a directory tree of Markdown files. The directory structure is
+up to the producer; it need not mirror any domain taxonomy. Every Markdown file
+whose name is not reserved is a concept document.
+
+RESERVED FILES
+
+  index.md    Optional per-directory listing for progressive disclosure.
+  log.md      Optional chronological history of updates for that scope.
+
+  These filenames are reserved at any level and are never concept documents.
+
+  A log.md has a title, date groups, and bullet entries:
+
+    # Directory Update Log
+
+    ## 2026-06-23
+    * **Update**: Refreshed schema notes.
+
+  Date headings must be real YYYY-MM-DD calendar dates. okf validate treats
+  malformed log.md files as hard errors.
+
+CONCEPT IDS
+
+  A concept ID is the bundle-relative path of a concept without the .md
+  suffix:
+
+    tables/orders.md     -> tables/orders
+    datasets/sales.md    -> datasets/sales
+
+  Each path segment must start with an ASCII letter, digit, or underscore.
+  The rest may also contain dot and hyphen.
+
+FRONTMATTER FIELDS
+
+  type          REQUIRED. Short string naming the kind of concept, e.g.
+                "PostgreSQL Table", "Metric", "Playbook". Not registered
+                centrally; consumers tolerate unknown types.
+  title         Recommended. Human-readable display name.
+  description   Recommended. One-sentence summary used in indexes and graphs.
+  resource      Optional. Canonical URI for the underlying asset (absent for
+                purely abstract concepts).
+  tags          Optional. List of short categorization strings.
+  timestamp     Optional. ISO 8601 datetime of last meaningful change.
+
+  Producers may add any other keys; the parser preserves unknown keys as
+  extension data and never rejects a document for carrying them.
+
+CONVENTIONAL BODY HEADINGS
+
+  The body is plain Markdown with no required sections. These headings carry
+  conventional meaning when applicable:
+
+    # Schema      Columns/fields of a structured asset.
+    # Examples    Concrete usage examples.
+    # Citations   Numbered external sources backing claims in the body.
+
+LINKS
+
+  Concepts relate to each other through standard Markdown links to .md files
+  inside the same bundle:
+
+    Absolute (recommended): [Customers](/tables/customers.md)
+    Relative:               [Sales](../datasets/sales.md)
+
+  A link asserts an (untyped) relationship; the kind is conveyed by the
+  surrounding prose. External URLs are allowed but never become graph edges.
+  A link to a .md concept that does not exist is a dangling reference: graph
+  ignores it, but okf validate reports it (see "okf help validation").
+
+SEE ALSO
+
+  okf help validation   How bundles are checked.
+  okf help okf          What OKF is, end to end.
diff --git a/help/interactive.md b/help/interactive.md
new file mode 100644
--- /dev/null
+++ b/help/interactive.md
@@ -0,0 +1,60 @@
+INTERACTIVE SELECTION
+
+  okf show can ask you which bundle and which concept you mean instead of
+  requiring you to type both.
+
+    okf show                     pick a bundle, then pick a concept
+    okf show BUNDLE              pick a concept in BUNDLE
+    okf show BUNDLE CONCEPT_ID   no menus; unchanged behavior
+
+  Only okf show is interactive. Every other command still takes BUNDLE as a
+  required argument.
+
+REQUIREMENTS
+
+  Interactive selection needs the fzf fuzzy finder on your PATH and a
+  terminal. Without them, okf show tells you which argument to pass and
+  exits 2. Nothing else in okf requires fzf.
+
+  fzf is read from the terminal device, not from standard input, so the
+  menus still work inside a pipeline such as 'okf show | less'.
+
+WHERE BUNDLES COME FROM
+
+  okf searches the current directory, four levels deep, for directories that
+  look like a bundle: one holding an index.md, or one holding a Markdown file
+  whose frontmatter declares a type. Once a directory qualifies, okf does not
+  look inside it, so subdirectories of a bundle are not offered separately.
+
+  Set OKF_BUNDLE_ROOTS to a colon-separated list of directories to search
+  somewhere else:
+
+    OKF_BUNDLE_ROOTS=~/knowledge:~/work okf show
+
+  Directories that do not exist or cannot be read are skipped silently.
+
+  A bundle whose top directory holds neither an index.md nor a concept
+  document of its own is offered as its first qualifying subdirectory
+  instead. Pass the bundle path explicitly when that happens.
+
+THE CONCEPT MENU
+
+  Concepts are listed as three aligned columns -- concept ID, type, title --
+  and the pane on the right previews the highlighted concept exactly as
+  'okf show BUNDLE CONCEPT_ID' would print it.
+
+  Typing filters on the concept ID, type, and title together.
+
+KEYS
+
+  Type to filter, arrow keys or ctrl-n/ctrl-p to move, Enter to choose, Esc
+  or ctrl-c to cancel. Cancelling exits with status 130 and prints nothing.
+
+EXIT STATUS
+
+  0    a concept was printed
+  1    nothing to choose from: no bundles found, or the bundle has no
+       concepts
+  2    no interactive selection available: fzf is missing, or there is no
+       terminal
+  130  you cancelled with Esc or ctrl-c
diff --git a/help/kit.md b/help/kit.md
new file mode 100644
--- /dev/null
+++ b/help/kit.md
@@ -0,0 +1,44 @@
+KIT
+
+okf kit installs reusable AI-agent skills and subagents from the configured
+okf-kit git repository. Installed items are made available to interactive agent
+sessions launched with okf assist.
+
+COMMANDS
+
+  okf kit list                      List skills and subagents available in the
+                                    configured kit repository.
+  okf kit install NAME              Install a skill or subagent to user scope.
+  okf kit install NAME --project    Install into this project instead.
+  okf kit update                    Refresh the kit repository and reinstall all
+                                    installed items.
+  okf kit update NAME               Refresh the kit repository and reinstall one
+                                    installed item.
+  okf kit uninstall NAME            Remove an installed item from user scope.
+  okf kit uninstall NAME --project  Remove an installed item from project scope.
+  okf kit status                    Show installed items and whether they are
+                                    current.
+
+SCOPES
+
+  User scope installs into the okf agent asset directory under your home
+  directory. Project scope installs into this project's local agent asset
+  directory. Use project scope when a repository should carry its own agent
+  helpers independently of your personal setup.
+
+CONFIGURATION
+
+  The kit repository and target providers come from okf configuration:
+
+    kit.repoUrl
+    kit.providers
+
+  The default repository is https://github.com/shinzui/okf-kit.git and the
+  default provider list is [Claude]. Run 'okf config show' to inspect the
+  effective values, or 'okf help config' for configuration details.
+
+PUBLISHING ITEMS
+
+  Skills are directories with a SKILL.md file. Subagents are Markdown persona
+  files. Add an entry to kit.json, commit, push, and run 'okf kit update' to
+  refresh local installs. No okf rebuild is required.
diff --git a/help/okf.md b/help/okf.md
new file mode 100644
--- /dev/null
+++ b/help/okf.md
@@ -0,0 +1,67 @@
+OPEN KNOWLEDGE FORMAT (OKF)
+
+OKF is an open, human- and agent-friendly format for representing knowledge:
+the metadata, context, and curated insight that surrounds data and systems.
+It is meant to be authored by people, generated by agents, exchanged across
+organizations, and consumed by both.
+
+The format is intentionally minimal -- a directory of Markdown files with YAML
+frontmatter. There is no schema registry, no central authority, and no
+required tooling. If you can "cat" a file you can read OKF; if you can
+"git clone" a repo you can ship it.
+
+WHY IT LOOKS THIS WAY
+
+  OKF standardizes only the small set of conventions needed to make a
+  knowledge corpus self-describing. It aims to be:
+
+    Readable    by humans, without tooling.
+    Parseable   by agents, without bespoke SDKs.
+    Diffable    in version control.
+    Portable    across tools, organizations, and time.
+
+CORE IDEAS
+
+  Bundle      A self-contained directory tree of knowledge documents -- the
+              unit of distribution (a git repo, a tarball, or a subdirectory).
+  Concept     A single unit of knowledge, written as one Markdown document. It
+              may describe a tangible asset (a table, an API) or an abstract
+              idea (a metric, a business process).
+  Concept ID  The concept file's bundle-relative path with the .md suffix
+              removed, e.g. tables/orders.md -> tables/orders.
+
+  A concept document is YAML frontmatter (metadata) followed by a Markdown body
+  (free-form knowledge). Only the frontmatter "type" field is required:
+
+    ---
+    type: PostgreSQL Table
+    title: Orders
+    description: One row per completed customer order.
+    ---
+
+    # Orders
+
+    Orders join to [Customers](/tables/customers.md).
+
+WHAT THE okf TOOL DOES
+
+  validate    Check frontmatter conformance and, as an authoring aid, that
+              links between concepts resolve and log.md files are structured.
+  index       Generate progressive-disclosure index.md files per directory.
+  log         Preview log.md files, check stale concepts, and append entries.
+  graph       Extract a JSON node/edge graph from links between concepts.
+  show        Print one concept's metadata and body.
+
+STANDALONE BY DESIGN
+
+  The okf CLI works on plain files only. It does not require Mori, Mina, an
+  LLM, PostgreSQL, or network access. It tracks Google's Open Knowledge Format
+  v0.1 specification (the knowledge-catalog okf SPEC.md).
+
+SEE ALSO
+
+  okf help format       Bundle layout, concept IDs, frontmatter, and links.
+  okf help validation   What "conformant" means and how the tool checks it.
+  okf help profiles     Checking a bundle against house conventions.
+
+  The full user guide lives under docs/user/ in the okf repository.
diff --git a/help/profiles.md b/help/profiles.md
new file mode 100644
--- /dev/null
+++ b/help/profiles.md
@@ -0,0 +1,48 @@
+PROFILE-BASED VALIDATION
+
+A profile descriptor declares house conventions layered on top of OKF: which
+type strings are allowed, which frontmatter keys are required, which
+resource:// schemes are expected, the file layout, and required # Schema
+columns. Profiles are written as Dhall descriptors.
+
+USAGE
+
+  okf validate BUNDLE --profile PROFILE.dhall
+  okf validate BUNDLE --profile PROFILE.dhall --profile-enforce
+
+ADVISORY VS ENFORCED
+
+  --profile PROFILE      Run profile checks after structural validation.
+                         Deviations print to stderr, each line prefixed
+                         "profile:". By default they are advisory and do NOT
+                         change the exit code.
+
+  --profile-enforce      Make profile deviations fail the command (non-zero
+                         exit).
+
+EXIT CODES
+
+  - Structural errors always exit non-zero, with or without --profile.
+  - Profile deviations exit 0 by default (advisory), or non-zero with
+    --profile-enforce.
+  - A descriptor that fails to load is always a hard error.
+
+EXAMPLE (ADVISORY)
+
+  profile: schemas/sales/tables/bad: type not in profile vocabulary: pg table
+  OK: 3 concepts
+  profile: 1 advisory deviation(s) (use --profile-enforce to fail)
+
+DOCUMENT IDS
+
+  A profile may name an idField such as "docId" and give selected type rules an
+  idPrefix such as "ADR". Those concepts must carry canonical handles such as
+  ADR-7. Use `okf id next BUNDLE ADR --profile PROFILE.dhall` to print the next
+  handle, `okf id list BUNDLE --profile PROFILE.dhall` to list allocations, and
+  `okf show BUNDLE ADR-7` to resolve one.
+
+SEE ALSO
+
+  okf help validation   Structural validation and referential integrity.
+
+  The full descriptor schema is documented in docs/user/profiles.md.
diff --git a/help/validation.md b/help/validation.md
new file mode 100644
--- /dev/null
+++ b/help/validation.md
@@ -0,0 +1,71 @@
+VALIDATING OKF BUNDLES
+
+okf validate checks every concept document in a bundle and the links between
+them, then exits non-zero if anything is wrong.
+
+  okf validate BUNDLE
+  okf validate BUNDLE --strict
+
+PERMISSIVE VS STRICT
+
+  Default (permissive) validation requires each concept to have a non-empty
+  type frontmatter field.
+
+  --strict additionally requires the recommended authoring fields:
+
+    title
+    description
+    timestamp
+
+REFERENTIAL INTEGRITY
+
+  Validation also checks the whole bundle, not just single files:
+
+    - A Markdown link to another .md concept that does not exist in the
+      bundle is a dangling reference and fails validation.
+    - Duplicate concept IDs are reported.
+    - Present log.md files must use valid YYYY-MM-DD headings and non-empty
+      date groups.
+    - External URLs and non-.md links are not checked.
+
+  These checks run in both permissive and strict modes.
+
+LOG ADVISORIES
+
+  okf validate reports concepts whose timestamp date is newer than the newest
+  entry in the nearest enclosing log.md. These stale-log findings are advisory
+  by default:
+
+    okf validate BUNDLE
+    okf validate BUNDLE --log-enforce
+
+  Use --log-enforce to make stale-log advisories fail the command. Use
+  okf log BUNDLE --check-stale for the same timestamp check without running all
+  validation, or okf log BUNDLE --since REF to ask git which changed concept
+  files did not change their nearest log.md in the same diff.
+
+CONFORMANCE VS AUTHORING CHECKS
+
+  OKF v0.1 conformance itself is permissive: it requires only parseable
+  frontmatter with a non-empty type field, and it tells consumers to TOLERATE
+  broken links (a link to a not-yet-written concept is not malformed). okf
+  validate is an authoring-time linter, so it deliberately goes further and
+  flags dangling references and duplicate IDs to catch mistakes before you
+  publish. Treat these as authoring aids, not as a gate on what consumers
+  will accept.
+
+OUTPUT
+
+  A valid bundle prints a concept count and exits 0:
+
+    OK: 4 concepts
+
+  An invalid bundle prints deterministic errors to stderr and exits non-zero,
+  for example:
+
+    orders: link to missing concept: customers
+
+SEE ALSO
+
+  okf help profiles   Checking a bundle against house conventions.
+  okf help format     Concept IDs, frontmatter, and links.
diff --git a/okf-cli.cabal b/okf-cli.cabal
--- a/okf-cli.cabal
+++ b/okf-cli.cabal
@@ -1,21 +1,26 @@
-cabal-version:   3.4
-name:            okf-cli
-version:         0.1.2.0
-synopsis:        Command-line interface for Open Knowledge Format bundles
+cabal-version:      3.4
+name:               okf-cli
+version:            0.2.0.0
+synopsis:           Command-line interface for Open Knowledge Format bundles
 description:
   okf-cli provides the @okf@ executable for working with Open Knowledge Format
   (OKF) bundles: validating bundles, inspecting their concept graph, and
   authoring documents from the command line, built on the okf-core library.
 
-category:        Data, CLI
-license:         BSD-3-Clause
-license-file:    LICENSE
-author:          Nadeem Bitar
-maintainer:      nadeem@gmail.com
-copyright:       (c) 2026 Nadeem Bitar
-build-type:      Simple
-extra-doc-files: CHANGELOG.md
+category:           Data, CLI
+license:            BSD-3-Clause
+license-file:       LICENSE
+author:             Nadeem Bitar
+maintainer:         nadeem@gmail.com
+copyright:          (c) 2026 Nadeem Bitar
+build-type:         Simple
+extra-doc-files:    CHANGELOG.md
 
+-- Help topic sources embedded at compile time by Okf.Cli.Help via
+-- file-embed's embedStringFile. They must ship in the sdist or the
+-- Hackage build fails resolving the Template Haskell splices.
+extra-source-files: help/*.md
+
 common common-options
   ghc-options:
     -Wall -Wcompat -Widentities -Wincomplete-uni-patterns
@@ -38,6 +43,8 @@
     Okf.Cli.Assist
     Okf.Cli.Completions
     Okf.Cli.Config
+    Okf.Cli.Fzf
+    Okf.Cli.Fzf.Selector
     Okf.Cli.Help
     Okf.Cli.Kit
     Okf.Cli.Kit.Config
@@ -59,7 +66,7 @@
     , generic-lens          >=2.2      && <2.4
     , githash               ^>=0.1
     , lens                  ^>=5.3
-    , okf-core              ^>=0.1.2.0
+    , okf-core              ^>=0.2.0.0
     , optparse-applicative  >=0.18     && <0.20
     , process               >=1.6      && <1.7
     , text                  ^>=2.1
@@ -75,6 +82,7 @@
     , directory
     , filepath
     , okf-cli
+    , okf-core
     , optparse-applicative  >=0.18
     , temporary
     , text                  ^>=2.1
diff --git a/src/Okf/Cli.hs b/src/Okf/Cli.hs
--- a/src/Okf/Cli.hs
+++ b/src/Okf/Cli.hs
@@ -2,6 +2,8 @@
 module Okf.Cli
   ( Command (..),
     GraphOptions (..),
+    IdOptions (..),
+    IdSub (..),
     IndexOptions (..),
     ConfigCommand (..),
     LogAddOptions (..),
@@ -19,6 +21,8 @@
 
 import Control.Exception (IOException, try)
 import Data.Aeson qualified as Aeson
+import Data.Aeson.Key qualified as AesonKey
+import Data.Aeson.KeyMap qualified as KeyMap
 import Data.ByteString.Lazy.Char8 qualified as LazyByteString
 import Data.Foldable (traverse_)
 import Data.List qualified as List
@@ -30,20 +34,38 @@
 import Okf.Cli.Assist (AssistOptions, assistOptionsParser, handleAssistCommand)
 import Okf.Cli.Completions (CompletionsShell, completionsParser, handleCompletions)
 import Okf.Cli.Config
+import Okf.Cli.Fzf (FzfConfig, detectFzfConfig)
+import Okf.Cli.Fzf.Selector
+  ( BundleSelection (..),
+    ConceptSelection (..),
+    bundleSearchRootsEnvVar,
+    selectBundle,
+    selectConcept,
+  )
 import Okf.Cli.Help (HelpCommand, handleHelpCommand, helpCommandParser)
 import Okf.Cli.Kit (KitCommand, handleKitCommand, kitCommandParser)
 import Okf.Cli.Version (appVersionWithGit)
 import Okf.ConceptId
-import Okf.Document (DocumentParseError (..), body)
+import Okf.Document (DocumentParseError (..), Frontmatter (..), OKFDocument (..), body)
 import Okf.Graph (buildGraph)
 import Okf.Index
 import Okf.Log qualified as Log
 import Okf.Prelude
-import Okf.Profile (ProfileViolation (..), loadProfileFile, validateProfile)
+import Okf.Profile
+  ( ProfileSpec (..),
+    ProfileViolation (..),
+    TypeRule (..),
+    documentIdsInBundle,
+    loadProfileFile,
+    nextDocumentId,
+    parseDocumentId,
+    renderDocumentId,
+    validateProfile,
+  )
 import Okf.Validation
 import Options.Applicative
 import System.Directory (createDirectoryIfMissing, doesFileExist)
-import System.Exit (ExitCode (..), exitFailure)
+import System.Exit (ExitCode (..), exitFailure, exitWith)
 import System.FilePath ((</>))
 import System.FilePath qualified as FilePath
 import System.IO (stderr)
@@ -55,6 +77,7 @@
   | Log LogOptions
   | GraphCommand GraphOptions
   | ShowConcept ShowOptions
+  | Id IdOptions
   | Config ConfigCommand
   | Kit KitCommand
   | Assist AssistOptions
@@ -105,11 +128,24 @@
   deriving stock (Show, Eq)
 
 data ShowOptions = ShowOptions
+  { bundlePath :: !(Maybe FilePath),
+    conceptIdText :: !(Maybe Text),
+    profilePath :: !(Maybe FilePath)
+  }
+  deriving stock (Show, Eq)
+
+data IdOptions = IdOptions
   { bundlePath :: !FilePath,
-    conceptIdText :: !Text
+    profilePath :: !FilePath,
+    idSub :: !IdSub
   }
   deriving stock (Show, Eq)
 
+data IdSub
+  = IdNext !Text
+  | IdList
+  deriving stock (Show, Eq)
+
 data ConfigCommand
   = ConfigShow
   | ConfigPath
@@ -152,6 +188,7 @@
         <> command "log" (info (Log <$> logOptionsParser <**> helper) (progDesc "Preview and check log.md files"))
         <> command "graph" (info (GraphCommand <$> graphOptionsParser <**> helper) (progDesc "Print a bundle graph"))
         <> command "show" (info (ShowConcept <$> showOptionsParser <**> helper) (progDesc "Show one concept"))
+        <> command "id" (info (Id <$> idOptionsParser <**> helper) (progDesc "Allocate and list document IDs"))
         <> command "config" (info (Config <$> configCommandParser <**> helper) (progDesc "Show and manage okf configuration"))
         <> command "kit" (info (Kit <$> kitCommandParser <**> helper) (progDesc "Install and manage agent skills and subagents"))
         <> command "assist" (info (Assist <$> assistOptionsParser <**> helper) (progDesc "Launch an interactive agent session with installed okf skills"))
@@ -255,12 +292,62 @@
     <$> bundleArgument
     <*> switch (long "json" <> help "Print JSON graph output")
 
+-- | The @show@ command spells out its own bundle argument instead of reusing
+-- 'bundleArgument', because only here is the argument optional and the help
+-- text must say so.
 showOptionsParser :: Parser ShowOptions
 showOptionsParser =
   ShowOptions
-    <$> bundleArgument
-    <*> (Text.pack <$> strArgument (metavar "CONCEPT_ID" <> help "Concept ID such as tables/users"))
+    <$> optional
+      ( strArgument
+          ( metavar "BUNDLE"
+              <> help "Path to an OKF bundle directory; omit to choose one interactively"
+          )
+      )
+    <*> optional
+      ( Text.pack
+          <$> strArgument
+            ( metavar "CONCEPT_ID"
+                <> help "Concept ID such as tables/users; omit to choose one interactively"
+            )
+      )
+    <*> optional
+      ( strOption
+          ( long "profile"
+              <> metavar "PROFILE"
+              <> help "Narrow document ID lookup to a profile's idField"
+          )
+      )
 
+idOptionsParser :: Parser IdOptions
+idOptionsParser =
+  hsubparser
+    ( command
+        "next"
+        ( info
+            ( IdOptions
+                <$> bundleArgument
+                <*> profileArgument
+                <*> (IdNext . Text.pack <$> strArgument (metavar "PREFIX" <> help "Profile-declared document ID prefix"))
+                  <**> helper
+            )
+            (progDesc "Print the next unused document ID")
+        )
+        <> command
+          "list"
+          ( info
+              (IdOptions <$> bundleArgument <*> profileArgument <*> pure IdList <**> helper)
+              (progDesc "List allocated document IDs")
+          )
+    )
+  where
+    profileArgument =
+      strOption
+        ( long "profile"
+            <> metavar "PROFILE"
+            <> help "Path to a Dhall profile descriptor declaring idField and idPrefix"
+        )
+
 configCommandParser :: Parser ConfigCommand
 configCommandParser =
   hsubparser
@@ -286,6 +373,7 @@
   Log options -> runLog options
   GraphCommand options -> runGraph options
   ShowConcept options -> runShow options
+  Id options -> runId options
   Config configCommand -> runConfig configCommand
   Kit kitCommand -> do
     config <- loadConfigOrDie
@@ -512,13 +600,137 @@
   LazyByteString.putStrLn (Aeson.encode (buildGraph concepts))
 
 runShow :: ShowOptions -> IO ()
-runShow ShowOptions {bundlePath, conceptIdText} = do
-  conceptId <- either (dieText . renderConceptIdError conceptIdText) pure (parseConceptId conceptIdText)
-  concepts <- loadBundleOrExit bundlePath
-  case findConcept conceptId concepts of
-    Nothing -> dieText ("Concept not found: " <> conceptIdText)
+runShow ShowOptions {bundlePath, conceptIdText, profilePath} = do
+  fzfConfig <- detectFzfConfig
+  resolvedBundle <- resolveBundlePath fzfConfig bundlePath
+  concepts <- loadBundleOrExit resolvedBundle
+  case conceptIdText of
+    Just rawIdentifier -> showConceptByIdentifier profilePath concepts rawIdentifier
+    Nothing -> do
+      selection <- selectConcept fzfConfig resolvedBundle concepts
+      case selection of
+        ConceptChosen concept -> renderConcept concept
+        ConceptNoCandidates ->
+          dieText ("No concepts found in " <> Text.pack resolvedBundle)
+        ConceptSelectionCancelled -> exitWith (ExitFailure 130)
+        ConceptSelectionUnavailable -> dieNoPicker "CONCEPT_ID"
+        ConceptSelectionError message -> dieFzf message
+
+-- | Use the given bundle, or ask the user to pick one.
+resolveBundlePath :: FzfConfig -> Maybe FilePath -> IO FilePath
+resolveBundlePath _ (Just path) = pure path
+resolveBundlePath fzfConfig Nothing = do
+  selection <- selectBundle fzfConfig
+  case selection of
+    BundleChosen path -> pure path
+    BundleNoCandidates roots ->
+      dieText
+        ( "No OKF bundles found under "
+            <> Text.intercalate ", " (Text.pack <$> roots)
+            <> ".\nA bundle directory holds an index.md or a Markdown file whose"
+            <> " frontmatter declares a type."
+            <> "\nPass a bundle path explicitly, or set "
+            <> Text.pack bundleSearchRootsEnvVar
+            <> " to a colon-separated list of directories to search."
+        )
+    BundleSelectionCancelled -> exitWith (ExitFailure 130)
+    BundleSelectionUnavailable -> dieNoPicker "BUNDLE"
+    BundleSelectionError message -> dieFzf message
+
+-- | The argument was omitted but no interactive picker can run.
+dieNoPicker :: Text -> IO a
+dieNoPicker missingArgument =
+  dieTextWith
+    (ExitFailure 2)
+    ( "okf show: no "
+        <> missingArgument
+        <> " given and interactive selection is unavailable."
+        <> "\nInstall fzf (https://github.com/junegunn/fzf) and run okf from a terminal,"
+        <> " or pass the argument: okf show [BUNDLE] [CONCEPT_ID]"
+    )
+
+dieFzf :: Text -> IO a
+dieFzf message =
+  dieTextWith (ExitFailure 2) ("okf show: interactive selection failed: " <> message)
+
+-- | Resolve one identifier against a walked bundle: canonical concept path
+-- first, then a profile-declared document ID. Unchanged from the previous
+-- implementation of 'runShow', so the resolution order fixed by ADR 1 cannot
+-- drift.
+showConceptByIdentifier :: Maybe FilePath -> [Concept] -> Text -> IO ()
+showConceptByIdentifier profilePath concepts conceptIdText =
+  case either (const Nothing) (`findConcept` concepts) (parseConceptId conceptIdText) of
     Just concept -> renderConcept concept
+    Nothing ->
+      case parseDocumentId conceptIdText of
+        Nothing ->
+          case parseConceptId conceptIdText of
+            Left err -> dieText (renderConceptIdError conceptIdText err)
+            Right _ -> dieText ("Concept not found: " <> conceptIdText)
+        Just _ -> do
+          searchField <-
+            case profilePath of
+              Nothing -> pure Nothing
+              Just path -> do
+                ProfileSpec {idField = profileIdField} <- loadProfileOrExit path
+                maybe
+                  (dieText ("Profile " <> Text.pack path <> " declares no idField"))
+                  (pure . Just)
+                  profileIdField
+          case findConceptsByDocumentId searchField conceptIdText concepts of
+            [] ->
+              dieText
+                ( "Concept not found: "
+                    <> conceptIdText
+                    <> " (no document carries that document ID)"
+                )
+            [concept] -> renderConcept concept
+            matches ->
+              dieText
+                ( "Ambiguous document ID "
+                    <> conceptIdText
+                    <> ", found on: "
+                    <> Text.intercalate ", " (renderConceptId . conceptIdOf <$> matches)
+                    <> "\nRun okf validate --profile <descriptor> to see the duplicate as a violation."
+                )
 
+runId :: IdOptions -> IO ()
+runId IdOptions {bundlePath, profilePath, idSub} = do
+  spec <- loadProfileOrExit profilePath
+  ProfileSpec {idField = profileIdField, types = typeRules} <- pure spec
+  when (isNothing profileIdField) $
+    dieText ("Profile " <> Text.pack profilePath <> " declares no idField")
+  concepts <- loadBundleOrExit bundlePath
+  case idSub of
+    IdNext requestedPrefix -> do
+      let declaredPrefixes =
+            List.sort
+              (List.nub [declaredPrefix | TypeRule {idPrefix = Just declaredPrefix} <- typeRules])
+      unless (requestedPrefix `List.elem` declaredPrefixes) $
+        dieText
+          ( "Profile declares no idPrefix "
+              <> requestedPrefix
+              <> ". Declared prefixes: "
+              <> renderDeclaredPrefixes declaredPrefixes
+          )
+      Text.IO.putStrLn (renderDocumentId (nextDocumentId spec concepts requestedPrefix))
+    IdList ->
+      mapM_
+        ( \(documentId, cid) ->
+            Text.IO.putStrLn (renderDocumentId documentId <> "  " <> renderConceptId cid)
+        )
+        (documentIdsInBundle spec concepts)
+  where
+    renderDeclaredPrefixes [] = "(none)"
+    renderDeclaredPrefixes prefixes = Text.intercalate ", " prefixes
+
+loadProfileOrExit :: FilePath -> IO ProfileSpec
+loadProfileOrExit profilePath = do
+  loaded <- loadProfileFile profilePath
+  case loaded of
+    Left err -> dieText ("Failed to load profile " <> Text.pack profilePath <> ": " <> err)
+    Right spec -> pure spec
+
 loadBundleOrExit :: FilePath -> IO [Concept]
 loadBundleOrExit bundlePath = do
   result <- walkBundle bundlePath
@@ -581,6 +793,12 @@
       <> renderList actual
       <> " do not start with required "
       <> renderList expected
+  MissingDocumentId cid ctype prefix ->
+    renderConceptId cid <> ": " <> ctype <> " requires a document ID with prefix " <> prefix
+  MalformedDocumentId cid prefix actual ->
+    renderConceptId cid <> ": document ID must look like " <> prefix <> "-<number>, found: " <> actual
+  DuplicateDocumentId handle cid other ->
+    renderConceptId cid <> ": duplicate document ID " <> handle <> " (also on " <> renderConceptId other <> ")"
   where
     renderList xs = "[" <> Text.intercalate ", " xs <> "]"
 
@@ -620,6 +838,9 @@
 renderConcept :: Concept -> IO ()
 renderConcept concept = do
   Text.IO.putStrLn ("id: " <> renderConceptId (conceptIdOf concept))
+  mapM_
+    (\(fieldName, handle) -> Text.IO.putStrLn (fieldName <> ": " <> handle))
+    (documentIdFields concept)
   Text.IO.putStrLn ("type: " <> conceptType concept)
   traverse_ (Text.IO.putStrLn . ("title: " <>)) (conceptTitle concept)
   traverse_ (Text.IO.putStrLn . ("description: " <>)) (conceptDescription concept)
@@ -632,6 +853,18 @@
 bodyText concept =
   body (conceptDocument concept)
 
+documentIdFields :: Concept -> [(Text, Text)]
+documentIdFields concept =
+  List.sortOn
+    fst
+    [ (AesonKey.toText key, fieldValue)
+    | (key, String fieldValue) <- KeyMap.toList rawFields,
+      isJust (parseDocumentId fieldValue)
+    ]
+  where
+    OKFDocument {frontmatter = Frontmatter {fields = rawFields}} =
+      conceptDocument concept
+
 renderBundleError :: BundleError -> Text
 renderBundleError = \case
   InvalidConceptPath path error_ -> Text.pack path <> ": " <> renderConceptIdParseError error_
@@ -654,6 +887,9 @@
   FrontmatterNotMapping -> "frontmatter must be a YAML mapping"
 
 dieText :: Text -> IO a
-dieText message = do
+dieText = dieTextWith (ExitFailure 1)
+
+dieTextWith :: ExitCode -> Text -> IO a
+dieTextWith exitCode message = do
   Text.IO.hPutStrLn stderr message
-  exitFailure
+  exitWith exitCode
diff --git a/src/Okf/Cli/Fzf.hs b/src/Okf/Cli/Fzf.hs
new file mode 100644
--- /dev/null
+++ b/src/Okf/Cli/Fzf.hs
@@ -0,0 +1,247 @@
+-- | A thin wrapper around the external @fzf@ fuzzy finder.
+--
+-- fzf is an optional runtime dependency: it is a separate executable the user
+-- installs, and every entry point here degrades to a typed failure when it is
+-- missing rather than throwing. fzf reads its candidate list from standard
+-- input, draws its interface on the terminal, reads keystrokes from
+-- @/dev/tty@, and prints the chosen line to standard output.
+--
+-- Selection never parses display text back into a value. Each line written to
+-- fzf is @\<index\>\\t\<display\>@ and fzf is told to hide the first field
+-- (@--with-nth 2..@), so the selected line always begins with the integer that
+-- indexes back into the caller's values.
+module Okf.Cli.Fzf
+  ( -- * Availability
+    FzfConfig (..),
+    detectFzfConfig,
+    isFzfAvailable,
+
+    -- * Options
+    FzfOpts (..),
+    withPrompt,
+    withHeader,
+    withHeight,
+    withPreview,
+    withAnsi,
+    withNoSort,
+    optsToArgs,
+
+    -- * Running
+    Candidate (..),
+    FzfResult (..),
+    runFzf,
+
+    -- * Pure helpers, exposed for tests
+    renderCandidateLines,
+    parseSelectionIndex,
+    shellQuote,
+  )
+where
+
+import Control.Applicative ((<|>))
+import Control.Exception (SomeException, try)
+import Data.Map.Strict qualified as Map
+import Data.Maybe (fromMaybe, isJust)
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Text.IO qualified as Text.IO
+import Data.Text.Read qualified as Text.Read
+import System.Directory (findExecutable)
+import System.Exit (ExitCode (..))
+import System.IO (IOMode (ReadMode), hClose, hIsTerminalDevice, hSetEncoding, openFile, stdin, stdout, utf8)
+import System.Process
+  ( CreateProcess (..),
+    StdStream (CreatePipe, Inherit),
+    createProcess,
+    proc,
+    waitForProcess,
+  )
+
+-- | Everything needed to decide whether an interactive picker can run, probed
+-- once per process.
+data FzfConfig = FzfConfig
+  { -- | Resolved path to the fzf executable, or the bare name as a fallback.
+    fzfBinary :: !FilePath,
+    -- | Was an fzf executable found on PATH?
+    fzfAvailable :: !Bool,
+    -- | Is standard input a terminal?
+    stdinIsTerminal :: !Bool,
+    -- | Is standard output a terminal? Recorded for diagnostics only: fzf draws
+    -- on the terminal device, so a redirected stdout does not prevent a picker.
+    stdoutIsTerminal :: !Bool,
+    -- | Can @/dev/tty@ be opened? fzf reads keystrokes from there, which is why
+    -- a picker still works inside a pipeline.
+    ttyAvailable :: !Bool
+  }
+  deriving stock (Show, Eq)
+
+detectFzfConfig :: IO FzfConfig
+detectFzfConfig = do
+  found <- findExecutable "fzf"
+  stdinTerminal <- hIsTerminalDevice stdin
+  stdoutTerminal <- hIsTerminalDevice stdout
+  ttyOk <- checkTtyAvailable
+  pure
+    FzfConfig
+      { fzfBinary = fromMaybe "fzf" found,
+        fzfAvailable = isJust found,
+        stdinIsTerminal = stdinTerminal,
+        stdoutIsTerminal = stdoutTerminal,
+        ttyAvailable = ttyOk
+      }
+
+checkTtyAvailable :: IO Bool
+checkTtyAvailable = do
+  opened <- try @SomeException (openFile "/dev/tty" ReadMode)
+  case opened of
+    Left _ -> pure False
+    Right handle -> hClose handle >> pure True
+
+-- | An interactive picker is possible when fzf exists and there is a terminal
+-- to draw on.
+isFzfAvailable :: FzfConfig -> Bool
+isFzfAvailable config =
+  fzfAvailable config && (stdinIsTerminal config || ttyAvailable config)
+
+-- | fzf options, combined with '<>'. Later options win for the @Maybe@ fields;
+-- boolean flags are sticky once set.
+data FzfOpts = FzfOpts
+  { fzfPrompt :: !(Maybe Text),
+    fzfHeader :: !(Maybe Text),
+    fzfHeight :: !(Maybe Text),
+    fzfPreview :: !(Maybe Text),
+    fzfAnsi :: !Bool,
+    fzfNoSort :: !Bool
+  }
+  deriving stock (Show, Eq)
+
+instance Semigroup FzfOpts where
+  earlier <> later =
+    FzfOpts
+      { fzfPrompt = fzfPrompt later <|> fzfPrompt earlier,
+        fzfHeader = fzfHeader later <|> fzfHeader earlier,
+        fzfHeight = fzfHeight later <|> fzfHeight earlier,
+        fzfPreview = fzfPreview later <|> fzfPreview earlier,
+        fzfAnsi = fzfAnsi earlier || fzfAnsi later,
+        fzfNoSort = fzfNoSort earlier || fzfNoSort later
+      }
+
+instance Monoid FzfOpts where
+  mempty =
+    FzfOpts
+      { fzfPrompt = Nothing,
+        fzfHeader = Nothing,
+        fzfHeight = Nothing,
+        fzfPreview = Nothing,
+        fzfAnsi = False,
+        fzfNoSort = False
+      }
+
+withPrompt :: Text -> FzfOpts
+withPrompt value = mempty {fzfPrompt = Just value}
+
+withHeader :: Text -> FzfOpts
+withHeader value = mempty {fzfHeader = Just value}
+
+withHeight :: Text -> FzfOpts
+withHeight value = mempty {fzfHeight = Just value}
+
+withPreview :: Text -> FzfOpts
+withPreview value = mempty {fzfPreview = Just value}
+
+withAnsi :: FzfOpts
+withAnsi = mempty {fzfAnsi = True}
+
+withNoSort :: FzfOpts
+withNoSort = mempty {fzfNoSort = True}
+
+optsToArgs :: FzfOpts -> [String]
+optsToArgs opts =
+  concat
+    [ maybe [] (\value -> ["--prompt", Text.unpack value]) (fzfPrompt opts),
+      maybe [] (\value -> ["--header", Text.unpack value]) (fzfHeader opts),
+      maybe [] (\value -> ["--height", Text.unpack value]) (fzfHeight opts),
+      maybe [] (\value -> ["--preview", Text.unpack value]) (fzfPreview opts),
+      ["--ansi" | fzfAnsi opts],
+      ["--no-sort" | fzfNoSort opts]
+    ]
+
+-- | What the user sees, paired with what the caller gets back.
+data Candidate a = Candidate
+  { candidateDisplay :: !Text,
+    candidateValue :: !a
+  }
+  deriving stock (Functor)
+
+data FzfResult a
+  = FzfSelected !a
+  | -- | Nothing to choose from, or fzf matched nothing.
+    FzfNoMatch
+  | -- | The user pressed Esc or Ctrl-C.
+    FzfCancelled
+  | FzfError !Text
+  deriving stock (Functor, Show, Eq)
+
+-- | Numbered, tab-prefixed input lines. Newlines are stripped from display text
+-- because a candidate must occupy exactly one line; tabs are preserved because
+-- they separate the display columns.
+renderCandidateLines :: [Candidate a] -> [Text]
+renderCandidateLines candidates =
+  [ Text.pack (show index) <> "\t" <> oneLine (candidateDisplay candidate)
+  | (index, candidate) <- zip [0 :: Int ..] candidates
+  ]
+  where
+    oneLine = Text.filter (\character -> character /= '\n' && character /= '\r')
+
+-- | The leading integer of fzf's output line, if there is one.
+parseSelectionIndex :: Text -> Maybe Int
+parseSelectionIndex output =
+  case Text.Read.decimal (Text.takeWhile (/= '\t') (Text.strip firstLine)) of
+    Right (index, rest) | Text.null rest -> Just index
+    _ -> Nothing
+  where
+    firstLine = Text.takeWhile (/= '\n') output
+
+-- | Wrap a string in POSIX single quotes so it can be embedded in a shell
+-- command line, such as the one given to fzf's @--preview@.
+shellQuote :: Text -> Text
+shellQuote value = "'" <> Text.replace "'" "'\\''" value <> "'"
+
+-- | Show a menu and return the chosen value. Never throws: every failure is a
+-- 'FzfError'. An empty candidate list short-circuits without spawning fzf.
+runFzf :: FzfConfig -> FzfOpts -> [Candidate a] -> IO (FzfResult a)
+runFzf config opts candidates
+  | null candidates = pure FzfNoMatch
+  | not (isFzfAvailable config) = pure (FzfError "fzf is not available")
+  | otherwise = do
+      let values = Map.fromList (zip [0 :: Int ..] (map candidateValue candidates))
+          inputLines = renderCandidateLines candidates
+          args = ["--select-1", "--delimiter", "\t", "--with-nth", "2.."] <> optsToArgs opts
+          spec =
+            (proc (fzfBinary config) args)
+              { std_in = CreatePipe,
+                std_out = CreatePipe,
+                std_err = Inherit,
+                delegate_ctlc = True
+              }
+      outcome <- try @SomeException $ do
+        (maybeIn, maybeOut, _, processHandle) <- createProcess spec
+        case (maybeIn, maybeOut) of
+          (Just inHandle, Just outHandle) -> do
+            hSetEncoding inHandle utf8
+            hSetEncoding outHandle utf8
+            mapM_ (Text.IO.hPutStrLn inHandle) inputLines
+            hClose inHandle
+            selected <- Text.IO.hGetContents outHandle
+            exitCode <- waitForProcess processHandle
+            pure (exitCode, selected)
+          _ -> fail "fzf: standard input and output pipes were not created"
+      pure $ case outcome of
+        Left exception -> FzfError (Text.pack (show exception))
+        Right (ExitSuccess, selected) ->
+          case parseSelectionIndex selected >>= (`Map.lookup` values) of
+            Just value -> FzfSelected value
+            Nothing -> FzfError ("unrecognised fzf selection: " <> Text.strip selected)
+        Right (ExitFailure 1, _) -> FzfNoMatch
+        Right (ExitFailure 130, _) -> FzfCancelled
+        Right (ExitFailure code, _) -> FzfError ("fzf exited with code " <> Text.pack (show code))
diff --git a/src/Okf/Cli/Fzf/Selector.hs b/src/Okf/Cli/Fzf/Selector.hs
new file mode 100644
--- /dev/null
+++ b/src/Okf/Cli/Fzf/Selector.hs
@@ -0,0 +1,146 @@
+-- | Interactive selection of the two things @okf show@ needs: a bundle
+-- directory and a concept inside it.
+module Okf.Cli.Fzf.Selector
+  ( BundleSelection (..),
+    ConceptSelection (..),
+    bundleSearchRootsEnvVar,
+    parseBundleSearchRoots,
+    bundleSearchRoots,
+    conceptCandidates,
+    conceptPreviewCommand,
+    selectBundle,
+    selectConcept,
+  )
+where
+
+import Data.List qualified as List
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Okf.Bundle (Concept, conceptIdOf, conceptTitle, conceptType)
+import Okf.Cli.Fzf
+import Okf.ConceptId (renderConceptId)
+import Okf.Discovery (defaultDiscoveryOptions, discoverBundleRoots)
+import System.Environment (getExecutablePath, lookupEnv)
+
+-- | Outcome of asking the user to pick a bundle.
+data BundleSelection
+  = BundleChosen !FilePath
+  | -- | Nothing was found; carries the roots that were searched so the caller
+    -- can say where it looked.
+    BundleNoCandidates ![FilePath]
+  | BundleSelectionCancelled
+  | -- | fzf is missing, or there is no terminal to draw on.
+    BundleSelectionUnavailable
+  | BundleSelectionError !Text
+  deriving stock (Show, Eq)
+
+-- | Outcome of asking the user to pick a concept.
+data ConceptSelection
+  = ConceptChosen !Concept
+  | ConceptNoCandidates
+  | ConceptSelectionCancelled
+  | ConceptSelectionUnavailable
+  | ConceptSelectionError !Text
+  deriving stock (Show, Eq)
+
+-- | Colon-separated list of directories to search for bundles, in the style of
+-- @PATH@.
+bundleSearchRootsEnvVar :: String
+bundleSearchRootsEnvVar = "OKF_BUNDLE_ROOTS"
+
+parseBundleSearchRoots :: String -> [FilePath]
+parseBundleSearchRoots raw =
+  [ Text.unpack trimmed
+  | piece <- Text.splitOn ":" (Text.pack raw),
+    let trimmed = Text.strip piece,
+    not (Text.null trimmed)
+  ]
+
+-- | Where to look for bundles: @OKF_BUNDLE_ROOTS@ when it names at least one
+-- directory, otherwise the current working directory.
+bundleSearchRoots :: IO [FilePath]
+bundleSearchRoots = do
+  configured <- lookupEnv bundleSearchRootsEnvVar
+  pure $ case configured of
+    Nothing -> ["."]
+    Just raw -> case parseBundleSearchRoots raw of
+      [] -> ["."]
+      roots -> roots
+
+selectBundle :: FzfConfig -> IO BundleSelection
+selectBundle fzfConfig
+  | not (isFzfAvailable fzfConfig) = pure BundleSelectionUnavailable
+  | otherwise = do
+      roots <- bundleSearchRoots
+      discovered <-
+        List.nub . List.sort . concat
+          <$> traverse (discoverBundleRoots defaultDiscoveryOptions) roots
+      case discovered of
+        [] -> pure (BundleNoCandidates roots)
+        bundles -> do
+          let candidates = [Candidate (Text.pack bundle) bundle | bundle <- bundles]
+              opts =
+                withPrompt "bundle> "
+                  <> withHeader "Select an OKF bundle"
+                  <> withHeight "40%"
+                  <> withNoSort
+          result <- runFzf fzfConfig opts candidates
+          pure $ case result of
+            FzfSelected bundle -> BundleChosen bundle
+            FzfNoMatch -> BundleNoCandidates roots
+            FzfCancelled -> BundleSelectionCancelled
+            FzfError message -> BundleSelectionError message
+
+selectConcept :: FzfConfig -> FilePath -> [Concept] -> IO ConceptSelection
+selectConcept fzfConfig bundlePath concepts
+  | not (isFzfAvailable fzfConfig) = pure ConceptSelectionUnavailable
+  | null concepts = pure ConceptNoCandidates
+  | otherwise = do
+      executablePath <- getExecutablePath
+      let opts =
+            withPrompt "concept> "
+              <> withHeader (Text.pack bundlePath)
+              <> withHeight "60%"
+              <> withNoSort
+              <> withPreview (conceptPreviewCommand executablePath bundlePath)
+      result <- runFzf fzfConfig opts (conceptCandidates concepts)
+      pure $ case result of
+        FzfSelected concept -> ConceptChosen concept
+        FzfNoMatch -> ConceptNoCandidates
+        FzfCancelled -> ConceptSelectionCancelled
+        FzfError message -> ConceptSelectionError message
+
+-- | One candidate per concept, displayed as three tab-separated columns --
+-- concept ID, type, title -- with the first two padded so the list lines up.
+-- Padding is safe: fzf strips leading and trailing whitespace from a field
+-- before substituting it into a preview command.
+conceptCandidates :: [Concept] -> [Candidate Concept]
+conceptCandidates concepts =
+  [ Candidate
+      { candidateDisplay =
+          Text.intercalate
+            "\t"
+            [ pad idWidth (conceptIdText concept),
+              pad typeWidth (conceptType concept),
+              fromMaybe "" (conceptTitle concept)
+            ],
+        candidateValue = concept
+      }
+  | concept <- concepts
+  ]
+  where
+    conceptIdText = renderConceptId . conceptIdOf
+    idWidth = maximum (0 : map (Text.length . conceptIdText) concepts)
+    typeWidth = maximum (0 : map (Text.length . conceptType) concepts)
+    pad width value = value <> Text.replicate (max 0 (width - Text.length value)) " "
+
+-- | The preview command fzf runs for the highlighted concept. @{2}@ is the
+-- concept ID: fzf extracts preview fields from the original input line, where
+-- field 1 is the hidden index, and it quotes the substitution itself.
+conceptPreviewCommand :: FilePath -> FilePath -> Text
+conceptPreviewCommand executablePath bundlePath =
+  shellQuote (Text.pack executablePath)
+    <> " show "
+    <> shellQuote (Text.pack bundlePath)
+    <> " {2}"
diff --git a/src/Okf/Cli/Help.hs b/src/Okf/Cli/Help.hs
--- a/src/Okf/Cli/Help.hs
+++ b/src/Okf/Cli/Help.hs
@@ -48,6 +48,7 @@
     HelpTopic "format" "Bundle layout, concept IDs, frontmatter, and links" formatTopicContent,
     HelpTopic "validation" "How bundles are validated and referential integrity" validationTopicContent,
     HelpTopic "profiles" "Checking a bundle against house conventions" profilesTopicContent,
+    HelpTopic "interactive" "Picking a bundle and concept with fzf" interactiveTopicContent,
     HelpTopic "config" "Config files, defaults, and agent settings" configTopicContent,
     HelpTopic "kit" "Installing and publishing agent skills and subagents" kitTopicContent,
     HelpTopic "agents" "Installing agent skills and launching assist" agentsTopicContent
@@ -64,6 +65,9 @@
 
 profilesTopicContent :: Text
 profilesTopicContent = $(embedStringFile "help/profiles.md")
+
+interactiveTopicContent :: Text
+interactiveTopicContent = $(embedStringFile "help/interactive.md")
 
 configTopicContent :: Text
 configTopicContent = $(embedStringFile "help/config.md")
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -4,10 +4,15 @@
 import Control.Monad (unless)
 import Data.Text qualified as Text
 import Data.Text.IO qualified as Text.IO
+import Okf.Bundle (conceptFromDocument)
 import Okf.Cli
 import Okf.Cli.Assist (AssistOptions (..), buildClaudeCommand)
 import Okf.Cli.Config (AssistSettings (..), ConfigSource (..), KitSettings (..), OkfConfig (..), OkfProvider (..), defaultOkfConfig, exampleConfigText, findConfigSource, loadOkfConfig, okfConfigEnvVar, projectConfigPath)
+import Okf.Cli.Fzf (Candidate (..), FzfOpts (..), optsToArgs, parseSelectionIndex, renderCandidateLines, shellQuote, withAnsi, withHeight, withNoSort, withPrompt)
+import Okf.Cli.Fzf.Selector (conceptCandidates, conceptPreviewCommand, parseBundleSearchRoots)
 import Okf.Cli.Help (HelpTopic (..), helpTopics)
+import Okf.ConceptId (parseConceptId)
+import Okf.Document (parseDocument)
 import Options.Applicative
 import System.Directory (createDirectoryIfMissing, getCurrentDirectory, getTemporaryDirectory, removeDirectoryRecursive, withCurrentDirectory)
 import System.Environment (lookupEnv, setEnv, unsetEnv)
@@ -84,7 +89,44 @@
                       }
               },
           parseSucceeds ["graph", "bundle", "--json"],
-          parseSucceeds ["show", "bundle", "tables/orders"],
+          parseShowMatches
+            ["show", "bundle", "tables/orders"]
+            ShowOptions
+              { bundlePath = Just "bundle",
+                conceptIdText = Just "tables/orders",
+                profilePath = Nothing
+              },
+          parseShowMatches
+            ["show", "b", "ADR-2", "--profile", "p.dhall"]
+            ShowOptions
+              { bundlePath = Just "b",
+                conceptIdText = Just "ADR-2",
+                profilePath = Just "p.dhall"
+              },
+          parseShowMatches
+            ["show"]
+            ShowOptions {bundlePath = Nothing, conceptIdText = Nothing, profilePath = Nothing},
+          parseShowMatches
+            ["show", "bundle"]
+            ShowOptions {bundlePath = Just "bundle", conceptIdText = Nothing, profilePath = Nothing},
+          parseShowMatches
+            ["show", "--profile", "p.dhall"]
+            ShowOptions {bundlePath = Nothing, conceptIdText = Nothing, profilePath = Just "p.dhall"},
+          parseIdMatches
+            ["id", "next", "b", "ADR", "--profile", "p.dhall"]
+            IdOptions
+              { bundlePath = "b",
+                profilePath = "p.dhall",
+                idSub = IdNext "ADR"
+              },
+          parseIdMatches
+            ["id", "list", "b", "--profile", "p.dhall"]
+            IdOptions
+              { bundlePath = "b",
+                profilePath = "p.dhall",
+                idSub = IdList
+              },
+          parseFails ["id", "next", "b", "ADR"],
           parseSucceeds ["completions", "bash"],
           parseSucceeds ["completions", "zsh"],
           parseSucceeds ["completions", "fish"],
@@ -111,6 +153,28 @@
           parseSucceeds ["help", "format"],
           any ((== "okf") . topicName) helpTopics,
           all (not . Text.null . topicContent) helpTopics,
+          optsToArgs (withPrompt "bundle> " <> withHeight "40%" <> withNoSort)
+            == ["--prompt", "bundle> ", "--height", "40%", "--no-sort"],
+          optsToArgs mempty == [],
+          fzfPrompt (withPrompt "first" <> withPrompt "second") == Just "second",
+          fzfNoSort (withNoSort <> mempty) && fzfAnsi (mempty <> withAnsi),
+          renderCandidateLines [Candidate "alpha" (), Candidate "beta" ()]
+            == ["0\talpha", "1\tbeta"],
+          renderCandidateLines [Candidate "two\nlines" ()] == ["0\ttwolines"],
+          parseSelectionIndex "2\ttables/orders\tTable\n" == Just 2,
+          parseSelectionIndex "" == Nothing,
+          parseSelectionIndex "not-a-number\tx" == Nothing,
+          shellQuote "plain" == "'plain'",
+          shellQuote "it's" == "'it'\\''s'",
+          parseBundleSearchRoots "/a:/b" == ["/a", "/b"],
+          parseBundleSearchRoots "" == [],
+          parseBundleSearchRoots " /a : : /b " == ["/a", "/b"],
+          conceptPreviewCommand "/usr/local/bin/okf" "my bundle"
+            == "'/usr/local/bin/okf' show 'my bundle' {2}",
+          conceptPreviewCommand "/opt/o'kf/okf" "b"
+            == "'/opt/o'\\''kf/okf' show 'b' {2}",
+          sampleConceptDisplays
+            == ["tables/orders\tTable\tOrders", "x            \t     \t"],
           parseShowsInfo ["--version"],
           parseFails ["hello"],
           logAddWrites,
@@ -123,6 +187,20 @@
         ]
   unless (and results) exitFailure
 
+-- | Two concepts whose ID and type widths differ, so the column padding in
+-- 'conceptCandidates' is actually exercised: the ID column pads to the width of
+-- @tables/orders@ and the type column to the width of @Table@. The second
+-- concept has a null @type@ and no @title@, which project to empty text.
+sampleConceptDisplays :: [Text.Text]
+sampleConceptDisplays = map candidateDisplay (conceptCandidates [longConcept, shortConcept])
+  where
+    longConcept = buildConcept "tables/orders" "---\ntype: Table\ntitle: Orders\n---\n\n# Orders\n"
+    shortConcept = buildConcept "x" "---\ntype:\n---\n\n# x\n"
+    buildConcept idText source =
+      case (parseConceptId idText, parseDocument source) of
+        (Right conceptId, Right document) -> conceptFromDocument conceptId document
+        _ -> error ("sample concept did not parse: " <> Text.unpack idText)
+
 parseSucceeds :: [String] -> Bool
 parseSucceeds args =
   case execParserPure defaultPrefs parserInfo args of
@@ -142,6 +220,18 @@
 parseLogMatches args expected =
   case execParserPure defaultPrefs parserInfo args of
     Success (Options (Log opts)) -> opts == expected
+    _ -> False
+
+parseIdMatches :: [String] -> IdOptions -> Bool
+parseIdMatches args expected =
+  case execParserPure defaultPrefs parserInfo args of
+    Success (Options (Id opts)) -> opts == expected
+    _ -> False
+
+parseShowMatches :: [String] -> ShowOptions -> Bool
+parseShowMatches args expected =
+  case execParserPure defaultPrefs parserInfo args of
+    Success (Options (ShowConcept opts)) -> opts == expected
     _ -> False
 
 testLogAddWritesFile :: IO Bool
