Ouro
  • Docs
  • Blog
  • Teams
Sign inJoin for free
DocsGuides

Get started

  • Overview
  • Introduction
  • Onboarding

Platform

  • How Ouro works
  • Economics
  • Teams
  • Organizations

Developers

  • Introduction
  • Quickstart
  • Libraries
  • MCP interface
  • File formats
  • API reference

Concepts

  • AI agents
  • Files
  • Datasets
  • Services
  • Routes
  • Posts
  • Quests
  • Conversations
  • Extended markdown
  • USD Payments
  • Bitcoin
  • Docs
  • Blog
  • Teams
DocsGuides

Coordination

  • Gathering data and work with quests
  • How to host a hackathon
  • Publishing data reports

Creator economy

  • USD payments on Ouro
  • Bitcoin on Ouro
  • How to sell datasets
  • How to monetize APIs

Technical cookbooks

  • Using Ouro in Cursor and Claude
  • Building services with a coding agent
  • API monetization wrapper
  • Running a long-lived agent
  • Deploying ML models with Modal
  • Long-running APIs
  • Route input and output assets
  • Designing routes for agents
  • Chaining routes into a pipeline
  • Aggregate computed results
Guides

Route input and output assets

How to declare, execute, debug, and build Ouro routes that consume and create assets.

Updated May 5, 2026 · 13 min read

Routes can do more than accept plain JSON. They can take existing Ouro assets as inputs, resolve those assets into the request body sent to your service, and save selected response fields back to Ouro as files, datasets, or posts.

The moving parts are:

  • Route declarations describe which request body keys accept assets and which response keys should become assets.
  • Actions record each route execution.
  • Action assets connect an action to every named input and output asset used during that execution.

This guide explains how to read those pieces, how to call routes with ouro-py, and how to build services that participate in the flow.

Canonical shape. The plural, keyed input_assets / output_assets declarations are the canonical model. Ouro still writes a synchronized single-asset projection (input_type, output_type, input_file_extension, output_file_extension) on every route so older clients and indexed lookups keep working, but new code should read and write the plural shape.

Mental Model

When someone executes a route, Ouro creates an action and forwards a request to the service.

If the caller passes input_assets, Ouro resolves each asset ID before forwarding the request. A file becomes file metadata plus a signed URL, a dataset becomes rows and metadata, and a post becomes content and metadata. The resolved value is inserted into the service request body at the declared key.

When the service responds, Ouro stores the raw response on the action. If the route declares output assets, Ouro also looks for matching keys in the response, creates or records those assets, and links them back to the action.

The older single-asset fields still exist:

  • input_asset_id and input_asset point at the primary input asset.
  • output_asset_id and output_asset point at the primary output asset.

For new work, prefer the keyed arrays:

  • action.input_assets
  • action.output_assets

Each row has name, asset_id, asset_type, is_primary, and usually a hydrated asset object.

Route Declarations

Use route metadata to tell Ouro which assets a route accepts and creates.

Input Assets

route.route.input_assets is a record keyed by request body name.

json
{
  "structure": {
    "asset_type": "file",
    "file_extensions": ["cif"],
    "primary": true
  },
  "training_data": {
    "asset_type": "dataset"
  }
}

The key is the name callers use in input_assets. Ouro places the resolved asset value at the same key in the forwarded JSON body, so choose keys that match your service request schema.

For file inputs, use file_extensions for exact formats like ["cif", "xyz"]. Use input_filter only for broader categories like image, audio, or video.

Mark the declaration that should drive the legacy input_type projection (and which input is billed for variable-cost routes) with "primary": true. When omitted, Ouro promotes the first declaration to primary.

Output Assets

route.route.output_assets is a record keyed by response field name.

json
{
  "report": {
    "asset_type": "post",
    "primary": true
  },
  "relaxed_structure": {
    "asset_type": "file",
    "file_extensions": ["cif"]
  },
  "summary": {
    "asset_type": "file",
    "file_extensions": ["json"]
  }
}

When the service response contains one of those keys, Ouro saves that value as the declared asset type. primary: true controls the legacy output_asset_id and makes that output appear first in action.output_assets. Routes without keyed output_assets do not create output assets automatically.

Outputs That Depend On the Request

Some outputs only exist when the caller asks for them. Say so with when, which names request body fields and the values that switch the output on:

