Canonical reference. If this document and scriptree/core/io.py
disagree, the code wins — open an issue and fix the docs.
Schema version — single source of truth
The current
schema_versionvalue is theSCHEMA_VERSIONconstant inscriptree/core/model.py. Read it from the constant. Do not copy the number out of this doc's title, JSON sketch below, or any example — those have shipped stale at least once when the schema rolled (LLM session wrote v2 after v3 was released; loader hard-rejected it). The same constant is shared by.scriptreeand.scriptreetreefiles (bothToolDefandTreeDefdefault toSCHEMA_VERSIONat construction). The loader hard-rejects files whoseschema_versionis above the current value (forward- compat tripwire); files below it load with in-memory upgrade unless the comments incore/model.pysay otherwise.
{
"schema_version": "<int — current SCHEMA_VERSION from scriptree/core/model.py; do NOT copy this string literally>",
"name": "string, required",
"description": "string, optional, default \"\"",
"executable": "string, required — absolute, relative-to-.scriptree, or bare PATH name",
"working_directory": "string or null, optional — absolute or relative-to-.scriptree",
"argument_template": [/* list of strings AND/OR nested lists; see below */],
"params": [/* list[ParamDef], may be empty */],
"sections": [/* list[SectionDef], may be empty/omitted */],
"env": { "KEY": "value" },
"path_prepend": ["directory", "..."],
"menus": [/* list[MenuItemDef], optional, omitted when empty */],
"cell": {/* CellAppearance, optional, see below — v0.2.7+ */},
"interactive": "bool, optional, default false — v0.3.0+",
"source": {
"mode": "manual | argparse | click | docopt | heuristic | powershell | winhelp",
"help_text_cached": "string or null"
}
}-
schema_version— int. Read the current value fromSCHEMA_VERSIONinscriptree/core/model.py— do not embed the number in generated files based on this doc. Older schemas (nosections, noenv, nopath_prepend) load cleanly when the loader is permissive (see themodel.pycomment block at the top of the file for which versions are accepted on read). -
name— user-visible. May contain spaces. Used as the window title. -
executable— must exist on disk at load time? No. ScripTree tolerates missing executables; the user sees an error at Run time. Relative paths (starting with./or../, or any path that isn't absolute and isn't a bare PATH name likepython) are resolved against the .scriptree file's directory at run time, not against the process's current working directory. This makes a folder containing a .scriptree and its sibling executables portable — move the folder, the tool still works. Bare names likepython,robocopy, orffmpegfall back to PATH resolution (they don't exist as sibling files). -
working_directory— if null,dirname(executable)is used as cwd. Relative paths are resolved against the .scriptree file's directory at run time, same asexecutable. -
path_prepend— entries' relative paths are resolved againstworking_directory(or the resolved executable directory), which in turn anchors on the .scriptree file's location. Net effect: a fully relative ToolDef is portable without manual path fixups. -
argument_template— a list whose entries are either strings (one argv token each) or nested lists of strings (a token group that emits all elements together or drops them together). For flag + value pairs use a nested list — writing"--out {out}"as a single string produces one argv token with a literal space, which almost every CLI rejects. Correct:["--out", "{out}"].Repeatable-flag pattern. A bare
"{id}"token whose param is astring(anyline_edit/textwidget) gets string-passthrough treatment — its value is shlex-tokenized into multiple argv elements. This is how a user types--include foo --include barinto one field and gets four argv tokens. The split honors quote rules so--name "John Doe"produces three tokens, not four. Auto-split fires only when the placeholder is the entire template token; it does not apply to embedded placeholders ("--out={x}"), token groups (["--include", "{x}"]), conditional flags ("{id?--flag}"), or non-string param types.See argument_template.md for the full grammar, the auto-split details, and common mistakes.
-
params— order matters; it's the form layout order within each section. -
sections— may be omitted. If present, defines section ordering and default collapsed state. Parameters withsection: ""fall into a synthetic "Other" bucket at the end. -
env/path_prepend— tool-level environment overrides, applied to every run regardless of active configuration. Omitted from the on-disk form when empty. -
Sibling imports (Python tools). If your tool is a Python script laid out as a folder with
_helper.py/_common.pysiblings the entry script imports, ScripTree v0.3.12+ makes those imports work reliably across:- the bundled embeddable Python that ships in
lib/python/(whosepython<ver>._pthfile would otherwise disable script-dir auto-prepending), - system Python with
-PorPYTHONSAFEPATH=1set, runpy-style invocations.
The runner injects two environment variables before spawn:
SCRIPTREE_TOOL_DIR— absolute path of the.scriptreefile's parent folder. Read by the bundled Python'ssitecustomize.py(atlib/python/Lib/site-packages/sitecustomize.py) and prepended tosys.path.PYTHONPATH— same directory prepended (any pre-existing PYTHONPATH entries are preserved at the tail).
You do not need to add any boilerplate to your tool scripts —
import _helperfrom a sibling module simply works. If you have a wrapper script that needs to setSCRIPTREE_TOOL_DIRto a different directory (e.g. a meta-tool that runs sub-tools), the wrapper-set value is preserved (the runner only fills the var when it isn't already set). - the bundled embeddable Python that ships in
-
source— provenance. Always present;help_text_cachedis null for manually-built tools.
{
"id": "python_identifier, required, unique within params[]",
"label": "string, required",
"description": "string, optional, default \"\"",
"type": "string|integer|number|boolean|path|enum|multiselect",
"widget": "text|textarea|number|checkbox|dropdown|file|save_file|folder|radio",
"required": "boolean, default false",
"default": "value of the param's type, or \"\" / null",
"choices": ["value", "value2"],
"choice_labels": ["Human label", "Human label 2"],
"file_filter": "Qt file filter string, path widgets only",
"section": "string, default \"\""
}Optional string fields that let one param show / require itself
based on another param's value. Empty (the default) means
"always visible" / "static required field governs".
Use them to slim down multi-mode forms — fields that only matter in some modes disappear when irrelevant rather than crowding the form.
Tiny declarative grammar (no embedded scripting):
<expr> := <or_expr>
<or_expr> := <and_expr> ( 'OR' <and_expr> )*
<and_expr> := <unary> ( 'AND' <unary> )*
<unary> := 'NOT' <unary> | '(' <expr> ')' | <comparison>
<comparison> := <ident> ('==' | '!=') <literal>
| <ident> 'in' '(' <literal_list> ')'
Examples:
{
"id": "bom_feature_name",
"type": "string",
"widget": "text",
"visible_when": "bom_source == 'drawing'"
}
{
"id": "bom_template",
"type": "string",
"widget": "text",
"visible_when": "bom_source in ('insert', 'auto')"
}
{
"id": "drawing_bom_policy",
"type": "enum",
"widget": "dropdown",
"required_when": "bom_source == 'drawing'"
}Rules:
- Comparisons are string equality on stringified values.
bom_type == 3works (a bare-token literal; both sides compared as"3"). For booleans use"true"/"false". - Identifiers are case-sensitive (match
ParamDef.id); keywords (AND/OR/NOT/in) are case-insensitive. - Unknown identifiers evaluate to the empty string — so
foo == 'bar'isFalsewhen there's nofooparam. - Parse errors fail OPEN — a typo logs to stderr and returns
Trueso a broken expression can't make a field permanently invisible.
A field hidden by visible_when:
- Doesn't render in the runner form.
- Skips the required check (the user couldn't fill it in).
- Its value is dropped from argv assembly (the
{id?--flag}conditional emission sees an empty value naturally). - Keeps its in-memory value across hide/show cycles —
toggling
bom_sourcebetween modes repeatedly doesn't clear what the user typed.
| type | allowed widgets |
|---|---|
string |
text, textarea |
integer |
number, text |
number |
number, text |
boolean |
checkbox |
path |
file, save_file, folder |
enum |
dropdown, radio |
multiselect |
dropdown, checkbox_list |
Changing type in the editor narrows the widget dropdown
automatically. Hand-edited files with incompatible combinations load
but the editor snaps the widget back on first save.
Three optional ParamDef fields make a parameter dynamic — its
choices (enum / multiselect / checkbox_list) or scalar value
(text / path / number / …) come from running an external command at
form-open / refresh time instead of a static choices list:
| Field | Type | Default | Meaning |
|---|---|---|---|
choices_provider |
object | null | null | {command:[…], working_directory?, refresh?, timeout_sec?, cache?}. Mutually exclusive with a non-empty static choices (loader rejects both). |
depends_on |
list[str] | [] |
Upstream param ids sent to the provider on stdin; a change re-runs it when refresh:"on_change". Cycle / unknown id ⇒ load error. |
select_all |
bool | false | Only with widget:"checkbox_list" — adds a tri-state select-all/none master. |
All optional and omitted-at-default, so a v3 file without them is
byte-identical and behaves exactly as before — no schema_version
bump. Full contract: LLM/dynamic_providers.md.
For enum / multiselect only. The canonical on-disk format uses two
parallel lists:
"choices": ["fast", "slow", "auto"],
"choice_labels": ["Fast mode", "Slow mode", "Auto-detect"]choices— the raw values that go into argv. Always flat strings.choice_labels— human-readable labels shown in the dropdown. Same length aschoices. Ifchoice_labelsis omitted or shorter, the value itself is used as the label for any missing entries.
The editor exposes this as a single-line string for ease of editing:
fast=Fast mode,slow=Slow mode,auto. Bare entries (no =) use the
value as its own label.
Do NOT use
[value, label]pair format forchoices. The loader tolerates it for compatibility, but the canonical form is two flat lists as shown above.
string/path— empty string""means "no default".integer/number—0is the null default.boolean—falseis the null default.enum— must be one of the values inchoices, or""for "no selection".multiselect— a list of values, may be empty.
{
"name": "string, non-empty, unique within sections[]",
"collapsed": "bool, default false",
"layout": "collapse | tab (optional, default collapse)"
}Sections with duplicate names are rejected by the loader.
Default to using sections. When generating a
.scriptreefor a tool with more than ~4 parameters, group them into 2–4 named sections. For 10+ params, prefer tab-mode sections. See the LLM README's "Form design defaults" for the heuristic table and rationale — flat ungrouped forms are the most common failure mode of AI-generated tools.
Each section independently controls how it renders in the runner form.
| Value | Rendering |
|---|---|
"collapse" |
A collapsible QGroupBox. The collapsed field controls initial state. This is the default. |
"tab" |
Rendered as a page in a QTabWidget. Each tab scrolls independently. The collapsed field is ignored. |
Consecutive tab sections are grouped into a single QTabWidget.
A collapse section between two tab runs creates separate tab widgets
above and below it. This means you can freely mix collapsible sections
and tabs in the same tool:
"sections": [
{ "name": "Source & Destination", "layout": "collapse" },
{ "name": "Copy Options", "layout": "tab" },
{ "name": "File Selection", "layout": "tab" },
{ "name": "Retry", "layout": "tab" },
{ "name": "Logging", "layout": "collapse" }
]This renders as: a collapsible "Source & Destination" group, then a 3-tab widget (Copy Options / File Selection / Retry), then a collapsible "Logging" group.
Legacy
section_layoutfield: older files may have a tool-level"section_layout": "tabs"instead of per-sectionlayout. The loader applies the tool-level default to every section that doesn't declare its ownlayout. New files should use per-sectionlayoutand omit the tool-level field.
Controls how the V3 cell shell paints a launcher cell bound to this
.scriptree. Entirely optional — when every field sits at its
default the whole sub-object is omitted from the on-disk JSON, so
legacy files stay byte-identical.
A tool/tree with no icon renders as a bare text row in the cell menu and the tree view. When you author or generate a
.scriptree/.scriptreetree, give it an icon.See
icon_library.mdfor the canonical reference: the full 54-icon bundled set grouped by category with "use for" hints (§2), the keyword → icon heuristic in match order (§3), the embed workflow (§5), worked examples for multi-leaf suites — ffmpeg, outlook migration, SolidWorks toolkit (§7), and the bar for generating a new archetype (§6).Short version, in order of preference:
- Reuse a bundled glyph. Pick the one whose functional category matches the tool by walking
icon_library.md§1 decision tree. One archetype per category, fixed program-wide. For a multi-leaf suite, vary the leaves by operation while keeping the parent tree's icon as the suite's identity (§7).- Generate a new one only if no §2 archetype fits — strictly per
../host-software-icon-style.md: 48-grid,fill="none", every elementstroke="currentColor" stroke-width="2.5", round caps/joins, 1–4 stroke-only primitives, a leading<!-- Generic … not the … trademark logo -->comment, content in the 4→44 band, must read at 24 px. Never a vendor's real logo/wordmark/brand colour (hard legal gate).Embed it as PNG, don't path-link it: rasterise the chosen SVG to PNG, set
cell.icon_datato the base64 of the PNG andcell.icon_formatto"png"(leavecell.iconempty). The.svgis the design source-of-truth; the embedded runtime artifact must be PNG.Why PNG, not SVG (v0.6.8 — learned the hard way): a
make_portabledeploy uses a trimmed vendored PySide6 that does not register theqsvgimage-format plugin and does not ship theQtSvgmodule — soQPixmap.loadFromData(bytes, "SVG")returnsFalsethere and every embedded-SVG icon renders blank on the portable/R: drive (it only worked in a full dev Qt). PNG decoding is built into QtGui core (no plugin) and renders in every deploy.QImageReader.supportedImageFormats()on a portable build haspng/jpgbut nosvg.Embedding (vs a relative
cell.iconpath) makes the icon travel with the file across deploy locations /make_portable/ repo moves. The shippedicons/set carries bothicon-<x>.svg(the spec source) andicon-<x>.png(embed this). Do not clobber an icon a human already set.
"cell": {
"icon": "string, optional — path to an icon file",
"icon_data": "string, optional — base64-encoded image bytes (embedded)",
"icon_format": "string, optional — \"png\" | \"jpg\" | \"jpeg\" | \"svg\" | ... — only meaningful when icon_data is set",
"text_label": "string, optional — explicit text override for the cell label",
"icon_scale": "number, optional — relative scale, range 0.25–2.00, default 1.00",
"label_opacity": "number, optional — alpha multiplier, range 0.20–1.00, default 1.00",
"fill_color": "string, optional — \"#RRGGBB\" override for the cell fill (v0.3.6+)",
"text_color": "string, optional — \"#RRGGBB\" override for the label text colour (v0.3.8+)"
}| Field | Type | Default | Range | Notes |
|---|---|---|---|---|
icon |
string | "" (empty) |
— | Path to an icon file. Forward slashes preferred, relative resolution: if the icon sits inside the .scriptree file's directory tree, the writer normalises to ./...-style relative-to-catalog. Otherwise an absolute path is stored. Either form loads. Mutually exclusive with icon_data (Embed clears icon; Unembed clears icon_data and writes a fresh relative icon). |
icon_data |
string | "" |
— | Base64-encoded image bytes. When non-empty, the cell renders from this in-JSON payload. Set by Embed in Settings → Cell label; cleared by Unembed (Save as…). |
icon_format |
string | "" |
— | Image format hint for icon_data ("png", "jpg", "svg", etc.). Required only when icon_data is set; ignored when icon is used. |
text_label |
string | "" |
— | Explicit label text. When non-empty, takes priority over auto-derived letters but loses to icons. |
icon_scale |
float | 1.00 |
[0.25, 2.00] |
Relative — the painter resizes the icon proportionally to the cell's current size_px. So a 100 % icon "feels the same size" on a 56-px cell as on a 96-px cell. Out-of-range values are clamped silently at load. |
label_opacity |
float | 1.00 |
[0.20, 1.00] |
Alpha multiplier on the painted label / icon. Out-of-range values are clamped. |
fill_color |
string | "" |
#RRGGBB (v0.3.6+) |
Override for the cell's fill colour. Empty = use branding default. Alpha is owned by the separate transparency slider; this field is RGB only. Invalid hex is silently dropped at load. |
text_color |
string | "" |
#RRGGBB (v0.3.8+) |
Override for the label text colour (auto-letters or text_label; icon labels are not tinted). Empty = follow stroke-derived default. Alpha is computed from transparency × label_opacity at paint time. Invalid hex is silently dropped. |
icon_data→ render embedded image aticon_scale×label_opacity.icon(file path) → render the file the same way.text_label→ render the explicit text atlabel_opacity.- Auto-derived letters from
name(CamelCase precedence, skip-word filter, two-letter fallback). Seehelp/cell_shell.mdfor the full rules. ?if all of the above produce nothing.
The whole cell sub-object is omitted from the on-disk JSON when
every field is at its default. Likewise, individual fields with
default values are omitted. Readers must treat any missing field as
its default.
"cell": {
"icon_data": "iVBORw0KGgoAAAANSUhEUgAAACAAAAAg...",
"icon_format": "png",
"icon_scale": 1.25,
"label_opacity": 0.85
}"cell": {
"icon": "./icons/dxf-export.svg",
"icon_scale": 0.8
}Top-level boolean. When true, the runner spawns the child process
with stdin=PIPE and shows a send-line widget below the output
pane (line edit + y / n / ! / q quick-response buttons +
Send + End input). Lines typed into the widget — or sent via the
quick buttons — are written to the running tool's stdin, so tools
can implement query-replace-style prompt loops (Emacs M-%) inside
the GUI.
"interactive": trueField rules:
- Type —
boolean. - Default —
false. Omitted from the JSON when at the default so v0.2.x files round-trip byte-identical. - Permission gate — the runner ALSO requires the
interactive_stdincapability to be granted at the app level (file present and writable in the deployedpermissions/directory). When the tool opts in but the permission denies, the runner falls back to one-shot mode and prints a one-line warning into the output pane. - Run-as-user incompatibility — interactive mode is suppressed
when a configuration is set to
prompt_credentials: true. A warning is emitted on stderr; the run proceeds non-interactively. - Stdout buffering — tools that read from stdin should
flush=Trueafter every prompt (print(..., flush=True)in Python) so the runner sees each prompt as it's emitted, not all at once after the script exits.
Use interactive: true for tools that genuinely benefit from a
live prompt loop:
- Find / replace with per-match accept / skip (
query-replace). - Confirm-each-file batch operations.
- REPL-style exploration tools.
Don't use interactive: true for tools that just emit progress
bars or status output — those work fine without stdin.
{
"mode": "manual",
"help_text_cached": null
}help_text_cached lets the editor re-run parsing with improved
heuristics without re-probing the executable. It's stored verbatim —
newlines, ANSI, trailing whitespace and all.
Custom menu items rendered at the top of the form (or in the standalone window's menu bar). Omitted when empty.
{
"label": "string, required — display text, or \"-\" for separator",
"menu": "string, optional — top-level menu name, default \"Tools\"",
"command": "string, optional — command to execute (split safely, no shell)",
"shortcut": "string, optional — e.g. \"Ctrl+L\"",
"tooltip": "string, optional",
"children": [/* list[MenuItemDef], optional — submenu */]
}Items with the same menu value are grouped under one menu. Commands
are split via CommandLineToArgvW (Windows) or shlex.split — never
shell=True.
The tool_from_dict function enforces:
schema_versionis an int and ≤ current version.nameis a non-empty string.executableis a non-empty string.argument_templateis a list whose entries are each either a string (single argv token) or a list of strings (token group that emits/drops as a unit).- Every
ParamDef.idmatches^[A-Za-z_][A-Za-z0-9_]*$. - Every
ParamDef.idis unique withinparams[]. - Every
ParamDef.typeandwidgetis from the allowed sets above. - Every
ParamDef.section, if non-empty, names a section insections[]. - Every
SectionDef.nameis non-empty and unique.
Violations raise ValueError with a message pointing to the offending
field.