Bundles

The bundle.yaml reference

Every key the runtime reads, what it falls back to, and the exact rules of the very small parser that reads the file.

A bundle is a folder. bundle.yaml is the optional file inside it that names the button, describes it, picks its engine and binds its keys. Nothing in it is required: a bundle with no bundle.yaml at all still produces a working button, because every value has a fallback.

folders
Count.pushbutton\
  script.py       # required: no script.py, no button
  bundle.yaml     # optional: metadata only
  icon.png        # optional

The file is read once, at discovery time. Change it and click Reload on the pyNavis panel; there is no need to restart Navisworks.

It is not YAML#

This is the single most useful thing to know about the file. pyNavis does not use a YAML library. It uses a forty-line reader that walks the file one line at a time and produces a flat dictionary of strings. The name and the .yaml extension are a convention inherited from pyRevit, not a promise of YAML semantics.

The whole algorithm, in order:

  1. Split the text on \n. Trim a trailing \r, then trim whitespace from both ends of the line.
  2. Skip the line if it is now empty, or if it starts with #.
  3. If the previous key had nothing after its colon and this line starts with - , the rest of the line is one item of that key's block list. One matching layer of quotes is removed from the item, exactly as for a value below, and the item is appended. Nothing else on this line is looked at.
  4. Otherwise find the first :. If there is none, or it is the first character, skip the line.
  5. The key is everything before that colon, trimmed and lowercased.
  6. The value is everything after that colon, trimmed.
  7. If the value is two characters or longer and starts and ends with the same quote character, " or ', remove exactly one character from each end.
  8. Store it. A key seen twice keeps the last value. An empty value opens a block list for the - lines that follow; any other line closes it.

That is all of it. There is no escape processing, no type coercion, no schema, and no error reporting. The block list is the one shape of structure the reader knows, and layout: is the only key that reads one. A file that cannot be read, or that does not exist, yields an empty dictionary and the bundle falls back on its defaults.

The line in the file What the parser stores title : Export # CSV title Export # CSV A trailing comment is part of the value. tooltip : Ratio is 3:1 tooltip Ratio is 3:1 Only the first colon splits; later ones are text. engine : 'cpython' engine cpython Indentation is trimmed away; one quote layer is removed. # engine: cpython Nothing. A line starting with # is skipped whole. Highlighted left to right: the key, lowercased; the first colon, which is the only separator; and the value, trimmed. Everything the parser does happens on one line, in that order.
The parser has almost no concept of structure. It sees lines, a first colon, and two strings; the block list under an empty value is the one exception.

Inputs that surprise people#

Most of these are consequences of the rules above rather than special cases, but they are worth reading once so you recognise them when a value comes out wrong.

LineKeyValueWhy
title: Export # for CSVtitle Export # for CSV Trailing comments are not a thing. Only whole-line comments are.
    title: Exporttitle Export The line is trimmed first, so indentation carries no meaning. An indented line is a top-level key.
TITLE: ExporttitleExport Keys are lowercased. Title:, TITLE: and tItLe: are the same key.
tooltip: Ratio is 3:1tooltip Ratio is 3:1 The split is on the first colon, so colons in prose are safe.
title: "Export"titleExport One matching layer of quotes is removed.
title: ""Export""title "Export" Exactly one layer, never two, and there is no escape syntax.
tooltip: "A" or "B"tooltip A" or "B The test is only that the first and last characters match. They do not have to be a pair.
title: "Export'title "Export' Mismatched quotes are left alone and become part of the value.
title Exportnonenone No colon, so the line is discarded silently.
: Exportnonenone The colon is at index 0, which the parser treats as no key.
title: A then title: Btitle BDuplicate keys overwrite. The last one wins.
title:titleempty string The key exists with an empty value, which is not the same as being absent. It also opens a block list, so any - lines under it become its items.
An empty value still counts

title: with nothing after it gives you a button labelled with an empty string, not a button labelled from the folder name. Fallbacks only apply when the key is missing, not when it is present and blank. If you want the default back, delete the line.

What the file cannot express#

Because the parser only ever produces flat string pairs, none of the following work. They do not raise an error either; they parse into keys you will never read again.

  • Nesting. An indented block is just more top-level keys, and the parent line becomes a key with an empty value.
  • Lists, except one shape. A key with nothing after its colon followed by - item lines is a block list, and layout: is the only key that reads one. A - item line anywhere else has no colon and is dropped.
  • Multi-line values. There is no | or > handling. A tooltip is one line.
  • Anchors, aliases, tags, documents. None of the YAML machinery exists.
  • Types. Every value is a string. There are no booleans, numbers or nulls.