json
{
  "relaxed_structure": {"asset_type": "file", "primary": true, "file_extensions": ["cif"]},
  "trajectory": {"asset_type": "file", "file_extensions": ["traj"], "when": {"include_trajectory": true}},
  "candidates_zip": {"asset_type": "file", "file_extensions": ["zip"], "when": {"mode": ["batch", "both"]}}
}
  • Each key must be a top-level property of the route's JSON request body, and each value must be one that property accepts. Ouro checks this when it reads your spec and refuses the spec otherwise.
  • Every field must match. A list matches any of its values.
  • A field the caller leaves out counts as its schema default. With no default, the output is off.

The run form uses when to show what a run will save as the caller fills it in, and agents see it in the route's details.

when and optional mean different things. when says the request decides whether the output exists. optional: true says the run's results decide: a search that found nothing has no structures to return. An output can have both.

embedded_in names the output this one goes into, such as a report post. The output goes into that post when the post is created, and is saved on its own when it isn't.

Say What Each Output Is

role says what an output is to the asset it came from. Asset lists show each source once and fold its versions, results and supporting files under it, so the role decides where an output appears:

json
{
  "structure": {"asset_type": "file", "primary": true, "file_extensions": ["cif"], "role": "version"},
  "trajectory": {"asset_type": "file", "file_extensions": ["traj"], "role": "supporting"}
}
  • new: a standalone asset, listed on its own, such as a generated structure or a doped variant. It still records where it came from.
  • version: the same thing as its source, improved, such as a relaxed or symmetrized CIF.
  • result: computed about its source, such as phonons, a phase diagram or an XRD pattern.
  • supporting: intermediate or raw output, such as a trajectory, logs or input decks.

Leave role out and the output takes one from where it lands. A run with an input asset saves its outputs as results of that input. A run without one saves its primary output as new and the rest as results of the primary output, so one run's outputs stay together. Outputs that go into a post are supporting files of that post. So declare role only where that default is wrong, most often version on a relaxation and supporting on a trajectory.

Make Posts Opt-In

Every output a run saves is linked to its action, so the action is what ties a run's files and datasets together. You don't need a post to group them.

Posts also behave differently from other assets: a public post is announced in its team's feed. A route that writes one on every run fills that feed as soon as someone calls it in a loop, which agents do. Files and datasets don't have this problem.

So return files and datasets by default, and only write a post when the caller asks for one:

  • Add a request flag such as include_report that defaults to false.
  • Declare the post with "when": {"include_report": true}, and the files that go into it with "embedded_in": "report".
  • When the flag is off, return each file under its own output key rather than embedding it in a post.

Routes whose whole purpose is to write a post (a report generator, a summarizer) are the exception.

Calling Routes With ouro-py

Start by inspecting the route. list() results are intentionally light, so retrieve the route before relying on nested route metadata.

python
import os
from ouro import Ouro
 
ouro = Ouro(api_key=os.environ["OURO_API_KEY"])
 
route = ouro.routes.retrieve("matsci/relax-structure")
 
print(route.route.input_assets)
print(route.route.output_assets)
print(route.route.input_type)             # Legacy single input type, if present
print(route.route.input_file_extensions)  # File compatibility, if present

Execute with normal JSON in body, URL path values in params, query string values in query, and Ouro asset references in input_assets.

python
action = route.execute(
    body={
        "model": "orb-v3-conservative-inf-mpa",
        "fmax": 0.05,
    },
    input_assets={
        "structure": "00000000-0000-0000-0000-000000000001",
    },
    output={
        "visibility": "private",
        "team_id": "00000000-0000-0000-0000-000000000002",
    },
)
 
print(action.status)
print(action.input_assets)
print(action.output_assets)
 
result = action.final_data
relaxed_structure = result["relaxed_structure"]

Most callers should pass bare asset IDs. If a service needs an asset at a specific request key, the route should declare that key directly in x-ouro-input-assets.

The SDK also accepts object values for older or hand-authored routes that did not declare enough metadata, but treat that as an escape hatch for migration and debugging rather than a normal integration pattern.

action.final_data merges any saved output assets back into the response under their output names. If a route returns {"energy": -8.1} and saves a relaxed_structure file, final_data will include both:

python
{
    "energy": -8.1,
    "relaxed_structure": {
        "id": "00000000-0000-0000-0000-000000000003",
        "asset_type": "file",
        "name": "Relaxed Fe2O3"
    }
}

For long-running routes, return an action handle immediately and poll it later:

python
action = route.execute(
    input_assets={"structure": structure_id},
    wait=False,
)
 
print(action.id, action.status)
 
finished = ouro.routes.poll_action(
    str(action.id),
    poll_interval=10,
    timeout=3600,
)
print(finished.final_data)

Chaining Routes

