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. Find the first :. If there is none, or it is the first character, skip the line.
  4. The key is everything before that colon, trimmed and lowercased.
  5. The value is everything after that colon, trimmed.
  6. 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.
  7. Store it. A key seen twice keeps the last value.

That is all of it. There is no escape processing, no type coercion, no schema, and no error reporting. 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 no concept of structure. It sees lines, a first colon, and two strings.

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.
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. A line of - item 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, options is an empty string, and the two list lines vanish as well. Nothing warns you.

The keys a pushbutton reads#

Exactly eight, and nothing else in the file is looked at. The same eight 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 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.
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.

The keys a pulldown reads#

A *.pulldown folder may carry its own bundle.yaml describing the menu button itself. It reads three keys: title, tooltip and keytip. 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 three 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 four 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.
%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.
%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 five 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.

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 two 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.
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.

Folders that read nothing#

Four folder kinds have no configuration file at all, and putting one there is a common way to lose an afternoon.

FolderConfig fileTitle comes from
*.tabNoneThe folder name
*.panelNoneThe folder name
*.stackNoneNothing: a stack has no visible label
*.slideoutNone Nothing: the flyout is labelled by the panel
bundle.yaml inside a .stack or a .slideout is read by nothing

Stacks are parsed by walking straight to the *.pushbutton folders inside them, and a slideout is parsed by walking straight to the panel items inside it. Neither folder's own bundle.yaml is ever opened, and neither 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; rename the folder instead.

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 01_Count → Count 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, betaNot read. There are no badges.
layout, collapsed, panel background Not read. Panel layout comes from folder names and ordering only.
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\01_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