Do
# Export the current selection
title: Export points
tooltip: Writes every selected item to a CSV file.
engine: ironpython
shortcut: Ctrl+Shift+E
keytip: EP
Don't
title: Export points  # to CSV
tooltip: >
  Writes every selected
  item to a CSV file.
options:
  - csv
  - json

In the second file the title keeps the comment, tooltip is literally >, the two prose lines vanish for having no colon, and options parses as a block list of two items that no key reads, so it is stored and ignored. Nothing warns you.

The keys a pushbutton reads#

Exactly nine, and nothing else in the file is looked at. The same nine apply to the three kinds that are parsed as pushbuttons: *.nobutton, *.toggle and *.smartbutton, wherever they sit, including inside a *.slideout. context: is the one key a nobutton reads to no effect, because greying needs a rendered control.

KeyFalls back toWhat it does
title __title__ in the script, then the folder name with any legacy NN_ prefix removed and underscores turned into spaces The caption on the button. A literal \n breaks it onto a second line, see below.
tooltip The script's module docstring, then nothing The hover tooltip. The ribbon appends the resolved shortcut in brackets, so a bound tool shows Writes a CSV. (Ctrl+Shift+E).
engine The extension's engine, then ironpython ironpython or cpython, matched case-insensitively.
shortcutnothing The author's default chord, for example Ctrl+Shift+E. Users can rebind or disable it. See Shortcuts and keytips.
keytipgenerated from the title The letters shown for Alt navigation of the ribbon, uppercased.
contextnothing; absent means always enabled Greys the button out unless a document condition holds. See below.
min_host_versionnothing; absent means no floor Oldest Navisworks release this button supports, as a year. See below.
max_host_versionnothing; absent means no ceiling Newest Navisworks release this button supports, as a year. See below.
title_on, tooltip_on nothing; absent means one caption and tooltip for both states *.toggle only: the caption and tooltip while the toggle is on, so a button named for an action can name the other one once pressed. See Buttons.
statusnothing; absent means a finished tool experimental marks the tool as one: a β joins the caption and the tooltip opens with Experimental:. Read on every kind that has a caption, pulldowns and dock panes included. See below.
A bad engine id fails at click time, not at load time

Discovery does not validate the engine name. engine: python3 builds a perfectly normal-looking button; clicking it raises Unknown engine 'python3'. into the output window and nothing runs. If a tool does nothing but open a window with that message, check this line first.

context: enabling and disabling a button#

Without a context: key a button is always enabled, exactly as before. With one, the runtime reads a small boolean expression and re-evaluates it against the live document on every hub event, flipping the button's enabled state to match. It never blocks a click by itself; it only decides whether the button can be clicked at all.

Seven condition names, answered from one snapshot of the active document:

ConditionTrue when
docA document is open with at least one model appended.
selectionThe current selection is not empty.
clash-testsClash Detective has at least one test.
clash-resultsAt least one clash test has at least one result.
viewpointsThe document has at least one saved viewpoint or folder.
selection-setsThe document has at least one saved selection set.
multi-modelMore than one model is appended to the document.

