Metadata-Version: 2.5
Name: mint-sdk
Version: 1.4.0b4
Summary: MINT Plugin SDK - Build analysis plugins for the MINT (Mass-spec INtegrated Toolkit) platform
Project-URL: Homepage, https://github.com/MorscherLab/MINT
Project-URL: Documentation, https://github.com/MorscherLab/MINT/tree/main/packages/sdk-python#readme
Project-URL: Repository, https://github.com/MorscherLab/MINT
Author-email: MorscherLab <morscher@chem.ethz.ch>
License-Expression: MIT
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.14
Requires-Dist: fastapi<1,>=0.141.1
Requires-Dist: httpx<1,>=0.28.1
Requires-Dist: jsonschema<5,>=4.26.0
Requires-Dist: packaging<27,>=26.3
Requires-Dist: pydantic[email]<3,>=2.13.5
Requires-Dist: python-multipart<1,>=0.0.32
Requires-Dist: starlette<2,>=1.7.0
Provides-Extra: cli
Requires-Dist: httpx2<3,>=2.13.1; extra == 'cli'
Requires-Dist: typer<1,>=0.27.2; extra == 'cli'
Provides-Extra: dev
Requires-Dist: aiosqlite<1,>=0.22.1; extra == 'dev'
Requires-Dist: alembic<2,>=1.20.0; extra == 'dev'
Requires-Dist: asyncpg<1,>=0.31.0; extra == 'dev'
Requires-Dist: greenlet<4,>=3.5.6; extra == 'dev'
Requires-Dist: httpx2<3,>=2.13.1; extra == 'dev'
Requires-Dist: import-linter>=2.15; extra == 'dev'
Requires-Dist: pytest-asyncio>=1.4.0; extra == 'dev'
Requires-Dist: pytest-xdist>=3.8.0; extra == 'dev'
Requires-Dist: pytest>=9.1.1; extra == 'dev'
Requires-Dist: ruff>=0.16.9; extra == 'dev'
Requires-Dist: sqlalchemy<2.1,>=2.0.54; extra == 'dev'
Requires-Dist: sqlmodel<1,>=0.0.47; extra == 'dev'
Requires-Dist: typer<1,>=0.27.2; extra == 'dev'
Requires-Dist: uvicorn[standard]<1,>=0.54.0; extra == 'dev'
Provides-Extra: local-db
Requires-Dist: aiosqlite<1,>=0.22.1; extra == 'local-db'
Requires-Dist: alembic<2,>=1.20.0; extra == 'local-db'
Requires-Dist: greenlet<4,>=3.5.6; extra == 'local-db'
Requires-Dist: sqlalchemy<2.1,>=2.0.54; extra == 'local-db'
Requires-Dist: sqlmodel<1,>=0.0.47; extra == 'local-db'
Provides-Extra: server
Requires-Dist: uvicorn[standard]<1,>=0.54.0; extra == 'server'
Description-Content-Type: text/markdown

# MINT SDK (Python)

SDK for building analysis plugins that integrate with the MINT platform.

