# Parameter Design

> MUST READ before creating or designing custom parameters on any COMP: pages, styles, ranges, help text, naming, ParGroup gotchas.

- Skill: `dylanroscover/parameter-design` (Agent Skill)
- Install (CLI): `npx skillmds@latest add dylanroscover/parameter-design`
- Raw SKILL.md: https://api.skillmd.com/api/skills/dylanroscover/parameter-design/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: dylanroscover (https://skillmd.com/u/dylanroscover)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/dylanroscover/parameter-design

---

<!-- Generated by Embody/Envoy - Do not remove this comment -->

# Parameter Design

## Ownership and Lifecycle

**Code owns the schema; the user owns the value.** A parameter's name, style, page, range, default and help text are yours to declare and re-assert. Its *value* is the user's, and nothing you write may quietly discard it.

Extensions reinitialize on every source-file save, so **every creation path must be get-or-create**, never create-blindly:

```python
def ensureCustomPage(comp, name):
    for page in comp.customPages:
        if page.name == name:
            return page
    return comp.appendCustomPage(name)

def ensureCustomPar(comp, page, name, style, **attrs):
    """Get-or-create one custom par: the Par, or the ParGroup for a tuplet."""
    # Probe the WHOLE COMP by tupletName. Names are a flat per-COMP namespace,
    # and a multi-value style (RGB, XYZ) is stored as <name>r/<name>g/<name>b,
    # so a probe by .name never finds it and append*() (replace=True) would
    # re-create it on every reinit.
    found = [p for p in comp.customPars if p.tupletName == name]
    if not found:
        getattr(page, 'append' + style)(name)
        found = [p for p in comp.customPars if p.tupletName == name]
    elif found[0].style not in STYLE_FAMILY.get(style, (style,)):
        raise ValueError('par %s is %s, declared %s -- refusing to replace it'
                         % (name, found[0].style, style))
    for p in found:                          # symmetric: schema attrs re-applied
        for key, value in attrs.items():     # on every reinit; the user's state
            if key not in ('val', 'expr', 'bindExpr', 'mode', 'export'):
                setattr(p, key, value)       # is never touched -- and a typo RAISES
    return found[0] if len(found) == 1 else found[0].parGroup

# TD reports a tuplet's style by family: RGB reads 'RGBA', XY/XYZ read 'XYZW'.
STYLE_FAMILY = {'RGB': ('RGB', 'RGBA'), 'XY': ('XY', 'XYZW'), 'XYZ': ('XYZ', 'XYZW')}
```

Four rules that make it safe:

- **Probe the whole COMP, not the page.** Custom parameter names are a flat per-COMP namespace, and `append*()` defaults to `replace=True` -- a page-scoped probe will happily destroy a same-named par living on another page.
- **Re-read after appending, by `tupletName`.** `append*()` returns a **ParGroup**, and a multi-value style is stored as component pars (`Tintr`/`Tintg`/`Tintb`) whose `tupletName` is the name you declared -- `comp.par['Tint']` is `None`. Embody ships this exact helper as `op.Embody.op('embody_pardef').module.ensureCustomPar`.
- **Never `destroy()` on style drift.** `Par.destroy()` takes the user's value, expressions and exports with it. Report the mismatch and refuse; removal is a separate, deliberate act.
- **Apply declared attributes symmetrically.** If you set `min` when creating, set it when the par already exists too, or a schema change never reaches existing installs.

## Parameter Callbacks

**One dispatcher, not one promoted method per parameter.** A parexec DAT that grows an `elif par.name == ...` chain drags a public-tier method onto the COMP for every branch (see the three tiers in `td-python.md`). Name the handler for its parameter instead and let the DAT find it:

```python
# the WHOLE onValueChange in the parexec DAT
def onValueChange(par, prev):
    ext = parent.MyComp.ext.MyExt
    handler = getattr(ext, '_on%sValueChange' % par.name, None)
    if handler is None:
        return
    try:
        handler(par, prev)
    except Exception as e:
        parent.MyComp.Error('%s handler failed: %s' % (par.name, e))
```

```python
# on the extension -- tier 3, invisible on the COMP
def _onSpeedValueChange(self, par, prev):
    self.ownerComp.op('./timer1').par.speed = par.eval()

def _onResetPulse(self, par):
    self.rebuild()
```

- **Audit once at init** that every `_on*` handler matches a real parameter. A renamed par turns its handler into dead code that fails no test; the audit turns that into a warning.
- **Never swallow the exception silently.** A handler that raises into nothing is indistinguishable from a parameter that does nothing.
- Pulse handlers take `(par)`; value handlers take `(par, prev)`.

## Parameter, Storage, or Dependency?

