---
title: "Chaining routes into a pipeline"
description: "Compose generate, relax, and predict routes into a tracked dataset and a published result using ouro-py."
date: "2026-08-18"
last_updated: "2026-08-18"
---

A single Ouro route is useful. A chain of them is how work actually gets done.

This guide walks through a small materials pipeline you can run today: generate a crystal, relax it, score its thermodynamic stability, record every hop in a dataset, and publish a post that embeds the evidence. The same pattern applies to any typed route chain — transcribe then summarize, parse a file then score it, generate then evaluate.

You will not deploy anything. You will call routes that already exist, pass their output files into the next call, and leave a trail other people (and future you) can query.

<Callout type="info">
  This guide assumes routes that declare [input and output assets](/guides/route-input-output-assets). If you are building a new service, start there. If a step takes hours, the [long-running APIs](/guides/long-running-apis) guide covers the webhook side — callers just poll.
</Callout>

## What you will build

```mermaid
flowchart LR
  A["Generate\nCIF file"] --> B["Relax\nCIF file"]
  B --> C["Energy above hull\nJSON + HTML"]
  C --> D["Dataset row\nwith refs"]
  D --> E["Result post"]
```

Each arrow is an **action**: a durable record of one route run, the assets it consumed, and the assets it created. The dataset is the ledger. The post is the notebook page.

## Prerequisites

- An [Ouro account](/signup) and an [API key](/settings/api-keys)
- Python 3.10+ and `ouro-py`

```bash
pip install ouro-py
export OURO_API_KEY=your-api-key
```

Pick an organization and a team to publish into. Assets created without `org_id` and `team_id` land in your catch-all **All** team — fine for a scratch run, wrong for work you want found.

```python showLineNumbers
import os
from ouro import Ouro

ouro = Ouro(api_key=os.environ["OURO_API_KEY"])

org = ouro.organizations.get_context()
teams = ouro.teams.list(org_id=str(org.id), joined=True)
for team in teams:
    print(team.slug, team.id)
```

Use a mission team (`#materials-science`, `#permanent-magnets`, or one of yours). Set:

```python showLineNumbers
ORG_ID = str(org.id)
TEAM_ID = "<your-team-id>"
OUTPUT = {"team_id": TEAM_ID, "visibility": "private"}
```

`OUTPUT` tells async routes where to create files. Dataset and post creates also take `org_id` and `team_id` explicitly.

## 1. Inspect before you call

Route identifiers look like `creator/slug`. Retrieve each route and read the keyed asset declarations — those keys are what you pass in `input_assets` and what you read back from `action.final_data`.

```python showLineNumbers
GENERATE = "mmoderwell/generate-a-crystal-structure-using-ggen"
RELAX = "mmoderwell/relax-a-crystal-structure"
EHULL = "mmoderwell/calculate-energy-above-hull"

for name in (GENERATE, RELAX, EHULL):
    route = ouro.routes.retrieve(name)
    print(name)
    print("  inputs ", route.route.input_assets)
    print("  outputs", route.route.output_assets)
```

You should see generate take JSON (`formula`) and emit a `file` (CIF); relax and energy-above-hull take a `structure` file and emit a `file`. If a slug has moved, search:

```python showLineNumbers
for route in ouro.routes.list("relax a crystal structure", limit=5):
    creator = route.user.username if route.user else "?"
    print(creator, route.slug or route.name, route.id)
```

<Callout>
  Do not guess input keys. Some routes declare `structure`, others `file`. Passing the wrong key silently skips asset resolution and the upstream service never sees the CIF.
</Callout>

## 2. Generate a structure

GGen samples crystals for an exact formula and returns the best CIF as a file asset.

```python showLineNumbers
generate = ouro.routes.execute(
    GENERATE,
    body={"formula": "Fe2O3", "num_trials": 10},
    output=OUTPUT,
    wait=True,
    raise_on_error=True,
)

generated = generate.final_data["file"]
print(generate.status, generate.id)
print(generated["id"], generated.get("name"))
```

`wait=True` (the default) polls async routes until they finish. Generation and relaxation are GPU jobs — if a call times out, the action keeps running. Capture `action.id` and call `ouro.routes.poll_action(action_id)` later rather than re-executing.

<Callout type="info">
  GGen already relaxes internally. The next step still matters: it is the file-to-file hop, and it lets you re-relax with a specific MLIP (Orb, MACE, CHGNet) so the energy-above-hull number refers to a geometry you chose.
</Callout>

## 3. Relax the CIF

Pass the generated file into relax by **id**, not by reconstructing a file object.

```python showLineNumbers
relax = ouro.routes.execute(
    RELAX,
    body={
        "model": "orb-v3-conservative-inf-mpa",
        "fmax": 0.03,
        "max_steps": 400,
        "optimize_cell": True,
    },
    input_assets={"structure": generated["id"]},
    output=OUTPUT,
    wait=True,
    raise_on_error=True,
)

relaxed = relax.final_data["file"]
print(relax.status, relaxed["id"])
```

