What a package IS, what it must carry, and what the machinery derives for you. The step-by-step release runbook is RELEASING.md; the reasoning behind the scheme is docs/PackagingScheme.md. This page is for the moment before either: you have a tool and want it to become a shippable package.
A package is a depth-1 COMP in the /FNSTools root that Private
Investigator tracks as a suspect with its own tox. That is the entire
identity test (build_manifest.Packages()):
family == 'COMP', directly under the toolkit rootexternaltoxset (PI's suspect binding gives you this)- the
pi_suspecttag present
Nothing registers a package anywhere. If it passes the test, every release
pass finds it, exports it, hashes it and publishes it; if it stops
passing, Stage() refuses the release until the drop is declared in
release.json retired (packages must never vanish silently).
The two exceptions: FNS_Installer and webBrowser are rails — build
artifacts published under the manifest's rails block, never as
installable packages — even though PI tracks them too.
A tool whose bytes and version are owned upstream — another repo,
another release rail (TDXMap is the first) — has no live COMP here, so the
identity test above cannot see it. It is declared instead: a catalog entry
with a source block (reasoning: docs/ForeignPackages.md).
"TDXMap": {
"category": "Control & mapping",
"description": "…",
"placement": "root",
"source": { "manifest": "https://…/manifest.json", "tox": "TDXMap.tox" },
"updates": "self",
"help_url": "https://tdxmap.functionstore.xyz/docs/",
"min_td_build": "2023.12120"
}The recipe, in order:
- Author the entry in the content CMS package editor (the "Foreign
package" block) or by hand.
source.manifestis what makes it foreign. The upstream manifest may be flat ({version|latest, url, sha256, notes_url}) or our own shape (packages[], withsource.packagenaming the row). It MUST carry a sha256 — unpinned bytes are refused. - Mirror it:
python packaging/foreign_sync.py TDXMap(or the row's Sync button in the CMS release table, which runs the same script detached). It downloads the tox intopackaging/dist/, verifies the sha, and pins{version, sha256, url, notes_url, fetched_at}inpackaging/foreign.lock.json. Commit the lock: it is what the manifest build reads, and git then shows which upstream version each release mirrored. The sync is the ONLY thing that talks to the upstream;Build()never does network I/O. - Write the doc (
packaging/docs/<Name>.md) like any package. - Release it like any package: it appears in the CMS release table
(lock version vs published, mirrored/not), Preflight blocks until it is
mirrored, and Release & stage ships the mirrored artifact under the
normal
base_url/<release>/path. It is never bumped, exported or PI-saved — the lock IS its version, the sync IS its export.
What the entry may declare that a live package may not — because a live
package has a live authority for each (FNS_About.Helpurl, the export
build) and authority must not fork; Preflight refuses these on a live row:
updates:self(the tool runs its own updater after install; the toolkit updater reports it as self-managed and never touches it — noPkgversionneeded inside the artifact) orstore(tracked like a tool; then the artifact must carryPkgversion).help_url,min_td_build— declared, since nothing can be reflected.
Retiring a foreign package = removing its catalog entry AND declaring it
in release.json retired; the CMS row's Retire does both. A foreign
package is not a recommendation (packaging/recommendations.json): that
lane LINKS to other creators' pages and never hosts or versions anything.
Foreign means we mirror the bytes and poll a source to keep them current.
The identity test above says PI's suspect binding sets externaltox. Two
things about that were paid for on 2026-09-01:
PI owns tool externalization, not Embody. externalizations.tsv had 15
rows and no tool extension in it; PI's suspects table had 468. Embody derives
an externalized path from the op path, so externalize_op on a tool DAT lands
in FNSTools/… — the tree retired in backlog item 13. Use PI:
pi = op('/private_investigator1').ext.PrivateInvestigator
pi.Add(comp) # tags it, creates Vcoriginal, sets externaltox, one suspects row
pi.Add(comp.op('MyToolExt')) # file/syncfile -> modules/suspects/..., Info Header stamped once
pi.Save(comp) # writes the suspect tox (and re-exports the root toolkit tox)Then check the DAT's row landed in suspects — on first use Add(dat) set the
file binding but its row was missing; append the path if so, which is the only
half Add does after Init. PI's Save writes externaltox with a backslash;
normalize to forward slashes to match every sibling.
Never pulse Scan or Updatesuspects to adopt one COMP. Scan only sees
COMPs that already carry Vcoriginal, so a fresh COMP is invisible to it; and
Updatesuspects re-exports every suspect tox on the main thread — 60+ of them,
long enough to wedge TD and need a restart.
A string version (1.2.0) the updater compares LIVE off the installed
component against the manifest. Hashes only verify downloads; this par
alone decides "is a newer build available". Bump it whenever the package
changes; forgetting is silent, which is why the release flow auto-bumps
what you select and refuses a release that bumps nothing.
Preferred shape: an FNS_About child COMP holds the authoritative
Pkgversion (plus Touchbuild and Helpurl), and the tool's own
Pkgversion par mirrors it by expression, read-only, on its About page --
registries too (measured live 2026-09-02: every master mirrors by
op('./FNS_About').par.Pkgversion; an earlier "registries: by bind" here
described a ported copy, not the masters). Expression, never bind, is a
rule here, not taste: a bind is two-way, so code that sets the mirror
writes THROUGH to FNS_About and silently rewrites the authority it was
meant to read -- readOnly stops the UI, not Python -- whereas an
expression is one-way, so a stray write breaks the mirror locally and
leaves the source of truth intact. "The child owns it" (a computed
readout: status, progress, a bound port) points at bind; "the child is
AUTHORITATIVE for it" points at expression, and Pkgversion is the
second kind (credit: the launcher's agent, 2026-09-02, after switching
its utility to the expression mirror). The
version lives on a child so it travels INSIDE the artifact through an
update reload (docs/UpdaterHardening.md §4 — the reload that rebuilt
children shipped stale versions fleet-wide when the par lived elsewhere).
Supported minimum: a bare Pkgversion custom par on the COMP itself,
no FNS_About at all. The whole rail honors it — the release bump writes
it (release_one._versionWritePar falls back to it), the manifest reads
it, the updater compares it, and the CMS release row shows it. What you
give up without FNS_About:
Touchbuild— the minimum TD build stamp that travels inside the tox; without it the manifest falls back to the build that did the export (usually fine, occasionally too strict).Helpurl— the one per-package docs override; without it the docs link derives from the package name.
Start with the bare par if FNS_About is friction; grow the child when
the package needs a build floor or a docs override.
Who wins on a mismatch: FNS_About, always. Bumps write the child,
and every reader in the rail — the manifest build, the release bump's own
read, the updater compare, the CMS row — reads the child FIRST, falling
back to the comp par only when there is no child (the bare-Pkgversion
shape). The comp-level par is a display mirror (expression on tools, bind
on registries), never the truth, so severing the mirror cannot invert
authority. A severed mirror is still a release-blocking defect
(version mirror severed in preflight): the stale constant shows on the
parameter page, exports INSIDE the artifact, and feeds any comp-first
reader still shipped in the field. The usual cause is an assignment to
.val, which silently flips the par to constant mode — never hand-edit
the comp-level par on a package that has FNS_About; edit the child, or
use the release flow's bump.
"MyTool": {
"category": "Visual",
"description": "One sentence, user-facing, ends with a period."
}Optional keys: access (a Patreon tier id — anything but "free" makes
the package gated: published under the plus/ prefix, served only
through the entitlement worker), license/seats (Gumroad lifetime-key
lane), recommended (rides the starter set), placement, nopick and
placeonce (below),
author {name, url} / homepage / changelog_url (presentation links —
the manifest carries them to the picker byline and the website badge;
this is the ONE home for credit, and a doc still carrying a frontmatter
credit block fails the site build), and for foreign entries only
source / updates / help_url / min_td_build (above).
Categories and their glyph/pitch live in the same file under
categories/category_meta.
placement — where the installer lands the package. Absent (the
default): a child of the toolkit container, update-tracked in place.
"placement": "root" lands it at the network root, beside the toolkit
container — for tools that must live at / rather than inside the
container. The address is known, so it behaves like a toolkit tool
otherwise: presence is the live comp, updates apply in place (the
updater's doorstep walk), and unselecting removes it for real.
"placement": "pane" declares a reusable component — something you
use in normal TouchDesigner work rather than a toolkit tool — and the
installer spawns it into the network the user is working in (the current
network editor pane's owner; the toolkit container is the fallback when
no editor is open or the visible network is protected). The trade, by
design (palette-component semantics):
- "installed" is the install record on the toolkit root, not a live
child — the picker pre-checks it from the record, and unselecting it
later only clears the record (spawned copies are the user's work and
are never touched). One exception — the installer's doorstep: a spawn
sitting in the toolkit container itself, or right beside it at the
network root (where the no-editor fallback and a
/-showing pane both land), is removed like any tool; copies elsewhere in the network are never. - instances are frozen at their spawn version — the updater reports
the package as
component, never updates it in place; the newest version arrives by reinstalling from the picker. The doorstep applies here too: a spawn in the toolkit container or beside it at the network root compares and updates in place like any tool.
Set it in the CMS package editor ("Installs into"), like the other
curated keys. Stored as presence: only "pane" or "root" is ever
written.
nopick — "nopick": true makes a package explicit-pick only.
It is still a real package: a normal card in the picker and on the site,
installable and updatable. But no bulk selection ever includes it — not
Select all, not the Everything preset, not Recommended (so it cannot also
be recommended; preflight refuses the pair), not a curated bundle in
presets (the build drops it and reports it), not the guided-setup
questionnaire. It arrives only when someone ticks its card, and nothing
removes a tick they made; a "Set up like last time" restore keeps it only
because nothing bulk could have put it in that setup. Meant for
hardware-specific integrations and anything else that must never land
because someone asked for everything. Set it in the CMS package editor
("Explicit pick only"). Presence-style: true or absent, any other value
is a preflight problem, and only true is ever written to the manifest.
placeonce — "placeonce": true gives the package's picker card a
second action beside the tick: place in this project only. A placed
tool lands and updates like any other, but its install record says
remember = 0, so "Set up like last time" never brings it into the next
project; ticking still makes it part of the setup. An FNS family member is
spawned into the working network when placed. Meant for tools someone wants
in one project rather than in every project (TDXMap, ParHoverMIDI_VSN1).
An FNS family member gets the Place button without the flag.
Set it in the CMS package editor ("Can be placed without joining the setup"). Presence-style like
nopick. Reasoning and the data it touches: docs/PlaceOnce.md.
preview — "preview": true ships the package in the release for the
owner's own testing and hides it from everyone else: only the creator's
signed-in account can see or install it, and the site, the picker, the
questionnaire, the bundles and the new-tools notice leave it out. access
stays the tier it will ship at. Set and clear it with the CMS checkbox
("Preview: not released yet") or python packaging/gate_package.py <Name> --preview / --release, never by hand: it also rewrites the Worker's
grants, so wrangler deploy follows. It overrides Recommended: the flag is
kept for the release, and the starter set leaves the tool out until then.
Reasoning and limits: docs/PreviewPackages.md.
The site build (npm run pages in website/) hard-fails on a
catalogued package with no doc, a doc with no catalog entry, or a doc
whose frontmatter package: does not match its filename. A package ships
with its doc or nothing ships. This is deliberate.
-
Dependencies. Tools depend only on CORE, never on each other. Registry masters live in core; your tool ships stamped registry hosts, and its
requiresis derived from exactly the registries it hosts. (Stamping a host: load/fns-registryfirst.) One exception:FNS_OverlayRegistryis a package, not core (owner, 2026-10-11), so a tool hosting it requires that package, and the installer pulls it in throughrequireslike any other (docs/NetworkOverlayRegistry.md). -
Surfaces (toolbar, hub, op menu…), integrations (
integrates_withdegrades gracefully by design — never a hard dependency), op counts, artifact hashes and URLs. -
Launcher reach — the manifest's
launcherblock ({surfaces, capabilities, seedable}, whereseedableis NESTED inside it:p.launcher.seedable, neverp.seedable), derived by reflecting over your commands. A package earns it by declaring asurfacetoken other thanquick, or acapability, on any command — see/fns-command-registration. Having commands is not enough: nearly every tool has quick-launch commands, and a launcher's bundler needs the few that reach a surface beyond it. Absent means "commands only", which is the normal answer. -
FNS family membership — the manifest's
familyblock, derived from the tool's ownFamManifest(TDFam's manifest base:OpInfo,ParRetain,StateRetain,ShortcutsDATs of JSON). Add or edit it from the content CMS package form, which writes the tool and PI-saves it.op_typemust be a lowercase word (TDFam looks it up verbatim in a lowercased cache, so a type with capitals places and never comes back from a stub);op_namekeeps the readable spelling. A member is placed into the working network, so it impliesplacement: pane. Only a foreign package curatesfamilyin the catalog. See docs/OperatorFamilyFromStore.md. -
A companion (catalog
companion: family) is infrastructure that exists only for other packages: the picker never offers it and the installer adds it exactly when the selection holds a package that needs it. The FNS operator family is the one today. Curate it incatalog.json; it is not a dependency, since the members work without it.
If you find yourself wanting to hand-declare any of these, the design is telling you the tool is shaped wrong — usually a tool-to-tool dependency trying to exist.
- No absolute operator paths, anywhere — parameter values included
(
.claude/rules/td-python.md). The package must survive rename, relocation and instancing. - Settings persist through ConfigRegistry, scoped correctly — load
/fns-config-scopebefore making anything persist. - Copies of a suspect-bound master must sever
externaltox(enableexternaltox=False,externaltox='', strippi_suspect) or boot reloads the wrong tox into them. Released artifacts are handled for you:build_manifest.ExportPackagescrubs the written tox ofpi_suspect,FNS_externalizedandVcoriginal(ScrubArtifact, on the artifact, never the live master). Hand stamp recipes inside the project still sever them themselves. - Hotkeys go through the conformance flow —
/fns-hotkey-conformance. - Quick-launch commands —
/fns-command-registration.
Once the COMP passes the identity test and carries its three maintained pieces, it appears in the CMS release table on its own. From there: RELEASING.md — preflight, Release & stage, upload. Cold test before claiming victory: drop the artifact in a bare project and walk the full bootstrap.
A package without one ships the authoring apparatus: the Version Ctrl page
and the vc_data tables that describe THIS checkout's save history rather
than the tool, plus it misses the loopback web-server rule, the
console-ships-dormant rule, the FNS_About.Owner expression and the
first-page rule. Nothing warns you -- which is exactly how four packages
shipped without one until 2026-09-18, all of them created by following this
document while it said nothing about hooks.
Create a Text DAT named pre_release as a DIRECT child of the package,
file-synced to modules/suspects/FNSTools/<Package>/pre_release.py, tagged
pi_suspect. Its first line is the generic strip:
exec(open('packaging/pre_release_common.py').read())Anything package-specific goes BELOW that line, never above it. The hook runs
on a STAGED COPY in /sys/quiet with extensions not initialized, so it does
direct par/table edits only and never touches the live component.
Check it landed by exporting and reading the artifact back: no Version Ctrl
page, no vc_data children.