Custom parameters are one of three ways to hold state on a COMP, and the choice is about lifetime, not taste:

| The state is | Use | Lives on |
|---|---|---|
| User-facing, persisted, part of the COMP's interface | **custom parameter** | the COMP |
| Durable bookkeeping the user should not see | **`storage`** | the COMP |
| A derived value that must recook whatever reads it | **`tdu.Dependency`** | usually the extension instance |

**What survives what.** Probed on TD 2025.33070; undocumented, so re-verify on another build.

| Event | Custom par | `storage` | Dependency on the extension |
|---|---|---|---|
| Extension reinit -- source `.py` edit, `initializeExtensions()` | survives | **survives** | **destroyed** |
| External-tox reload -- `enableexternaltoxpulse`, COMP restore | survives | **wiped** | destroyed |
| `.toe` / `.tox` save and load | survives | survives | survives only if stored |

`storage` lives on the COMP, not on the extension instance, so a hot-sync reinit does **not** clear it -- the common belief that it does is wrong. What clears it is the COMP's contents being replaced: a tox reload, a TDXN reconstruction, a restore. Embody hits that case, which is why Envoy's state machine trusts the `Envoystatus` parameter rather than its `envoy_running` store.

Do not reach for a `Dependency` where a parameter works -- a par is inspectable, persisted, exportable and free. Reach for one when a computed value has no business on the user's parameter dialog but still has to invalidate whatever reads it. A Dependency assigned to `self` in `__init__` dies with the instance on every source save; rebuild it there, never treat it as persistent.

### Publish the value; do not push it

