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.
Count.pushbutton\
script.py # required: no script.py, no button
bundle.yaml # optional: metadata only
icon.png # optionalThe 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:
- Split the text on
\n. Trim a trailing\r, then trim whitespace from both ends of the line. - Skip the line if it is now empty, or if it starts with
#. - 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. - Otherwise find the first
:. If there is none, or it is the first character, skip the line. - The key is everything before that colon, trimmed and lowercased.
- The value is everything after that colon, trimmed.
- 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. - 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.
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.
| Line | Key | Value | Why |
|---|---|---|---|
title: Export # for CSV | title |
Export # for CSV |
Trailing comments are not a thing. Only whole-line comments are. |
title: Export | title |
Export |
The line is trimmed first, so indentation carries no meaning. An indented line is a top-level key. |
TITLE: Export | title | Export |
Keys are lowercased. Title:, TITLE: and
tItLe: are the same key. |
tooltip: Ratio is 3:1 | tooltip |
Ratio is 3:1 |
The split is on the first colon, so colons in prose are safe. |
title: "Export" | title | Export |
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 Export | none | none | No colon, so the line is discarded silently. |
: Export | none | none | The colon is at index 0, which the parser treats as no key. |
title: A then title: B | title |
B | Duplicate keys overwrite. The last one wins. |
title: | title | empty 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. |
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
- itemlines is a block list, andlayout:is the only key that reads one. A- itemline 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.
# Export the current selection title: Export points tooltip: Writes every selected item to a CSV file. engine: ironpython shortcut: Ctrl+Shift+E keytip: EP
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.
| Key | Falls back to | What 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. |
shortcut | nothing | The author's default chord, for example Ctrl+Shift+E. Users can rebind
or disable it. See Shortcuts and keytips. |
keytip | generated from the title | The letters shown for Alt navigation of the ribbon, uppercased. |
context | nothing; absent means always enabled | Greys the button out unless a document condition holds. See below. |
min_host_version | nothing; absent means no floor | Oldest Navisworks release this button supports, as a year. See below. |
max_host_version | nothing; 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. |
status | nothing; 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. |
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:
| Condition | True when |
|---|---|
doc | A document is open with at least one model appended. |
selection | The current selection is not empty. |
clash-tests | Clash Detective has at least one test. |
clash-results | At least one clash test has at least one result. |
viewpoints | The document has at least one saved viewpoint or folder. |
selection-sets | The document has at least one saved selection set. |
multi-model | More 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.
context: doc
context: selection & clash-tests
context: viewpoints | selection-sets
context: selection & !multi-modelA 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:
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:
Bundle 'D:\NavisTools\...\Export.pushbutton': bad context rule - Unexpected '&' in context
rule '& selection'. Button stays always enabled.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.
title: New clash API demo
min_host_version: 2024A ceiling on its own, for a tool that drives something the host later removed:
title: Legacy clash report
max_host_version: 2025Or both, for a tool that only works inside one window of releases:
title: Bridge export
min_host_version: 2024
max_host_version: 2026An 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:
Requires Navisworks 2024 or newer (this is 2023).
Requires Navisworks 2026 or older (this is 2027).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:
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:
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 readsName β 🡅 ●. Unlike those two it has no switch inconfig.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 readsExperimental: Groups clash results by the element they share.A bundle with no tooltip shows the bare wordExperimental. 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:
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.
| Key | Falls back to | What it does |
|---|---|---|
url | nothing; required | The address opened on click. Missing or blank and the bundle is dropped. |
title | the folder name, prefix stripped | The button caption. |
tooltip | the url value itself |
Hover tooltip. There is no docstring to fall back to. |
keytip | generated from the title | Alt-navigation letters. |
status | nothing | experimental marks the caption and prefixes the tooltip; see
status above. |
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.
| Key | Falls back to | What it does |
|---|---|---|
plugin | nothing; required | The other add-in's plugin id. Missing or blank and the bundle is dropped. |
title | the folder name, prefix stripped | The button caption. |
tooltip | nothing | Hover tooltip. There is no docstring and no fallback. |
keytip | generated from the title | Alt-navigation letters. |
status | nothing | experimental marks the caption and prefixes the tooltip; see
status above. |
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.
| Key | Falls back to | What it does |
|---|---|---|
title | the folder name, prefix stripped | The ribbon toggle's caption. |
tooltip | nothing | Hover tooltip. There is no docstring to fall back to. |
engine | the extension's engine, then
ironpython |
The engine script.py runs under, when the bundle has one. |
keytip | generated from the title | Alt-navigation letters. Assigned to the ribbon toggle exactly like any other button's. |
shortcut | nothing | 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. |
status | nothing | 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.
| Key | Falls back to | What it does |
|---|---|---|
name | The folder name minus .extension |
Identifies the extension and feeds the generated tab ids. |
engine | ironpython |
The default engine for every pushbutton in the extension. A bundle's own
engine still overrides it. |
layout | Tab folders in name order | The order of the extension's *.tab folders, as a block list. See
below. |
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.
layout:
- Remember
- Recall
- Set
- Step
- MoreThe 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:
RecallandRecall.pushbuttonboth match. Case is ignored, and so is a legacyNN_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.
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.
| Folder | Config file | Title comes from |
|---|---|---|
*.tab | bundle.yaml, layout: only |
The folder name |
*.panel | bundle.yaml, layout: only |
The folder name |
*.stack | bundle.yaml, layout: only |
Nothing: a stack has no visible label |
*.slideout | bundle.yaml, layout: only |
Nothing: the flyout is labelled by the panel |
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.
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.
__title__ = 'Export points'
__title__="Export points"
__title__ = 'Export points' # the trailing comment is fine here __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 signNothing 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.
# Licence header comments are skipped.
"""Exports the current selection to CSV."""
from pynavis import selectionfrom pynavis import selection
"""Exports the current selection to CSV."""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.
| Key | Status in pyNavis |
|---|---|
author | Not 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#
# 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