# Foldnano Plugin Developer Guide ──────────────────────────────────────────────────────────── # Plugin Quick Start This guide is for developers and AI agents writing plugins for Foldnano. A plugin is Python code plus a manifest that tells Foldnano how to run it and whether it can modify the design. ## Minimal Plugin Every file/URL-delivered plugin is a single zip bundle with `fplugin.json` and `plugin.py` at its root. Start with a plugin folder: **`my_plugin/plugin.py`** ```python from foldnano import PluginContext def run(ctx: PluginContext) -> None: ctx.log("Hello from my plugin") ctx.set_result({"status": "ok"}) ``` **`my_plugin/fplugin.json`** ```json { "schemaVersion": 2, "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "format": "cadnano_json", "applyStrategy": "previewOnly", "description": "One-line description shown in the plugin panel." } ``` `foldnano` gives you `ctx` and read-only access to the design. To edit a design you also import from `foldnano.edit`; see [which package to import](https://fold.xinshilab.com/docs/plugin-development/runtime-model#which-package-to-import). The manifest inside the zip doesn't need `entrypointUrl`. The installer already knows `plugin.py` is in the zip. Zip the two files together **from inside the folder** (no directory prefix): ```bash cd my_plugin zip my_plugin.zip fplugin.json plugin.py ``` Install it with the **Import zip** button in the Plugin panel (or by opening the `.zip` file directly). ### Sharing your plugin Share the `.zip` file itself, for example by email, a lab file share, or a release on GitHub. Your users install it with **Import zip**. During the beta, Foldnano installs plugins only from a local zip or from its own Hub, not from links to other websites; a manifest whose `entrypointUrl` points at another site is refused. ## What Happens When It Runs 1. Foldnano reads the manifest. 2. The plugin runtime loads the Python entrypoint in a [Pyodide](https://pyodide.org/en/314.0.7/index.html) worker (CPython in WebAssembly; see [Runtime Model](https://fold.xinshilab.com/docs/plugin-development/runtime-model) for what does and doesn't work there). 3. Foldnano passes the active design to Python in the format declared by the manifest. 4. The plugin logs messages, writes artifacts, returns structured results, or returns a design mutation. 5. If the plugin is mutating, the user chooses whether to apply the result. Preview-only plugins are the safest starting point. They can inspect a design and produce reports without changing the model. ## Adding More Files The zip isn't limited to one file: add helper modules, data tables, or a `.whl` wheel at the zip root (subfolders are fine too). Everything ships in the same bundle as `fplugin.json` and `plugin.py`. Keep the zip at **50 MB or less**, and under **200 MB** once unpacked. Use the testing guide before distributing a mutating plugin. ──────────────────────────────────────────────────────────── # Runtime Model Foldnano plugins run in a browser Python runtime ([Pyodide](https://pyodide.org/en/314.0.7/index.html), CPython compiled to WebAssembly) hosted by a Web Worker. The main editor remains the owner of the real model; Python receives a serialized input and returns either analysis output or an explicit result to apply. ## Runtime Pipeline 1. The user installs a plugin: either by importing a `.zip` locally, or from Foldnano's Hub, whose zips are served by Foldnano itself and fetched once at install time. 2. Foldnano validates the manifest inside the zip (format, apply strategy, entrypoint shape) and stores the whole zip in IndexedDB. 3. When the plugin is activated, the plugin coordinator loads the cached zip from IndexedDB. Activation never re-fetches the plugin's code. (Script-Lab-like `sidePanel.codeEditor` plugins are the one exception: their source is the live editor buffer, not a stored zip.) 4. Pyodide extracts the zip and loads `plugin.py`, plus any requirements: bundled wheels come from the zip; package names are installed from the Pyodide distribution or PyPI at this point. 5. Foldnano builds a `PluginContext` with the active part, active region, parameters, and side-panel state. 6. Python runs `run(ctx)`. 7. The host receives logs, artifacts, JSON results, or a mutation result. 8. Mutating results are applied only through the selected apply strategy. If `run()` raises, the run ends with an error: the panel shows the traceback, and the log keeps every line written before the exception. Nothing is applied. To report a problem more readably, catch it and log a message instead, for example around `ctx.get_part_as_foldnano_json()`, which raises `RuntimeError` when the manifest declares another format. A result can also be refused after a clean run; see [When a Result Can't Be Applied](https://fold.xinshilab.com/docs/plugin-development/plugin-formats#when-a-result-cant-be-applied). Plugins installed under the old, pre-unification shapes (inline `entrypoint` on a normal plugin, or a raw `.py` `entrypointUrl`) are flagged `needs-reinstall` and cannot be activated. Re-import the zip to fix. ## Environment Tiers Your `requirements` and `network` decide which of two Python runtimes your plugin uses. Plugins whose requirements are all unversioned Pyodide-distribution names (e.g. `numpy`, `pandas`) or standard extras (first-party pinned wheels referenced by bare name, currently `cadnano2_5_mini`) run in the shared **standard environment**, a persistent runtime where standard plugins activate side by side with no reinstall on switch. Anything else (declared [network access](https://fold.xinshilab.com/docs/plugin-development/plugin-manifest#network-access), off-distribution package names, any version specifier, or any `.whl`) routes the plugin to a dedicated **custom environment**, of which only one can be active at a time: activating a custom plugin evicts the previous one (with confirmation if it is busy). Within the standard environment, your plugin's own code runs in a fresh namespace each run, but installed libraries and their state are shared with other standard plugins. Do not mutate global library configuration (`np.seterr`, `pd.set_option`, monkey-patching shared modules): it leaks into every other standard plugin's runs; if unavoidable, save and restore the setting inside `run(ctx)`. Prefer staying standard-tier: avoid unnecessary version pins, and ship wheels only for libraries missing from the Pyodide distribution. Foldnano currently pins **Pyodide 314.0.7** (Python 3.14); the About dialog shows the version your copy runs. The package names and exact versions available in the standard environment are listed in [Packages in Pyodide 314.0.7](https://pyodide.org/en/314.0.7/usage/packages-in-pyodide.html) (machine-readable: [`pyodide-lock.json`](https://cdn.jsdelivr.net/pyodide/v314.0.7/full/pyodide-lock.json)). Develop and test against these versions. Anything importable but not on that list is a pure-Python package micropip fetches from PyPI at its latest version, which classifies your plugin custom-tier. Bundled wheels must match this runtime. Pure-Python wheels (`py3-none-any`) work across Pyodide versions; a wheel with compiled code must be built for Python 3.14 and Pyodide 314 (`cp314` with platform tag `pyemscripten_2026_0_wasm32`). Wheels built for Pyodide 0.29 (Python 3.13, `pyemscripten_2025_0` or `pyodide_2025_0`) or earlier no longer load. Pyodide now numbers its releases after the Python version they ship (314 is Python 3.14) and plans one new major release a year. When Foldnano moves to a new one, the distribution packages move with it (Pyodide 314 brought pandas 3, for example), so test your plugin again after an upgrade. ## Writing Python That Runs In Pyodide Pyodide is CPython in WebAssembly, and most pure-Python code just works, but the browser sandbox has real limits that surface as runtime errors in plugins. Before debugging in Foldnano, check the upstream Pyodide 0.29 documentation: - [Pyodide documentation](https://pyodide.org/en/314.0.7/index.html): entry point for everything below. - [Python compatibility and WASM constraints](https://pyodide.org/en/314.0.7/usage/wasm-constraints.html): what does not work in the browser, including no `threading`/`multiprocessing` (they import but can't spawn), no `subprocess`, no blocking `socket` networking (so `requests`-style HTTP calls fail), and other removed or stubbed stdlib modules. - [Loading packages and micropip](https://pyodide.org/en/314.0.7/usage/loading-packages.html): how `requirements` entries are actually installed, and which wheels micropip accepts (pure-Python wheels from PyPI, or Pyodide/emscripten builds). - [Pyodide FAQ](https://pyodide.org/en/314.0.7/usage/faq.html): common patterns and workarounds, including HTTP requests from Python. Rule of thumb: keep plugins CPU-bound and self-contained, reading the design from `ctx`, computing, and returning results or a mutation log. If a library spawns threads or touches the network at import time, it will not survive the worker even if it installs cleanly. ## Model Ownership Python never receives direct access to the live JavaScript `Part`. This keeps undo/redo, region scoping, and validation on the host side. | Format | Python receives | Python returns | | --- | --- | --- | | `cadnano_json` | cadnano JSON string | cadnano JSON result or preview output | | `foldnano_json` | Foldnano `Part.toJSON()` snapshot | Foldnano JSON result or preview output | | `foldnano_edit` | Foldnano JSON loaded into a mutable Python mirror | structured mutation log | `foldnano_edit` is the preferred model for direct edits because the host replays the mutation log into the real part inside one undoable command transaction. ## Which Package To Import | You want to | Import from | Main names | | --- | --- | --- | | Type `ctx`, read inputs, log, return results | `foldnano` | `PluginContext` | | Read a design without changing it | `foldnano` | `parse_foldnano_json`, `PartData` | | Edit a design | `foldnano.edit` | `load_part`, `MutablePart` | | Store a wireframe region's geometry | `foldnano` | `ReferenceGeometry`, `ReferenceMesh` | | Convert nm to bp along a helix | `foldnano` | `BACKBONE_RISE_NM` | `foldnano` reads a design. `foldnano.edit` edits one, recording each change so the host can replay it as one undo step. An editing plugin usually imports from both. ## Region Scope Plugins launched from the editor receive `ctx.active_region_id` when a region is active. Use it to keep plugin behavior predictable in multi-region designs. When using `replayMutationLogInRegion`, the host rejects mutation-log operations that target virtual helices outside the active region. ## Side Panels Manifests can request host-rendered side panels. A form side panel shows parameter controls. A code-editor side panel can either pass editor text as input or execute the editor contents as the plugin entrypoint for that run. ## Security And Trust Your plugin runs in a sandbox: a Web Worker inside a hidden iframe with no origin of its own, plus a Content Security Policy. From Python, that means: - **No access to Foldnano itself.** Requests to Foldnano's server (including the user's GitHub connection) fail, and browser storage such as IndexedDB is unavailable. Everything your plugin needs arrives through `ctx`. - **Network only as declared.** Requests go only to the Pyodide CDN, to PyPI when your `requirements` need it, and to the hosts in your manifest's [`network`](https://fold.xinshilab.com/docs/plugin-development/plugin-manifest#network-access). Anything else is blocked, and the run's log gets a line such as `[sandbox] Blocked a request to api.example.org`. - **No `eval` of JavaScript strings** (`js.eval` fails), and no nested workers from Foldnano's site. - **Runs are bounded.** A run that takes more than 5 minutes, or that the user stops, is ended by terminating the worker. Users see your manifest's format, apply strategy, network access and packages (grouped by source, with PyPI packages flagged) before they install. Mutating plugins change the design only when the user clicks **Apply**, as one undoable step. Users install plugins from the Hub or from a local zip; Foldnano doesn't review third-party plugins, so users are told to install only from people they trust. ──────────────────────────────────────────────────────────── # Formats and Apply Strategies ## Choosing a Format | Question | Use | | --- | --- | | I only need helix topology and colors | `cadnano_json` | | I need sequences, oligo names, or modifications | `foldnano_json` | | I need to mutate the model and preserve sequences | `foldnano_json` with `replacePartKeepAnnotationsWhereMatched` | | I want to write code that mutates the part directly (one undo step) | `foldnano_edit` with `replayMutationLog` / `…InRegion` | **`cadnano_json`** is a lossy format: it carries helix topology and staple colors but drops sequences, modifications, oligo names, and the explicit scaffold flag. cadnano's `scaf`/`stap` arrays are only a direction convention, so a `cadnano_json` result carries no scaffold flag; `replaceRegionKeepAnnotationsWhereMatched` puts it back on oligos whose segments still match, `replaceRegion` doesn't. Before a `cadnano_json` plugin runs, Foldnano checks whether the active region fits in cadnano JSON. Problems that would corrupt the plugin's input, such as a connection between a cadnano scaffold path and a staple path, always ask the user first. For `replaceRegion` strategies the user is also told what applying the result would lose (connections to other regions, repeat-group copies, and so on), since the region is then replaced by what came back. The user can cancel; the plugin doesn't run. **`foldnano_json`** is the lossless `Part.toJSON()` snapshot. It includes everything: per-slot sequences, skip/insert structure, modifications, oligo names, `isScaffold` flag, and UUIDs. It is the only format that exposes sequence data to plugins. Its shape is the same as one entry of `parts` in a `.fn.json` file; the [File Format](https://fold.xinshilab.com/docs/contributors/file-format#part) page documents every field. **`foldnano_edit`** is the mutable API. The plugin operates on a `MutablePart` whose mutations are captured as a structured op log; the host replays the log against the real JS-side `Part` under one undo step. This is the only format that lets you express _direct_ edits (`oligo.color = '#f00'`, `part.create_segment(...)`, `part.make_crossover(...)`, `part.break_at(...)`) instead of producing a whole-part snapshot. **`live`** is reserved for a future phase and is not currently installable. Manifests that declare `format: "live"` are rejected at install time. --- ## Choosing an Apply Strategy | Strategy | When to use | | --- | --- | | `previewOnly` | Analysis-only plugins that produce logs, CSV reports, or other artifacts without mutating the design. The model is never touched. | | `replacePartKeepAnnotationsWhereMatched` | Whole-part replacement for `foldnano_json` plugins when sequences, modifications, and names should be re-grafted onto matched segments after the replace. | | `replacePart` | Whole-part replacement for `foldnano_json` plugins when annotation re-grafting would be incorrect. Shows a data-loss warning before apply. | | `replaceRegion` / `replaceRegionKeepAnnotations…` | Region-scoped replacement for `cadnano_json` plugins. Cross-region links are re-grafted automatically. Region replacement must preserve the region's VH index set; creating new VHs is not supported. | | `replayMutationLog` | Replay a `foldnano_edit` mutation log against the existing part. One undo step covers the whole run. | | `replayMutationLogInRegion` | As above, but every op's target VH must belong to the active region; otherwise the replay aborts cleanly. | For `foldnano_json` mutations, **always prefer `replacePartKeepAnnotationsWhereMatched`** unless you are intentionally changing the topology. It ensures sequences assigned before the run survive the apply. For `foldnano_edit` mutations, choose `replayMutationLogInRegion` when your plugin only touches the active region (the host enforces the scope) and `replayMutationLog` when it touches the whole part. For `cadnano_json` region replacement, treat the active region's VH numbering as fixed. A `replaceRegion*` plugin may rewrite topology within those helices, but it must not introduce brand-new VHs for that region. Install-time validation enforces the supported combinations: - `cadnano_json`: `previewOnly`, `replaceRegion`, `replaceRegionKeepAnnotationsWhereMatched` - `foldnano_json`: `previewOnly`, `replacePart`, `replacePartKeepAnnotationsWhereMatched` - `foldnano_edit`: `previewOnly`, `replayMutationLog`, `replayMutationLogInRegion` ## When a Result Can't Be Applied For `replacePart*` and `replaceRegion*` strategies, a run that returns a result doesn't always offer **Apply**. Before it does, Foldnano checks the returned JSON twice: - **Result validation** checks its shape against the declared `format`: well-formed `vstrands` or `Part.toJSON()` data, and references that point somewhere. Small inconsistencies are repaired (for example, a connection recorded on only one side is cleared when the other end is free); malformed data is rejected. - **Design verification** rebuilds the result the way Apply would, on a throwaway copy, and looks for dangling references, segments outside their helix, connections that don't match up, and, for region strategies, a change to the active region's set of helices. It also rejects designs the editor itself never produces: a skip or insert that differs between the two strands of one base, a skip or insert on a constrained crossover site, two helices on one lattice position, or a negative lattice position. A helix whose number is odd on an even lattice position (or the reverse) is only logged to the console. If either check fails, the plugin panel names the offending field or id instead of showing **Apply**, and your design is untouched. So a `replaceRegion*` plugin that adds or removes a helix in the active region runs without error but never offers Apply. ──────────────────────────────────────────────────────────── # Plugin Manifest ## Manifest Reference ```json { "schemaVersion": 2, "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "format": "cadnano_json", "applyStrategy": "previewOnly", "entrypointUrl": "/plugins/my_plugin/my_plugin.zip", "requirements": ["numpy"], "description": "Optional description shown in the plugin panel.", "icon": "icon.svg", "citations": [{ "text": "Author, A. and Author, B., Journal, 2026, 1(2), p.3.", "doi": "10.1234/example" }] } ``` | Field | Required | Notes | | --- | --- | --- | | `schemaVersion` | Recommended | Manifest schema version, currently `2`. Read as `1` when missing, but include it; `icon` and `citations` need `2`. Not your plugin's version; see [Versioning](#versioning-and-unknown-fields). | | `$schema` | No | Link to the [published JSON Schema](#machine-readable-schema) for editor support. Ignored by Foldnano. | | `id` | Yes | Unique key used by the registry. Starts with a letter, then letters, digits, `.`, `_` or `-`, up to 64 characters; kebab-case such as `my-plugin` is recommended. Checked when the plugin is installed. | | `name` | Yes | Display name in the panel. | | `version` | Yes | A [semantic version](https://semver.org) such as `1.0.0` or `1.2.0-beta.1` (not `latest` or `v1`). Bump it whenever the plugin's files change. Checked when the plugin is installed. | | `format` | Yes | `"cadnano_json"` \| `"foldnano_json"` \| `"foldnano_edit"`. (`"live"` is reserved and rejected at install time.) | | `applyStrategy` | Yes | Must match the selected `format`. | | `entrypoint` | Either/or | Inline Python source string. Only valid for `sidePanel.codeEditor` plugins with `execution: "entrypoint"`; see below. | | `entrypointUrl` | Either/or | URL to a `.zip` plugin bundle. Required for every other plugin. | | `requirements` | No | micropip package names, bare `.whl` filenames shipped in the zip, or wheel URLs fetched at activation. | | `network` | No | Hosts your plugin may contact, or `["*"]` for any HTTPS host. Omit it for no network access; see [Network access](#network-access). | | `description` | No | Human-readable description. | | `icon` | No | Path of an SVG icon inside your zip, such as `"icon.svg"`. Needs `"schemaVersion": 2`; see [Plugin icon](#plugin-icon). | | `citations` | No | Papers you ask users to cite. Needs `"schemaVersion": 2`; see [Citations](#citations). | | `parameters` | No | Array of `PluginParamDef`: user-configurable inputs shown in a run dialog. | | `sidePanel` | No | Optional host-rendered side panel request. Supports `form` and `codeEditor` UIs. | | `x-…` | No | Your own metadata (for example `"x-homepage"`). Allowed at the top level only; ignored by Foldnano. | `entrypoint` and `entrypointUrl` are **mutually exclusive**: a manifest that sets both fails validation. For nearly every plugin, `entrypointUrl` is required and must point to a `.zip` bundle (checked by parsed URL pathname, so `plugin.zip?v=2` is still recognized). Raw `.py` URLs are no longer accepted. `entrypointUrl` can be: - A relative path to a `.zip` bundle served by Foldnano (e.g. `/plugins/my_plugin/my_plugin.zip`) - An absolute URL on Foldnano's own site (see below) The manifest **inside** the zip (`fplugin.json`) may omit `entrypointUrl` entirely, since the installer already knows the code is in the zip it just unpacked. Only a pointer manifest (a Hub entry that ships with Foldnano) needs `entrypointUrl`, and its job is purely to locate the zip: the manifest fetched from inside that zip is what actually gets registered, and its `id` must match the pointer's `id`. The only case where inline `entrypoint` is allowed is a `sidePanel.codeEditor` plugin with `execution: "entrypoint"`; see [Code-editor execution modes](#code-editor-execution-modes) below. Such a manifest must not also set `entrypointUrl`. ### Versioning and unknown fields `schemaVersion` tells Foldnano which version of this manifest format you wrote against. It is separate from `version`, which is your plugin's own release number. - A manifest without `schemaVersion` is read as version 1, so plugins written before the Foldnano beta keep working. Include `"schemaVersion"` anyway: fields added later (such as `icon` and `citations` in version 2) only work once you declare the version that introduced them. - A manifest with a higher `schemaVersion` than the running app supports is refused with a request to update Foldnano, rather than having its newer fields silently ignored. - Unknown fields are rejected, at every level, so a typo such as `"netwrok"` fails loudly instead of quietly doing nothing. Put your own metadata in top-level fields starting with `x-`. When a field is added, `schemaVersion` normally goes up and the new schema is published next to the old one. During the beta, an optional field that only affects what is displayed can join the current version instead, as `citations` did. Existing fields never change meaning between versions, so a manifest that works today keeps meaning the same thing. ### Machine-readable schema Each manifest schema version has a JSON Schema (draft 2020-12), published with the app: | Schema version | Schema | | --- | --- | | 1 | [`/schemas/fplugin-v1.schema.json`](https://fold.xinshilab.com/schemas/fplugin-v1.schema.json) | | 2 | [`/schemas/fplugin-v2.schema.json`](https://fold.xinshilab.com/schemas/fplugin-v2.schema.json) (adds `icon` and `citations`) | Point `$schema` in your manifest at the one matching your `schemaVersion` (for example `"$schema": "https:///schemas/fplugin-v2.schema.json"`) and editors such as VS Code will autocomplete fields and flag mistakes as you type. The schema checks structure, types and the `format`/`applyStrategy` pairing. Foldnano checks a little more when installing (allowed URLs, field defaults and ranges, parameters versus side panel forms), so a manifest can pass the schema and still be refused; the install error says why. ### Network access Plugins run in a sandbox with **no network access by default**. To call a web API, list its hosts in `network`: ```json "network": ["api.example.org", "*.data.example.org"] ``` - Entries are lowercase host names, optionally with a `*.` prefix or a port (`"localhost:8000"`). Leave out `https://` and any path. - `["*"]` on its own allows any HTTPS host. Prefer listing hosts: users see the list before they install your plugin. - `localhost` and `127.0.0.1` are reached over `http://`, everything else over `https://`. The server has to allow cross-origin requests (CORS), because the sandbox has no origin of its own. - PyPI access for your `requirements` is granted automatically. - A plugin can never reach Foldnano's own server or the user's GitHub account, whatever it declares. - Declaring network access puts your plugin in the custom environment; see [Runtime Model → Environment Tiers](https://fold.xinshilab.com/docs/plugin-development/runtime-model). ### Entrypoint URL restrictions During the beta, `entrypointUrl` must point at Foldnano's own site: in practice, the zips bundled with its Hub. A manifest pointing at any other website is refused at install time, with a suggestion to download the zip and use **Import zip** instead. To share your plugin, share the zip. ### Wheel requirements A `requirements` entry ending in `.whl` is handled one of two ways: - **Bare filename** (e.g. `"pkg-1.0-py3-none-any.whl"`, no slash): the wheel must be present at the root of the plugin zip. It is stored in IndexedDB alongside the plugin at install time; activation never re-fetches it. - **Path or URL on Foldnano's own site** (e.g. `"/plugins/cadnano_mini/v0.1.0/pkg.whl"`): fetched at activation time. Used by first-party plugins; a wheel on any other website is refused, so bundle yours in the zip. Regular package names (e.g. `"numpy"`, `"humanize>=4"`) are installed from the Pyodide distribution or PyPI. A path, a URL, or a direct reference such as `"pkg @ https://..."` is refused for anything that isn't a `.whl`. For bare package names, prefer packages from the pinned Pyodide distribution and develop against their exact versions; see [Packages in Pyodide 314.0.7](https://pyodide.org/en/314.0.7/usage/packages-in-pyodide.html). Staying within that list (unversioned) keeps your plugin in the shared standard environment; see [Runtime Model → Environment Tiers](https://fold.xinshilab.com/docs/plugin-development/runtime-model) for what that means. ### Host-rendered side panels Plugins can request a dedicated sidebar tab by declaring `sidePanel` in the manifest. The plugin still ships only Python; the host chooses the UI: - `type: "form"` renders manifest-declared fields in the upper section and runtime output below - `type: "codeEditor"` renders a built-in Python editor in the upper section and runtime output below For `type: "codeEditor"`, the optional `sidePanel.codeEditor.execution` field controls what the editor contents mean: - `"input"` (default): the plugin's manifest entrypoint runs, and the editor contents are passed to Python via `ctx.get_side_panel_code()` - `"entrypoint"`: the editor contents themselves are executed as the plugin source for that run `sidePanel.icon` (`form`, `code` or `analysis`) is deprecated for packaged plugins: bundle an SVG with the top-level `icon` instead, which wins when both are set. Code-editor plugins with `execution: "entrypoint"` have no zip, so `sidePanel.icon` remains their only icon option. ### Plugin icon Give your plugin its own icon by putting an SVG file in the zip and naming it in the manifest: ```json "schemaVersion": 2, "icon": "icon.svg" ``` The icon appears on your plugin's sidebar tab and on its row in the Plugins list. Foldnano checks it when the plugin is installed: - At most 32 KB, with an `` root that has `xmlns="http://www.w3.org/2000/svg"` and a `viewBox`. - No scripts, event handlers, `foreignObject`, `` or animation elements. - No links to other files: `href` and `url()` may only point at an `#id` in the same file. - The path is relative to the zip root (`"icon.svg"` or `"assets/icon.svg"`) and ends in `.svg`. Foldnano draws the icon in a single colour that follows the app theme, so design it as a monochrome glyph. A 24×24 stroke icon in the style of [Lucide](https://lucide.dev) fits in well. Code-editor plugins with `execution: "entrypoint"` have no zip, so they can't use `icon`. ### Citations List the papers you would like people to cite when they use your plugin in their work: ```json "schemaVersion": 2, "citations": [ { "text": "Aksel, T., J. Navarro, E., Fong, N. and Douglas, S.M., Proceedings of the National Academy of Sciences, 2024, 121(48), p.e2406769121.", "doi": "10.1073/pnas.2406769121" }, { "text": "My Plugin source code, version 1.0.", "url": "https://example.org/my-plugin" } ] ``` Users see citations when they review your plugin before installing it, and when they click its name in **My Plugins**. - `text` is required and shown as plain text, at most 500 characters. Format the reference however you like. - `doi` is a bare DOI such as `10.1073/pnas.2406769121`, without `https://doi.org/`. Foldnano builds the link. - `url` is an `https://` link for work without a DOI. Set `doi` or `url`, not both; both are optional. - List at most 10 references. ### Code-editor execution modes #### `execution: "input"` Use this when your plugin has a fixed implementation but wants user-authored code-like text as input. In this mode, `entrypoint` or `entrypointUrl` is the real code that runs, and the editor buffer is available through `ctx.get_side_panel_code()`. #### `execution: "entrypoint"` Use this when the editor buffer is the full Python source of truth. In this mode, the host executes the current editor buffer as `pluginCode` for that run. This is the **only** case where a manifest may set inline `entrypoint` instead of `entrypointUrl`. The value is a placeholder never actually run, since the real source is the editor buffer. Such a manifest must not also set `entrypointUrl`. Minimal manifest shape: ```json { "schemaVersion": 2, "id": "my-script-plugin", "name": "My Script Plugin", "version": "1.0.0", "format": "foldnano_edit", "applyStrategy": "replayMutationLog", "entrypoint": "def run(ctx):\n raise RuntimeError('Editor contents execute at runtime')\n", "sidePanel": { "type": "codeEditor", "title": "My Script Plugin", "icon": "code", "codeEditor": { "filename": "script.py", "initialCode": "from foldnano import PluginContext\n\n\ndef run(ctx: PluginContext) -> None:\n pass\n", "execution": "entrypoint" } } } ``` ## Next The `ctx` your `run(ctx)` receives is documented in the [Context API Reference](https://fold.xinshilab.com/docs/plugin-development/context-api). ──────────────────────────────────────────────────────────── # Context API Reference Every plugin's `run(ctx)` receives one `PluginContext`. It is the plugin's only connection to the host: it hands in the design, the parameters and the side-panel state, and it takes back the result, the log and any downloads. It never changes the design itself; the host applies the result afterwards, using the manifest's apply strategy (see [Formats and Apply Strategies](https://fold.xinshilab.com/docs/plugin-development/plugin-formats)). To read the design as objects instead of a JSON string, pass `ctx.get_part_as_foldnano_json()` to the [Parse API](https://fold.xinshilab.com/docs/plugin-development/plugin-api) or to [foldnano_edit](https://fold.xinshilab.com/docs/plugin-development/foldnano-edit-api). **Kind** says what each name does: - **read-only**: a property you read; assigning to it is an error. - **query**: a method that only reads. - **output**: a method that stores something for the host to pick up after `run()` returns. Calling it again replaces what an earlier call stored (`log` and `write_artifact` add to it instead). - **constant**, **class**: module-level names. ## Module | API | Kind | Behaviour | | --- | --- | --- | | `PluginContext` | class | The type of `ctx`, imported with `from foldnano import PluginContext`. `from foldnano.plugin import PluginContext` also works. The host builds it; you construct one yourself only in tests ([below](#in-tests)). | | `MAX_ARTIFACT_BYTES` | constant | The most all of one run's artifacts may add up to: 100 MB (text counts as UTF-8). In `foldnano.plugin`. | ## Input | API | Kind | Behaviour | | --- | --- | --- | | `ctx.format` | read‑only | The manifest's `format`: `'cadnano_json'`, `'foldnano_json'` or `'foldnano_edit'`. | | `ctx.get_part_as_cadnano_json(part_index=0)` | query | The active part as a cadnano JSON string. Only for `format: "cadnano_json"`; any other format raises `RuntimeError`. | | `ctx.get_part_as_foldnano_json(part_index=0)` | query | The active part as a Foldnano JSON string (the `Part.toJSON()` shape). For `foldnano_json` and `foldnano_edit`; `cadnano_json` raises `RuntimeError`. | | `ctx.active_region_id` | read‑only | The id of the region that was active in the strand view when the plugin started, or `None`. Region-scoped plugins pass it to `parse_foldnano_json` or `load_part`. | | `ctx.params` | read‑only | Every parameter value confirmed in the run dialog, keyed by the `id`s in the manifest's `parameters`. | | `ctx.get_param(key, default=None)` | query | One parameter value, or `default` if it isn't set. | | `ctx.side_panel_type` | read‑only | `'form'` or `'codeEditor'` when the run started from a side panel, else `None`. | | `ctx.get_side_panel_code(default="")` | query | The code-editor side panel's contents, or `default` when there is none. | Documents hold one part, so `part_index` must be 0; any other value raises `NotImplementedError`. ## Output | API | Kind | Behaviour | | --- | --- | --- | | `ctx.set_result_cadnano_json(json_str)` | output | The changed design as a cadnano JSON string: the result the apply strategy applies. | | `ctx.set_result_foldnano_json(json_str)` | output | The changed design as a Foldnano JSON string, for `foldnano_json` plugins. | | `ctx.set_result_mutation_log(log)` | output | A `foldnano_edit` mutation log (normally `part.mutation_log`), replayed onto the design as one undo step. Only for `format: "foldnano_edit"`; each entry must be a dict with an `op` key. | | `ctx.set_result(data)` | output | A JSON-serialisable dict shown in the plugin output panel. It doesn't change the design. | | `ctx.write_artifact(name, data)` | output | A named download (a CSV, a PDB file, ...) of text or bytes, listed in the plugin output panel. Writing the same name again replaces it. Raises `ValueError` when the run's artifacts would pass `MAX_ARTIFACT_BYTES`. | | `ctx.log(msg)` | output | Add a line to the plugin log, shown when the run finishes. `print()` output is captured too, one log line per printed line. The log keeps the first 5,000 lines, each up to 10,000 characters. | Only the `set_result_*` methods produce something the host can apply. `set_result`, `write_artifact` and `log` are for reading and downloading. `ctx.set_artifact` doesn't exist; use `write_artifact`. ## In tests Outside the browser you build the context yourself and read back what the plugin stored. See [Testing Plugins](https://fold.xinshilab.com/docs/plugin-development/plugin-testing). | API | Kind | Behaviour | | --- | --- | --- | | `PluginContext(format, cadnano_json_in=None, foldnano_json_in=None, params_json=None, side_panel_input_json=None, active_region_id=None)` | class | A context with the given inputs. Pass the design as a JSON string to the `*_in` argument matching `format`, and parameters and side-panel state as JSON strings. | | `ctx.primary_artifact` | read‑only | The string from `set_result_cadnano_json` / `set_result_foldnano_json`, or `None`. | | `ctx.mutation_log` | read‑only | The list from `set_result_mutation_log`, or `None`. | | `ctx.plugin_result` | read‑only | The dict from `set_result`, or `None`. | | `ctx.artifacts` | read‑only | The artifacts from `write_artifact`, by name. | | `ctx.logs` | read‑only | The lines from `ctx.log`, in order. | ──────────────────────────────────────────────────────────── # Parse API Reference `parse_foldnano_json()` turns the JSON string from `ctx.get_part_as_foldnano_json()` into read-only Python objects, so you can walk the design instead of indexing raw dicts. These names come from the `foldnano` package ([which package to import](https://fold.xinshilab.com/docs/plugin-development/runtime-model#which-package-to-import)). It is the reading half of the `foldnano_json` format: to change the design, edit `part._raw` and hand it back with `ctx.set_result_foldnano_json(json.dumps(part._raw))` (see the [round-trip pattern](https://fold.xinshilab.com/docs/plugin-development/plugin-patterns#round-trip-mutation-pattern)). For an API you edit directly, use [foldnano.edit](https://fold.xinshilab.com/docs/plugin-development/foldnano-edit-api). ```python from foldnano import parse_foldnano_json part = parse_foldnano_json(ctx.get_part_as_foldnano_json(), active_region_id=ctx.active_region_id) ``` **Kind** says what each name does: - **read-only**: a value parsed from the JSON. Assigning to it changes only the Python object, never the result; edit `part._raw` instead. - **query**: a method that only reads. - **function**, **class**: module-level names. The proxies are snapshots of the JSON at parse time. Bases (`bp`) are 0-based indices along a helix; strand 0 runs 5' to 3' with increasing index, strand 1 with decreasing index. ## Module | API | Kind | Behaviour | | --- | --- | --- | | `parse_foldnano_json(json_str, active_region_id=None)` | function | Parse a Foldnano JSON string into a `PartData`. Pass `ctx.active_region_id` through for region-scoped plugins. | | `parse_cadnano_json(json_str)` | function | Parse a cadnano JSON string into a plain dict (`name`, `vstrands`). cadnano designs have no proxies. | | `PartData` | class | The parsed part ([below](#partdata)). | | `OligoProxy`, `OligoSegmentProxy` | class | An oligo and one of its segments. | | `VirtualHelixProxy` | class | A helix. | | `RegionProxy`, `LatticedRegionProxy`, `FreeformRegionProxy`, `WireframeRegionProxy` | class | A region; lattice and freeform regions are subclasses, and a `WireframeRegionProxy` is a `FreeformRegionProxy`. Test `region.supports_lattice_grid` rather than the class. | | `PlaneProxy` | class | The plane a region lives on. | | `ReferenceGeometry`, `ReferenceMesh` | class | The geometry a wireframe region was built from. | | `BACKBONE_RISE_NM` | constant | The rise along a helix axis, in nm per bp, that Foldnano draws and exports helices with (about 0.332). | All of these import from `foldnano`. ## `PartData` | API | Kind | Behaviour | | --- | --- | --- | | `part.name`, `part.id` | read‑only | The part's name and id. | | `part.virtual_helices`, `part.oligos`, `part.regions`, `part.planes` | read‑only | Everything in the part, in JSON order. | | `part.lattice_type` | read‑only | **Deprecated, and logs a warning.** The first region's kind only. Use `region.kind`. | | `part.max_base` | read‑only | **Deprecated, and logs a warning.** The first region's length only. Use `region.max_base`. | | `part.active_region_id`, `part.active_region` | read‑only | The region passed to `parse_foldnano_json` (id, and its proxy or `None`). | | `part._raw` | read‑only | The parsed JSON dict itself. Change it and serialise it back to return an edited design. | | `part.get_vh(index)`, `part.get_oligo(id)`, `part.get_region(id)`, `part.get_plane(id)` | query | The proxy with that index or id, or `None`. | | `part.region_for_vh(vh_index)` | query | The region a helix belongs to, or `None`. | | `part.vhs_in_region(region_id)` | query | Helices in a region. | | `part.oligos_in_region(region_id)` | query | Oligos whose segments all lie in that region (oligos crossing into another region are left out). | ## `OligoProxy` | API | Kind | Behaviour | | --- | --- | --- | | `oligo.id`, `oligo.name` | read‑only | Id, and the name (or `None`). | | `oligo.color` | read‑only | Hex color, e.g. `'#0066cc'`. | | `oligo.is_scaffold`, `oligo.is_sequence_locked` | read‑only | Whether it is a scaffold, and whether its sequence is locked against overwriting. | | `oligo.segments` | read‑only | Its segments, 5' to 3'. | | `oligo.sequence` | read‑only | The bases of all segments, 5' to 3'; empty where none are assigned. | | `oligo.is_circular` | read‑only | True when the last segment's 3' end joins the first one's 5' end. | | `oligo.touches_regions(part)` | query | The ids of the regions its segments visit. More than one means it crosses regions. | ## `OligoSegmentProxy` | API | Kind | Behaviour | | --- | --- | --- | | `seg.id` | read‑only | The segment's id. | | `seg.vh_index` | read‑only | The helix it lies on. | | `seg.five_prime_index`, `seg.three_prime_index` | read‑only | Its 5' and 3' bases on that helix. | | `seg.length` | read‑only | Bases spanned, both ends included. | | `seg.five_prime_modification`, `seg.three_prime_modification` | read‑only | Modification codes (e.g. `'/5Alex488N/'`), or `None`. | | `seg.connected_from_segment_id`, `seg.connected_to_segment_id` | read‑only | The segment joined to its 5' end and to its 3' end, or `None` at a free end. | | `seg.is_free_five_prime`, `seg.is_free_three_prime` | read‑only | Whether its 5' / 3' end is free (the oligo's own end). | | `seg.slots` | read‑only | One dict per base, 5' to 3': `{"bpIndex": int, "seq": str}`. `seq` is empty for a skip and longer than one base for an insertion. | | `seg.sequence` | read‑only | Its bases, 5' to 3'. | ## `VirtualHelixProxy` | API | Kind | Behaviour | | --- | --- | --- | | `vh.index`, `vh.id` | read‑only | Its index in the part, and its stable id (regions list helices by `id`). | | `vh.region_id` | read‑only | The region it belongs to. | | `vh.length` | read‑only | Bases along the helix. | | `vh.row`, `vh.col` | read‑only | Lattice position, or `None` for a freeform helix. | | `vh.is_freeform` | read‑only | True when it has no lattice position. | | `vh.parity_is_even` | read‑only | True when `row % 2 == col % 2`. Raises `ValueError` on a freeform helix. | | `vh.local_frame` | read‑only | A freeform helix's placement (`origin`, `axis`, `radial0`), or `None`. | | `vh.roll_deg` | read‑only | A freeform helix's rotation about its own axis, in degrees (-180 to 180], or `None`. 0 points `radial0` along the region plane's normal; positive turns right-handed about the axis. | | `vh.list_row` | read‑only | Its row in the strand view, or `None`. | | `vh.strand_set_segment_ids` | read‑only | `[strand 0 ids, strand 1 ids]`: the segments on each strand. | ## `RegionProxy` | API | Kind | Behaviour | | --- | --- | --- | | `region.id`, `region.name`, `region.kind`, `region.plane_id` | read‑only | Identity, name, `'HONEYCOMB'` / `'SQUARE'` / `'FREEFORM'` / `'WIREFRAME'`, and its plane. | | `region.vh_order` | read‑only | Its helices' `id`s in display order (ids, not indices; use `part.vhs_in_region` for helices). | | `region.step`, `region.turns_per_step`, `region.sub_step_size`, `region.twist_offset` | read‑only | Helical geometry: bases per step (21 honeycomb, 32 square), turns per step, crossover sub-step, and twist offset. | | `region.min_base`, `region.default_max_base` | read‑only | Its first base, and the default length of new helices. | | `region.max_base` | read‑only | The length of every helix in the region. | | `region.supports_lattice_grid` | read‑only | True for honeycomb and square regions. | | `region.reference_geometry` | read‑only | Wireframe regions only: the `ReferenceGeometry` it was built from, or `None`. | | `geometry.mesh`, `geometry.unit` | read‑only | Its `ReferenceMesh` or `None`, and the unit, always `'nm'`. | | `mesh.vertices`, `mesh.faces`, `mesh.edges` | read‑only | `(x, y, z)` positions in nm in the region's frame, faces of three or more vertex indices, and `(a, b)` vertex pairs. | | `mesh.all_edges()` | query | Every distinct edge as a sorted `(a, b)` pair: `mesh.edges` plus the sides of `mesh.faces`. | | `mesh.to_ply()` | query | The mesh as ASCII PLY text. | ## `PlaneProxy` | API | Kind | Behaviour | | --- | --- | --- | | `plane.id`, `plane.name` | read‑only | Identity and name. | | `plane.origin`, `plane.u`, `plane.v`, `plane.normal` | read‑only | Its origin and basis vectors in the lab frame, each with `.x`, `.y`, `.z`. `u` and `v` span the plane. | ──────────────────────────────────────────────────────────── # foldnano.edit API Reference This package was previously called `foldnano_mini`, and the manifest format `foldnano_edit` was previously `foldnano_mini` too. The old names still work, so existing scripts and plugins keep running, but they are deprecated: use `foldnano.edit` and `foldnano_edit` in new code. Importing `foldnano_mini` logs a warning. For how these fit together (loading a part, checking before you edit, crossovers, drawing paths), see [Plugin Patterns](https://fold.xinshilab.com/docs/plugin-development/plugin-patterns#writing-plugins-with-foldnanoedit). The same names, with signatures and docstrings, are in the type stubs and in Script Lab's autocomplete. Every public name in `foldnano.edit`, with what it does ([which package to import](https://fold.xinshilab.com/docs/plugin-development/runtime-model#which-package-to-import)). Reads come from the in-worker mirror and always reflect earlier edits in the same run. Each edit records one or more ops in `part.mutation_log` (named in the **Records** notes) and updates the mirror at once. Where an edit has a `can_*` twin, the edit runs that check first and raises `InvalidOperationError` with its reason before recording anything (see [Asking before you edit](https://fold.xinshilab.com/docs/plugin-development/plugin-patterns#asking-before-you-edit)). **Kind** says what each name does to the design: - **read-only**: a property you read; assigning to it is an error. - **settable**: a property you can assign to; the assignment records an op. - **edit**: a method that changes the design and records ops. - **query**: a method that only reads (lookups and `can_*` checks); nothing is recorded. - **helper**, **function**, **constant**, **class**: module-level names and utilities; they don't change the design. Arguments named `vh` take a helix object or its index. `strand_index` is 0 (bases run 5' to 3' with increasing index) or 1 (decreasing). Bases (`bp`) are 0-based indices along the helix. ## Module | API | Kind | Behaviour | | --- | --- | --- | | `load_part(json_str, active_region_id=None)` | function | Parse a foldnano_json string (from `ctx.get_part_as_foldnano_json()`) into a `MutablePart` with an empty log. Pass `ctx.active_region_id` through. | | `KNOWN_OPS` | constant | The set of op kinds the host can replay. | | `MutablePart`, `MutableOligo`, `MutableSegment`, `MutableSegmentEnd`, `MutableSlot`, `MutableVirtualHelix`, `MutableStrandSet` | class | The editable proxies, below. You get them from `load_part` and from each other; don't construct them. | | `MutableRegion`, `LatticedRegion`, `FreeformRegion`, `WireframeRegion`, `MutablePlane` | class | Regions and planes ([below](#regions-and-planes)). Lattice and freeform regions are subclasses, and `WireframeRegion` is a `FreeformRegion`; test `region.supports_lattice_grid` rather than the class. | | `MutableRepeatGroup`, `MirrorOligo` | class | Repeat groups and their mirror oligos ([below](#repeat-groups)). | | `OligoView`, `SegmentView`, `SegmentEndView`, `SlotView` | class | The read-only side of the oligo proxies: everything you can read, nothing you can change. Mirror oligos are built from these. | | `MutationLog`, `Transport`, `OfflineTransport` | class | The log and transport behind `MutablePart`. Plugins don't need them; `part.mutation_log` is the log to return. | ## `MutablePart`: contents and lookups | API | Kind | Behaviour | | --- | --- | --- | | `part.name`, `part.id` | read‑only | The part's name and id. | | `part.regions`, `part.virtual_helices`, `part.oligos`, `part.planes` | read‑only | Lists of proxies for everything in the part, current as of the last edit. `part.oligos` includes repeat groups' mirror oligos, as read-only `MirrorOligo`s. | | `part.real_oligos` | read‑only | Every oligo except mirror oligos: the ones you can edit, and what the app's oligo list and exports show. | | `part.repeat_groups` | read‑only | The part's repeat groups (`MutableRepeatGroup`). | | `part.active_region_id`, `part.active_region` | read‑only | The region passed to `load_part` (id, and its proxy or `None`). | | `part.lattice_type` | read‑only | **Deprecated, and logs a warning.** The first region's kind only. Use `region.kind`. | | `part.max_base` | read‑only | **Deprecated, and logs a warning.** The first region's length only. Use `region.max_base`. | | `part.get_region(id)`, `part.get_vh(index)`, `part.get_oligo(id)`, `part.get_repeat_group(id)` | query | The proxy with that id or index, or `None`. | | `part.vhs_in_region(region_id)` | query | Helices in a region. | | `part.oligos_in_region(region_id)` | query | Oligos whose segments all lie in that region (oligos crossing into another region are left out). | | `part.get_segment_at(vh, strand_index, bp)` | query | The segment covering that base on that strand, or `None`. | | `part.get_oligo_at(vh, strand_index, bp)` | query | The oligo covering that base, or `None`. | | `part.get_end_at(vh, strand_index, bp)` | query | The 5' or 3' end sitting exactly at that base (free or connected), or `None`. | | `part.get_slot_at(vh, strand_index, bp)` | query | The slot at that base of that strand (its sequence, skip or insertion), or `None` where no strand covers it. | | `part.potential_xover_sites(vh)` | query | `[strand 0 sites, strand 1 sites]`, as `vh.potential_xover_sites`. | | `part.mutation_log` | read‑only | The ops recorded so far, as JSON-ready dicts. Return it with `ctx.set_result_mutation_log(...)`. | | `part.to_json()`, `part.to_json_string()` | query | The whole mirror as a dict (a copy) or a JSON string, e.g. for `ctx.set_result_foldnano_json(...)`. | | `MutablePart.replay_log_into_fresh_mirror(json_str, log)` | query | Replay a log onto a fresh copy of `json_str` and return the result; for testing that a log reproduces `part.to_json()`. | | `part.allocate_vh_id()`, `allocate_oligo_id()`, `allocate_segment_id()`, `allocate_plane_id()`, `allocate_region_id()` | helper | A new unique id. The edits call these themselves; you rarely need them. | ## `MutablePart`: edits | API | Kind | Behaviour | | --- | --- | --- | | `part.create_segment(vh, five_index, three_index, color="#888888", is_scaffold=False, name=None)` | **edit** | A new oligo with one segment. It runs on strand 0 when `five_index < three_index`, otherwise strand 1. Both ends must be inside the region's bases and the range free. Returns the segment. **Records** `create_segment`. Check: `can_create_segment`. | | `part.create_oligo(segments, color="#888888", name=None, is_scaffold=False, circular=False)` | **edit** | One oligo from a path: `segments` lists `(vh, five, three)` tuples or `SegmentSpec`s from 5' to 3', and each segment's 3' end joins the next one's 5' end. Consecutive entries that continue each other on the same strand become one segment; `circular=True` also joins the last 3' end to the first 5' end. The whole path is checked first, so nothing is recorded if any part is refused. Returns the oligo; `oligo.segments` is in 5' to 3' order. **Records** `create_segment` per segment and `connect` per junction. Check: `can_create_oligo`. | | `part.fill_empty_strand_set(vh, strand_index, color="#888888")` | **edit** | One segment along a whole empty strand, covering every base inside both the region and the helix, 5' to 3' in that strand's direction. Returns the segment. **Records** `create_segment`. Check: `can_fill_empty_strand_set`. | | `part.connect(seg_from, seg_to)` | **edit** | Join `seg_from`'s 3' end to `seg_to`'s 5' end; both must be free and on different segments. If the ends are on the same helix and strand one base apart, the segments **merge** into one (`seg_to` is gone afterwards) instead of crossing over. If the segments were in different oligos, `seg_from`'s oligo absorbs the other. **Records** `connect`. Check: `can_connect`. | | `part.connect_oligos(from_oligo, to_oligo)` | **edit** | Join `from_oligo`'s free 3' end to `to_oligo`'s free 5' end, like dragging one end onto the other in the editor. The same oligo twice closes it into a ring. Adjacent ends merge as with `connect`. Clears modifications on the two joined ends. **Records** `connect` (and `set_segment_modification` when clearing). Check: `can_connect_oligos`. | | `part.create_crossover(from_oligo, vh, strand_index, bp)` | **edit** | Like dragging `from_oligo`'s 3' end onto a strand: break the target strand just before `bp` and join the 3' end to it at `bp`. If the target oligo's free 5' end is at `bp`, it is joined without a break. Refused onto a 3' end, onto the base right after a free 5' end (it would leave a 1-nt piece), within one segment, or from a circular oligo. **Records** `break_oligo` and `connect`. Check: `can_create_crossover`. | | `part.make_crossover(vh, strand_index, bp, to_vh)` | **edit** | Like clicking a potential crossover site: `(vh, strand_index, bp)` and `to_vh` must name a site from `vh.potential_xover_sites(strand_index)`. The strand whose 3' end leaves at the site is broken there and joined to the other helix's strand at the same base. Both strands must be present, and the receiving strand must not end at or 1 nt before its 3' end there. **Records** `break_oligo` and `connect`. Check: `can_make_crossover`. | | `part.break_at(vh, strand_index, bp)` | **edit** | The break tool: break the strand at that base so its new 3' end is at `bp`. Returns the oligo holding the 3' side. Same behaviour as `break_oligo`. Check: `can_break_at`. | | `part.break_oligo(seg, at_index)` | **edit** | Break `seg`'s oligo so the new 3' end is at `at_index`, which must be inside the segment. Breaking at the oligo's own free 5'/3' end, or one base before a free 3' end, does nothing. Breaking a circular oligo opens it into one linear oligo (same id). Otherwise the 3' side becomes a new staple oligo (green, not scaffold), and a mid-segment break drops the segment's 3' modification. Refused if it would leave a 1-nt segment before a connected 3' end. Returns the oligo holding the 3' side. **Records** `break_oligo`. Check: `can_break_oligo`. | | `part.add_skip_at(vh, bp)` | **edit** | The skip tool: skip that base on **both** strands. Refused at a segment end on both strands, at a crossover site, without a complementary strand, or where either strand already has a skip or insertion. **Records** `add_skip_or_insertion`. Check: `can_add_skip_at`. | | `part.add_insert_at(vh, bp, count=1)` | **edit** | The insert tool: add `count` inserted bases at that base on **both** strands, keeping the existing bases. Calling it again adds more. Refused on a skip and in the places a skip is. **Records** `add_skip_or_insertion`. Check: `can_add_insert_at`. | | `part.remove_skip_or_insertion_at(vh, bp)` | **edit** | Turn a skip or insertion back into one base on both strands; nothing happens on a plain base. **Records** `set_slot_skip`. | | `part.remove_segment(seg)` | **edit** | Remove one segment. Segments before it stay in the oligo; those after it become a new oligo, which is returned (`None` if there were none). Removing an oligo's first segment removes that oligo id. **Records** `remove_segment`. | | `part.remove_oligo(oligo)` | **edit** | Remove the oligo and all its segments. **Records** `remove_oligo`. | | `part.remove_vhs([vh, ...])` | **edit** | Remove helices and everything on them; oligos passing through are split where they lose segments. **Records** `remove_vhs`. | | `part.reorder_vh(vh, target_region_row)` | **edit** | Move a helix to another row (0-based) within its region's list. **Records** `reorder_vh`. | | `part.add_region(kind, name, max_base, *, min_base=0, plane_origin=..., plane_u=..., plane_v=..., plane_normal=..., plane_name=None, step=None, turns_per_step=None, sub_step_size=None, twist_offset=None)` | **edit** | A new region (`'HONEYCOMB'`, `'SQUARE'` or `'FREEFORM'`) on its own new plane; lattice settings default per kind. The plane basis must be right-handed (`plane_u` × `plane_v` along `plane_normal`), or it raises `ValueError`. Returns the region. **Records** `add_region_and_plane`. | | `part.split_region(region, split_after_vh)` | **edit** | Split a region after the given helix; the helices after it move to a new region with a copy of the plane. Returns the new region. **Records** `split_region`. | | `part.set_plane_transform(plane_id, origin, u, v, normal)` | **edit** | Move a plane: each vector is `(x, y, z)` or `{x, y, z}`. The basis must be right-handed (`u` × `v` along `normal`); a mirrored one raises `ValueError`, since its helices would twist the wrong way in 3D and export. **Records** `transform_plane`. | | `part.create_repeat_group(oligos, bp_offset)` | **edit** | Make `oligos` the originals of a new repeat group with one copy `bp_offset` bases along the same helices, like the editor's **Duplicate as linked mirror copies**. The copy's mirror oligos appear at once; a region the copy runs past grows. Returns the group. **Records** `create_repeat_group`. Check: `can_create_repeat_group`. | ## `MutablePart`: checks Each returns a `Check`: truthy when the edit would be accepted, with `.reason` when not. They never record anything. | API | Kind | Checks | | --- | --- | --- | | `part.can_create_segment(vh, five_index, three_index)` | query | Both ends inside the region's bases; the range free on its strand. | | `part.can_create_oligo(segments, circular=False)` | query | Every segment as above, no two entries overlapping, no junction on a skip or insertion. | | `part.can_fill_empty_strand_set(vh, strand_index)` | query | The strand is empty and the helix has room. | | `part.can_connect(seg_from, seg_to)` | query | Different segments, both ends free, neither end on a skip or insertion. | | `part.can_connect_oligos(from_oligo, to_oligo)` | query | A free 3' end and a free 5' end, not on one segment, not on a skip or insertion. | | `part.can_create_crossover(from_oligo, vh, strand_index, bp)` | query | The rules listed for `create_crossover`. | | `part.can_make_crossover(vh, strand_index, bp, to_vh)` | query | The rules listed for `make_crossover`. | | `part.can_add_skip_at(vh, bp)`, `part.can_add_insert_at(vh, bp)` | query | The rules listed for `add_skip_at` / `add_insert_at`. | | `part.can_break_at(vh, strand_index, bp)`, `part.can_break_oligo(seg, at_index)` | query | A strand at that base; the base inside the segment; no 1-nt piece; a mid-segment break not leaving an end on a skip or insertion. | | `part.can_create_repeat_group(oligos, bp_offset)` | query | At least one oligo, none a mirror or already in a group, and the copy placeable (see [Repeat groups](#repeat-groups)). | Each edit check above also refuses an edit to a mirror oligo, and an edit to a repeat group's original whose rebuilt copies wouldn't fit (the reason starts "Copy N of Repeat M"). ## `MutableOligo` | API | Kind | Behaviour | | --- | --- | --- | | `oligo.id` | read‑only | Stable id, shared with the app. | | `oligo.color` | **settable** | Hex color `'#rrggbb'`. **Records** `set_oligo_color`. | | `oligo.name` | **settable** | Optional free-form label, or `None`. Not unique and never used for identity (use `oligo.id`); it shows in the editor, the oligo report and the CSV `Name` column. **Records** `set_oligo_name`. | | `oligo.is_scaffold` | **settable** | Scaffold flag. **Records** `set_oligo_is_scaffold`. | | `oligo.is_sequence_locked` | read‑only | Whether the sequence is locked (read-only). | | `oligo.segments` | read‑only | Its segments. | | `oligo.get_segment(segment_id)` | query | One of its segments by id, or `None`. | | `oligo.is_circular` | read‑only | True when it has no free ends. | | `oligo.five_prime`, `oligo.three_prime` | read‑only | Its free 5' / 3' end (`MutableSegmentEnd`), or `None` when circular. | | `oligo.five_modification`, `oligo.three_modification` | **settable** | The modification on its free 5' / 3' end; `None` clears. Refused on a circular oligo. **Records** `set_segment_modification`. | | `oligo.touches_regions()` | query | The set of region ids its segments lie in. | | `oligo.is_mirror` | read‑only | False for a `MutableOligo` (mirror oligos are `MirrorOligo`s). | | `oligo.repeat_group` | read‑only | The repeat group this oligo is an original of, or `None`. | | `oligo.remove()` | **edit** | Same as `part.remove_oligo(oligo)`. | ## `MutableSegment` | API | Kind | Behaviour | | --- | --- | --- | | `seg.id` | read‑only | Stable id, shared with the app. | | `seg.oligo`, `seg.oligo_id` | read‑only | Its oligo (looked up fresh, so still right after merges). | | `seg.in_virtual_helix`, `seg.vh_index` | read‑only | Its helix (proxy / index). | | `seg.in_strand_index` | read‑only | 0 or 1. | | `seg.five_index`, `seg.three_index` | read‑only | Base of its 5' / 3' end. | | `seg.low_index`, `seg.high_index` | read‑only | Its base range regardless of direction. | | `seg.length` | read‑only | Number of bases. | | `seg.five_prime`, `seg.three_prime` | read‑only | Its ends (`MutableSegmentEnd`). | | `seg.five_modification`, `seg.three_modification` | **settable** | Shorthand for `seg.five_prime.modification` / `seg.three_prime.modification`. | | `seg.connected_from_segment_id`, `seg.connected_to_segment_id` | read‑only | Id of the segment joined to its 5' / 3' end, or `None`. | | `seg.slots` | read‑only | Its stored slots: one per base for a segment loaded from the app, only the written ones for a segment made in this run. | | `seg.slot_at(bp)` | query | The slot at a base of this segment (created on first write). | | `seg.move_five_prime_to(bp)`, `seg.move_three_prime_to(bp)` | **edit** | Same as setting `seg.five_prime.index` / `seg.three_prime.index`. | | `seg.remove()` | **edit** | Same as `part.remove_segment(seg)`. | ## `MutableSegmentEnd` | API | Kind | Behaviour | | --- | --- | --- | | `end.segment`, `end.is_three_prime` | read‑only | The segment it belongs to, and which end it is. | | `end.in_virtual_helix`, `end.in_strand_index` | read‑only | Where it sits. | | `end.index` | **settable** | Its base. Setting it moves the end: within the helix, not over another segment, without reversing the segment, not onto a skip or insertion; an end on a lattice crossover may only move to another matching site. Content outside the new range is dropped. **Records** `move_terminus`. | | `end.can_move_to(bp)` | query | The check for setting `end.index`. | | `end.modification` | **settable** | Its modification or `None`. Can be set on a free end only (`None` clears). **Records** `set_segment_modification`. | | `end.is_free` | read‑only | Not connected. | | `end.connected_segment`, `end.connected_end` | read‑only | The segment / end on the other side of its connection, or `None`. | | `end.is_constrained` | read‑only | Connected across a lattice crossover site: the two ends are on different strand indices at the same base, and the helix has a site there toward the other helix. Freeform connections never are. | | `end.is_adjacent_to(other_end)` | query | Same helix and strand, one base apart (connecting them would merge the segments). | ## `MutableSlot` | API | Kind | Behaviour | | --- | --- | --- | | `slot.bp_index` | read‑only | Its base. | | `slot.seq` | **settable** | Its sequence: one base normally, empty for a skip, longer for an insertion. Settable with the **same length** only, so it can't make the two strands differ; change the length with `part.add_insert_at` / `add_skip_at` / `remove_skip_or_insertion_at`. **Records** `set_slot_sequence`. | | `slot.skip` | read‑only | True when it is a skip. | | `slot.set_skip(True)` | **edit** | Make it a skip on both strands (same as `part.add_skip_at`, through this slot). **Records** `set_slot_skip`. Check: `slot.can_set_skip()`. | | `slot.set_insertion(sequence)` | **edit** | Replace the whole slot with `sequence` (e.g. `'AT'` is one inserted base); the complementary slot becomes that many `N`s, losing its base. Prefer `part.add_insert_at`, which works like the editor. **Records** `set_slot_insertion`. Check: `slot.can_set_insertion()`. | | `slot.can_set_skip()`, `slot.can_set_insertion()` | query | Inside the segment, not at its end, the complementary strand present, not at a crossover site; a skip needs both strands plain, an insertion can't go on a skip. | | `slot.remove_skip_or_insertion()`, `slot.set_skip(False)` | **edit** | Back to a plain base on both strands; a plain slot is left alone. **Records** `set_slot_skip`. | ## `MutableVirtualHelix` | API | Kind | Behaviour | | --- | --- | --- | | `vh.index`, `vh.id` | read‑only | Its index and stable id. A helix added in this run has a provisional index; use `vh.id` to refer to it reliably. | | `vh.region_id` | read‑only | Its region's id. | | `vh.length` | read‑only | Number of bases, always the region's `max_base`. Setting it raises `InvalidOperationError`; use `region.set_max_base`. | | `vh.row`, `vh.col` | read‑only | Lattice position, or `None` for a freeform helix. | | `vh.is_freeform` | read‑only | True when it has no lattice position. | | `vh.parity_is_even`, `vh.is_even_parity` | read‑only | Lattice parity (`row % 2 == col % 2`). | | `vh.local_frame` | **settable** | A freeform helix's frame (`origin`, `axis`, `radial0`). The axis is normalized and `radial0` made perpendicular to it; a `radial0` parallel to the axis is refused. **Records** `set_vh_local_frame`. | | `vh.roll_deg` | **settable** | A freeform helix's rotation about its own axis, in degrees (-180 to 180], or `None` for a lattice helix. 0 points `radial0` along the region plane's normal (the plane's U axis for a helix standing along the normal); positive turns right-handed about the axis. Setting it keeps the origin and axis. **Records** `set_vh_local_frame`. | | `vh.move_to(col, row)` | **edit** | Move a lattice helix; refused if it would change parity. **Records** `move_lattice_vh`. | | `vh.strand_sets` | read‑only | `[strand 0, strand 1]` as `MutableStrandSet`s. | | `vh.segments_at(bp, strand_index=None)` | query | Segments covering a base, on one strand or both. | | `vh.potential_xover_sites(strand_index)` | query | Potential crossover sites from that strand, as `CrossoverSite(bp_index, to_vh_index, right)`; only sites whose neighbour position holds a helix in the same region. Freeform helices have none. | | `vh.strand_set_segment_ids` | read‑only | Segment ids on each strand, as stored. | ## `MutableStrandSet` | API | Kind | Behaviour | | --- | --- | --- | | `ss.strand_index`, `ss.virtual_helix` | read‑only | Which strand, and its helix. | | `ss.segments`, `ss.segment_ids` | read‑only | Its segments, ordered by base. | | `ss.is_empty` | read‑only | No segments. | | `ss.is_index_occupied(bp)`, `ss.is_index_occupied_by(bp)` | query | Whether a segment covers that base / which one (or `None`). | | `ss.is_range_occupied(a, b)`, `ss.segments_in_range(a, b)` | query | Whether any segment overlaps the inclusive range (either order) / which ones. | | `ss.free_ranges()` | query | Free `(low, high)` ranges within the region's bases: where a new segment fits. | ## Regions and planes | API | Kind | Behaviour | | --- | --- | --- | | `region.id`, `region.name`, `region.kind`, `region.plane_id` | read‑only | Identity, name, `'HONEYCOMB'` / `'SQUARE'` / `'FREEFORM'` / `'WIREFRAME'`, and its plane. | | `region.step`, `region.min_base`, `region.max_base`, `region.default_max_base` | read‑only | Lattice step (21 honeycomb, 32 square); its first base; one past its last base; the stored default length. | | `region.vh_order`, `region.virtual_helices` | read‑only | Helix ids in display order / its helices. | | `region.supports_lattice_grid` | read‑only | True for honeycomb and square regions. | | `region.set_max_base(n)` | **edit** | Lengthen or shorten every helix in the region so it ends at `n`. The change must be a multiple of `step`; shortening is refused while strands reach that far. **Records** `set_region_max_base`. Check: `region.can_set_max_base(n)`. | | `region.can_set_max_base(n)` | query | Whether `set_max_base(n)` would be allowed, with the reason if not. | | `region.increase_length_by(delta)` | **edit** | Lengthen by a positive multiple of `step`. | | `region.reduce_unused_length()` | **edit** | Shorten to the strands it holds, rounded up to `step` and at least two steps, like the strand view's reduce button. | | `region.remove_all_skips()`, `region.remove_all_insertions()` | **edit** | Turn every skip / insertion in the region back into a plain base; returns how many. | | `lattice_region.add_vh_at(row, col, length=None)` | **edit** | A new helix at a lattice position (`LatticedRegion`). A `length` other than `max_base` raises `InvalidOperationError`. **Records** `add_vh_at_coords`. | | `freeform_region.add_vh_at(origin, direction=(0, 0, 1), length=None)` | **edit** | A new helix at a 3D position in nm (`FreeformRegion`), with roll 0. A `length` other than `max_base` raises `InvalidOperationError`. **Records** `add_freeform_vh`. | | `wireframe_region.reference_geometry` | read‑only | The `ReferenceGeometry` it was built from, or `None`. It is for reference: nothing keeps it in step with the helices. | | `wireframe_region.set_reference_geometry(geometry)` | **edit** | Store a `ReferenceGeometry`, or remove it with `None`. **Records** `set_region_reference_geometry`. | | `geometry.mesh`, `geometry.unit` | read‑only | `ReferenceGeometry(mesh=None)`, imported from `foldnano`: its `ReferenceMesh` or `None`, and the unit, always `'nm'`. | | `mesh.vertices`, `mesh.faces`, `mesh.edges` | read‑only | `ReferenceMesh(vertices, faces=(), edges=())`, imported from `foldnano`: `(x, y, z)` positions in nm in the region's frame, faces of three or more vertex indices, and `(a, b)` vertex pairs. Bad values raise `ValueError`. | | `mesh.all_edges()` | query | Every distinct edge as a sorted `(a, b)` pair: `mesh.edges` plus the sides of `mesh.faces`. | | `mesh.to_ply()` | query | The mesh as ASCII PLY text. | | `plane.id`, `plane.name`, `plane.origin`, `plane.u`, `plane.v`, `plane.normal` | read‑only | A plane's placement (read-only; move it with `part.set_plane_transform`). | Every virtual helix spans its region's `max_base`, in every kind of region. That is the space available, not the length of the DNA: strands can cover any part of it. For edges of different lengths, set the region's `max_base` to the longest edge and create strands only over the bases each edge needs. See [Wireframe Plugins](https://fold.xinshilab.com/docs/plugin-development/wireframe-plugins) for worked examples. ## Repeat groups A repeat group copies its original oligos at each instance's offset along the same helices. The copies, **mirror oligos**, are rebuilt from their originals after every edit, here and in the app, and can't be edited themselves. Edit an original and its mirrors follow: color, name, scaffold flag, modifications and bases at once, anything else by rebuilding them. If a rebuilt copy wouldn't fit (it would overlap a strand, lose a lattice crossover site, or leave a skip or insertion unpaired), the edit raises `InvalidOperationError` and nothing is recorded. | API | Kind | Behaviour | | --- | --- | --- | | `group.id`, `group.name` | read‑only | Stable id and display name (`Repeat 1`, `Repeat 2`, ...). | | `group.instances` | read‑only | Its copies, as `RepeatInstance(id, bp_offset)`, in the order they are placed. | | `group.get_instance(id)` | query | One instance by id, or `None`. | | `group.originals` | read‑only | The oligos being repeated. | | `group.mirrors`, `group.mirrors_of(instance)` | read‑only / query | All its mirror oligos, or one instance's. | | `group.add_instance(bp_offset)` | **edit** | Add a copy at that offset (not 0 or one the group already has); its mirrors appear at once. Returns the `RepeatInstance`. **Records** `add_repeat_instance`. Check: `group.can_add_instance(bp_offset)`. | | `group.can_add_instance(bp_offset)` | query | Whether `add_instance(bp_offset)` would be allowed, with the reason if not. | | `group.remove_instance(instance)` | **edit** | Remove one copy and its mirrors. Removing the last copy deletes the group. **Records** `remove_repeat_instance`. | | `group.detach()` | **edit** | Dissolve the group, keeping its mirrors as ordinary, editable oligos. **Records** `detach_repeat_group`. | | `group.delete()` | **edit** | Remove the group and its mirrors; the originals stay as ordinary oligos. **Records** `delete_repeat_group`. | | `MirrorOligo` | class | A mirror oligo: the same reads as `MutableOligo`, with no setters or edits. Its segments, ends and slots are `SegmentView`, `SegmentEndView` and `SlotView`: read-only versions of the mutable proxies. Assigning to any of them raises `InvalidOperationError`. | | `mirror.is_mirror` | read‑only | True. | | `mirror.mirror_of` | read‑only | `MirrorInfo(group, instance, source)`: what it copies. | | `mirror.source` | read‑only | The original it copies, as a `MutableOligo`. | Joining an ordinary oligo to an original (with `connect` or a crossover) pulls it into the group; both halves of a broken original stay in it; two groups can't be joined. A mirror's id is `@` here, stable across rebuilds, but the app gives its mirrors its own ids, so don't keep a mirror id after the run. To edit every oligo, loop over `part.real_oligos`. ## Values and errors | API | Kind | Behaviour | | --- | --- | --- | | `Check` | class | Result of a `can_*` call: truthy when allowed; `.ok`, `.reason`, and `.raise_if_failed()`. | | `InvalidOperationError` | class | Raised by an edit its check refuses (a `ValueError`). Nothing is recorded. | | `MIRROR_EDIT_ERROR` | constant | The message an edit to a mirror oligo fails with. | | `RepeatInstance(id, bp_offset)`, `MirrorInfo(group, instance, source)` | class | A repeat group's copy, and what a mirror oligo copies. | | `SegmentSpec(vh, five, three)` | class | A named `(vh, five, three)` entry for `create_oligo`. | | `CrossoverSite(bp_index, to_vh_index, right)` | class | One potential crossover site. | The proxies follow Foldnano's own model, so if you know `Part`, `Oligo`, `OligoSegment`, `SegmentEnd` and `StrandSet`, the names carry over in snake_case: `seg.three_prime.connected_end`, `vh.strand_sets[0].is_range_occupied(10, 30)`. ──────────────────────────────────────────────────────────── # Plugin Patterns ## Round-Trip Mutation Pattern For `foldnano_json` plugins that mutate the design: ```python import json from foldnano import PluginContext, parse_foldnano_json def run(ctx: PluginContext) -> None: # 1. Parse into typed proxies part = parse_foldnano_json(ctx.get_part_as_foldnano_json()) # 2. Build a lookup from oligo id → index in _raw["oligos"] raw_oligos = part._raw.get("oligos", []) id_to_idx = {o["id"]: i for i, o in enumerate(raw_oligos)} # 3. Mutate _raw in-place using proxy objects to find what to change for oligo in part.oligos: if oligo.is_scaffold: continue idx = id_to_idx.get(oligo.id) if idx is not None: raw_oligos[idx]["color"] = "#ff0000" # 4. Return the modified _raw ctx.set_result_foldnano_json(json.dumps(part._raw)) ``` **Key rule:** Use the proxy objects (`.oligos`, `.segments`, etc.) for reading and finding things. Mutate `part._raw` for writing. Do not mutate proxy attributes: they are read-only views and changes will not be serialised back. --- ## Writing plugins with `foldnano.edit` `foldnano.edit` is a mutable, ergonomic Python API. You assign to attributes and call methods on the part; each mutation is recorded as a structured op in `part.mutation_log`, and the host replays the log against the real JS-side `Part` inside one history transaction. ```python from foldnano import PluginContext from foldnano.edit import LatticedRegion, load_part def run(ctx: PluginContext) -> None: part = load_part( ctx.get_part_as_foldnano_json(), active_region_id=ctx.active_region_id, ) # Direct attribute writes record ops automatically. for oligo in part.oligos_in_region(part.active_region_id): if not oligo.is_scaffold: oligo.color = "#ff8800" # Structural mutations are method calls. region = part.get_region(part.active_region_id) if isinstance(region, LatticedRegion): new_vh = region.add_vh_at(row=4, col=2) seg = part.create_segment( vh=new_vh, five_index=0, three_index=20, color="#33cc66", ) seg.slot_at(5).seq = "A" ctx.set_result_mutation_log(part.mutation_log) ``` ### Finding your way around the API Two generated references list the whole surface, so you don't need to memorise it: - **Type stubs** in the repository under `src/lib/plugin/python/stubs/`. `foldnano/edit/__init__.pyi` and `foldnano/__init__.pyi` each hold every public class, method and property of their package, with signatures and docstrings. Read them, give them to a coding agent, or point your type checker at that folder (`MYPYPATH`, or pyright's `stubPath`) to get completion and type errors in your own editor. - **Script Lab autocomplete**, which is built from the same data. See [Script Lab](https://fold.xinshilab.com/docs/guide/script-lab). ### Loading and the active region ```python part = load_part(json_str, active_region_id=None) # -> MutablePart ``` `active_region_id` should usually be threaded from `ctx.active_region_id`. It enables `part.active_region`, `part.oligos_in_region(region_id)`, `part.vhs_in_region(region_id)`, and (with `replayMutationLogInRegion`) is enforced by the host as a scope constraint at apply time. ### API reference Every public name, with what it does and whether it reads or changes the design, is on its own page: [foldnano.edit API Reference](https://fold.xinshilab.com/docs/plugin-development/foldnano-edit-api). The sections below show how the pieces fit together. ### Asking before you edit Every structural call has a `can_*` check that returns a `Check`. It is truthy when the edit is allowed and carries a `reason` when it is not: ```python check = part.can_create_segment(vh, 10, 30) if check: part.create_segment(vh, 10, 30) else: ctx.log(f"skipped: {check.reason}") ``` The edit itself runs the same check and raises `InvalidOperationError` before anything is recorded, so a refused call never leaves a half-written log. The checks cover: - **Creating** a segment: both ends inside the region's bases, on free bases of the strand it runs along (5' below 3' is strand 0, otherwise strand 1). - **Moving** an end (`seg.five_prime.index = 12`, or `end.can_move_to(12)` first): inside the helix, not over another segment, not reversing the segment. An end on a crossover site may only move to another matching site. - **Connecting**: two different segments with free ends. - **Skips and insertions** (`slot.can_set_skip()`, `slot.can_set_insertion()`): inside the segment, not at its end, with the complementary strand present, and not at a crossover site. - **Skip and insertion positions** are also off limits for connecting, moving an end, or breaking. Free bases are easy to find too: `vh.strand_sets[0].free_ranges()` lists them, and `ss.is_index_occupied_by(bp)` returns the segment at a base. ### Crossovers and segment ends `part.connect(a, b)` joins any free 3' end to any free 5' end. To land on a lattice crossover site, pick a base from `vh.potential_xover_sites(strand_index)` (each site has `bp_index`, `to_vh_index` and `right`), or check afterwards with `seg.three_prime.is_constrained`. Freeform helices have no sites. When the two ends are on the same helix and strand, one base apart, `connect` **merges** the two segments into one instead of making a crossover. Leave a gap of at least one base if you want a crossover. Each segment has `seg.five_prime` and `seg.three_prime` end objects with `index`, `modification`, `is_free`, `connected_segment`, `connected_end` and `is_constrained`. ### Editing like the strand view These calls do what the strand view's tools do, and each has a `can_*` check: ```python oligo = part.get_oligo_at(vh, 0, 21) # what's at a base (also get_segment_at, get_end_at) part.break_at(vh, 0, 21) # the break tool: new 3' end at base 21 part.make_crossover(vh, 0, 21, other_vh) # click a crossover site: break both strands, cross them part.create_crossover(oligo, vh, 1, 30) # drag an oligo's 3' end onto a strand at base 30 part.connect_oligos(oligo_a, oligo_b) # join a free 3' end to a free 5' end part.remove_oligo(oligo) part.remove_segment(seg) # what was downstream becomes its own oligo part.fill_empty_strand_set(vh, 1) # one strand along the whole helix part.add_insert_at(vh, 30) # the insert tool: +1 base on both strands part.add_skip_at(vh, 35) # the skip tool: skip on both strands part.remove_skip_or_insertion_at(vh, 30) ``` Modifications live on free ends, so set them on the oligo: `oligo.five_modification = "/5Bio/"`, `oligo.three_modification = "/3Cy3/"`. A connected end refuses one. For a region, `region.set_max_base(n)` grows or shrinks it (a shrink is refused while strands reach that far), `region.reduce_unused_length()` trims it to its strands, and `region.remove_all_skips()` / `region.remove_all_insertions()` clear those in one call. A single slot goes back to a plain base with `slot.remove_skip_or_insertion()`, and `part.get_slot_at(vh, strand_index, bp)` finds a slot by position. ### Drawing an oligo along a path `part.create_oligo` builds a whole multi-segment oligo in one call. List one `(vh, five, three)` entry per segment, from 5' to 3'; each segment's 3' end joins the next one's 5' end: ```python oligo = part.create_oligo( [ (vh_a, 10, 30), # strand 0 on vh_a (vh_b, 30, 10), # crossover at base 30, then strand 1 on vh_b ], color="#ff8800", name="my staple", ) ``` The whole path is checked before anything is recorded. Entries that continue each other on the same strand become a single segment, `circular=True` closes the loop, and `SegmentSpec(vh, five, three)` can stand in for a tuple when you want named fields. ### Common pitfalls - `replayMutationLogInRegion` aborts if any op's target VH is outside the active region. Use `part.oligos_in_region(...)` / `part.vhs_in_region(...)` to filter inputs up front. - `region.add_vh_at(...)` only exists on `LatticedRegion`. For freeform regions, use `vh.local_frame = {...}`. - `MutableSegment.oligo_id` is resolved dynamically on each access, so it stays correct after `part.connect(...)` merges two oligos. - Connecting two ends that are one base apart on the same strand merges the segments; the second segment no longer exists afterwards. - Breaks behave as in the editor. Breaking at an oligo's own free end, or one base before a free 3' end, does nothing. Breaking a circular oligo opens it into one linear oligo with the same id. A piece that splits off is a new staple oligo (green, not scaffold), and a mid-segment break drops the segment's 3' modification. - `part.remove_segment(seg)` keeps the segments before it in the oligo and moves those after it into a new oligo (returned). Removing an oligo's first segment therefore removes that oligo id. - Crossover calls clear modifications on the ends they join, since only free ends carry one. - Validation errors raise immediately and do **not** pollute `part.mutation_log`. --- ## User-Configurable Parameters Plugins can declare a `parameters` array in their manifest to collect user input before each run. The dialog remembers what was entered: the next run opens with the last values, not the manifest defaults, until the page is reloaded. ### PluginParamDef schema ```json { "id": "threshold", "label": "Score threshold", "type": "number", "default": 0.5, "description": "Minimum score to include", "required": true, "min": 0, "max": 1, "step": 0.01, "options": ["fast", { "value": "thorough", "label": "Thorough (slow)" }] } ``` All fields except `id`, `label`, and `type` are optional. ### Python-side usage ```python def run(ctx: PluginContext) -> None: threshold = float(ctx.get_param("threshold", 0.5)) mode = ctx.get_param("mode", "fast") verbose = bool(ctx.get_param("verbose", False)) ctx.log(f"params: threshold={threshold} mode={mode!r} verbose={verbose}") ``` ### Type coercion | Dialog input type | Python type received | | --- | --- | | `number` | `float` (may be integral, e.g. `72.0`) | | `boolean` | `bool` | | `string` | `str` | | `select` | `str` (the `value` of the selected option) | Always cast `number` values explicitly if your logic requires an integer: ```python n_helices = int(ctx.get_param("n_helices", 8)) ``` --- ## Patterns by Use Case ### Analysis / report (previewOnly) ```python import csv, io from foldnano import PluginContext, parse_foldnano_json def run(ctx: PluginContext) -> None: part = parse_foldnano_json(ctx.get_part_as_foldnano_json()) staples = [o for o in part.oligos if not o.is_scaffold] rows = [] for oligo in staples: real_seq = (oligo.sequence or "").replace("N", "") gc = (real_seq.count("G") + real_seq.count("C")) / len(real_seq) if real_seq else None rows.append({"name": oligo.name or "", "gc": f"{gc:.3f}" if gc is not None else ""}) buf = io.StringIO() w = csv.DictWriter(buf, fieldnames=["name", "gc"], lineterminator="\n") w.writeheader() w.writerows(rows) ctx.write_artifact("report.csv", buf.getvalue()) ctx.set_result({"total_staples": len(staples)}) ``` ### Color mutation (replacePartKeepAnnotationsWhereMatched) ```python import json from foldnano import PluginContext, parse_foldnano_json def run(ctx: PluginContext) -> None: part = parse_foldnano_json(ctx.get_part_as_foldnano_json()) raw_oligos = part._raw.get("oligos", []) id_to_idx = {o["id"]: i for i, o in enumerate(raw_oligos)} for oligo in part.oligos: if oligo.is_scaffold: continue color = "#cc3300" idx = id_to_idx.get(oligo.id) if idx is not None: raw_oligos[idx]["color"] = color ctx.set_result_foldnano_json(json.dumps(part._raw)) ``` ### Multi-file plugin (shared helpers, extra modules) Every plugin ships as a zip, so growing beyond one file just means adding more files at the zip root: no format change is needed. **Directory layout:** ``` static/plugins/my_plugin/ fplugin.json ← manifest (source of truth once zipped) plugin.py ← entry point (must be named plugin.py) helper.py my_plugin.zip ← built from the files above ``` **Build the zip from inside the directory** (no subdirectory prefix): ```bash cd static/plugins/my_plugin zip my_plugin.zip fplugin.json plugin.py helper.py ``` **Manifest** (`fplugin.json`, at the zip root; omit `entrypointUrl`, since the installer already knows the code is in this zip): ```json { "schemaVersion": 2, "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "format": "cadnano_json", "applyStrategy": "previewOnly" } ``` **`plugin.py`**: import sibling modules the normal way: ```python from helper import do_thing def run(ctx): result = do_thing() ctx.set_result(result) ``` The worker extracts the zip to `/plugin_run/` and adds that path to `sys.path`, so all files land at the same level and relative imports work. To share this plugin, share the `.zip`; users install it with **Import zip**. See the [quick start](https://fold.xinshilab.com/docs/plugin-development/plugin-quickstart) for why links to other websites aren't supported during the beta. ### Handling unsequenced designs Always strip `"N"` placeholder characters before computing sequence-based statistics: ```python seq = oligo.sequence or "" real_seq = seq.replace("N", "") if not real_seq: continue # Skip: oligo has no real sequence yet gc = (real_seq.count("G") + real_seq.count("C")) / len(real_seq) ``` **Do not** check `if not seq`: `"NNNNN"` is truthy but represents an unsequenced oligo. ──────────────────────────────────────────────────────────── # Testing Plugins ## Testing `foldnano.edit` plugins offline The offline transport is pure Python, so `foldnano.edit` plugins can be unit-tested in CPython without Pyodide or the browser. There are three layers: ### 1. Local invariants `foldnano/edit/invariants.py` runs cheap validators _before_ an op is appended to the log: hex-color shape, integer endpoint distinctness, strand-set-index ∈ {0, 1}, etc. They fail fast with a clear `ValueError` / `TypeError` and never touch the JS side. Topology edits are checked the same way, before anything is recorded: occupancy, region bounds, end moves, crossovers, breaks, and skip or insertion placement. A refused edit raises `InvalidOperationError` (a `ValueError`) with the reason, and every such call has a `can_*` twin that returns the same answer without raising, so tests can assert on either: ```python import pytest from foldnano.edit import InvalidOperationError, load_part def test_occupied_range_refused(multi_region_part_foldnano_json): part = load_part(multi_region_part_foldnano_json) check = part.can_create_segment(0, 15, 25) # strand 0 of VH 0 is taken there assert not check and "occupied" in check.reason with pytest.raises(InvalidOperationError): part.create_segment(0, 15, 25) assert part.mutation_log == [] ``` These checks are kept in step with the editor's own rules by parity tests in the repository, so a log that passes them replays in the app. ```python import pytest from foldnano.edit import load_part def test_bad_color_rejected(multi_region_part_foldnano_json): part = load_part(multi_region_part_foldnano_json) with pytest.raises(ValueError, match="hex color"): part.get_oligo("oligo-honey-only").color = "red" assert part.mutation_log == [] # bad write does not pollute the log ``` ### 2. Real fixtures `src/lib/plugin/python/tests/fixtures/` ships real `Part.toJSON()` snapshots: - `minimal_part.foldnano.json`: small lattice design. - `multi_region_part.foldnano.json`: HONEYCOMB + SQUARE + FREEFORM regions, cross-region oligos, sequences and modifications. Load them in `pytest` and run plugins against real data: ```python def test_my_plugin(multi_region_part_foldnano_json): from foldnano import PluginContext from my_plugin.plugin import run ctx = PluginContext( format="foldnano_edit", foldnano_json_in=multi_region_part_foldnano_json, active_region_id="region-honey", ) run(ctx) assert ctx.mutation_log[0]["op"] == "set_oligo_color" ``` Available pytest fixtures: `minimal_part_foldnano_json`, `multi_region_part_foldnano_json`, `only_scaf_cadnano_json`. ### 3. Self-consistency replay check `MutablePart.replay_log_into_fresh_mirror(json_str, log)` re-applies a captured log onto a fresh mirror and returns the resulting dict. Asserting it matches `part.to_json()` catches op-encoding bugs and replay determinism issues: ```python from foldnano.edit import MutablePart, load_part def test_replay_matches_mirror(multi_region_part_foldnano_json): part = load_part(multi_region_part_foldnano_json) part.get_oligo("oligo-honey-only").color = "#abcdef" new_seg = part.create_segment(vh=2, five_index=50, three_index=40) new_seg.slot_at(45).seq = "T" replayed = MutablePart.replay_log_into_fresh_mirror( multi_region_part_foldnano_json, part.mutation_log ) assert replayed == part.to_json() ``` ### Running the tests ```bash cd src/lib/plugin/python uv run pytest tests/ -v ``` Reference test files: - `tests/test_foldnano_edit_offline.py`: offline transport behaviour, op recording, validation. - `tests/test_foldnano_edit_replay_self.py`: self-consistency between mirror and replay. - `tests/test_foldnano_edit_topology.py`: occupancy queries, segment ends, `can_*` checks, `create_oligo` and the editor-level calls. - `tests/test_foldnano_edit_parity.py` and `tests/test_foldnano_edit_replay_host.py`: agreement with the app's own model, in both directions. - `tests/test_foldnano_regions.py`: Plane / DesignRegion proxy surface. ### What is _not_ covered offline - Repeat groups (mirror oligos), and the active-region limit of `replayMutationLogInRegion`: the checks don't model them. - 3D / view-side regressions. - Cross-plugin interactions in the editor. Validate those by running the plugin in the app (see [Testing In The App](#testing-in-the-app)) against a real design, and confirming the result visually and with a single Undo. --- ## Testing Outside the Browser `PluginContext` is plain Python. You can test plugins in a local CPython environment: ```python import json from foldnano import PluginContext with open("my_design.json") as f: cadnano_str = f.read() params = json.dumps({"palette": "rainbow", "saturation": 80, "colorize_scaffold": False}) ctx = PluginContext( format="cadnano_json", cadnano_json_in=cadnano_str, params_json=params, ) from static.plugins.my_plugin.plugin import run run(ctx) print(ctx.logs) print(ctx.plugin_result) print(list(ctx.artifacts.keys())) ``` The `foldnano` and `foldnano.edit` packages live at `src/lib/plugin/python/`. Add it to your `PYTHONPATH` when running outside the browser: ```bash PYTHONPATH=src/lib/plugin/python python my_plugin_test.py # or: cd src/lib/plugin/python && uv run pytest tests/ -v ``` --- ## Testing In The App Offline tests are fast and repeatable, but they don't run your plugin in Pyodide or apply its result to a real design. To check those, use [Plugin Dev Mode](https://fold.xinshilab.com/docs/plugin-development/plugin-dev-mode): link your plugin folder, and each **Run** reads the folder again, with no zipping or reinstalling. Dev mode is the place to check: - **Your manifest and run dialog.** **Check** lists what would stop the plugin installing or running. - **Your dependencies.** Packages load in Pyodide, not your local Python, so something that passes in pytest can still fail here. - **The applied result.** Check it in the strand and 3D views, and confirm one Undo reverts it. - **Repeat groups and `replayMutationLogInRegion`.** The offline checks don't model these. For a quick script, [Script Lab](https://fold.xinshilab.com/docs/guide/script-lab) needs no folder or manifest. Dev mode needs a Chromium-based browser. In other browsers, use **Import zip** for each change. --- ## New Plugin Checklist - [ ] `/plugin.py`: `run(ctx: PluginContext)` entry point, at the root of the plugin folder - [ ] `/fplugin.json`: manifest with unique `id` (kebab-case), correct `format`, and correct `applyStrategy`, also at the root - [ ] Zipped together with no subdirectory prefix: `cd && zip .zip fplugin.json plugin.py` - [ ] For `foldnano_json` mutations: mutate `part._raw`, return via `set_result_foldnano_json` - [ ] For `foldnano_edit` mutations: thread `ctx.active_region_id` into `load_part`, return via `set_result_mutation_log(part.mutation_log)` - [ ] For sequence-based computations: strip `"N"` placeholders before computing GC or other stats - [ ] Log a summary at the end: counts of processed/skipped items - [ ] `applyStrategy: previewOnly` unless the plugin intentionally mutates the model - [ ] **Check** in dev mode reports no problems - [ ] Final test: **Zip**, unlink, then **Import zip**, because a linked plugin runs in its own dev environment rather than the one an installed copy gets ### Parameters checklist - [ ] Each `PluginParamDef` has a unique `id` - [ ] `default` is set on every parameter - [ ] `number` inputs: set `min`, `max`, and `step` - [ ] Cast number params explicitly if integer arithmetic is required: `int(ctx.get_param(...))` ### Zip packaging checklist - [ ] `fplugin.json` and all `.py` files stored at the **root level** of the zip (no subdirectory prefix) - [ ] Entry point is named `plugin.py` - [ ] Zip built from _inside_ the plugin directory: `cd && zip .zip fplugin.json plugin.py *.py` - [ ] Sibling module imports use plain `from import ...` (no relative dot syntax) - [ ] Any bundled `.whl` wheels listed as bare filenames in `requirements` are also present at the zip root - [ ] The zip installs with **Import zip** in a fresh browser profile (no leftover cached version) ──────────────────────────────────────────────────────────── # Plugin Dev Mode Dev mode lets you keep a plugin in a folder on your computer, edit it in your own editor (VS Code, PyCharm, any other), and run it in Foldnano against real designs. There is no zipping, no version bump and no reinstall between edits: each **Run** reads the folder again. Dev mode needs a Chromium-based browser (Chrome, Edge, Brave, Arc). It reads the folder with the File System Access API, which Safari and Firefox don't support yet. In other browsers, package the plugin as a zip and use **Import zip**. ## Link a folder 1. Open the **Plugins** panel and switch to the **Dev** tab. 2. Turn on **Plugin dev mode**. 3. Click **Link plugin folder…** and pick the folder that has `fplugin.json` and `plugin.py` at its top level. 4. Review what the manifest declares (format, apply strategy, packages, network access) and click **Link**. The plugin appears in the Dev tab with a **dev · linked** badge. Click **Activate**, then **Run**. Foldnano stores only the browser's permission to read the folder, never the files. It writes to the folder only when you create a new plugin there (below). ## Start a new plugin Pick an empty folder (or one with only a `plugin.py`) in **Link plugin folder…**, and Foldnano opens a **New plugin** form instead: - **Name** and **Id** (the id follows the name until you edit it), **Version**, **Description**. - **Format** and **What it does to the design**: only the apply strategies that format allows are offered. - **Requirements** and **Network access**, if the plugin needs them. - **Add API stubs for editor autocomplete** writes the `foldnano` and `foldnano.edit` type stubs into `.foldnano-stubs/` and a `pyrightconfig.json`, so VS Code (Pylance), Zed (basedpyright) and other Pyright-based editors complete and type-check the plugin API. Neither is part of the plugin. The config turns off the "could not be resolved from source" warning (the packages only exist inside Foldnano) and uses the `standard` checking mode; set `typeCheckingMode` to `strict` in `pyrightconfig.json` if you want more checks. The form checks the manifest as you type and shows a preview of `fplugin.json`. **Create and link** asks for permission to write to the folder, writes `fplugin.json` and a starter `plugin.py` that already runs (an existing `plugin.py` is kept), and links the folder. The manifest includes a `$schema` line, so editors that understand JSON Schema check `fplugin.json` as you edit it. ## The edit loop Edit any file in the folder, save, and click **Run**. Each run: - reads the whole folder again, so edited, added, renamed and deleted modules are all picked up; - re-reads `fplugin.json`, so a new parameter appears in the run dialog (values you already chose are kept); - runs through the same checks as an installed plugin, so a manifest error shows up as it would on install. `print()` output appears in the run's **Console** while the run is still going. Tracebacks name files by their path in your folder (for example `my-plugin/helpers.py`), so you can jump to the line. ## What Foldnano reads Everything in the folder except: - dot-files and dot-folders such as `.git`, `.venv`, `.vscode` and `.DS_Store`; - `__pycache__`, `node_modules`, `venv`, `env` and `site-packages` folders; - `*.pyc`, `*.pyo` and `*.zip` files, and `pyrightconfig.json`. The rest must fit the plugin zip limit (50 MB). Keep large test data outside the plugin folder. ## The dev environment A linked plugin runs in its own Python environment, separate from installed plugins: - Code edits reuse the running environment, so runs start quickly. - When `requirements` or `network` change, Foldnano asks you to accept the change, then builds a fresh environment on the next run. Rebuilt wheels in the folder also trigger a fresh environment. - **Stop** and run timeouts only end the dev environment; installed plugins keep running. - **Restart env** builds a fresh environment on request, for example when module-level state got stuck. - One linked plugin is active at a time. Activating another one takes over the dev environment. The run log's first line names the environment an installed copy would get, for example `dev (would run as: custom)`. See [Runtime Model](https://fold.xinshilab.com/docs/plugin-development/runtime-model) for what standard and custom mean. ## Check and package - **Check** lists what would stop the plugin installing or running (no `plugin.py`, no `run(ctx)` function, an `fplugin.json` that isn't valid) together, then manifest problems as an install reports them, then notes: packages that come from PyPI, why the plugin would run in the custom environment, wheels in the folder that `requirements` doesn't list, and which files are ignored. - **Zip** downloads the folder as the plugin zip that **Import zip** expects, without the ignored files. ## Let a coding agent run it (experimental) A coding agent working in a linked folder (Claude Code, Codex, Cursor or a plain script) can run Python on the open design without anyone clicking **Run**. This is off until you turn on **Agent runs** (marked *Experimental*) under the **Plugin dev mode** switch. See [Agent Runs](https://fold.xinshilab.com/docs/plugin-development/agent-runs) for how to set it up, how it works, and the risks, including that the agent can send your design to its AI provider. ## Permission, moved folders and unlinking - After a reload, Chromium may ask again for permission to read the folder when you click **Activate** or **Run**. Each browser tab asks separately. - If you move or rename the folder, click **Re-link** and pick it again. The folder must still declare the same plugin `id`. - **Unlink** forgets the folder. Your files are not touched. - A plugin id can't be linked and installed at the same time. Remove the installed copy first, or unlink before installing the released zip. - Turning dev mode off stops and hides every linked plugin; turning it back on brings them back. ## When you're done Click **Zip** (or zip the folder's contents yourself, as described in [Plugin Quick Start](https://fold.xinshilab.com/docs/plugin-development/plugin-quickstart)), then unlink and test the zip with **Import zip**. ──────────────────────────────────────────────────────────── # Agent Runs (Experimental) Agent runs let a coding agent on your computer (Claude Code, Codex, Cursor, Aider, or a plain script) change the design open in Foldnano. The agent writes small Python scripts in a [linked plugin folder](https://fold.xinshilab.com/docs/plugin-development/plugin-dev-mode), and Foldnano runs them on the design for it, the same way it runs a plugin when you click **Run**. You watch every change appear in the editor, and each one is a single **Undo** step. > **Warning: Highly experimental** > > How agent runs work, and the files they write, may change or be removed in a later version. Read [Risks that remain](#risks-that-remain) before you connect a design you care about. > ## Why not let the agent edit the design file? You could export a design, let an agent edit the `.json` file, and import it again. Agent runs exist because that is fragile: - A design file is full of cross-references (oligos to segments, segments to helices, crossovers to both ends). An agent editing the text can break one without noticing, and you only find out when the import fails or the design looks wrong. - A rewritten file hides what changed. You get a new design, not a list of edits you can check. - There is no undo, no preview and no way to stop halfway. With agent runs the agent never edits the design file. It writes Python against Foldnano's plugin API, and Foldnano applies the result. ### What the plugin system adds - **Edits go through the editor's own operations.** With a [`foldnano_edit`](https://fold.xinshilab.com/docs/plugin-development/foldnano-edit-api) plugin, the recommended setup for agent runs, every change the script makes (connect, break, recolour, add a helix) is recorded as an operation and replayed by the same code the editor uses. - **Nothing reaches your design unchecked.** A change the editor would refuse fails with an error the agent can read, instead of being applied. A plugin that returns a whole design instead of a list of changes (the `foldnano_json` and `cadnano_json` formats, which suit plugin development better than agent work) has its result validated and rebuilt on a scratch copy first, and Foldnano asks before giving it a design its format can't fully hold. Either way, a bad result is rejected and your design is not touched. - **Never over your newer edits.** If you change the design while a script runs, its result is thrown away rather than applied over your work, and the agent is told to run it again. - **A sandbox.** Scripts run in the same sandbox as any plugin: no access to your disk, and network access only to hosts the plugin declares, which you must approve. - **A record of every run.** Each run leaves a record in `.foldnano/agent/runs/` with the script's hash, its parameters, its output, and whether its result was applied. - **A view of what it built.** After each run the agent gets a text overview of the design's structure (see [What the agent sees](#what-the-agent-sees-after-each-run)), so it can check its own result instead of guessing. ### What the editor adds - **Instant feedback.** Each applied run shows up straight away in the strand view and the 3D view. - **Undo and redo.** Every applied run is exactly one step on the undo stack. - **Review when you want it.** A run started with `--ask-user-approval` waits for you to **Apply** or **Discard** it in the Plugins panel, with a summary of what it would change. - **A dry run that really changes nothing.** With `--dry-run`, Foldnano applies the change to a scratch copy of the design, shows the agent the result, and throws it away. Your design and its undo history are not touched. - **Everything else stays available:** design checks, autosave and backups, saving to GitHub, and the toolbar badge showing which folder is connected to which design and what is running. ## Set it up You need a Chromium-based browser (Chrome, Edge, Brave, Arc), Python 3.9 or newer, and a coding agent that can run shell commands. 1. **Make a folder for the project** and link it in Plugins → **Dev** (see [Plugin Dev Mode](https://fold.xinshilab.com/docs/plugin-development/plugin-dev-mode)). For design work, create the plugin with format `foldnano_edit` and apply strategy `replayMutationLog`: the agent then works with the design as Python objects, and every change is recorded as an operation. Keeping the folder in git gives you a history of the agent's scripts. 2. **Turn on Agent runs.** Under the **Plugin dev mode** switch, turn on **Agent runs** (marked *Experimental*). 3. **Open the design** the agent should work on, then click **Agent runs** on the folder's bar. Foldnano asks for permission to write to the folder and connects the folder to that design. It adds: - `.foldnano/run.py`, the command the agent uses (Python standard library only); - `AGENTS.md`, instructions for the agent, and a `CLAUDE.md` that imports it (Claude Code loads both when it starts in the folder). `AGENTS.md` opens with first steps and a map of the folder, so the agent can start from the local API stubs without exploring or searching the web. Foldnano keeps its own `AGENTS.md` up to date each time you connect, until you edit it; after that the file is yours and stays as you left it; - the Python API stubs in `.foldnano-stubs/`, so the agent can look up the API offline; - an empty `agent-scripts/` folder for the agent's scripts. 4. **Start the agent in that folder**, not in a parent folder or somewhere else. The agent reads `AGENTS.md` from the folder it starts in, and `.foldnano/run.py` only talks to the design connected to its own folder. With Claude Code, for example, open a terminal, `cd` into the folder and run `claude`. The toolbar now shows a badge such as **Agent runs: my-folder → My design**. Keep the editor tab open while the agent works. ## Working with the agent Talk to the agent about the design in plain language. Behind the scenes it writes a script in `agent-scripts/`, runs it with `python .foldnano/run.py agent-scripts/