Ouro resolves `structure` into the signed URL and metadata the service expects. The new CIF is a separate file. The original is unchanged. That parent/child relationship is what the provenance graph is for — two later runs can prove they started from the same input.

## 4. Predict a property

Energy above hull needs a relaxed geometry. Feed it the relaxed CIF, not the generated one.

```python showLineNumbers
ehull = ouro.routes.execute(
    EHULL,
    input_assets={"structure": relaxed["id"]},
    output=OUTPUT,
    wait=True,
    raise_on_error=True,
)

data = ehull.final_data
e_above_hull = data["e_above_hull"]
predicted_stable = data["predicted_stable"]
phase_diagram = data["file"]  # interactive HTML

print(f"e_above_hull={e_above_hull:.3f} eV/atom")
print("stable" if predicted_stable else "unstable")
print("diagram", phase_diagram["id"])
```

`final_data` is the JSON response plus any saved output assets merged in under their declared names. Scalars like `e_above_hull` live on the action; the HTML diagram is a file. Neither is stored on the CIF itself — that is why the dataset in the next step holds **references** to files and actions, not copies of numbers with no lineage.

## 5. Gate downstream work

Do not blindly run every expensive route on every candidate.

```python showLineNumbers
EHULL_MAX = 0.15
passed = e_above_hull is not None and e_above_hull <= EHULL_MAX
status = "complete" if passed else "failed"
failure_reason = None if passed else "above_hull_threshold"
```

A real screening campaign stops here for the failures, records why, and saves MAE / phonons / Curie temperature for the ones that pass. For this walkthrough we still write the row either way.

## 6. Record the run in a dataset

Create a small tracking table the first time. Columns that hold Ouro ids get `refs` so they are real foreign keys, not opaque strings.

```python showLineNumbers
from datetime import datetime, timezone

row = {
    "formula": "Fe2O3",
    "status": status,
    "e_above_hull": e_above_hull,
    "predicted_stable": predicted_stable,
    "failure_reason": failure_reason,
    "generated_file_id": generated["id"],
    "relaxed_file_id": relaxed["id"],
    "phase_diagram_file_id": phase_diagram["id"],
    "generate_action_id": str(generate.id),
    "relax_action_id": str(relax.id),
    "ehull_action_id": str(ehull.id),
    "last_updated": datetime.now(timezone.utc).isoformat(),
}

dataset = ouro.datasets.create(
    name="pipeline-demo",
    description="Generate → relax → e-hull runs",
    visibility="private",
    org_id=ORG_ID,
    team_id=TEAM_ID,
    data=[row],
    refs={
        "generated_file_id": "file",
        "relaxed_file_id": "file",
        "phase_diagram_file_id": "file",
        "generate_action_id": "action",
        "relax_action_id": "action",
        "ehull_action_id": "action",
    },
    enum_columns={
        "status": ["pending", "running", "complete", "failed", "error"],
    },
)
print(dataset.id, dataset.url)
```

Later runs **append**:

```python showLineNumbers
ouro.datasets.update(id=str(dataset.id), data=[row], data_mode="append")
```

Query it like a SQL table. Column names are lowercase snake_case.

```python showLineNumbers
hits = ouro.datasets.query(
    str(dataset.id),
    "SELECT formula, e_above_hull, status FROM {{table}} "
    "WHERE status = 'complete' ORDER BY e_above_hull ASC LIMIT 10",
)
print(hits)
```

Use `resolve_refs=True` on the paginated (non-SQL) path when you want names and URLs next to the ids.

## 7. Publish a result

A post is the human-readable end of the chain. Use [extended markdown](/docs/concepts/extended-markdown) so the CIF, the dataset, and the actions render inline — not as dumped JSON.

Write the body as markdown (typed links plus `assetComponent` embeds), then pass it to `content_markdown`:

````markdown
# Fe2O3 pipeline run

Generated with GGen, relaxed with Orb v3, then scored against the convex hull.

- Energy above hull: 0.042 eV/atom
- Tracking table: [pipeline-demo](dataset:<dataset-uuid>)
- Relaxed structure: [CIF](file:<relaxed-file-uuid>)

```assetComponent
{
  "id": "<relaxed-file-uuid>",
  "assetType": "file",
  "viewMode": "preview"
}
```

```assetComponent
{
  "id": "<relax-route-uuid>",
  "assetType": "route",
  "viewMode": "preview",
  "displayConfig": { "actionId": "<relax-action-uuid>" }
}
```
````

```python showLineNumbers
post = ouro.posts.create(
    name="Fe2O3 generate → relax → e-hull",
    content_markdown=md,  # the markdown above, with real ids filled in
    visibility="private",
    org_id=ORG_ID,
    team_id=TEAM_ID,
)
print(post.url)
```

Mentions, typed links (`file:<uuid>`, `action:<uuid>`), and embeds are the same syntax in the web editor, the SDK, and MCP. Pin `displayConfig.actionId` on a route embed so readers see *this* run, not the endpoint in general.

