> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.labric.co/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.labric.co/_mcp/server.

# Run a job off-platform

Most jobs run in a sandbox on Labric. Sometimes the script has to run somewhere else: on the PC attached to an instrument, on a machine inside your network, or from a scheduler you already operate. Off-platform executions cover that case. Your script opens an execution, tags its writes and uploads with the execution's ID, and closes it when done. Labric then shows the run on the job page like any other execution, with the same provenance, so you can inspect what it produced and revert it as a unit.

## Prerequisites

* Python 3.9 or later with the SDK installed: `pip install labric`
* An API key with the **write** scope, created under **Settings > API Keys** on [platform.labric.co](https://platform.labric.co) and exported as `LABRIC_API_KEY`

## Start an execution

Open an execution before the script does any work. The response carries the `job_execution_id` that everything else in the run is tagged with.

```python
from labric import Labric, StartJobExecutionSchema

client = Labric()

execution = client.jobs.start(
    request=StartJobExecutionSchema(
        job_name="Plate Reader Import",
        timeout_minutes=30,
    )
)
execution_id = execution.job_execution_id
```

`job_name` is the job the run is filed under. Labric creates it if it doesn't exist, so the first run of a new script creates its job. Pass `job_id` instead to run under a job that already exists. Omit both and the run goes under a job called "Off-Platform Manual Job".

`timeout_minutes` is a safety net. If the script crashes before closing the execution, Labric marks it failed after that long instead of leaving it running forever. Set it a little above the longest run you expect.

## Write data and upload files

Pass `job_execution_id` on every write so the rows are attributed to this run:

```python
client.tools.write(
    target_type="table",
    target_name="plate_reads",
    data=records,
    mode="create",
    job_execution_id=execution_id,
)
```

Files work the same way. Uploading with `job_execution_id` stores the file as an artifact of the run:

```python
with open("raw_export.csv", "rb") as f:
    client.files.upload(file=f, job_execution_id=execution_id)
```

If you skip `job_execution_id` on a write, Labric still records it, but under a new execution of its own. Tagging is what turns a script's separate calls into one run.

## Close the execution

When the script finishes, close the execution as `completed` or `failed`. Both are final: closing twice with the same status is a no-op, and switching status afterwards is rejected.

```python
client.jobs.close(execution_id, status="completed")
```

## Putting it together

```python
import csv
from labric import Labric, StartJobExecutionSchema

client = Labric()

execution = client.jobs.start(
    request=StartJobExecutionSchema(job_name="Plate Reader Import", timeout_minutes=30)
)
execution_id = execution.job_execution_id

try:
    with open("raw_export.csv", "r") as f:
        records = list(csv.DictReader(f))

    client.tools.write(
        target_type="table",
        target_name="plate_reads",
        data=records,
        mode="create",
        job_execution_id=execution_id,
    )
    with open("raw_export.csv", "rb") as f:
        client.files.upload(file=f, job_execution_id=execution_id)
except Exception:
    client.jobs.close(execution_id, status="failed")
    raise

client.jobs.close(execution_id, status="completed")
```

The run shows up on the job's page in [Jobs](https://platform.labric.co/jobs), with the rows it wrote and the file it uploaded.

## Undo a run

If a run wrote bad data, revert it. This deletes every row the execution created in one transaction. Rows it updated or deleted can't be restored and are listed as warnings in the result.

```python
result = client.jobs.revert(execution_id)
print(result)
```

## Instrument data without a script

If you only need to get files off an instrument that the Sync app can't reach, you don't need an execution at all. Upload with `instrument_id` instead, and the file is attached to that instrument so its triggers and parsers pick it up:

```python
client.files.upload(file=f, instrument_id="your-instrument-id")
```