> **Full Documentation:** See the [MINT SDK docs](https://github.com/MorscherLab/MINT-docs/blob/main/sdk/index.md) for detailed API reference and guides.
> - [API Reference](https://github.com/MorscherLab/MINT-docs/blob/main/sdk/api/python.md)
> - [Plugin Development Guide](https://github.com/MorscherLab/MINT-docs/blob/main/sdk/index.md)
> - [MINT SDK 1.2 Migration Guide](https://github.com/MorscherLab/MINT-docs/blob/main/sdk/operations/migrating-to-1.2.md)
> - [CLI Reference](https://github.com/MorscherLab/MINT-docs/blob/main/sdk/api/cli-reference.md)
> - [Exception Handling](https://github.com/MorscherLab/MINT-docs/blob/main/sdk/api/exceptions.md)

## Installation

```bash
# From PyPI (when published) — runtime core
uv add mint-sdk

# Development tooling: the `mint` CLI (typer) and in-process serving (uvicorn)
uv add --dev 'mint-sdk[cli,server]'

# From git
uv add git+https://github.com/MorscherLab/MINT#subdirectory=packages/sdk-python
```

### Extras

| Extra | Adds | Needed for |
|---|---|---|
| `cli` | `typer` | the `mint` command |
| `server` | `uvicorn[standard]` | `mint dev`, `mint platform foreground`, serving a plugin in-process |
| `local-db` | SQLModel, SQLAlchemy, aiosqlite, greenlet | SQL-backed plugin storage |
| `s3` | boto3 | S3 object storage |

`mint-sdk` alone installs only what a plugin process needs to serve requests
(FastAPI, Pydantic, HTTPX, jsonschema, python-multipart, packaging). Running
`mint` without the `cli` extra prints the install hint instead of a traceback.

MINT SDK 1.2 requires **Pydantic >=2.12.0** and supplies it transitively.
Plugins must not declare a separate Pydantic range.

### SDK-owned runtime dependencies

A plugin declares `mint-sdk` plus packages unique to its scientific or service
domain. The SDK owns FastAPI, Pydantic, HTTPX, jsonschema, python-multipart and
packaging at runtime, and Typer/Uvicorn through the `cli`/`server` extras;
plugin code may import them but must not list them again in `pyproject.toml` —
ask for the extra instead (`mint-sdk[cli,server]` in the dev group). For
SQL-backed plugin storage, depend on `mint-sdk[local-db]` instead of declaring
SQLModel, SQLAlchemy, aiosqlite, or greenlet directly.

The plugin `uv.lock` selects exact versions for standalone development. An
in-process plugin follows the platform lock, so removing duplicate constraints
prevents a plugin from blocking a compatible platform patch.

## Quick Start

Platform and standalone plugin HTTP errors share `code`, `message`, `status`,
`request_id`, `details`, and the legacy `detail` field. Existing platform error
codes and SDK exception `error` values remain valid. The REST client's
`APIError` exposes `status` as an alias of `status_code`, with `code`,
`request_id`, `details`, and the original `body`. Its subclasses are `AuthError`
(401 and 403), `NotFoundError`, `ConflictError` and `NetworkError`; any other
error status raises `APIError` itself. `NotFoundError` and `ConflictError`
retain their SDK runtime exception inheritance, a 403 is also a
`PermissionException` and a 422 is also a `ValidationException`. JSON error
responses are decoded even when the request expects a binary download, and
`X-Request-ID` is used when the body has no request ID.

Create a Python-only plugin whose standard UI is supplied by the SDK:

```bash
mint init my-plugin --mode generated --yes
cd my-plugin
mint dev
```

Generated mode uses typed Pydantic inputs with `ui = "generated"` on the
plugin's `Metadata` and `@job`.
It does not create a Vue project and `mint build` does not
require Bun.

Use standard mode when the plugin needs a custom Vue page:

```bash
mint init my-plugin --mode standard --yes
cd my-plugin
mint sdk generate
mint dev
```

Standard mode starts with a small `PluginWorkspaceView`, a working form, and a
generated typed client. That workspace is starter code, not a required layout;
it can be replaced or removed without making `mint doctor` reject the mode.
`mint init --yes` defaults to generated mode. Pass `--mode standard` when the
plugin needs a custom Vue frontend.

`mint doctor --strict` is a pre-commit and CI validation step, not a startup
requirement for `mint dev`.

`mint sdk generate` writes the frontend contract and typed client from backend routes and Pydantic schemas, so Vue code can call plugin endpoints without hand-writing route prefixes or request/response types.
Use `mint docs contract` inside a plugin to inspect the generated endpoint and client-call contract without writing files.
Use `mint docs python job` or `mint docs frontend ExperimentSelectorModal` to
see the installed API contract together with its recommended usage. Add
`--json` to receive structured examples for tooling.
Use `mint sdk generate --check --json` in CI or editor tasks when you need machine-readable drift status.
Use `mint doctor --json` for machine-readable project health checks and safe-fix status.
When adding backend pieces, pass `--generate` to supported `mint add` commands to refresh the generated client in the same step.

## Declarative plugin contracts

A plugin declares its options in one `Metadata` subclass, passes it to
`@plugin`, and subclasses `Plugin`:

```python
from pydantic import BaseModel

from mint_sdk import (
    Health,
    Metadata,
    Plugin,
    SettingsChange,
    health,
    on,
    plugin,
)

class PeakQcSettings(BaseModel):
    threshold: float = 0.05

class PeakQc(Metadata):
    domain = "metabolomics"
    settings = PeakQcSettings
    settings_access = "configure"

@plugin(PeakQc)
class PeakQcPlugin(Plugin[PeakQcSettings]):
    def routers(self):
        return []

    async def shutdown(self) -> None:
        pass

    @health(timeout=2.0)
    async def probe(self) -> Health:
        return Health(message="ready")

    @on("settings", fields={"threshold"})
    def threshold_changed(self, change: SettingsChange[PeakQcSettings]) -> None:
        self._threshold = change.current.threshold
```

Package identity comes from PEP 621 metadata and the sole `mint.plugins`
entry point; the route prefix defaults to `"/" + <entry-point key>`. A
`Metadata` subclass declares only runtime behavior, and an unknown attribute,
a wrong type or an unknown value raises `TypeError` when the class is defined.
`writes` (default `{"artifacts"}`) declares the platform data the plugin
writes, `auth` (default `True`) whether its routes need a signed-in user, and
`settings_access` (default `"admin"`) who may change its settings. `Plugin[S]`
is optional; `@plugin` checks that `S` is `Metadata.settings`. `@health`
accepts sync or async handlers; sync handlers run in a worker thread, and
timeouts or exceptions produce an unhealthy result. The SDK serves the result
at `GET /__mint/health`, which the platform polls, so a plugin does not write
its own `/health` route. `@on("settings")` handlers are synchronous, run after
the settings commit in declaration order, and receive only actual top-level
field changes. `@on("experiment.before_save")` handlers return `None` to allow
the save and raise `EventVetoException` to refuse it.

## Access levels

`access` says who may use an `@endpoint`, `@job`, MCP `Tool` or nav page. The
platform owns the three levels; a plugin cannot name permissions or define
roles.

| `access` | Platform requirement |
|---|---|
| `"use"` (default) | `plugins.use` and a role that sees the plugin |
| `"configure"` | `"use"` plus `plugins.configure` |
| `"admin"` | a platform administrator |

```python
from mint_sdk import endpoint

class MethodRoutes:
    @endpoint.delete("/methods/{method_id}", access="configure")
    async def delete_method(self, method_id: str) -> None: ...
```

An endpoint with `auth=False` takes only `access="use"`. Standalone mode
skips the check. `actor.can(access)` gives the same answer in plugin code.
`access` does not replace `CurrentExperiment` or repository ownership and
visibility checks.

## Notifications and calendar feeds

Async plugin methods may return typed notification or calendar values. The
decorators validate each result and publish it when the plugin instance is
bound to an integrated platform context:

```python
from datetime import UTC, datetime

from mint_sdk import Plugin
from mint_sdk.integrations import (
    CalendarEvent,
    NotificationEvent,
    NotificationSeverity,
    calendar_event,
    notify,
)


class InstrumentPlanner(Plugin):
    @notify(channels={"email", "teams"})
    async def report_stop(self, run_id: str) -> NotificationEvent:
        return NotificationEvent(
            event_key=f"run:{run_id}:stopped",
            severity=NotificationSeverity.CRITICAL,
            title="Acquisition stopped",
            message=f"Run {run_id} stopped before completion.",
            occurred_at=datetime.now(UTC),
        )

    @calendar_event
    async def schedule(
        self,
        run_id: str,
        user_id: int,
        start: datetime,
        end: datetime,
    ) -> CalendarEvent:
        return CalendarEvent(
            event_key=f"run:{run_id}",
            title=f"Run {run_id}",
            start=start,
            end=end,
            participant_user_ids=(user_id,),
        )
```

An instrument probe may call a normal, plugin-owned API and then invoke an
async `@notify` method. The plugin owns and validates the probe token; MINT
does not issue, store, rotate, or read it. MINT receives only the method's
structured notification result.

Only unacknowledged Critical notifications are delivered externally. MINT
selects email recipients and owns the SMTP, Teams, and Slack targets. Calendar
events use stable keys for upsert or cancellation and appear in read-only ICS
feeds. Each decorator accepts at most 100 typed results per call.

An unbound or standalone direct call returns the method result without
publishing. An integrated host-bound direct call can publish. Stacking
`@notify` with `@job` requests completed/failed owner email only for a managed
Job; calling that function directly does not create a terminal Job email. See
the [plugin development guide](https://github.com/MorscherLab/MINT-docs/blob/main/sdk/index.md)
and [1.2 migration guide](https://github.com/MorscherLab/MINT-docs/blob/main/sdk/operations/migrating-to-1.2.md)
for the full contracts.

## Backend-only generated UI

Plugins with simple controls and results can omit frontend source. Declare an
optional Pydantic settings model with `Metadata.settings`, calculations
with `@job`, and `ui = "generated"` on the `Metadata`. MINT then serves a
standard workspace in standalone and integrated modes. An existing plugin Vue
frontend always takes precedence.

```python
from mint_sdk import Metadata, Plugin, TableResult, job, plugin
from pydantic import BaseModel, Field

class Inputs(BaseModel):
    threshold: float = Field(1.0, ge=0.0)

class PluginSettings(BaseModel):
    method: str = "default"

class SimpleAnalysis(Metadata):
    domain = "test"
    title = "Simple analysis"
    settings = PluginSettings
    ui = "generated"

@plugin(SimpleAnalysis)
class SimpleAnalysisPlugin(Plugin):
    @job()
    def run(self, inputs: Inputs) -> TableResult:
        return TableResult(
            columns=["threshold"],
            rows=[{"threshold": inputs.threshold}],
        )
```

Input v1 supports typed scalar fields, Pydantic models, enums, toggles, string
arrays, and ordinary `Path` file/directory inputs. Result v1 supports text, JSON, tables/DataFrames,
images, artifacts, Matplotlib figures, and interactive Plotly figures.
Artifact v1 embeds its download bytes. Arbitrary HTML, JavaScript, and frontend
callbacks are rejected.

The generic workspace cannot infer domain-specific part batching.
`ui = "generated"` therefore rejects a class containing a staged job (a `Job`
spec with `parts`); use a standard/custom frontend and its generated
`useJobs()` client for that lifecycle.

Job options go on a `Job` subclass, and `@job(Spec)` references it. `@job()`
takes every default. The SDK checks the options when the subclass is created
and raises `TypeError` for an unknown option, a wrong type, or options of two
execution modes. The handler sets the mode: `async def` is a service job
(`timeout`, `concurrency`); `def` is a process job (`cpu`), staged when `parts`
is set (`min_parts`, `max_parts`, `max_bytes`). The job id defaults to the
method name in kebab-case and the title to the id in title case.

Call a decorated job like an ordinary synchronous Python method. The platform schedules the
same job through its HTTP routes. `@job`, `Job`, `JobContext`, `JobManager`, and the result
types are stable SDK APIs.
`JobContext.report(percent, message, *, stage=...)` publishes percent/stage/message plus
structured current-file progress; `JobContext.warn()` records bounded non-fatal warnings
without replacing the progress message. `JobContext.check()` raises `asyncio.CancelledError`
once the job is cancelled; the scheduler records that job as cancelled, not failed.

When one definition processes different datasets, set `name` and `folders` on
the spec to derive the persisted Job name and source folders from each
validated submission:

```python
from mint_sdk import Job, job

class Analyze(Job):
    title = "Analyze"
    name = lambda inputs: inputs.dataset_name
    folders = lambda inputs: inputs.dataset_ids

@job(Analyze)
def analyze(inputs: Inputs) -> TableResult:
    ...
```

The callbacks are synchronous and receive a detached copy of the validated
input. `name` returns a string, which the SDK cuts to 255 characters; an empty
name falls back to the job title. The canonical in-memory Job state remains available after clients
refresh, but it is not restored after a daemon restart.

The generated workspace schema is versioned but remains experimental in this minor release. See the
[backend-only example](../../examples/backend-only-generated-ui/README.md).

## Runtime configuration and concurrency

`Metadata.settings = PluginSettings` is the single settings-model
declaration for standalone and integrated settings.
`Metadata.settings_access` defaults to `"admin"`: integrated mutation and reset
routes then require a platform administrator. Set it to `"configure"` when
users with `plugins.configure` may change the settings; reads and standalone
operation keep their existing behavior. Custom form schemas and out-of-process runtime
metadata inherit the same declaration.
Effective values resolve in this order:

```text
model defaults < saved store < explicit .env < process environment < startup override
```

Environment variables use
`MINT_PLUGIN_<NORMALIZED_PLUGIN_NAME>__<FIELD>`; nested fields add another
double underscore. For example,
`MINT_PLUGIN_MY_ANALYSIS__DATABASE__HOST`. Values supplied by `.env`, the
process environment, or startup overrides remain effective. Writes still
replace the saved backing value; that value appears in `stored_settings` and is
marked `inactive` until the higher-priority runtime override is removed. Secret
fields are never returned or persisted by the public settings API.

Full replacement uses optimistic concurrency. Pass the opaque revision that
backs the candidate; do not parse or calculate it:

```python
candidate = self.settings.model_copy(update={"threshold": 0.1})
await self.update_settings(candidate, replace=True, revision=self.settings_revision)
```

A full save makes one compare-and-swap attempt. It does not merge a stale
candidate and does not retry it, because the SDK cannot infer which fields the
caller intended to replace. A stale revision raises `SettingsConflictError`
with `committed=False`; reload before rebuilding the candidate.

For a partial update, use
`await self.update_settings({"threshold": 0.1})`. A patch
shallow-merges into the latest durable backing value and retries
compare-and-swap conflicts up to three times, so concurrent requests changing
different fields do not overwrite each other. A patch does not take an
`expected_revision`. Validation or preflight failure does not change durable
or in-memory state. A preflight may run again during a patch retry, so it must
be repeatable and side-effect free; `@on("settings")` handlers run once,
only after a successful commit. A post-commit handler failure uses
`SettingsTransactionError` with `committed=True`. If a CAS response is lost,
the SDK reads the authoritative snapshot before deciding the result. A matching
target is treated as committed. If neither the commit nor the readback can be
confirmed, the error has `commit_state="unknown"` and `committed=None`;
settings reads and new writes are fenced until an authoritative read succeeds,
hot-applies the exact snapshot, and runs the `@on("settings")` handlers once.

The settings GET response carries the same opaque typed-settings content token
as `revision` in its JSON body and as a quoted `ETag`. Each managed GET reads a
fresh snapshot from authoritative persistence, but it does not hot-apply that
snapshot to the running plugin or run `@on("settings")` handlers. Full HTTP writes
(`PUT /settings` and `POST /settings/reset`) return
`428 Precondition Required` without `If-Match: "<revision>"`, and
`409 Conflict` when that revision is stale. The token is a CAS value, not a
monotonic generation: equal durable content can produce the same token, so it
does not detect an A-to-B-to-A transition. An unconfirmed commit returns
`503 Service Unavailable` with `committed: null`; reload before deciding
whether to retry the mutation.

`SettingsTransactionError.commit_state` is `committed`, `not_committed`, or
`unknown`; `committed` remains its `True`, `False`, or `None` compatibility
projection. Readback uses strict JSON identity, so `true` and `1` are distinct,
and non-finite numbers are rejected before persistence.

`PUT {}` and reset clear the durable typed backing instead of materializing
model defaults. The effective runtime model still receives those defaults
with `DEFAULT` provenance, so a later plugin release can change a default
without an older saved copy shadowing it.

Every resolver-backed runtime uses the synchronous host protocol
`mint_sdk.host.SettingsCompareAndSwapAdapter`: standalone apps use
`JsonSettingsStore`, in-process platform plugins use the central platform
store, and isolated or Docker plugins use `RemotePlatformContext` to reach that
same store. An integrated host subclasses `mint_sdk.host.HostContext` and
implements `get_plugin_settings_cas_adapter()` with the complete plugin-owned
key scope. Missing or unreachable CAS support fails app startup
before plugin initialization instead of falling back to a best-effort
read-modify-write.

Config models use a strict, fail-fast storage contract. Supported fields are
named `BaseModel` fields composed from the standard scalar types, dates and
times, `Decimal`, `UUID`, `Path`, `Enum`, Pydantic secret types, nested
`BaseModel` values, explicit mappings/sequences, `Literal`, unions, and
`Annotated` with ordinary `Field` constraints or discriminators. Enum values
must survive a JSON round trip without type drift. Mapping keys must be `str`
or string-valued `Literal` annotations; JSON object storage does not preserve
integer or Enum key identity. Validators may normalize values, but the returned
runtime shape must still match the annotation. Dynamic `Any` values are
limited to string-keyed JSON-shaped trees and recognized secret leaves.
Do not union `SecretStr` or `SecretBytes` with UUID, date/time, `Decimal`,
`Path`, bytes, or another scalar that uses the same JSON string
representation; use a small `BaseModel` with separate public identity and
secret fields.

MINT rejects `RootModel`, `TypedDict`, dataclasses, Pydantic dataclasses,
`NamedTuple`, `extra="allow"`, arbitrary types, unsafe mapping keys, and
serialization-changing features. The latter include field/model serializers,
`json_encoders`, `GetPydanticSchema`, `SerializeAsAny`, plain/wrap serializers,
and field `exclude`/`exclude_if`. Broad Pydantic helper types such as `Json`
and `ImportString` are not config scalars; use a supported storage type plus a
validator. Numeric `ser_json_temporal` modes are rejected. Legacy
`ser_json_timedelta="float"` is supported, while non-UTF-8 byte serialization
requires the same `ser_json_bytes` and `val_json_bytes` mode. See the
[1.2 migration guide](https://github.com/MorscherLab/MINT-docs/blob/main/sdk/operations/migrating-to-1.2.md)
for the complete migration list.

The platform owns `allowed_experiment_types`; a decorator config model and a
plugin-supplied `owned_keys` scope cannot claim it. The value must be
`list[str] | None`, and corrupt persisted policy denies every experiment type
until repaired. Typed and platform-owned fields are updated separately.

The user-facing plugin-config GET returns platform-owned public fields plus a
fresh, sanitized typed snapshot only when the loaded provider, plugin name,
resolver, and exact decorator model all match. The typed snapshot is read from
authoritative persistence for each request without hot-applying it to the
running plugin. It does not fall back to raw stored values for unloaded,
untyped, external, or Docker-hosted plugins. The response revision and `ETag`
cover only the plugin-owned typed snapshot; they do not cover
`allowed_experiment_types` or other platform-owned fields. The plugin-scoped
internal transport can still read raw backing values for an isolated runtime,
and every write through that transport must supply explicit `owned_keys`
without platform-owned fields.

Integrated plugin frontends should let the injected
`plugin.settings_api` capability choose the settings route. Isolated
subprocess, Docker-managed, external HTTP, and dev-proxy runtimes receive
`"plugin"` and use their proxied plugin-local `/settings` API. In-process
plugins receive `"platform"` and use the platform config facade; an absent
field keeps that legacy platform behavior. The frontend SDK composables perform
this selection automatically.

Full saves and shallow patches prepare a secret-free backing value together
with its exact effective snapshot. Environment/startup overrides are included
in preflight and the live commit but are not materialized into storage. A patch
replaces only its explicit top-level keys and uses bounded compare-and-swap
retries. Preflight and host persistence callbacks may be synchronous or return
one awaitable; nested awaitables fail the transaction instead of leaving work
running after an uncommitted result.

Secret-bearing list items need at least one stable non-secret field, such as an
ID or a discriminating `Literal`, so masked values can be associated after a
reorder. If a positional item becomes entirely secret after masking while
other items remain, a public full save returns `422` instead of storing an
ambiguous placeholder. Use a small `BaseModel` item with a stable public field
instead of mixing bare secret values with ordinary list values. A locked
secret override also keeps the pre-existing lower backing branch; unlike a
locked non-secret scalar, its masked effective branch is never copied down.
If a different locked branch occurs inside a positional collection, public
full replacement returns `422` instead of silently discarding sibling edits.

Each browser tab gets an ephemeral session. Jobs are independent and may run
concurrently across a user's tabs. Process and staged jobs snapshot input and
effective settings at submission time. Service jobs snapshot input and expose
the submitted settings as `JobContext.settings`, but run against the initialized
host object: `self.settings`, plugin attributes, and services are live when the
handler executes. Sessions and jobs are not persisted and do not survive a
daemon restart.

`@job` works for both custom Vue and generated-UI plugins. Generated
contracts expose typed `useJobs(definition)` for definition inputs, job
handles, and outputs, plus `refresh`, `delete`, `clear`, `pause`, `resume`, and
idempotent `dispose` lifecycle helpers. Use ordinary `Path` parameters for file or directory inputs; HTTP
submissions stage opaque owner/session-scoped uploads into per-job writable
workspaces, while direct Python calls keep the caller's local paths.
Integrated generated UI may stage an accessible file-backed analysis artifact
into the same workspace by experiment and artifact database ID; the copy and
permission checks occur only when the user submits the job.
`mint_sdk.testing.Harness` runs the real scheduled path without test-owned
sessions, actors, polling, or temporary-file plumbing. `Harness(plugin,
settings=..., context=...)` overrides the plugin settings and gives the plugin
a platform context such as `RecordingContext`; `harness.client` is a
`TestClient` on the plugin's own app.

Set `access = "configure"` or `access = "admin"` on the `Job` spec for maintenance or
provisioning work that must not be submitted by ordinary platform users.
Integrated submission and all associated Path/staged upload and seal operations
then require `plugins.configure` or a platform administrator (`"configure"`),
or a platform administrator only (`"admin"`).
`jobs.manage_all` alone does not grant admission. Direct Python calls and
standalone mode remain local operations, and the original job owner may still
cancel or delete an existing job after losing that access. Generated clients bind
Path uploads to the selected definition automatically; custom callers can pass
the optional definition ID to `uploadPath(input, definitionId)`.

For large outputs, write beneath `JobContext.output_path()` and return
`FileResult`. The host adopts and hashes the file without base64 or a
worker-IPC copy. It either exposes an expiring authenticated download or runs a
trusted finalizer with `ManagedFile`, the original actor, validated
input and settings snapshots, and a stable retry idempotency key.
`@finalize(Spec, attempts=3, timeout=30.0)` binds the finalizer to the job
declared with `@job(Spec)`; a job without a spec cannot have one. The finalizer
takes `(result: ManagedFile, *, context: FinalizeContext[InputsT, SettingsT])`;
the SDK checks the written type arguments against the job's input model and the
plugin's settings model. The finalizer's
return annotation becomes the typed value inside the generated wire-result
envelope. Finalization is async and cannot be cancelled by clients. Each
attempt has a configured timeout; blocking libraries must run through
`FinalizeContext.run_blocking()` so timeout cancellation drains the
thread before the actor scope, managed file, quota, or live plugin services are
released. A thread that never returns can therefore extend wall-clock teardown
beyond that timeout. Platform mutation APIs remain responsible for current
authorization. See the
[1.2 migration guide](https://github.com/MorscherLab/MINT-docs/blob/main/sdk/operations/migrating-to-1.2.md).

The shared scheduler enforces CPU slots globally and a concurrent-job limit per
user across all of that user's sessions. Each synchronous job runs in a fresh
worker process. The pool defaults to one less than the host CPU count. A
plugin's explicit `Job.cpu` request is reduced to the pool size when the
deployment has fewer CPUs, and the effective value is available as
`JobContext.cpu` and applied to common Python and Rust thread-pool environment
variables. Configure the two primary limits with `MINT_JOB_CPU_SLOTS` and
`MINT_JOB_MAX_CONCURRENT_PER_USER`, or the matching `mint daemon` flags.

The in-memory runtime also bounds queued work, retained terminal jobs,
serialized payloads, and result lifetime. Configure these limits with
`MINT_JOB_MAX_QUEUED`, `MINT_JOB_MAX_QUEUED_PER_USER`,
`MINT_JOB_MAX_SESSIONS`, `MINT_JOB_MAX_SESSIONS_PER_USER`,
`MINT_JOB_MAX_RETAINED`, `MINT_JOB_MAX_RETAINED_PER_USER`,
`MINT_JOB_MAX_INPUT_BYTES`, `MINT_JOB_MAX_TEMP_BYTES_TOTAL`,
`MINT_JOB_MAX_TEMP_BYTES_PER_USER`, `MINT_JOB_MAX_RESULT_BYTES`,
`MINT_JOB_MAX_MANAGED_RESULT_BYTES`,
`MINT_JOB_MAX_RESULT_BYTES_TOTAL`, `MINT_JOB_MAX_RESULT_BYTES_PER_USER`, and
`MINT_JOB_RESULT_TTL_HOURS`.

The temporary-byte limits are shared service-wide across plugins, ordinary
path storage, staged parts, and multipart request spools. During upload
adoption, both the request spool and retained workspace copy may count.

Process workers reconstruct the zero-argument plugin class and inject the
submission-time settings snapshot. Jobs should depend on typed input,
`self.settings`, and `JobContext`.
The trusted worker snapshot contains effective secret values, but job state,
events, and the public settings API never expose them.

For Docker, Linux, and WSL, run one foreground host process:

```bash
mint daemon \
  --app mint_sdk.app:create_app \
  --port 8000 \
  --cpu-slots 4 \
  --max-concurrent-per-user 2
```

Forwarded headers are trusted only from loopback by default. When a reverse
proxy runs on another address, pass its explicit IP or CIDR with
`--forwarded-allow-ips`; do not use a wildcard on an exposed deployment.

`--app` defaults to the plugin runtime. The daemon serves the plugin in the
current directory; use `mint -C DIR daemon` for another directory. The MINT platform has its own commands in the platform checkout:
`mint-platform serve` and `mint-platform daemon ...`.

Monorepo plugins must declare non-standard project paths so every CLI command
uses the same frontend and generated client:

```toml
[tool.mint]
frontend_dir = "packages/ui"
generated_dir = "packages/contracts/client"
```

Both values are project-relative. `generated_dir` defaults to
`<frontend_dir>/src/generated` when omitted. Conventional single-package
plugins need no path settings.

`mint doctor` can also inspect split Python packages and several plugin
projects from one repository root:

```toml
[tool.mint]
python_source_dirs = ["packages/leaf"]

[tool.mint.workspace]
plugin_members = ["plugins/*"]

[[tool.mint.doctor.import_rules]]
source = "leaf.analyzer"
forbid = ["mint_sdk", "fastapi", "leaf.api"]
```

Workspace members are explicit globs. Directories without a
`mint.plugins` entry point are reported as skipped, and each plugin receives a
separate text/JSON result group. Run `mint doctor --strict` when warnings must
also fail CI. Import rules are static checks of normal Python imports; they do
not import project code or guess dynamic imports.

For R-backed analyses:

```bash
mint init drp-r --mode standard
mint add r-analysis drp-fit --page
mint doctor --only r --explain
mint sdk generate
```

This creates an `RAnalysisBridge` service, FastAPI route, typed frontend composable, optional starter page, and a small `mint_bridge.R` helper for reading inputs, writing outputs, accessing the current experiment id, and writing analysis artifacts while keeping Python/Pydantic as the frontend contract source of truth.

For experiment object files, let the platform choose local, S3, or OpenStack
Swift storage and keep only the returned reference in your experiment data.
Standalone plugin runs use a local store next to the plugin's SQLite
database, or under `~/.mint/plugins/<plugin-name>/objects`.

```python
class MyPlugin(Plugin):
    async def save_report(self, experiment_id: int, payload: bytes) -> dict:
        store = self.context.objects(experiment_id)
        ref = await store.put(
            "reports/report.json",
            payload,
            media_type="application/json",
            metadata={"kind": "qc-report"},
        )
        return ref.to_dict()
```

The platform stores object bytes under `{server.dataPath}/objects` by default.
Admins can switch the object backend between `storage.objects.backend = "local"`
`"s3"`, and `"swift"` and can set `storage.objects.localPath` for local storage.
When the backend is S3, the same SDK calls write to `storage.s3.objectBucket`
under `storage.s3.objectPrefix`. When the backend is Swift, they write to
`storage.swift.objectContainer` under `storage.swift.objectPrefix`. The platform
owns endpoint, region, access key, secret key, session token, SSL, path-style,
Keystone auth URL, project/domain, and Swift password settings; saved
credentials are encrypted at rest and redacted from admin config responses. Use
Admin -> Configuration -> Object Storage to choose Local Path, S3 Bucket, or
OpenStack Swift, and the Test Connection button to validate provider access.
Isolated plugin uploads of a `Path` or a file object use multipart transfer
instead of base64 JSON.

S3-compatible provider settings may also come from environment variables. The
platform reads endpoint/region names such as `MINT_S3_ENDPOINT_URL`,
`MINT_S3_REGION_NAME`, `S3_ENDPOINT_URL`, and `AWS_ENDPOINT_URL_S3`; credential
names such as `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and
`AWS_SESSION_TOKEN`; and MINT-specific `MINT_S3_*` /
`MINT_STORAGE__S3__*` variants for SSL and path-style addressing.
Native Swift settings may come from `MINT_SWIFT_*` /
`MINT_STORAGE__SWIFT__*` variables or standard OpenStack variables such as
`OS_AUTH_URL`, `OS_USERNAME`, `OS_PASSWORD`, `OS_PROJECT_NAME`,
`OS_USER_DOMAIN_NAME`, `OS_PROJECT_DOMAIN_NAME`, `OS_AUTH_VERSION`, and
`OS_REGION_NAME`.

Scripts can use `MINTClient.objects` for the same storage API by passing
`plugin_id` explicitly. `get_s3_connector()` and platform S3 credential access
were removed in 1.1. Integrated plugins call `self.context.objects(experiment_id)`.
A standalone script that needs S3 directly uses `boto3` with its own credentials.

For first-class analysis artifacts, use the read-only
`client.experiments.artifacts` resource:

```python
artifacts = client.experiments.artifacts
rows = artifacts.list(42)
detail = artifacts.resolve(
    42,
    plugin_id="drp-analysis",
    artifact_key="qc-report",
)
artifacts.download_file(
    42,
    "qc-report.pdf",
    plugin_id="drp-analysis",
    artifact_key="qc-report",
)
```

`resolve()` uses `(experiment_id, plugin_id, artifact_key)` as the stable
identity; `artifact_id` is only an optional hint. `get_file_bytes()` buffers a
file in memory with a 256 MiB default limit. `download_file()` streams through a
temporary file and atomically replaces the target after validation, so it is
the appropriate choice for larger files. Downloads have no resume support and
restart from byte zero after failure.

Both download methods accept `on_progress` with the SDK's `DataTransferProgress`
callback. Reads verify the file reference's size and, when present, the managed
artifact's `metadata.mint_result_sha256`; malformed checksum provenance is an
error. Archived artifacts remain opt-in with `include_archived=True`. Mid-stream
failures are not replayed automatically, and progress observers do not change
the transfer outcome if they raise an exception.

At the Python layer, plugins subclass `Plugin`:

```python
from mint_sdk import Metadata, Plugin, plugin
from fastapi import APIRouter

router = APIRouter()

@router.get("/hello")
async def hello():
    return {"message": "Hello from my plugin!"}

class MyMetadata(Metadata):
    domain = "metabolomics"

@plugin(MyMetadata)
class MyPlugin(Plugin):
    def routers(self):
        return [(router, "")]

    async def shutdown(self):
        pass
```

## Plugin Package Structure

```
mint-plugin-example/
├── pyproject.toml
├── README.md
└── src/mint_plugin_example/
    ├── __init__.py
    └── plugin.py
```

### pyproject.toml

```toml
[project]
name = "mint-plugin-example"
version = "0.1.0"
dependencies = ["mint-sdk>=1.4.0b1,<1.5"]

[dependency-groups]
dev = ["mint-sdk[cli,server]>=1.4.0b1,<1.5"]

[project.entry-points."mint.plugins"]
example = "mint_plugin_example.plugin:MyPlugin"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/mint_plugin_example"]
```

The entry point `mint.plugins` is how the platform discovers your plugin.

## Platform Context

`self.context` is the plugin's `PlatformContext`. It is never `None`: without
the platform it is a `StandaloneContext`. It provides:

- Typed request actors (`CurrentActor` / `Actor`)
- Capability-scoped data namespaces: `design`, `artifacts`, `experiments`,
  `users`, `instruments`, `objects(experiment_id)`, `files` and `db()`
  (`self.design` and `self.artifacts` are shortcuts)
- `publish(name, payload)`, which delivers a plugin event to the plugin's own
  `@on` handlers
- Persisted configuration for the current plugin
- Typed notification and calendar publication

The context does not expose full platform settings or platform secrets. Use
`self.settings` for the plugin's typed configuration and
`self.context.objects(experiment_id)` for experiment files. Standalone, calls
that need the platform raise `UnavailableException` (HTTP 503); `objects()`
and `db()` use local storage.

```python
async def initialize(self) -> None:
    if self.is_standalone:
        return
    experiments = await self.context.experiments.list(status="ongoing")
```

```python
await self.design.save(experiment_id, {"plate": "A"})
await self.artifacts.save(experiment_id, {"rows": rows})          # key "default"
await self.artifacts.save(experiment_id, report_path, "report")   # a file needs a key
summary = await self.artifacts.get(experiment_id, model=Summary)
```

## Installation Commands

These commands are for a developer environment or the trusted in-process
plugin path. In-process installation retains normal package, Git,
editable/local-source, wheel, and source-distribution support.

```bash
# Install from GitHub
uv add git+https://github.com/org/mint-plugin-example

# Install specific version
uv add git+https://github.com/org/mint-plugin-example@v1.0.0

# Install from PyPI
uv add mint-plugin-example

# Install local plugin for development
uv add --editable ./my-plugin
```

### Subprocess installation

A subprocess plugin source must be an administrator-trusted, existing local
`.whl`. MINT rejects package requirements, Git URLs, editable installs,
source trees, source archives, and source distributions for this path.
Dependencies must have compatible binary wheels; source builds are disabled.

Keep the selected wheel at its persisted path. If it is missing at startup,
MINT leaves the plugin disabled and does not use a registry, network download,
or source fallback. This release does not include a trusted wheel builder or a
content-hash artifact cache.

The virtual environment and separate process isolate dependencies and ordinary
failures, not hostile code. They are not an OS sandbox. Deploy untrusted code
through an external HTTP or Docker-managed runtime with explicit operating
system or container controls. Snapshot rollback can remove packages added
after the snapshot, but MINT refuses to restore missing or changed packages
without immutable trusted wheels.

For an external HTTP runtime, the deployment operator sets
`MINT_EXTERNAL_PLUGIN_<NORMALIZED_NAME>_TOKEN` on MINT and passes the same
secret to the external service as `MINT_PLUGIN_TOKEN`. The normalized name is
uppercase with runs of non-alphanumeric characters replaced by `_`. Use at
least 32 non-whitespace characters. MINT reads the value at startup and does
not register the runtime when it is missing or weak. It does not store, expose,
or rotate this secret. Normalized token keys must be unique; all colliding
runtimes remain disabled. Targets must be absolute `http` or `https` URLs with
a hostname and valid optional port, without userinfo, query strings, or
fragments. Plain HTTP is accepted only for `localhost`, `127.0.0.0/8`, or
`::1`; remote targets require HTTPS. Tokens used by an instrument to
authenticate to a plugin-owned API are separate credentials owned entirely by
that plugin.
