# Plugins

Add your own actions to a space: an HTTPS service in any language, or a command on your own machine through a runner. Signed jobs, ranged reads of the input, results as sidecars and files.

A plugin adds actions to Side Space: a QC check, your own transcription service, an upscaler on your GPU. Its code never runs on the Side Space server. It is either an **HTTPS service** that receives signed jobs, or a **runner**: a command on a machine of yours that claims jobs, with no public address needed.

Once a space manager installs it, its actions appear as `x-acme.qc.check` next to the built-in ones. They can be run by hand, used in [pipelines](/docs/actions#pipelines), called by agents over MCP, and are [cached by content](/docs/actions#cached-by-content) the same way (within the space, and run again when the plugin's version changes).

## The manifest

```json
{
  "name": "x-acme.qc",
  "title": "Acme QC",
  "version": "1.2.0",
  "endpoint": "https://qc.acme.example/side/jobs",
  "timeout_seconds": 1800,
  "actions": [
    {
      "name": "check",
      "title": "Check for dropped frames",
      "accepts": ["video/"],
      "writes": ["x-acme.qc"],
      "params_schema": { "type": "object", "properties": { "strict": { "type": "boolean" } } }
    },
    { "name": "report", "title": "Make a QC report", "accepts": ["video/"], "writes": ["x-acme.qc", "files"] }
  ]
}
```

- `name` starts with `x-`; actions are called `<name>.<action>`.
- `accepts` lists MIME prefixes (empty means any file). Params are checked against `params_schema` before a job is sent.
- `writes` is everything the action may write: `x-…` namespaces, and if declared `transcript`, `ai.tags`, `ai.generation`, `user` and `files` (new files, which get a `derived` sidecar). A result that writes anything else fails as a whole and writes nothing.
- `version` is part of the cache key: a new version processes files again.

```sh
sidespace plugin install "Film A" https://qc.acme.example/side/manifest.json   # prints the signing secret, once
sidespace plugin list "Film A"
sidespace run "Film A" /Ep01/a.mov x-acme.qc.check --param strict=true
sidespace plugin enable "Film A" plg_… --off
sidespace plugin rm "Film A" plg_…
```

> [!WARNING]
> Installing a plugin hands it the content of the space's files, so only space managers can, and tokens need `plugins:manage`. Read what it declares it will write before installing.

## HTTPS plugins

The server POSTs a **job** to `endpoint`, signed like a [webhook](/docs/changes#check-the-signature) (`Side-Signature`, with the secret from install):

```json
{
  "type": "run",
  "run_id": "run_…",
  "action": "check",
  "attempt": 1,
  "space_id": "spc_…",
  "node_id": "nod_…",
  "version_id": "ver_…",
  "path": "/Ep01/a.mov",
  "name": "a.mov",
  "mime": "video/quicktime",
  "size": 734003200,
  "params": { "strict": true },
  "sidecars": { "media": { "duration_s": 92.4, "width": 3840, "height": 2160, "fps": 24 } },
  "input": { "url": "https://blocks.side.example.com/files/ver_…?t=…", "expires_at": "…" },
  "callback": { "url": "https://side.example.com/v1/plugin-runs/run_…", "token": "srt_…", "expires_at": "…" }
}
```

`input.url` reads the version's bytes with Range support, valid for the whole timeout, so you can read only the part you need. Answer within 60 seconds:

| Answer | Means |
|---|---|
| `200` with a result `{"sidecars": {"x-acme.qc": {…}}, "files": […]}` | Done |
| `202` | You're working in the background; finish through the callback |
| `4xx` | The run fails with the reason you give |
| `5xx` or a timeout | Retried like any action |

After a `202`, call the callback with `Authorization: Bearer <callback.token>`:

- `POST {callback.url}/progress` with `{"progress": 0.4}`
- `POST {callback.url}/upload` for an upload ticket, then POST the file to the gateway's `/stream-upload` to get its manifest
- `POST {callback.url}/complete` with the result, or `{"error": "…", "retry": true}`

If a run isn't completed within `timeout_seconds`, it is sent again, up to three times.

```ts tab=TypeScript
import { PluginRun } from "@sidespace/sdk";
import { verifyWebhookSignature } from "@sidespace/sdk/webhooks";

// In your HTTP handler, with the raw body:
if (!verifyWebhookSignature(secret, req.headers["side-signature"], rawBody)) return res.writeHead(401).end();
res.writeHead(202).end();

const run = new PluginRun(JSON.parse(rawBody));
await run.progress(0.5);
const manifest = await run.upload(reportBytes);
await run.complete({ sidecars: { "x-acme.qc": { passed: true } }, files: [{ path: "/Ep01/a.qc.pdf", manifest }] });
```

A complete reference plugin using only the Python standard library (answering at once, answering 202 and calling back, uploading a file, checking signatures) is in the repository at `tests/e2e/fakeplugin.py`.

## Runners: plugins on your own machine

When you'd rather not run an HTTPS service, or the work needs local models and GPUs (whisper.cpp, a local vision model, grading tools), set `"runtime": "runner"` instead of an `endpoint` and run this on that machine:

```sh
sidespace plugin install "Film A" ./local-tools.json         # note the plg_… id and whsec_… secret
sidespace runner --plugin plg_… --secret whsec_… --jobs 2 -- ./qc.sh
```

- The runner long-polls the server for jobs, so the machine needs no public address: behind NAT or on a laptop is fine. Jobs queue while it is away.
- Your command runs once per job. The input file is already downloaded at `SIDE_INPUT`; the job JSON is in `SIDE_JOB` (and on stdin); `SIDE_ACTION` and `SIDE_PARAMS` hold the action and params; write new files into `SIDE_OUTPUT`.
- Print the result JSON on stdout. The runner uploads the files it names and completes the run:

```json
{ "sidecars": { "x-acme.qc": { "passed": true } }, "files": [{ "path": "/Ep01/qc.pdf", "file": "qc.pdf", "mime": "application/pdf" }] }
```

- `progress 0.4` (or `progress: 40%`) on stderr shows as progress; other output is passed through. Exit code `75` means "try again later"; any other non-zero code fails the run with the last lines of stderr as the reason.
- If no runner claims a job within `timeout_seconds`, it is queued again, and fails after three tries saying no runner came. `plugin list` shows when a runner last checked in.

Runners in other languages call `POST /v1/plugin-runs/claim?wait=25` with `Authorization: Bearer plg_…:whsec_…`, get the same job an HTTPS plugin would, and report through its callback.