Combine names with & (and), | (or) and ! (not). ! binds tightest, then &, then |, and both binary operators are left-associative. There is no way to write parentheses: ( is not part of the vocabulary and produces Unexpected character '(' in context rule. Split the rule across two buttons, or simplify the condition, when a different grouping is genuinely needed. The word forms and, or and not, and the doubled && and ||, are not accepted either.

bundle.yaml
context: doc
context: selection & clash-tests
context: viewpoints | selection-sets
context: selection & !multi-model

A condition name that does not match one of the seven above is legal syntax and evaluates to false forever, but discovery says so rather than staying silent:

%APPDATA%\pyNavis\logs\pyNavis.log
Bundle 'D:\NavisTools\...\Export.pushbutton': unknown context condition 'selction'.

A rule that fails to parse outright, for instance a stray leading operator, is also logged, and the button falls back to always enabled rather than never enabled:

%APPDATA%\pyNavis\logs\pyNavis.log
Bundle 'D:\NavisTools\...\Export.pushbutton': bad context rule - Unexpected '&' in context
rule '& selection'. Button stays always enabled.
One snapshot per event, not one per button

The seven conditions are read into a set once per hub event, and every gated button on the ribbon is checked against that same set. Gating a hundred buttons costs one pass over the document, not a hundred.

min_host_version and max_host_version: gating on the Navisworks release#

Two keys, each a bare year. Either can be used alone, and neither is required.

Clash_Demo.pushbutton\bundle.yaml
title: New clash API demo
min_host_version: 2024

A ceiling on its own, for a tool that drives something the host later removed:

Legacy_Report.pushbutton\bundle.yaml
title: Legacy clash report
max_host_version: 2025

Or both, for a tool that only works inside one window of releases:

Bridge.pushbutton\bundle.yaml
title: Bridge export
min_host_version: 2024
max_host_version: 2026

An out-of-range button still renders exactly like any other: it is not greyed out and it is not hidden. The check happens on click, before the script runs, and shows a warning toast in place of running it:

text
Requires Navisworks 2024 or newer (this is 2023).
Requires Navisworks 2026 or older (this is 2027).
The block happens at click time, exactly like a bad engine id

A version-gated button that can never run on the installed release still looks completely normal on the ribbon; there is no visual difference until someone clicks it. There is no context condition for "the running host year" either, so combine min_host_version/max_host_version with context: when you want both a greyed-out state and a version floor.

A value that is not a whole number is logged and ignored, leaving that side of the range open rather than failing the bundle. The test is a plain integer parse, so 2024 works and 2024.1, v2024 and 20.0 do not:

%APPDATA%\pyNavis\logs\pyNavis.log
Bundle 'D:\NavisTools\...\Export.pushbutton': min_host_version 'v2024' is not a year - ignored.

The host's own year is derived from the Navisworks API assembly version. On the rare install where that cannot be read, the gate is disabled entirely and every button runs, because refusing to run on a host you cannot identify is the worse failure. The gate also covers only the two runs that go through the script executor, a click or a chord: a *.smartbutton's build-time run and a *.dockpane's script.py are not version checked.

status: marking a tool experimental#

One value is accepted, experimental, matched case-insensitively. It says the tool works but has not earned the trust of a finished one, and the ribbon says so in two places without touching the icon:

Clash_Grouper.pushbutton\bundle.yaml
title: Clash grouper
tooltip: Groups clash results by the element they share.
status: experimental
  • The caption gains a Greek beta after the name: Clash grouper β. It sits on the last line of a wrapped caption and goes before the Shift+Click and shortcut markers, so a caption carrying all three reads Name β 🡅 ●. Unlike those two it has no switch in config.json: it is the author's statement about the tool, not a hint the user tunes.
  • The tooltip opens with Experimental: , so the one above reads Experimental: Groups clash results by the element they share. A bundle with no tooltip shows the bare word Experimental. A shortcut still follows in brackets.

The key works on every kind with a caption: *.pushbutton, *.toggle (the title_on/tooltip_on wording is marked too), *.smartbutton (a title or tooltip set through __button__ keeps the mark), *.nobutton, *.pulldown, *.splitbutton, *.splitpushbutton, *.urlbutton, *.linkbutton and *.dockpane. On a pulldown it marks the header only; mark each child in its own bundle.yaml. Nothing else changes: the tool runs, binds shortcuts and logs exactly as an unmarked one does.

Any other value is reported as a bundle problem and has no effect:

%APPDATA%\pyNavis\logs\pyNavis.log
Bundle 'D:\NavisTools\...\Export.pushbutton': status 'beta' is not experimental - ignored.

The keys a pulldown reads#

A *.pulldown folder may carry its own bundle.yaml describing the menu button itself. It reads five keys: title, tooltip, keytip, status (see status above), and layout, which orders the menu rows (see layout below). shortcut and engine are ignored there, because a pulldown does not run anything; only the pushbuttons inside it do.

*.splitbutton and *.splitpushbutton are parsed exactly the same way and read the identical five keys. Buttons, stacks and pulldowns covers what makes their header click different from a plain pulldown's.

The keys a urlbutton reads#

A *.urlbutton has no script.py, so engine and shortcut are not read: there is no code for either of them to apply to. It reads five keys, one of them required.

KeyFalls back toWhat it does
urlnothing; required The address opened on click. Missing or blank and the bundle is dropped.
titlethe folder name, prefix stripped The button caption.
tooltipthe url value itself Hover tooltip. There is no docstring to fall back to.
keytipgenerated from the title Alt-navigation letters.
statusnothing experimental marks the caption and prefixes the tooltip; see status above.
%APPDATA%\pyNavis\logs\pyNavis.log
Urlbutton 'D:\NavisTools\...\Documentation.urlbutton' has no url: in bundle.yaml - skipped.

The keys a linkbutton reads#

A *.linkbutton also has no script.py. Its required key is plugin, the target add-in's id in Id.DeveloperId form, the same identity Navisworks itself uses to find a registered plugin.

KeyFalls back toWhat it does
pluginnothing; required The other add-in's plugin id. Missing or blank and the bundle is dropped.
titlethe folder name, prefix stripped The button caption.
tooltipnothing Hover tooltip. There is no docstring and no fallback.
keytipgenerated from the title Alt-navigation letters.
statusnothing experimental marks the caption and prefixes the tooltip; see status above.
%APPDATA%\pyNavis\logs\pyNavis.log
Linkbutton 'D:\NavisTools\...\Legacy_Tool.linkbutton' has no plugin: in bundle.yaml - skipped.

The keys a dockpane reads#

A *.dockpane renders as a ribbon toggle rather than a button that runs and returns, but its bundle.yaml is parsed by the same reader and reads six keys. There is no width or height key: a pane's size belongs to the slot it lands in, identical for all five compile-time slots, not to the bundle that borrows one.

KeyFalls back toWhat it does
titlethe folder name, prefix stripped The ribbon toggle's caption.
tooltipnothing Hover tooltip. There is no docstring to fall back to.
enginethe extension's engine, then ironpython The engine script.py runs under, when the bundle has one.
keytipgenerated from the title Alt-navigation letters. Assigned to the ribbon toggle exactly like any other button's.
shortcutnothing Parsed and stored, but not wired to anything: ShortcutManager dispatches through ScriptExecutor.Run, which a dockpane click never goes through. Treat this key as reserved, not as a working chord, in this release.
statusnothing experimental marks the caption and prefixes the tooltip; see status above.

pane.xaml in the bundle folder is required; a folder without one is skipped (Dockpane '<dir>' has no pane.xaml - skipped.). script.py is optional, unlike a pushbutton where its absence is what makes the folder invisible. Buttons, stacks and pulldowns covers what the toggle does and what __pane__ exposes.

The keys an extension reads#

An *.extension folder may carry extension.yaml, a different filename read by the same parser. It has three keys.

KeyFalls back toWhat it does
nameThe folder name minus .extension Identifies the extension and feeds the generated tab ids.
engineironpython The default engine for every pushbutton in the extension. A bundle's own engine still overrides it.
layoutTab folders in name order The order of the extension's *.tab folders, as a block list. See below.
Engine Count.pushbutton bundle.yaml, engine: if absent MyTools.extension extension.yaml, engine: if absent ironpython the built-in default Set the extension default once when a whole extension needs CPython, and override a single bundle when only one tool does.
The same two-step fallback applies to every pushbutton in the extension.

layout: ordering the folders inside a container#

Every container reads one more key, layout, from its own file: the tabs of an extension from extension.yaml, and the folders inside a *.tab, *.panel, *.stack, *.pulldown, *.splitbutton, *.splitpushbutton or *.slideout from a bundle.yaml in that folder. Its value is a block list of child folder names, in the order they should appear.

Selection.panel\bundle.yaml
layout:
  - Remember
  - Recall
  - Set
  - Step
  - More

The block list is the one piece of structure the reader understands: a key with nothing after its colon, followed by lines that start with - . Quotes around an item are optional, and one matching layer is removed exactly as for a value. A # line between items is skipped and the list carries on. The next line that is neither an item nor a comment closes the list.

How the list is applied:

  • Listed children come first, in list order.
  • Unlisted children follow in case-insensitive name order, so a forgotten entry never hides a tool.
  • An entry may name the folder with or without its kind suffix: Recall and Recall.pushbutton both match. Case is ignored, and so is a legacy NN_ prefix on either side.
  • An entry that names no folder in the container is logged, counted into the extension's problem list on the same channel as a mistyped suffix, and skipped.
%APPDATA%\pyNavis\logs\pyNavis.log
The layout in 'D:\NavisTools\...\Selection.panel' names 'Recal', but no such folder is there - ignored.

Without a layout: list the folders sort by name, which is how older extensions ordered themselves, with an NN_ prefix on every folder. That still works. Anatomy of an extension covers the rules in full.

Folders that read only a layout list#

Four folder kinds read a single key, layout, from their bundle.yaml and nothing else. Putting a title or an icon there is a common way to lose an afternoon.

FolderConfig fileTitle comes from
*.tabbundle.yaml, layout: only The folder name
*.panelbundle.yaml, layout: only The folder name
*.stackbundle.yaml, layout: only Nothing: a stack has no visible label
*.slideoutbundle.yaml, layout: only Nothing: the flyout is labelled by the panel
bundle.yaml inside a .stack or a .slideout is read for layout: and nothing else

A stack is parsed by reading its layout: list, if there is one, and then walking to the *.pushbutton folders inside it; a slideout is parsed the same way over the panel items inside it. No other key in that bundle.yaml is read, and neither folder is looked at for an icon. If you want a title on a stacked row, put it in the pushbutton's bundle.yaml, one level down. There is also no panel.yaml or tab.yaml: a tab or a panel is titled by its folder name, and its bundle.yaml carries only the list.

How title and tooltip actually resolve#

Metadata resolves most specific first: bundle.yaml, then the script, then the folder name. The two chains are not the same length, which matters.

Title always ends with a value bundle.yaml title: then script.py __title__ = '...' then folder name Fit_All → Fit All Tooltip may end with nothing at all bundle.yaml tooltip: then script.py module docstring then no tooltip the button has none The shortcut is appended to whatever tooltip survives, so a bound tool with no tooltip still shows its chord.
Title has three sources and cannot fail. Tooltip has two, and a bundle with neither simply has no tooltip.

Breaking a long caption onto two lines#

A large button is as wide as its caption, so four long names can eat half the ribbon. A literal \n in the title breaks it:

title: Export\nViewpoints

The break applies to the large vertical caption only: on the ribbon the button reads Export over Viewpoints, while everywhere the name appears as text, it stays one line with the break as a single space. That includes the log, toasts, the output window title, the generated keytip letters, the Shortcuts editor and __title__ inside the script. Spaces around the break are optional, so Export \n Viewpoints gives the same two lines.

A stacked or menu row draws its caption beside the icon rather than under it, so those never wrap: the same bundle used in a .stack shows the one-line form. Use one break, not several; the ribbon gives a large button room for two lines.

How the script is scanned#

When bundle.yaml does not supply a title or tooltip, the runtime reads script.py as text and looks for two things with a regular expression and a small hand-written scan. It is not a Python parser, and the difference shows.

__title__#

The pattern is an assignment to __title__ that starts at column zero, using a single or double quoted string. That is the entire grammar.

works
__title__ = 'Export points'
__title__="Export points"
__title__  =  'Export points'     # the trailing comment is fine here
silently does not work
    __title__ = 'Export points'   # indented: the pattern is anchored to column zero
__title__ = """Export points"""   # triple quotes are not matched
__title__ = 'Export ' + 'points'  # only the first literal is taken: "Export "
__title__ = f'Export {kind}'      # the f is not a quote, so no match at all
__title__ = TITLES['export']      # no quote after the equals sign

Nothing reports these. The button falls back to the folder name and looks like it ignored your script.

The docstring#

The scan skips blank lines and lines starting with #, then requires the next content to open with """ or '''. It takes everything up to the next occurrence of the same delimiter and trims it. If the first real statement is anything else, there is no docstring, and the search stops there: a triple-quoted string further down the file is never found.

works
# Licence header comments are skipped.

"""Exports the current selection to CSV."""
from pynavis import selection
silently does not work
from pynavis import selection

"""Exports the current selection to CSV."""
Use the docstring, and reach for bundle.yaml when you need more

A one-line docstring is the cheapest possible tooltip and keeps the description next to the code it describes. Move to tooltip: when the tooltip and the docstring want to say different things, or when the docstring is long enough to make an unwieldy tooltip: the tooltip is a single line either way, so long text just gets awkward.

Keys that do nothing#

Every key in the file is parsed, but only the ones listed above are ever read. Anything else sits in a dictionary that nobody queries. This matters most if you are arriving from pyRevit, because several familiar keys have no equivalent here.

KeyStatus in pyNavis
authorNot read anywhere. There is no author display.
min_navis, max_navis, version Not read. The real keys are min_host_version and max_host_version, both a bare year; see above.
highlight, beta Not read. The one badge-like key is status: experimental, a caption marker and tooltip prefix rather than a badge on the icon; see above.
layout on a pushbutton Not read there. A pushbutton has no children to order; the key is read on containers only, as a plain list of folder names. See above.
collapsed, panel background Not read. A panel is the folder tree and its layout: list, and nothing collapses.
modules Not read. The search path is the bundle folder plus the extension's lib\.
help_url, hyperlink Not read. Link to your docs from a toast or the output window instead.

None of these produce a warning, in the log or anywhere else. If a key is not in the tables on this page, assume it does nothing.

A complete example#

Survey.tab\Export.panel\Points.pushbutton\bundle.yaml
# Points exporter. Comments have to live on their own line.
title: Export points
tooltip: Writes every selected item to a CSV file, one row per item.
engine: ironpython
shortcut: Ctrl+Shift+E
keytip: EP