Every extension point
LSD is built to be extended from a single import — lsdtools. This page
catalogs everything a package can contribute: steps, entities, parameters & widgets,
CLI commands, desktop menus, custom UI panels, events, and more. Each item is tagged
Available, Partial, or
Proposed — so this doubles as the roadmap for what's next.
New here? Start with the Quickstart. This page is the deep reference.
The contribution model Available
A package is plain Python. Two mechanisms cover almost everything:
- Auto-registration — define a
SteporEntitysubclass (or a decorated function) and importing the module registers it. No boilerplate. register(host)— an optional function in your package's__init__.py. It receives aPackageHostand pushes contributions (CLI commands, menus, views) viacontribute(...).
from lsdtools import contribute, CliCommand, MenuItem, ViewContribution
def register(host):
contribute(
CliCommand("hello", "say hi", run=lambda a: print("hi")),
MenuItem("Do it", callback=do_it, menu="run"),
ViewContribution("my.panel", "My Panel", make_widget, location="sidebar"),
)Core (lsd) holds contributions in a registry; each front-end renders what it
supports — the desktop renders views/menus, the CLI wires commands — so lsd itself
imports neither GTK nor argparse.
1 · Steps Available
The unit of work. Decorate a function or subclass a base; both auto-register and appear in the Add step picker. (Full walkthrough in the Quickstart.)
Advanced step features
| Feature | How |
|---|---|
| Typed parameters | param(type, …) (class) or function args (decorator) |
| Output schema contract | output_schema = pa.schema([...]) — validated after run() |
| Multiple inputs/outputs | input_ports=[…] / output_ports=[…] (or @shape(inputs=…, outputs=…)) |
| Cross-entity wiring | step.bind_from("key", other_step) — drives run order |
| Read-only results | param(..., output=True) — written by run(), shown in the UI |
| Lifecycle hook | on_attached() — called when added to an entity |
class Join(ShapeStep):
input_ports = ["main", "lookup"]
output_ports = ["joined"]
key = param(str, default="id")
def run(self, inputs):
return inputs.main.join(inputs["lookup"], keys=self.key)2 · Entities Available
Entities are the nodes in the project tree that hold steps. Subclass Entity to add a
new creatable type — it appears in the desktop's Add entity menu and
lsd entity add automatically.
from lsdtools import Entity, param
class Dataset(Entity):
create_label = "Dataset" # menu label (defaults to class name)
source_url = param(str, required=True, label="Source URL")
icon = param(str, default="lsd-folder-symbolic")| Capability | What it gives you |
|---|---|
creatable / create_label | Show (or hide) the type in “Add entity”; set its label |
| Capability flags | can_rename, can_delete, can_create_children, can_move, can_debug, has_context_menu, is_visible, sort_children |
traits | Free-form tags (add_trait/has_trait) front-ends/packages can branch on |
creatable_child_types() | Restrict which entity types can be created under this one |
get_actions() → EntityAction | Add items to the entity's right-click context menu |
CreateSpec | Drive a “new child” flow (type picker + param form) from an action |
from lsdtools import EntityAction
class Dataset(Entity):
def get_actions(self):
return [EntityAction("Refresh", callback=self._refresh, icon="lsd-refresh-symbolic")]3 · Parameters & widgets Available
param() declares an input on a step or entity. The desktop renders an appropriate
control; the CLI renders a flag. One declaration, both front-ends.
amount = param(float, default=1.0, min=0, max=10, label="Amount", group="Tuning")
mode = param(str, choices=["fast", "accurate"], default="fast")
notes = param(str, widget="textarea", visibility="advanced")
secret = param(str, widget="password")
folder = param(str, widget="dir")| Option | Effect |
|---|---|
type, default, required | Value type, default, and whether it must be set |
label, description | UI label + helptext / CLI help |
choices | Renders a dropdown (combo); validates membership |
min, max | Numeric range (validated; spin-button bounds) |
widget | Hint: password, dir, file, textarea |
group | Groups params into tabs (desktop) / argument-groups (CLI) |
visibility | normal · advanced · hidden |
validators | Extra (value) → error? callables |
output | Read-only result written by run() |
Built-in form controls Available
The desktop maps each parameter to one of: text, multiline, numeric, checkbox, combo, file, directory, plus action controls (button, split-button, menu-button).
widget="my-thing" hint (e.g. a color
picker, a query builder), so packages aren't limited to the built-in set. The widget
field already exists on every parameter; the registry/extension API does not yet.4 · CLI commands Available
Add a lsd <name> subcommand. configure(parser) adds arguments;
run(args) executes and returns an exit code. Available to both lsd and
the installed LSDCli.
from lsdtools import CliCommand, contribute
def _configure(p): p.add_argument("model", help="path to a .onnx file")
def _run(args): print("inspecting", args.model); return 0
def register(host):
contribute(CliCommand("inspect-model", "summarise an ONNX graph",
configure=_configure, run=_run))
# → lsd inspect-model model.onnx6 · Custom UI panels Available
Add a panel to the desktop — an editor tab or a sidebar section. The common way is declarative: describe fields + actions as data and LSD renders them with its own controls — no GTK import in your package. Action callbacks receive the panel's current field values.
from lsdtools import Panel, Field, Action, panel_view, contribute
def _greet(values): # {field name → current value}
print("hello", values["name"], values["times"])
def register(host):
panel = Panel(
title="Greeter",
fields=[Field("name", default="world"),
Field("times", kind="int", default=1, min=1, max=10)],
actions=[Action("Greet", _greet, style="suggested-action")],
)
contribute(panel_view("my.greeter", "Greeter", panel, location="sidebar"))Field kinds: text, password, textarea, int,
float, bool, file, dir, or any field with
choices=[…] (a dropdown). Same controls the inspector uses.
Escape hatch — a raw widget Available
Need something the declarative panel can't express? Contribute a factory() that
returns any GTK 4 widget. Full power; couples that package to GTK (as the built-in DL viewer does).
from lsdtools import ViewContribution, contribute
def make_panel():
import gi; gi.require_version("Gtk", "4.0")
from gi.repository import Gtk
return Gtk.Label(label="Anything you can build in GTK")
def register(host):
contribute(ViewContribution("my.custom", "Custom", make_panel, location="editor"))lsd-studio. The in-app authoring panel
(generate steps with AI, open in VS Code, hot-reload) is itself a package built only on
lsdtools — a declarative panel + host services, no GTK, no lsd.*. It's the
proof the whole authoring UX can be built (and sold) on the public API. lsd package lint
confirms it stays clean.7 · Events Available
Subscribe to the app's lifecycle with host.on(event, handler) inside
register. Handlers are async-capable and receive Event(type, data).
Pipeline & engine events
| Event | When |
|---|---|
engine/run/started · …/finished | A run begins / ends |
engine/entity/started · finished · blocked | Per-entity execution |
pipeline/started · finished | A pipeline's overall span |
pipeline/step/started · skipped · finished · failed | Per-step (cache hits emit skipped) |
entity/child/added · removed · moved · entity/renamed | Tree edits |
Desktop events
| Event | When / payload |
|---|---|
desktop/project/opened · closed · saved | Project lifecycle (path, engine) |
desktop/menu/build | Collect EngineActions (see Menus) |
desktop/ui/theme | Theme changed (dark/light) |
desktop/packages/loaded · installed · removed | Package lifecycle |
desktop/scripts/reloaded | Loose project scripts hot-reloaded |
8 · Host services Available
register(host) receives a PackageHost. It transparently proxies the event
bus (host.on/emit) and exposes optional services so a package can act on the
app without importing the desktop:
def register(host):
eng = host.engine # current project's engine (or None)
host.notify("Indexing finished", "info")
host.open_in_editor("steps.py") # open a file in the code editor
host.request_reload() # ask the app to reload project scriptsPackageHost API, the event proxy, and
the desktop implementations now ship — register(host) receives a host whose
engine/notify/open_in_editor/request_reload are
wired to the running app. (notify records to the log today; an on-screen toast is a
small future polish.) Off the desktop — e.g. the CLI — service calls remain safe no-ops/None.9 · Step hooks & agents Available
The lsd-agent package wraps pipeline execution with hooks — for human-in-the-loop
approval, auditing, or AI assistance — without changing your steps.
from lsd_agent import run_with_hooks, HITLHook, AuditHook
run_with_hooks(engine, "sales", hooks=[HITLHook(), AuditHook()])
# HITLHook pauses for approval before each step; AuditHook records what ran.Write your own by subclassing BaseHook (pre/post-step callbacks with a
StepContext).
AI step generation Available
Describe a step in plain English and have it written for you. The public
lsdtools.generate_step bridges to the private lsd-agent engine,
which calls Claude with your project's live API context (the same cheat-sheet from §10) so the
output uses exactly the steps and params available here. The generated step lands in your
steps.py and hot-reloads.
from lsdtools import generate_step
generate_step("a deliver step that writes inputs.main to a parquet file", project_dir=".")In the desktop: Develop → Generate Step with AI…. From the terminal:
lsd ai "keep the top 100 rows by value". Needs an ANTHROPIC_API_KEY
(or pass a custom completer=).
10 · Packaging & lifecycle Available
| Task | Command / API |
|---|---|
| Scaffold a package | lsd package new my-package |
| Manifest | lsd_package.json (name, version, deps, entry, register) |
| Check the boundary | lsd package lint . (errors on lsd.* imports) |
| Install / enable / disable | lsd package install · enable · disable |
| Add a folder you're editing | desktop Packages → Add local… — registered live (by reference, not copied) and pinned to the project |
| Seal & sign | lsd package pack → a signed .lsdpkg |
| Publish to the marketplace | lsd package publish (lint + seal) → register, then users lsd market install <name> |
| Find the installed runtime | from lsdtools import find_runtime → LSDCli.exe |
| Describe the API (editors / AI) | lsd api describe --json · from lsdtools import describe_api |
| Write an AI context file | lsd api context --out LSD_CONTEXT.md (the desktop also refreshes it on “Open in VS Code”) |
lsdtools; never lsd.*.
lsd package lint enforces it (CI-friendly). See the Quickstart.Roadmap — what's proposed Proposed
The plan, distilled from the gaps above. Review and we'll prioritize, then implement.
| Item | Why |
|---|---|
| Custom param widgets in panels | Declarative panels now cover the common cases (see §6). Next: pluggable custom controls (color picker, query builder…) for both panels and the inspector. panels shipped |
| On-screen notifications | A desktop toast for host.notify() (it records to the log today). Host services are otherwise wired. recently shipped |
| Custom parameter widgets | Public registry so a package can supply a control for a custom widget= hint (color picker, query builder, …). |
| Typed editor stubs | lsd api describe --json + lsd api context (Markdown cheat-sheet) ship. Next: a generated .pyi for inline editor type-checking. describe + context shipped |
| Inline AI iteration | Step generation ships (§9: lsdtools.generate_step / lsd ai / Develop → Generate Step with AI). Next: in-place edit/refine of an existing step and auto-wiring it into the pipeline. generate shipped |
| Toolkit-agnostic views beyond panels | Longer-term: editor surfaces that aren't tied to GTK at all. |