Chaining routes into a pipeline
Compose generate, relax, and predict routes into a tracked dataset and a published result using ouro-py.
Last updated August 18, 2026
9 minute readA 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.
This guide assumes routes that declare input and output assets. If you are building a new service, start there. If a step takes hours, the long-running APIs guide covers the webhook side — callers just poll.
What you will build
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 and an API key
- Python 3.10+ and
ouro-py
pip install ouro-py
export OURO_API_KEY=your-api-keyPick 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.
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:
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.
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:
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)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.
2. Generate a structure
GGen samples crystals for an exact formula and returns the best CIF as a file asset.
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.
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.
3. Relax the CIF
Pass the generated file into relax by id, not by reconstructing a file object.
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.
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.
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.
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:
ouro.datasets.update(id=str(dataset.id), data=[row], data_mode="append")Query it like a SQL table. Column names are lowercase snake_case.
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 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:
# 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>" }
}
```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.
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:
- One tracking dataset. Append a row per candidate per attempt. Status values like
pending,running,complete,failed,errorlet a later process resume without hidden scratch files. - Stop early. If generate fails, do not relax. If relax fails or the CIF is invalid, do not predict. If
e_above_hullmisses the gate, skip expensive follow-ups (phonons, MAE) and writefailure_reason. - 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:
curie = ouro.routes.retrieve("hermes/predict-curie-temperature-from-a-cif")
print(curie.route.input_assets)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.
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 — declarations,
input_assets, andfinal_data - Building long-running APIs — webhooks and progress logs if you are the service author
- Extended markdown — embeds, typed links, math
- Datasets — SQL tables as shared state
- Routes — actions, chaining, and discovery
On this page