## Put it together

One script, one candidate. Swap the formula — or wrap the middle in a loop — when you are ready to screen.

```python showLineNumbers
import os
from datetime import datetime, timezone

from ouro import Ouro

GENERATE = "mmoderwell/generate-a-crystal-structure-using-ggen"
RELAX = "mmoderwell/relax-a-crystal-structure"
EHULL = "mmoderwell/calculate-energy-above-hull"
EHULL_MAX = 0.15
FORMULA = "Fe2O3"

ouro = Ouro(api_key=os.environ["OURO_API_KEY"])
org = ouro.organizations.get_context()
ORG_ID = str(org.id)
TEAM_ID = os.environ["OURO_TEAM_ID"]
OUTPUT = {"team_id": TEAM_ID, "visibility": "private"}


def file_out(action, name="file"):
    data = action.final_data or {}
    asset = data.get(name)
    if isinstance(asset, dict) and asset.get("id"):
        return asset
    raise RuntimeError(f"{action.id} has no output {name!r}: {action.status}")


generate = ouro.routes.execute(
    GENERATE,
    body={"formula": FORMULA, "num_trials": 10},
    output=OUTPUT,
    wait=True,
    raise_on_error=True,
)
generated = file_out(generate)

relax = ouro.routes.execute(
    RELAX,
    body={"model": "orb-v3-conservative-inf-mpa", "fmax": 0.03},
    input_assets={"structure": generated["id"]},
    output=OUTPUT,
    wait=True,
    raise_on_error=True,
)
relaxed = file_out(relax)

ehull = ouro.routes.execute(
    EHULL,
    input_assets={"structure": relaxed["id"]},
    output=OUTPUT,
    wait=True,
    raise_on_error=True,
)
result = ehull.final_data
e_above_hull = result["e_above_hull"]
passed = e_above_hull <= EHULL_MAX

row = {
    "formula": FORMULA,
    "status": "complete" if passed else "failed",
    "e_above_hull": e_above_hull,
    "predicted_stable": result["predicted_stable"],
    "failure_reason": None if passed else "above_hull_threshold",
    "generated_file_id": generated["id"],
    "relaxed_file_id": relaxed["id"],
    "phase_diagram_file_id": result["file"]["id"],
    "generate_action_id": str(generate.id),
    "relax_action_id": str(relax.id),
    "ehull_action_id": str(ehull.id),
    "last_updated": datetime.now(timezone.utc).isoformat(),
}

dataset = ouro.datasets.create(
    name=f"{FORMULA} pipeline",
    visibility="private",
    org_id=ORG_ID,
    team_id=TEAM_ID,
    data=[row],
    refs={
        "generated_file_id": "file",
        "relaxed_file_id": "file",
        "phase_diagram_file_id": "file",
        "generate_action_id": "action",
        "relax_action_id": "action",
        "ehull_action_id": "action",
    },
)

print("dataset", dataset.url)
print("e_above_hull", e_above_hull, "passed" if passed else "gated")
```

Set `OURO_TEAM_ID` to the team you chose in setup.

## Scale to a campaign

The walkthrough is one row. A screening campaign is the same loop over a candidate list, with three extra rules:

1. **One tracking dataset.** Append a row per candidate per attempt. Status values like `pending`, `running`, `complete`, `failed`, `error` let a later process resume without hidden scratch files.
2. **Stop early.** If generate fails, do not relax. If relax fails or the CIF is invalid, do not predict. If `e_above_hull` misses the gate, skip expensive follow-ups (phonons, MAE) and write `failure_reason`.
3. **Small batches.** GPU routes are slow. Process ten candidates, append, stop. Query `WHERE status IN ('pending', 'error')` on the next run.

Optional fan-out after the gate — inspect the route first, then call it with the relaxed CIF:

```python showLineNumbers
curie = ouro.routes.retrieve("hermes/predict-curie-temperature-from-a-cif")
print(curie.route.input_assets)
```

<Callout>
  Re-executing a still-running action duplicates work. If `execute` returns `status="pending"` or raises a timeout with an `action_id`, poll that id. Do not call the route again.
</Callout>

## What good output looks like

- A dataset that answers “what ran, what failed, and why?” without reading a chat log
- Files whose parent chain shows generate → relax
- Actions you can embed in a post so a reader sees the exact run, not a screenshot of numbers
- No properties stored only in prose — the numbers live on actions, the table points at them

That is the platform: routes produce assets, actions are provenance, datasets are state, posts are how you tell the story.

## Related

- [Route input and output assets](/guides/route-input-output-assets) — declarations, `input_assets`, and `final_data`
- [Building long-running APIs](/guides/long-running-apis) — webhooks and progress logs if you are the service author
- [Extended markdown](/docs/concepts/extended-markdown) — embeds, typed links, math
- [Datasets](/docs/concepts/datasets) — SQL tables as shared state
- [Routes](/docs/concepts/routes) — actions, chaining, and discovery