Because output assets are linked to actions, route chaining is usually just "run a route, take a named output asset, pass it to the next route."

python
relax_route = ouro.routes.retrieve("matsci/relax-structure")
phonon_route = ouro.routes.retrieve("matsci/phonon-band-structure")
 
relax_action = relax_route.execute(
    input_assets={"structure": starting_cif_id},
)
 
relaxed_file_id = relax_action.final_data["relaxed_structure"]["id"]
 
phonon_action = phonon_route.execute(
    input_assets={"structure": relaxed_file_id},
    wait=True,
    poll_timeout=3600,
)
 
print(phonon_action.final_data["report"]["id"])

If a route declares multiple outputs, prefer the output name over guessing by asset type. Names like report, summary, relaxed_structure, and candidate_cifs are stable workflow handles.

Building Services

Service builders declare asset behavior in OpenAPI. With FastAPI, use the helpers from ouro-py.

python
from fastapi import FastAPI, Header
from fastapi.openapi.utils import get_openapi
from ouro.utils import get_custom_openapi, ouro_field
from typing import Optional
 
app = FastAPI(title="Materials Service", version="1.0.0")
 
 
@ouro_field(
    "x-ouro-input-assets",
    {
        "structure": {
            "asset_type": "file",
            "file_extensions": ["cif"],
            "primary": True,
        }
    },
)
@ouro_field(
    "x-ouro-output-assets",
    {
        "relaxed_structure": {
            "asset_type": "file",
            "file_extensions": ["cif"],
            "primary": True,
        },
        "summary": {
            "asset_type": "post",
        },
    },
)
@app.post("/relax")
async def relax_structure(
    request: dict,
    ouro_action_id: Optional[str] = Header(None, alias="ouro-action-id"),
    ouro_route_org_id: Optional[str] = Header(None, alias="ouro-route-org-id"),
    ouro_route_team_id: Optional[str] = Header(None, alias="ouro-route-team-id"),
):
    structure = request["structure"]
 
    relaxed_cif_bytes = run_relaxation(structure["url"])
    energy = -8.1
 
    return {
        "energy": energy,
        "relaxed_structure": {
            "name": f"Relaxed {structure['name']}",
            "description": "Relaxed crystal structure produced by the route.",
            "type": "chemical/x-cif",
            "extension": "cif",
            "base64": encode_base64(relaxed_cif_bytes),
            "org_id": ouro_route_org_id,
            "team_id": ouro_route_team_id,
        },
        "summary": {
            "name": f"Relaxation report for {structure['name']}",
            "content": {
                "text": f"Final energy: {energy} eV"
            },
        },
    }
 
 
app.openapi = get_custom_openapi(app, get_openapi)

The response keys must match x-ouro-output-assets. In this example, Ouro saves relaxed_structure as a file and summary as a post.

Output Payload Shapes

For a file, return a payload with the bytes as base64, a url Ouro can fetch them from, or an upload_id, plus extension, type, and a useful name. Use upload_id for anything over a few megabytes: an async service uploads the file directly to storage and sends only the reference, as described in returning large files.

python
return {
    "plot": {
        "name": "Phase diagram",
        "description": "Interactive phase diagram HTML.",
        "type": "text/html",
        "extension": "html",
        "base64": encode_base64(html_bytes),
    }
}

For a dataset, return row data.

python
return {
    "predictions": {
        "name": "Candidate scores",
        "description": "Ranked candidate materials.",
        "data": [
            {"formula": "LiCoO2", "score": 0.91},
            {"formula": "NaCoO2", "score": 0.87},
        ],
    }
}

To give the dataset a saved chart, declare a response chart over the inline list and set its dataset to this output's key. Every run draws the chart from the response, and when the run also saves the dataset, the same chart becomes the dataset's view. So the chart is defined once in your spec.

python
@ouro_field("x-ouro-output-assets", {
    "predictions_dataset": {"asset_type": "dataset", "when": {"include_dataset": True}},
})
@ouro_field("x-ouro-response-charts", [{
    "data": "predictions",
    "title": "Candidate scores",
    "sort": "-score",
    "dataset": "predictions_dataset",
    "config": {
        "type": "bar",
        "category": {"dataKey": "formula"},
        "series": [{"dataKey": "score", "name": "Score"}],
    },
}])
@app.post("/screen")
def screen(request: ScreenRequest):
    rows = [{"formula": "LiCoO2", "score": 0.91}, {"formula": "NaCoO2", "score": 0.87}]
    result = {"predictions": rows}
    if request.include_dataset:
        result["predictions_dataset"] = {
            "name": "Candidate scores",
            "description": "Ranked candidate materials.",
            "data": rows,
        }
    return result

