> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ionworks.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Python API client

> Install and configure the ionworks-api Python package: authentication, dataframe backend, retries, and sub-clients reference

The [`ionworks-api`](https://github.com/ionworks/ionworks-api) Python package
provides a programmatic interface for managing resources, running simulations,
submitting pipelines, and uploading data in Ionworks Studio.

## Installation

Install the package from the repository:

```bash theme={null}
pip install ionworks-api
```

## Authentication

Get your API key from the Ionworks account settings and configure it:

```python theme={null}
from ionworks import Ionworks

# Option 1: Environment variable (recommended)
# Set IONWORKS_API_KEY in your shell environment before constructing the client
client = Ionworks()

# Option 2: Direct configuration
client = Ionworks(api_key="your_key")

# Option 3: Custom timeout and retry settings
client = Ionworks(
    timeout=30,       # Request timeout in seconds (default: 10)
    max_retries=3     # Max retries on failure (default: 5)
)
```

<Warning>
  Never commit API keys to version control. Use environment variables or a
  secrets manager for credential management.
</Warning>

<Note>
  Starting in `ionworks-api` 0.10.0, importing `ionworks` no longer
  auto-loads a `.env` file. Set `IONWORKS_API_KEY` in your shell
  environment, load the `.env` file yourself (for example with
  [python-dotenv](https://pypi.org/project/python-dotenv/)) before
  constructing the client, or pass `api_key=` explicitly to `Ionworks(...)`.
</Note>

### Verify your API key

Use `client.whoami()` to confirm which user and organization the configured
API key resolves to. This is the recommended way to debug `401`/`403` errors,
or to check why you're seeing data from the wrong organization.

```python theme={null}
me = client.whoami()
print(me["email"], me["authorized_organization"])
# alice@example.com {'id': 'org_abc123', 'name': 'Acme Battery'}
```

The response has two organization fields and the distinction matters:

* `authorized_organization` — the org this request is **authorized as**. For
  SDK calls, this is the org the configured API key was issued for, and is
  the source of truth for permission checks on every request the client
  makes. It's `None` if no org context could be resolved.
* `organizations` — the user's full membership list (every org they belong
  to). This is a different question and is **not** what permission checks
  run against.

If the `id` or `name` in `authorized_organization` doesn't match what you
expect, the wrong key is in use — regenerate one for the correct org from
your [account settings](https://app.ionworks.com/dashboard/settings).

## Default project

Most sub-clients (pipelines, studies, optimizations, cell specifications, ...)
operate within a [project](/core-concepts/projects-studies). Rather than
threading a `project_id` through every call, you can configure a default once
on the client. Sub-client methods that take a `project_id` argument fall back
to this default when one isn't passed explicitly.

The client resolves the default in this order:

1. The `project_id=` argument passed to `Ionworks(...)`.
2. The `IONWORKS_PROJECT_ID` environment variable.
3. Otherwise, no default is set — methods that need a project will raise
   `ValueError` unless `project_id` is passed at the call site.

```python theme={null}
# Option 1: Environment variable (recommended)
# Set IONWORKS_PROJECT_ID in your environment or .env file
client = Ionworks()

# Option 2: Pass to the client constructor
client = Ionworks(project_id="your-project-id")

# Override on a per-call basis when needed
client.study.list(project_id="other-project-id")
```

You can find your project ID in the URL of the project settings page:
`https://app.ionworks.com/dashboard/projects/<project-id>/settings`.

<Note>
  The `PROJECT_ID` environment variable is still accepted for backwards
  compatibility but is deprecated. Set `IONWORKS_PROJECT_ID` instead — using
  the old name emits a `DeprecationWarning` and will stop working in a future
  release.
</Note>

### Environment variables

The client reads the following variables from your shell environment when
constructed. `.env` files are **not** loaded automatically — populate the
environment yourself (for example by sourcing the file, or by calling
[python-dotenv](https://pypi.org/project/python-dotenv/)) before
constructing the client.

| Variable                     | Required                 | Default                    | Description                                |
| ---------------------------- | ------------------------ | -------------------------- | ------------------------------------------ |
| `IONWORKS_API_KEY`           | Yes                      | —                          | API key from your account settings.        |
| `IONWORKS_API_URL`           | No                       | `https://api.ionworks.com` | API base URL.                              |
| `IONWORKS_PROJECT_ID`        | For project-scoped calls | —                          | Default project ID for sub-client methods. |
| `IONWORKS_DATAFRAME_BACKEND` | No                       | `polars`                   | DataFrame backend: `polars` or `pandas`.   |

## DataFrame backend

By default, the client returns data as [polars](https://pola.rs/) DataFrames. You can
switch to [pandas](https://pandas.pydata.org/) if your workflow requires it.

```python theme={null}
# Option 1: Set via constructor
client = Ionworks(dataframe_backend="pandas")

# Option 2: Set via environment variable
# IONWORKS_DATAFRAME_BACKEND=pandas

# Option 3: Set at runtime
from ionworks import set_dataframe_backend, get_dataframe_backend

set_dataframe_backend("pandas")
print(get_dataframe_backend())  # "pandas"
```

All methods that return DataFrames (time series, steps, cycles) respect this setting.

## Timeout and retry behavior

The client automatically retries failed requests on connection errors, timeouts, and server errors (5xx). By default:

* Requests time out after **10 seconds**
* Failed requests retry up to **5 times** with exponential backoff
* **Dropped connections** (the server closed the socket before the request reached the application — common on reused keep-alive connections) are retried for **all methods**, including POST and PATCH. The request never reached the server, so resending is safe.
* **Read timeouts** and **5xx responses** are only retried for idempotent methods (**GET** and **DELETE**). The server may have already processed a POST or PATCH, so resubmitting could duplicate the operation.

You can customize these settings:

```python theme={null}
# Longer timeout for large uploads
client = Ionworks(timeout=60)

# Disable retries
client = Ionworks(max_retries=0)
```

## Sub-clients

The `Ionworks` client exposes domain-specific sub-clients:

| Sub-client                 | Access                             | Documentation                                                             |
| -------------------------- | ---------------------------------- | ------------------------------------------------------------------------- |
| Projects                   | `client.project`                   | [Projects API](/core-concepts/api)                                        |
| Models                     | `client.model`                     | [Build API](/build/api)                                                   |
| Parameterized models       | `client.parameterized_model`       | [Build API](/build/api)                                                   |
| Studies                    | `client.study`                     | [Simulate API](/simulate/api)                                             |
| Protocols                  | `client.protocol`                  | [Protocol API](/simulate/protocol-api)                                    |
| Simulations                | `client.simulation`                | [Simulate API](/simulate/api)                                             |
| Pipelines                  | `client.pipeline`                  | [Simulate API](/simulate/api)                                             |
| Simple pipelines           | `client.simple_pipeline`           | [Simulate API](/simulate/api#running-simple-pipelines)                    |
| Optimizations              | `client.optimization`              | [Optimize API](/optimize/api)                                             |
| Cell specifications        | `client.cell_spec`                 | [Uploading data](/data/uploading)                                         |
| Cell instances             | `client.cell_instance`             | [Uploading data](/data/uploading)                                         |
| Cell measurements          | `client.cell_measurement`          | [Measurements](/data/measurements)                                        |
| Sites                      | `client.site`                      | [Lab view](/operate/lab-view)                                             |
| Cyclers                    | `client.cycler`                    | [Lab view](/operate/lab-view)                                             |
| Channels                   | `client.channel`                   | [Lab view](/operate/lab-view)                                             |
| Lab occupancy              | `client.lab`                       | [Lab view](/operate/lab-view#querying-occupancy-from-the-sdk)             |
| Planned measurements       | `client.planned_measurement`       | [Planned measurements](/operate/planned-measurements)                     |
| Analyses                   | `client.analysis`                  | [Analyses](/data/analyses)                                                |
| Materials                  | `client.material`                  | [Materials & property datasets](/data/materials#python-client)            |
| Material property datasets | `client.material_property_dataset` | [Materials & property datasets](/data/materials#python-client)            |
| Jobs                       | `client.job`                       | [Canceling jobs](#canceling-jobs)                                         |
| Search                     | `client.search`                    | [Searching across your organization](#searching-across-your-organization) |
| Web app URLs               | `client.urls`                      | [Web app URL helpers](#web-app-url-helpers)                               |

## Searching across your organization

`client.search.query()` finds entities by name or description across your whole
organization in one call, without knowing which sub-client to reach for:

```python theme={null}
response = client.search.query("LGM50")

for result in response.results:
    print(result.entity_type, result.id, result.name)
```

Name fields match on case-insensitive substring; descriptions and notes match
on a prefix full-text search. Substring matches rank ahead of full-text matches
within each entity type.

Searchable entity types are `project`, `model`, `cell_specification`,
`cell_instance`, `cell_measurement`, `parameterized_model`,
`experiment_template`, `optimization_template`, `material`, `study`,
`optimization`, and `pipeline`.

| Parameter      | Default | Description                                                                      |
| -------------- | ------- | -------------------------------------------------------------------------------- |
| `q`            | —       | Query string. Must be at least two characters.                                   |
| `limit`        | `25`    | Results per page (1–100).                                                        |
| `offset`       | `0`     | Results to skip, for pagination.                                                 |
| `per_type`     | `5`     | Maximum results per entity type (1–20), so one type cannot crowd out the rest.   |
| `entity_types` | all     | Restrict to specific entity types.                                               |
| `project_id`   | none    | Scope project-scoped types (`study`, `optimization`, `pipeline`) to one project. |

```python theme={null}
# Only measurements and instances, 10 of each
response = client.search.query(
    "aging",
    entity_types=["cell_measurement", "cell_instance"],
    per_type=10,
)
```

<Note>
  Unlike most sub-clients, `search` does **not** fall back to the client's
  default project — it spans the whole organization unless you pass
  `project_id`. Results are always limited to what your account can already
  read.
</Note>

## Discovering the API

`client.capabilities()` and `client.schema(name)` describe the API from the
API itself. Use them from a notebook or script to introduce yourself — or a
coding agent you're scripting against — to the platform's data hierarchy and
to fetch the authoritative JSON Schemas for measurements and UCP protocols.

```python theme={null}
caps = client.capabilities()
print(caps["domain_context"]["hierarchy"])
# organization -> project -> cell_specification -> cell_instance
# -> cell_measurement -> [time_series, steps, cycles, analysis] ...

# Standard time-series column names, required fields, sign conventions
data_schema = client.schema("data")

# Full UCP JSON Schema plus runnable examples
protocol_schema = client.schema("protocol")
```

`capabilities()` also returns pointers to the OpenAPI spec
(`/openapi.json`) and per-resource JSON Schema endpoints under
`caps["schemas"]`, so agents can fetch the exact shape of any create/update
payload before calling it.

<Tip>
  Coding agents driving Ionworks should call `client.capabilities()` and
  `client.schema(name)` before generating request bodies — the responses
  reflect the running server, so agents never guess at endpoint shapes or
  column names that may have drifted.
</Tip>

## Web app URL helpers

`client.urls` builds links to resource pages in the Ionworks web app
(Ionworks Studio) without requiring you to hand-construct URLs from entity
IDs. Use it to surface clickable links from notebooks, scripts, dashboards,
or Slack/email reports so collaborators can jump straight to the resource in
the web app.

Every helper runs entirely locally — no network call — except
`client.urls.simulation()`, which fetches the simulation once when
`parameterized_model_id` isn't supplied (see below).

```python theme={null}
client = Ionworks(project_id="proj_abc")

client.urls.study("study_xyz")
# https://app.ionworks.com/dashboard/projects/proj_abc/studies/study_xyz

client.urls.parameterized_model("pm_123")
# https://app.ionworks.com/dashboard/projects/proj_abc/parameterized-models/pm_123
```

Each helper takes the resource's own ID plus whatever parent IDs the route
requires. `project_id` is optional — it falls back to the
[default project](#default-project) configured on the client.

| Helper                                                                                | Returns a link to                                              |
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `client.urls.project(project_id=None)`                                                | Project landing page (its studies list).                       |
| `client.urls.study(study_id, project_id=None)`                                        | Study detail page.                                             |
| `client.urls.model(model_id, project_id=None)`                                        | Model detail page.                                             |
| `client.urls.parameterized_model(parameterized_model_id, project_id=None)`            | Parameterized model detail page.                               |
| `client.urls.simulation(simulation_id, parameterized_model_id=None, project_id=None)` | Simulation detail page (nested under its parameterized model). |
| `client.urls.protocol(protocol_id, project_id=None)`                                  | Protocol detail page.                                          |
| `client.urls.pipeline(pipeline_id, project_id=None)`                                  | Pipeline detail page.                                          |
| `client.urls.optimization(optimization_id, project_id=None)`                          | Optimization detail page.                                      |
| `client.urls.material(material_id, project_id=None)`                                  | Material detail page.                                          |
| `client.urls.cell_specs(project_id=None)`                                             | Cell specifications list page.                                 |
| `client.urls.cell_instances(spec_id, project_id=None)`                                | Cell instances list page nested under a spec.                  |
| `client.urls.measurement(measurement_id, project_id=None)`                            | Measurement detail page.                                       |

### Simulation URLs

A simulation's web-app route is nested under its parameterized model. If you
already know the `parameterized_model_id`, pass it in to avoid a network
call:

```python theme={null}
client.urls.simulation("sim_1", parameterized_model_id="pm_123")
```

Otherwise the helper fetches the simulation to look it up:

```python theme={null}
# One GET to client.simulation.get(simulation_id), then builds the URL.
client.urls.simulation("sim_1")
```

If the simulation has no `parameterized_model_id` (e.g. a study-only
simulation), the helper raises `ValueError` and you must pass the parent
explicitly.

## Canceling jobs

You can cancel running jobs (simulations, pipelines, and optimizations) using
the Python API. Each job has a unique ID that you can use to cancel it.

```python theme={null}
# Cancel a job by ID
client.job.cancel(job_id="job_abc123")
```

Cancelling a parent job automatically cancels all of its child jobs. For
example, cancelling a pipeline cancels all of its running elements.

## Resubmitting a failed pipeline

If a pipeline stops because its first failed element could not be submitted
(`error_code = SUBMISSION_FAILED`), you can resubmit it without rebuilding the
configuration. Completed elements are preserved; execution resumes from the
failed element.

The Python client does not yet expose a resubmit method, so call the REST
endpoint directly as a temporary workaround. Resubmission goes through the
generic jobs endpoint, and a pipeline is identified by its job ID — the same
ID you pass to `client.pipeline.get(...)`:

```bash theme={null}
curl -X POST \
  -H "Authorization: Bearer $IONWORKS_API_KEY" \
  https://api.ionworks.com/jobs/{pipeline_id}/resubmit
```

Resubmission only applies to `SUBMISSION_FAILED` errors. For execution
timeouts or configuration mistakes, create a new pipeline with the corrected
configuration. For internal errors, wait briefly and create a new pipeline if
the issue persists. See [Handling pipeline
failures](/guide/pipelines/overview#handling-pipeline-failures) for details.

## Retrieving job metadata

Standard job-detail responses strip fields that can grow large — submitted
configs, full result payloads, and serialized PyBaMM models — so listing
and polling stay fast even for pipelines or optimizations with heavy inline
data. When you actually need those fields, fetch them with
`client.job.get_metadata`:

```python theme={null}
# Fetch the full, untruncated metadata for a job
metadata = client.job.get_metadata(job_id="job_abc123")

# Validation payloads, when present
results = metadata.get("validation_results")
plot_config = metadata.get("validation_plot_config")
```

Use this when you need to:

* Re-run a job from its original submitted config without keeping a local
  copy.
* Audit the exact inputs that produced a result.
* Read large result blobs — for example, optimization traces, or the
  `validation_results` and `validation_plot_config` produced by pipeline
  validations — that aren't included in the default job response.

The returned object is a plain `dict` parsed from the job's
`metadata.json.gz` blob. If a job has no metadata file (for example, it
failed before writing one) the call raises an error — wrap it in a
`try`/`except` if you're iterating over many jobs.

<Tip>
  For pipelines, `client.pipeline.result(pipeline_id)` is still the easiest
  way to get parsed, fitted parameter values. Use `client.job.get_metadata`
  when you need the raw, unparsed payload backing a job.
</Tip>

## Retrieving MCMC posterior samples

Data-fit jobs that use a sampler (for example, `PintsSampler`) evaluate
many parameter vectors rather than converging on a single point
estimate. The resulting chain is too large for the standard job `result`
column, so it is offloaded to the job's metadata blob and retrieved with
`client.job.get_posterior_samples`:

```python theme={null}
import numpy as np

# Fetch the sample chain for a sampler-based datafit
samples = client.job.get_posterior_samples(job_id="job_abc123")

chain = samples["samples"]                    # dict[str, list] keyed by parameter name
costs = samples["sample_costs"]               # cost per sample
param_names = samples["sample_param_names"]   # parameter names in column order
burnin = samples["sample_burnin"] or 0        # burn-in length (may be None)

# The chain is 2-D (start, iteration) for a multistart fit and 1-D
# (iteration,) for a single start, so drop burn-in off the last axis.
post_burnin = {k: np.asarray(v)[..., burnin:] for k, v in chain.items()}
```

The returned dict has four keys:

* `samples` — the chain as a `dict[str, list]`, keyed by parameter name.
  Each value is a nested list of shape `(starts, iterations)` for a
  multistart fit, and a flat list of length `iterations` when the fit ran
  a single start. Index the iteration axis last (`[..., burnin:]`) so both
  shapes work.
* `sample_costs` — the objective value at each sample, in the same shape
  as one parameter's chain.
* `sample_param_names` — the parameter names in column order.
* `sample_burnin` — the number of initial iterations the sampler treats
  as burn-in. The chain includes them; discard them before downstream
  analysis. This is `None` for samplers that have no burn-in concept
  (`GridSearch`, `PointEstimateSampler`), so guard against it (e.g.
  `burnin = samples["sample_burnin"] or 0`) before using it as a slice
  boundary.

Every sampler-based fit populates these fields — not just Bayesian ones.
`GridSearch` and `PointEstimateSampler` runs also return a populated
chain, with `sample_burnin` set to `None`. Fits driven by a conventional
optimizer (`CMAES`, `ScipyMinimize`, and friends), along with
optimization and validation jobs, return an empty dict.

An empty dict also comes back when there is no metadata blob to read —
a job that failed before writing one, an unknown `job_id`, or a job
belonging to another organization all surface as "no samples" rather
than an error. Check that the job exists and completed before reading
an empty result as "this fit produced no chain".

<Tip>
  Use `client.job.get_posterior_samples` for the sample chain alone.
  For everything else in the metadata blob — submitted configs,
  validation payloads, optimization traces — use
  `client.job.get_metadata`.
</Tip>