The common failure (Function Store, issue #94): code recomputes something and then *pushes* it -- `other.par.Value = x` on each consumer, or a method call on each -- where it should publish ONCE as a dependable and let consumers pull. A push must enumerate its consumers, so it goes stale the moment someone adds a reader, fires whether or not the value changed, and inverts the cook model TD already gives you.

```python
# push -- every new reader is a new line here, and nobody else knows
for w in self.widgets: w.par.Scale = value

# publish -- readers subscribe by reading; adding one costs nothing
self.Scale.val = value          # tdu.Dependency built in __init__
# reader: parent.Host.Scale.val  (or an expression, which recooks on change)
```

Publish through `tdu.Dependency` (derived runtime state), `store()` (durable bookkeeping), or a custom parameter (user-facing). Push is still right for exactly one case: a consumer that is NOT dependency-aware -- an external process, a file, a network peer.

### What actually notifies dependents

The whole point of choosing between these is whether a change *reaches* the expressions that read it. Measured on 2025.33070 with a CHOP parameter expression as the dependent:

| Change | Dependent expression | Cooked |
|---|---|---|
| `comp.store('k', 99)` -- immutable | 11 -> **99** | **yes**, same frame |
| `comp.storage['k'] = 55` -- direct dict write | still reads **1** (value really is 55) | **no** |
| `comp.fetch('lst').append(4)` -- in-place mutation | still reads **3** (list really is 4 long) | **no** |
| `self.Scale = 42` -- plain extension attribute | still reads **5** (attribute really is 42) | **no** |
| `self.DepScale.val = 77` -- `tdu.Dependency` | -> **77** | **yes** |

Three of those five are silent: the value is genuinely updated and every `print` looks right, but nothing downstream hears about it. That is the entire failure mode.

- **Always write through `store()`, never `comp.storage[k] = v`.** Re-storing an unchanged value still notifies, so `store(k, fetch(k))` is the documented way to publish an in-place mutation.
- **A plain extension attribute is not dependable.** This is exactly what `tdu.Dependency` exists to fix -- not storage, which is already dependable for immutable values.

### Mechanics worth knowing

- **`fetch(key)` with no default RAISES**, it does not return `None` -- `tdError: The fetched item was not found and no default was specified`. Pass a default, or use `fetchOwner(key)` (returns `None`) to test presence. Derivative's reason: a stored value could legitimately BE `None`.
- **`fetch()` searches UP the parent chain by default.** A missing local key silently resolves to an ancestor's value of the same name. `search=False` for local-only; `fetchOwner()` names the operator that answered.
- **`unstore()` is glob-matched, not a literal key delete.** Verified: `unstore('sales*')` removed `sales_a` and `sales_b` and left `keep`. Never pass a computed or user-derived key -- one containing `*`, `?` or `[` will take its siblings with it.
- **"You cannot store operator references" is a `StorageManager` rule, not a storage rule.** Raw `store()` takes any Python object and TD's docs demonstrate storing an OP; verified round-tripping one through a `.tox`. `StorageManager` is stricter because it pickles -- store a `.path` string there.
- **Storage is pickled into the `.toe`/`.tox` at save.** An unpicklable value logs a *warning* and vanishes on reload while the save itself succeeds, so it is easy to miss. Locks, `threading.Event`, sockets and file handles are out -- and so is an instance of a class defined in a DAT, which stops pickling the moment that DAT recompiles. A file-synced `.py` edit does that routinely, which makes it an Embody-shaped hazard specifically.
- **`storeStartupValue()` writes a separate startup dictionary** that overrides the saved value on load. Use it to force a known state on open and to keep session state out of the file.

### Dependency: the two idioms, and the trap

```python
import TDFunctions as TDF

class MyExt:
    def __init__(self, ownerComp):
        self.ownerComp = ownerComp
        TDF.createProperty(self, 'Scale', value=1.0, dependable=True)  # expression: op('c').Scale
        self.Raw = tdu.Dependency(5)                                   # expression: op('c').Raw.val
```

**The property's name is its access tier.** `createProperty(self, 'MyProperty', ...)` makes a capitalized instance attribute, and TD promotes those onto the COMP exactly like methods -- a dependable property named `Upper` IS tier-1 public API (the promoted-surface census counts them). Capitalize it only when users/agents should read it off the COMP; internal state stays `_lower` or `lower`.

The two idioms need **different expressions**, and swapping them fails quietly: the raw form read as `op('c').Raw` evaluates the Dependency *object*, which is always truthy and never changes.

- **`self.Raw = 5` destroys the Dependency** -- it rebinds the attribute to a plain int and removes the cook dependency. Nothing raises, and a Dependency reads as its underlying value, so every `print` and comparison still looks correct. Write `self.Raw.val = 5`.
- **`.peekVal` reads without creating a dependency**; reading `.val` inside an evaluating expression is what forms it. `dep.ops` lists the operators currently dependent on it -- the right tool for "why is this cooking".
- **Mutating a container inside a Dependency notifies nothing.** Call `.modified()`, or hold it in `TDStoreTools.DependDict` / `DependList` / `DependSet` (those names live in `TDStoreTools`; `tdu.DependableDict` does not exist on any build). Note `DependDict` subclasses `MutableMapping`, not `dict`, so `isinstance(x, dict)` is `False` and `json.dumps` raises -- use `getRaw()` for plain data.
- **A bind expression to a Dependency is BI-DIRECTIONAL** (Function Store, issue #94; verified 2026-08-30 on 2025.33070). Dependency objects are legal bind MASTERS: a par with `bindExpr = "me.fetch('probedep')"` in bind mode read the Dependency's value, followed `dep.val = 7.5` immediately -- and setting the BOUND PAR wrote 3.25 back into `dep.val`, with the par staying in bind mode. The value lives at the master, so a bound par is a two-way control surface for extension state, not a read-only view. Choose bind mode deliberately: an expression-mode reference is one-way by construction.
- **`dep.callbacks` leaks across a hot reload.** Appending a bound method holds a strong reference to the extension instance, so every source save adds another live subscriber and one change fires N callbacks against dead state. It reads like a race, not a leak. Remove it in `onDestroyTD` by copying the list, mutating, and reassigning -- an in-place `.append`/`.remove` is documented as insufficient.
- **Both are main-thread only.** Setting `.val` dirties dependents and runs callbacks synchronously; `store()` participates in the cook model. Resolve values on the main thread and hand plain data to a worker.

## Help Text

Every custom parameter MUST have `help` text set via `par.help = "..."`. Help text appears as a tooltip when users hover the parameter name in the dialog. Describe what the parameter controls and what its values mean.

- Good: `"Maximum number of rows displayed in the manager list. Set to 0 for unlimited."`
- Bad: `"Max rows"` (just restates the label)
- Unacceptable: no help text at all

In TDXN files, include `"help": "..."` in the parameter definition. Embody exports and imports help text automatically.

## Section Breaks

Use `par.startSection = True` on the first parameter of each logical group. This draws a horizontal separator line above the parameter, visually grouping related controls.

In TDXN: `"startSection": true` in the parameter definition.

## Parameter Ordering

Parameters appear in the order they are appended. Keep related parameters together and maintain a logical flow within each page:

1. Primary controls first (what users interact with most)
2. Secondary/advanced settings after
3. Read-only status/info parameters last

If reordering after creation, use `par.order` (accepts float values like `11.5` to insert between existing positions).

## Page Organization

Group parameters into pages by function. Use `comp.appendCustomPage('PageName')` -- pages appear in creation order.

Common patterns:

| Page | Purpose |
|------|---------|
| Main / Settings | Primary configuration |
| Tags | Externalization tags, strategies |
| UI | Visual and display options |
| About | Version, build, author (read-only) |

## Naming

- First letter MUST be uppercase, rest lowercase letters and numbers only
- No underscores, spaces, or special characters
- Examples: `Speed`, `Maxrows`, `Autosave`, `Envoyenable`

## Style Selection

| Use case | Style | Notes |
|----------|-------|-------|
| On/off toggle | `Toggle` | Values are `0`/`1` |
| Fire-once action | `Pulse` | No persistent value |
| Enumerated choices | `Menu` | Set `menuNames` and `menuLabels` separately |
| Editable dropdown | `StrMenu` | Free-text input with suggestions |
| Numeric value | `Float` or `Int` | Set range properties (see below) |
| Text input | `Str` | Free-form string |
| File/folder path | `File` / `Folder` | Opens system dialog |
| Operator reference | `OP`, `COMP`, `TOP`, `CHOP`, `SOP`, `DAT`, `MAT` | Filtered by family |
| Section header | `Header` | Visual label only, no value |

## Numeric Ranges

For `Float` and `Int` parameters, configure the range:

| Property | Purpose |
|----------|---------|
| `min` / `max` | Minimum and maximum values |
| `clampMin` / `clampMax` | Whether to enforce min/max as hard limits (`True`) or allow values outside (`False`) |
| `normMin` / `normMax` | Slider range in the UI (what the slider covers visually) |

Example:
```python
p = page.appendFloat('Speed', label='Speed')[0]
p.default = 1.0
p.min = 0.0
p.max = 10.0
p.clampMin = True
p.clampMax = False    # Allow values above 10 via manual entry
p.normMin = 0.0
p.normMax = 5.0       # Slider covers 0-5, but values up to 10+ accepted
p.help = "Playback speed multiplier. 1.0 = normal speed."
```

## Read-Only Parameters

Use `par.readOnly = True` for status and informational parameters that users should see but not edit (version, build number, connection status).

## Defaults

Always set `par.default = value`. This enables "Revert to Default" in the TD parameter dialog and ensures TDXN round-trips produce consistent results.

**`.default` never sets the value.** After `appendFloat` + `p.default = 5.0` the par still evaluates 0.0, and a later `min`/`clampMin` clamps it to the min, not the default (verified 2025.33230, issue #94). Set `p.val = p.default` -- only on the create branch of get-or-create, so a user's value is never overwritten.

## Creating Custom Parameters

All `page.append*()` methods return a **ParGroup** (tuple-like), not a single `Par`. Index with `[0]` to get the `Par` object:

```python
page = comp.appendCustomPage('Settings')
pg = page.appendFloat('Speed', label='Speed')  # Returns ParGroup
p = pg[0]                                        # Get the Par
p.default = 1.0
p.help = "Playback speed multiplier."
p.startSection = True
```

## Sequence Parameters

Resizable parameter blocks (glslTOP uniforms, constantCHOP channels, mathmixPOP
combines, and custom sequences). Function Store's note on issue
#94 -- "building sequence pars takes a bit more thinking than it should" -- is
fair, and it is because three separate things are easy to get wrong.

**Reach the sequence, not the parameter.** `comp.seq.<name>` from the operator,
or `par.sequence` from any block parameter. The individual parameters are named
`<seq><index><par>` (`uniname0`, `uniname1`), so never build those names by hand
when you can go through the sequence object.

**`numBlocks` is the get-or-create.** Assigning it grows or shrinks in one step
and is idempotent, which is what you want in an extension that reinitializes on
every source save. Use `insertBlock(i)` / `destroyBlock(i)` only when position
matters.

```python
seq = comp.seq.uni
seq.numBlocks = max(seq.numBlocks, len(uniforms))   # grow, never shrink blindly
for i, u in enumerate(uniforms):
    seq[i].par.uniname = u.name                     # block i, by index
```

**Shrinking destroys values.** `destroyBlock` / lowering `numBlocks` takes the
block's values, expressions and exports with it, exactly like `Par.destroy()`
(see Ownership and Lifecycle). Code owns the block COUNT only where the count is
genuinely derived; if the user can add blocks, grow to fit and leave the excess
alone.

**In Embody's TDXN, sequences are stored by BASE name.** `sequences:` is keyed by
sequence name, each value a list of block objects holding only non-default values
under base names (`uniname`, not `uniname0`). Two consequences worth knowing: the
default block count is *probed* from a throwaway instance rather than assumed to
be 1, so a sequence sitting below its type default still exports its full block
list and the shrink survives reimport; and blocks are created in import Phase 2.5,
before Phase 3 sets parameters, so the block parameters exist before anything
writes to them. Full detail: `docs/tdxn/specification.md`.