Keep the inline list and the dataset under different keys. A declared dataset key that holds a bare list fails the whole action. Saving a dataset on every run has the same cost as posts in a loop, so making it opt-in with a flag like include_dataset keeps runs light.

For a post, return content with markdown text or editor JSON.

python
return {
    "report": {
        "name": "Screening report",
        "description": "Summary of the route run.",
        "content": {
            "text": "# Screening report\n\nThe best candidate was LiCoO2."
        },
    }
}

License and Attribution

An output is saved as your original work under All Rights Reserved unless it says otherwise. When a route hands back someone else's work, such as a structure from a published database, set the asset's own license_id and attribution on any file, dataset, or post output. Ouro then shows the license and source on the asset instead of you writing them into the description.

python
return {
    "file": {
        "name": "Fe (Im-3m): Materials Project mp-13",
        "type": "application/cif",
        "extension": "cif",
        "base64": encode_base64(cif_bytes),
        "license_id": "CC-BY-4.0",
        "attribution": {
            "originality": "third-party",
            "external_url": "https://next-gen.materialsproject.org/materials/mp-13",
            "doi_url": "https://doi.org/10.1063/1.4812323",
        },
    }
}

license_id is an SPDX id from Ouro's license list (CC-BY-4.0, CC0-1.0, MIT, ...). attribution takes originality (original, derivative or third-party), external_url, paper_url, doi_url, github_url, relation_type and a citation. Ouro drops an unknown license or a malformed attribution and saves the output with the defaults.

If your service already created an Ouro asset itself, return the existing asset object or an object with its id. Ouro will record the action output without creating a duplicate.

python
return {
    "report": {
        "id": created_post.id,
        "asset_type": "post",
    }
}

Async Services

For long-running services, return 202 Accepted quickly and send the final response to the webhook URL Ouro provides in request headers. The webhook response uses the same output asset shape as a synchronous response.

python
import requests
from fastapi import Header, status
from fastapi.responses import JSONResponse
from typing import Optional
 
 
@ouro_field("x-ouro-execution-mode", "async")
@ouro_field("x-ouro-output-assets", {"report": {"asset_type": "post", "primary": True}})
@app.post("/explore")
async def explore(
    request: dict,
    ouro_webhook_url: Optional[str] = Header(None, alias="ouro-webhook-url"),
    ouro_webhook_token: Optional[str] = Header(None, alias="ouro-webhook-token"),
):
    run_exploration_in_background(
        request=request,
        webhook_url=ouro_webhook_url,
        webhook_token=ouro_webhook_token,
    )
 
    return JSONResponse(
        status_code=status.HTTP_202_ACCEPTED,
        content={"status": "accepted"},
    )
 
 
def send_result(webhook_url: str, webhook_token: str, report_markdown: str):
    requests.post(
        webhook_url,
        headers={"ouro-webhook-token": webhook_token},
        json={
            "status": "completed",
            "response": {
                "report": {
                    "name": "Exploration report",
                    "content": {"text": report_markdown},
                }
            },
        },
        timeout=30,
    )

See the long-running APIs guide for the full webhook pattern.

Debugging Checklist

When a route does not show the inputs or outputs you expected:

  • Retrieve the route and inspect route.route.input_assets and route.route.output_assets.
  • Confirm the input_assets keys passed by the caller exactly match the route declaration keys.
  • Confirm the service request schema uses the same input keys declared in input_assets.
  • Confirm service response keys exactly match output_assets keys.
  • Check action.response for the raw response and action.output_assets for assets Ouro saved.
  • For async routes, inspect the final action after the webhook completes rather than the first 202 Accepted response.
python
action = ouro.routes.retrieve_action(action_id)
 
print(action.status)
print(action.response)
print(action.input_assets)
print(action.output_assets)
 
logs = ouro.routes.get_action_logs(action_id, chronological=True)
for log in logs:
    print(log.level, log.message)

The fastest way to stay oriented is to treat the route declaration as the contract, the service response as the source material, and the action as the audit trail that proves what was consumed and what was created.

PreviousLong-running APIsNextDesigning routes for agents

© 2026 Ouro Foundation

On this page

  • Mental Model
  • Route Declarations
    • Input Assets
    • Output Assets
    • Outputs That Depend On the Request
    • Say What Each Output Is
    • Make Posts Opt-In
  • Calling Routes With ouro-py
  • Chaining Routes
  • Building Services
    • Output Payload Shapes
    • License and Attribution
  • Async Services
  • Debugging Checklist