Learn how to interact with Ouro from Python.
Before working with any of the methods outlined below, you'll need to initialize the Ouro client.
Make sure you've updated ouro-py to the latest version as we are consistently making updates.
More details can be found in the quickstart.
import os
from ouro import Ouro
ouro = Ouro(api_key=os.environ.get("OURO_API_KEY"))An unpinned client works in your personal context. To work in an organization, pin the client to it. Everything it creates goes there, and its requests run in that organization's context:
ouro = Ouro(organization="acme-lab") # name or ID, or set OURO_ORG_ID
ouro = Ouro(organization="acme-lab", team=team_id) # or OURO_TEAM_ID
post = ouro.posts.create(name="Run notes", content_markdown="...") # lands in acme-labNew assets go to team when a call doesn't name one, and otherwise to the
organization's default team. A pinned client refuses to create in, or move an
asset to, another organization (OuroError); reads aren't restricted. Switch
with ouro.use_organization("other-org") and unpin with
ouro.use_organization(None).
An API key bound to an organization pins the client to it automatically. A key bound to your personal context can't be pinned to an organization.
Every method returns typed objects, so you read fields as attributes
(asset.id, team.user_membership.role). Methods that list things return a
Page, which you can iterate, index, and pass to len(). It also carries
has_more, total, and offset for pagination. Call model_dump() on any
object when you need a plain dict, for example to serialize it as JSON.
Ouro assets include files, datasets, services, routes, posts, quests, and
conversations. Pass org_id and team_id when creating assets so they land in
the right team (see Teams).
Skip to one of the sections below:
Upload, read, update, and delete files
Structured tabular data, SQL, and saved views
External APIs and route endpoints
Rich text content with the Editor class
Team quests, items, entries, and reviews
Create threads and exchange messages
Further down: working with any asset, organizations and teams, users, notifications, money, and error handling.
For every asset on the platform, Ouro stores the following information:
| Field | Description |
|---|---|
id | Unique identifier |
name | Display name |
description | Optional summary |
metadata | Type-specific details, like file size or number of rows |
visibility | Who can see it: public, private, monetized, or organization |
created_at | When the asset was created |
last_updated | When the asset was last changed |
user | Owner |
organization | Organization the asset belongs to |
team | Team the asset belongs to |
price | Amount charged when the asset is monetized |
price_usd | Price in dollars, or None when the asset isn't sold in USD |
price_sats | Price in sats, or None when the asset isn't sold in Bitcoin |
Unless you pass an organization and team (or pin the client), assets are created in your global organization and default team.
Control who can see an asset with visibility. Leave it unset and a new asset
takes the audience of its team: public in a public team, organization in an
internal one. A team is the boundary for what's in it, so public and
monetized are refused in an internal team with PermissionDeniedError. To
publish internal work, move it to a public team:
ouro.posts.update(post_id, team_id=public_team_id, visibility="public")Whether an organization has public teams, and who may move work into them, is the organization's own setting.
A monetized asset needs visibility="monetized" as well as a monetization
model and a price. A price on a public asset doesn't lock it.
Choosing the right team keeps activity grouped and helps the right people find the work.
Files are the most basic asset on Ouro. Any file is fair game, and many file types have rich visualizations on the web platform.
Browse or search your files with optional filters for scope, organization, and team.
# List your recent files
files = ouro.files.list()
# Search with a query
files = ouro.files.list(query="climate data", limit=10)
# Scope to an organization
files = ouro.files.list(org_id=org_id, team_id=team_id)You can upload any file, up to 5GB in size.
file = ouro.files.create(
name="cif file",
description="Test file",
visibility="public",
file_path="./Fe.cif",
)
print(file)file_path is the path to the file on your local machine.
You can also pass the bytes directly with file_content and file_name.
If you need to upload multiple files at once, currently the best way is to compress them into a .zip file and upload that.
When the bytes live somewhere else, such as an agent's sandbox or a browser,
reserve a signed upload URL, have the holder of the bytes PUT them there, and
create the file from the upload_id:
upload = ouro.files.create_upload_url("spectrum.csv")
# upload["upload_url"], upload["method"], upload["headers"], upload["expires_in"]
# Whoever holds the bytes sends them, with no Ouro credentials:
# curl -X PUT --data-binary @spectrum.csv "<upload_url>" (plus the returned headers)
file = ouro.files.create(name="Spectrum", upload_id=upload["upload_id"])ouro.files.update(file_id, upload_id=...) replaces a file's bytes the same
way. ouro.files.read_upload(upload_id) returns the bytes instead, for content
that is going to be a post body or dataset rows, and
ouro.files.discard_upload(upload_id) deletes an upload you didn't use.
Just after the bytes land, Ouro inspects structure files and records what it
found in file.metadata. A .cif gets a classification such as
{"kind": "crystal", "inspector": "cif", "version": 1} and a structure
summary (formula, space group, lattice); an .xyz is classified as a
structure or a trajectory. The classification decides which viewer opens
the file. These fields always come from the bytes: values you send for them
are ignored, and they are worked out again when you replace the file.
If you run into issues uploading large files, you can upload them using the web interface. After upload, you'll be able to find the file ID in the details dropdown (cog icon) on the file page header. Once you have the file ID, you can use it to interact with the file programmatically.
id = '48ec6563-5520-4756-ac7f-b38f4933ac95'
file = ouro.files.retrieve(id)
# file is an object with properties like:
# file.id, file.name, file.description, file.metadata, file.visibility, file.created_at, file.userIf you created the file from the SDK, the ouro.files.create response will be a file object which has the ID in the id field.
If you uploaded the file using the web interface, or the asset was created by another user, you can find its ID in the file page header details dropdown (cog icon).
See the highlighted section in the screenshot below:

Once you've retrieved a file object with ouro.files.retrieve, you can read its data using the read_data method.
file_data = file.read_data()
print(file_data.url)The read_data method returns a FileData object which has a url property that you can use to download the file.
import requests
url = file_data.url
response = requests.get(url)
print(response.content)To be able to update a file, you must have admin or write permission on the file. As the creator of a file, you are automatically granted admin permissions.
file_id = file.id
updated_file = ouro.files.update(
id=file_id,
file_path="./Fe2BiNi.cif",
name="Fe2BiNi (Pmmm) updated"
)
# Returns the updated file object
print(updated_file)You can update the file object with any of asset properties using named parameters. Only id is required.
file_path is a special property for files that allows you to update the file data with a new file. This is also optional.
You must have admin permissions on the file to delete it. This will completely remove the file from the platform.
ouro.files.delete(id=file.id)Assets that may have referenced the file will no longer show a connection to the file.
Datasets are structured tabular assets stored in the datasets schema. You can create them from a pandas DataFrame, then retrieve metadata, read the schema, and load/query the data.
Browse or search datasets with optional filters.
datasets = ouro.datasets.list()
datasets = ouro.datasets.list(query="temperature", scope="org", limit=10)Provide a pandas DataFrame and required asset fields. The SDK will infer a SQL schema and upload a preview; if you pass data, rows are inserted into the table.
import pandas as pd
data = pd.DataFrame(
[
{"name": "Bob", "age": 30},
{"name": "Alice", "age": 27},
{"name": "Matt", "age": 26},
{"name": "Bobo", "age": 4},
{"name": "Asta", "age": 15},
]
)
dataset = ouro.datasets.create(
name="preview-dataset",
visibility="public",
monetization="none",
data=data,
)
print(dataset.id)Notes:
table_name (spaces -> underscores, lowercase) stored in dataset.metadata.table_name.data, the dataset (asset + empty table) is created without rows.Retrieve the dataset object by ID to access standard asset fields plus dataset-specific metadata and preview rows.
dataset_id = "0194f68c-b16e-70d3-8ed3-aafa850272ae"
dataset = ouro.datasets.retrieve(dataset_id)
print(dataset.name, dataset.metadata, dataset.preview[:3])Get column definitions for the underlying table.
columns = ouro.datasets.schema(dataset_id)
for col in columns:
print(col.column_name, col.data_type) # e.g., age integer, name textFetch the dataset's rows as a pandas DataFrame via the Ouro API. Timestamp and date columns are parsed to pandas types.
df = ouro.datasets.query(dataset_id)
print(df.head())Pass a SQL string as the second argument to query() to run a read-only
PostgreSQL query against the dataset's table. Reference the table as
{{table}} - the backend rewrites the placeholder to the fully-qualified
datasets."..." name. Read-only is enforced server-side and queries time
out after 10 seconds. Include LIMIT/OFFSET directly in the SQL.
# Aggregations
totals = ouro.datasets.query(
dataset_id,
"SELECT species, count(*) AS n FROM {{table}} GROUP BY species ORDER BY n DESC",
)
# Time-series buckets
daily = ouro.datasets.query(
dataset_id,
"SELECT date_trunc('day', created_at) AS day, count(*) AS n "
"FROM {{table}} GROUP BY 1 ORDER BY 1",
)
# Sample rows
sample = ouro.datasets.query(dataset_id, "SELECT * FROM {{table}} LIMIT 5")A view is a saved chart on a dataset. You describe what to plot; the API generates the SQL and chart config. The same chart then appears on the dataset page and can be embedded in posts. See Datasets → Views for the product model.
List views for a dataset:
views = ouro.datasets.list_views(dataset_id)
for view in views:
print(view.id, view.name)Create a view with a prompt. Be specific: chart type, columns, series, order, and axis format. This is the same pattern Chronos uses for forecast reports:
view = ouro.datasets.create_view(
dataset_id,
name="30-year mortgage rate",
prompt=(
"Line chart of the 30-year fixed mortgage rate over time: "
"filter to series_id = 'MORTGAGE30US', plot `value` against `date` "
"as two series from the `type` column — solid for observed history, "
"dashed for the forecast — ordered by date, y-axis as percent."
),
)
print(view.id)You can pass sql_query and config yourself if you already have them. Use
{{table}} as the dataset placeholder; column names in the config (dataKey,
nameKey) must match the SQL result. A view needs both halves — provide them,
or a prompt. Chart type can be bar, line, area, composed, scatter,
pie, donut, or radar.
Update with another prompt, or delete:
ouro.datasets.update_view(
dataset_id,
view.id,
prompt="Switch this to a pie chart",
)
ouro.datasets.delete_view(dataset_id, view.id)Embed the saved view in a post:
content = ouro.posts.Editor()
content.new_inline_asset(
dataset_id,
asset_type="dataset",
view_mode="preview",
display_config={"visualizationId": str(view.id)},
)See Create a post.
Update asset properties and optionally write rows to the table by passing a DataFrame.
New rows are appended by default. Pass data_mode="overwrite" to replace the
table's rows, or data_mode="upsert" to merge rows by their id.
updated = ouro.datasets.update(
dataset.id,
visibility="private",
)
# Append rows (optional)
data_update = pd.DataFrame([
{"name": "Charlie", "age": 33},
])
updated = ouro.datasets.update(dataset.id, data=data_update)
# Replace every row instead
updated = ouro.datasets.update(dataset.id, data=data_update, data_mode="overwrite")Requires admin permission on the asset.
ouro.datasets.delete(dataset.id)Tips:
public, private, monetized, or organization.External APIs on Ouro are organized with two asset types: services and routes.
A service is a collection of routes that all share the same base URL. Routes are individual endpoints of the API defined by an HTTP method, path, optional URL and query parameters, and an optional body.
Ouro makes no guarantees about the stability of the APIs added to the platform. Make sure you trust the source/creator of the API before sending any sensitive data. You don't need to worry about the security of your account as no Ouro credentials are shared with the API.
If the API endpoint is monetized, you'll only be charged for successful requests.
Register an API by its base URL. Pass spec_url (or a local spec_path) to
an OpenAPI spec and Ouro creates a route for each endpoint. Omit both to create
a bare service and add routes with ouro.routes.create.
service = ouro.services.create(
name="Materials predictor",
base_url="https://predictor.example.com",
spec_url="https://predictor.example.com/openapi.json",
org_id=org_id,
team_id=team_id,
)authentication is one of "None" (default), "Ouro", or
"Personal Access Token". The base_url must be unique across Ouro.
You can also create a service in the web interface.
For a protected API, store the secret Ouro sends with each request. Only the service owner can set it, and calling it again with the same secret changes nothing.
auth = ouro.services.set_authentication(service.id, secret, method="Ouro")
print(auth.rotated) # False when the secret was already storedYou can learn more about creating services with our guide How to monetize APIs. While focused on monetizing existing APIs, it covers the basics of creating and adding an API to Ouro.
service_id = "438e454b-cf9e-40d9-b53d-b9b250087179"
service = ouro.services.retrieve(service_id)Like the file object, using ouro.services.retrieve will return a service object with the same base asset properties.
Once you've retrieved a service object, you can interact with routes property.
# Returns a list of Route objects
service_routes = service.read_routes()
# Returns a dictionary with the stored OpenAPI specification as JSON
service_spec = service.read_spec()
print(service_spec['info']['title'])The read_spec() method retrieves the stored OpenAPI specification from our database. This is the parsed specification that was either uploaded or fetched from a remote URL when the service was created. The spec is stored as a JSON object and remains stable even if the original remote spec changes, until the service is updated.
You can execute a service's endpoint with the execute_route method. Pass a
route ID, a full "creator/route-name" identifier, or just the route's name,
which is looked up within this service.
route = service_routes[0]
action = service.execute_route(
route.id,
body={"composition": "Fe2Ni", "temperature": 0.8, "max_new_tokens": 3000}
)
response = action.final_dataexecute_route takes the same arguments as route.execute.
Pass only the fields you want to change. Set refresh_spec=True to re-fetch the
service's stored OpenAPI spec and sync its routes after you deploy API changes.
service = ouro.services.update(
service.id,
description="Predicts formation energy and band gap",
)
# Pick up new or changed endpoints from the remote spec
service = ouro.services.update(service.id, refresh_spec=True)To change an individual route, use ouro.routes.update.
Requires admin permission. Deleting a service also deletes its routes. Pass
dry_run=True to preview what would be removed.
ouro.services.delete(service.id, dry_run=True) # preview
ouro.services.delete(service.id)Routes are the individual endpoints of a service. Each route represents one HTTP endpoint of the underlying web API.
Add a route to an existing service with ouro.routes.create. Pass the parent
service_id and the fields your API expects (method, path, schemas, and so on).
For complex OpenAPI-backed services, creating routes in the web UI
is often easier.
route = ouro.routes.create(
service_id,
name="transcribe",
method="POST",
path="/transcribe",
description="Transcribe audio files to text",
input_assets={"audio": {"asset_type": "file", "input_filter": "audio"}},
output_assets={"transcript": {"asset_type": "post", "primary": True}},
execution_mode="async",
)method and path must be unique within the service. Everything else is optional:
parameters and request_body: what callers send. See Describe the requestinput_assets and output_assets: the assets the route takes and creates, keyed by name. See the route input and output assets guideexecution_mode: "sync" (default) when your API returns the result in the response, or "async" when it returns 202 Accepted and finishes latervisibility: defaults to "inherit", so the route follows its serviceorg_id and team_id: default to the service'sparameters and request_body tell Ouro what a route takes. They drive the
form on the route page, the route's Docs tab, and the schema agents see when
they call the route over MCP. Both use OpenAPI's own shapes, so you can copy
them straight from a spec.
request_body is an OpenAPI request body object with a JSON schema for the
body's fields:
route = ouro.routes.create(
service_id,
name="predict",
method="POST",
path="/predict/{model}",
request_body={
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["composition"],
"properties": {
"composition": {
"type": "string",
"description": "Chemical formula to predict for",
"examples": ["Fe2Ni"],
},
"temperature": {
"type": "number",
"description": "Sampling temperature",
"default": 0.8,
"minimum": 0,
"maximum": 2,
},
"method": {
"type": "string",
"enum": ["fast", "accurate"],
"default": "fast",
},
},
}
}
},
},
parameters=[
{
"name": "model",
"in": "path",
"required": True,
"schema": {"type": "string"},
"description": "Which model to run",
},
{
"name": "verbose",
"in": "query",
"required": False,
"schema": {"type": "boolean", "default": False},
},
],
)parameters is a list of OpenAPI parameter objects. Set in to "path" for a
{placeholder} in the route's path or "query" for a query string value.
Callers pass them as params and query when they
execute the route, and body fields as body.
A few things make a schema more useful:
required. The Docs tab builds its example from them.description, and an examples or default value. They show up in the form and in generated code samples.enum, minimum, and maximum to say what values are allowed.$ref pointers aren't resolved for routes created this way.A body field that should receive an Ouro asset belongs in input_assets
instead. Callers then pass an asset ID and Ouro fills in the field.
To change the schema later, pass the whole new value to ouro.routes.update.
It replaces the old one, so include every field you want to keep:
ouro.routes.update(route.id, request_body=new_request_body)On a service created from an OpenAPI spec, refreshing the spec overwrites each
route's parameters and request_body with what the spec says. Change the
spec for those routes, not the route.
Search routes by what they do with ouro.routes.list. It returns a Page of
routes.
routes = ouro.routes.list("predict curie temperature", limit=5)
# Most-used routes in an organization this week
popular = ouro.routes.list(org_id=org_id, sort="popular", time_window="week")sort is "relevant", "recent", "popular", or "updated". With
"popular", time_window is "day", "week", "month" (default), or "all".
Results leave out the endpoint definition, so call ouro.routes.retrieve on the
one you want. To find routes that accept an asset you already have, use
ouro.assets.compatible_routes.
To get the details of a route, use the ouro.routes.retrieve method.
You can supply either the route ID or the asset identifier, which consists of
the creator and the route name plus method.
The identifier is the section of the URL after routes/.
Unless you own the route, use the ID because it remains fixed even if the asset
name changes. When an asset name changes, its identifier and URL also change.
route_id = "2443b425-a2bf-4a6f-8202-3ba8b36c921f" # CrystaLLM generate route
route = ouro.routes.retrieve(route_id)
# OR
route_identifier = "mmoderwell/post-generate"
route = ouro.routes.retrieve(route_identifier)
# Returns a Route objectInspect the keyed route declarations to see accepted asset types and file compatibility:
route = ouro.routes.retrieve(route_id)
for name, declaration in route.route.input_assets.items():
print(name, declaration.asset_type)
print(declaration.input_filter) # e.g. "audio"
print(declaration.file_extensions) # e.g. ["xy", "xye"]file_extensions is the canonical field for exact file-format matching inside
each declaration. Singular route fields remain compatibility projections for
older clients. See the route input and output assets guide.
A route also reports how it runs and how long it takes:
print(route.route.execution_mode) # "sync" or "async", as declared
print(route.route.observed_execution_mode) # what recent runs actually did
if route.metrics:
print(route.metrics.avg_completion_ms, route.metrics.p95_completion_ms)
stats = route.read_stats()
print(stats.total, stats.user_total) # all runs, and yours
print(stats.in_progress) # actions still runningmetrics and observed_execution_mode are empty until the route has completed
runs.
Update route metadata with ouro.routes.update. You can pass the route ID or
asset identifier (same as retrieve).
route = ouro.routes.update(
route.id,
description="Transcribe audio files to text",
)Only the fields you pass are changed. update takes the same arguments as
create, including parameters and request_body to
change what the route accepts.
To charge for a route, set its visibility to monetized and give it a price.
unit_cost_usd is in dollars and unit_cost_sats is in sats. Set one, or set
both to let callers choose which currency to pay in.
# A fixed price per successful run, in either currency
ouro.routes.update(
route.id,
visibility="monetized",
monetization="pay-per-use",
cost_accounting="fixed",
unit_cost_usd=0.05,
unit_cost_sats=50,
)
# Per second of runtime, billed up to 1 hour per run
ouro.routes.update(
route.id,
visibility="monetized",
monetization="pay-per-use",
cost_accounting="runtime",
unit_cost_usd=0.0003,
max_billable_seconds=3600,
)With both prices set, price_currency is the currency charged when a caller
doesn't choose. Pass 0 for a currency to stop selling in it. Failed runs are
free. See How to monetize APIs
for how per-second billing works.
A route can also charge per unit of input (cost_accounting="variable" with a
cost_unit), where the price depends on the size of the asset passed in. Check
what a run will cost before you start it:
cost = ouro.routes.cost(route.id, file_id, currency="usd")
print(cost.quantity, cost.cost_unit, cost.unit_cost, cost.total_cost)If your API is built with FastAPI, you can declare prices next to each endpoint instead. See Declare routes in code.
Requires admin permission on the route. Pass the route ID; identifiers aren't
accepted here. Use dry_run=True to preview what would be removed.
ouro.routes.delete(route.id, dry_run=True) # preview
ouro.routes.delete(route.id)You can execute a route with the execute method, on a route object or as
ouro.routes.execute(name_or_id, ...). It returns an Action
with the response, status, and input/output assets. See
Routes and actions for how async runs and
webhooks work on the platform.
route = ouro.routes.retrieve("mmoderwell/post-generate")
action = route.execute(
body={
"composition": "Fe2Ni", "temperature": 0.8, "max_new_tokens": 3000
}
)
generation = action.final_dataexecute takes these arguments:
| Argument | Purpose |
|---|---|
body | Request body |
query | Query string parameters |
params | URL path parameters |
input_assets | Dictionary mapping the route's input names to Ouro asset IDs |
currency | "usd" or "btc": what to pay in on a route sold in both. Defaults to the route's primary currency (price_currency). A currency the route isn't sold in is refused |
wait | True (default) blocks until the run finishes. False returns the action right away |
poll_interval | Seconds between status checks while waiting |
poll_timeout | Most seconds to wait for the run to finish |
timeout | HTTP timeout in seconds for the initial request |
raise_on_error | True raises when the run fails. False (default) returns the errored action |
Sync and async routes use the same call. By default execute waits: for a
route that returns 202 Accepted, it polls until the action finishes. When
you don't set poll_interval and poll_timeout, they come from the route's
observed latency, so fast routes are checked often and slow ones get more time.
A route with no history yet is checked every 10 seconds for up to 10 minutes.
For long jobs, pass wait=False to get the action back immediately and collect
the result later:
action = route.execute(body={"composition": "Fe2Ni"}, wait=False)
print(action.id, action.status) # "queued" or "in-progress"
# Later, even from another process
action = ouro.routes.poll_action(action.id, poll_interval=5, timeout=1800)
print(action.final_data)If waiting runs out of time, execute raises TimeoutError. The run keeps
going on the server, and the exception carries its action_id so you can pick
it up again:
try:
action = route.execute(body={"composition": "Fe2Ni"}, poll_timeout=120)
except TimeoutError as exc:
action = ouro.routes.poll_action(exc.action_id, timeout=None) # wait as long as it takesBy default execute returns the action even when the run failed, so check
action.is_success before using the result. Pass raise_on_error=True to
raise instead:
from ouro import ExternalServiceError, RouteExecutionError
try:
action = route.execute(body={"composition": "Fe2Ni"}, raise_on_error=True)
except ExternalServiceError as exc:
# The API behind the route failed
print(exc.status_code, exc.retryable)
except RouteExecutionError as exc:
print(exc.action_id, exc.status, exc.response)ouro.routes.poll_action and action.wait() raise on a failed run. Pass
raise_on_error=False to poll_action to get the errored action back.
You can chain multiple routes into custom workflows. Many routes create files,
datasets, or posts as outputs. The Python SDK automatically adds saved output
assets to action.final_data.
generation_route = ouro.routes.retrieve("mmoderwell/post-generate")
generation_action = generation_route.execute(
body={
"composition": "Fe2Ni", "temperature": 0.8, "max_new_tokens": 3000
}
)
generation = generation_action.final_data
# Read the generated file from the action's final data.
file_id = generation["file"]["id"]
prediction_route = ouro.routes.retrieve("mmoderwell/post-magnetism-curie-temperature")
prediction_action = prediction_route.execute(
input_assets={"file": file_id}
)
prediction = prediction_action.final_data
print(prediction)For routes with multiple named outputs, read outputs by their declared names from
action.final_data, action.output_assets, or the full action record.
Every time a route is executed, Ouro stores the request and response in an action object. Actions track which inputs created which outputs, including any assets created by the route.
action = ouro.routes.retrieve_action(action_id)
print(action.status) # "queued", "in-progress", "success", "error", or "timed-out"
print(action.final_data) # the response, with output assets added under their names
print(action.output_assets)
print(action.started_at, action.finished_at)| Property | True when |
|---|---|
is_pending | The run is queued or in-progress |
is_complete | The run reached success, error, or timed-out |
is_success | The run succeeded |
is_error | The run failed |
is_timed_out | Ouro stopped waiting; the run may still finish later |
An action you already hold can update itself:
action.refresh() # fetch the latest status once
action.wait(poll_interval=5, timeout=600) # block until it finishesroute.read_actions() returns a Page of your runs. Use
ouro.routes.list_actions for paging and to include other people's runs that
you're allowed to see:
mine = route.read_actions()
everyone = ouro.routes.list_actions(
route.id,
include_other_users=True,
limit=50,
offset=0,
)
print(everyone.has_more)Pass exclude_self=True with include_other_users=True to see only other
people's runs. To go from an asset to the runs that made or used it, see
ouro.assets.actions.
Read the progress and error logs a run recorded:
logs = action.read_logs(chronological=True)
for entry in logs:
print(entry.created_at, entry.level, entry.message)
# Only errors, by action ID
errors = ouro.routes.get_action_logs(action_id, level="error", limit=20)Logs come back newest first unless you pass chronological=True.
If you're building the service behind a route, write logs to the run with
action.log. Callers see them on the route page while the run is in progress.
action = ouro.routes.retrieve_action(action_id) # from the `ouro-action-id` header
action.log("Relaxation converged after 42 steps")
action.log("Falling back to CPU", level="warning")On a paid route, the action records what the run cost. usage_record holds a
USD charge (total_cents, unit_cost_cents, quantity, status) and
btc_charges lists Bitcoin charges in sats. Both are empty for free runs and
for charges you aren't allowed to see.
If your API is built with FastAPI, ouro.utils has decorators that write
Ouro's settings into your OpenAPI spec. Ouro applies them when the service is
created and every time its spec is refreshed.
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from ouro.utils import (
get_custom_openapi,
ouro_execution_mode,
ouro_field,
ouro_pricing,
)
app = FastAPI(title="Materials predictor")
@app.post("/relax")
@ouro_field("x-ouro-input-assets", {"structure": {"asset_type": "file", "file_extensions": ["cif"]}})
@ouro_field("x-ouro-output-assets", {"relaxed": {"asset_type": "file", "primary": True}})
@ouro_execution_mode("async")
@ouro_pricing(per_second=0.0003, max_seconds=3600)
def relax(...): ...
app.openapi = get_custom_openapi(app, get_openapi)| Helper | What it declares |
|---|---|
ouro_field | Any x-ouro-* field on the endpoint, such as input and output assets |
ouro_execution_mode | "sync" or "async", so callers know whether to wait or check back |
ouro_pricing | The route's price |
ouro_capabilities | A standard capability the route provides: text translation, text to speech, or speech transcription |
get_custom_openapi | Builds the spec with these fields included. Without it, the decorators have no effect |
ouro_pricing takes exactly one of per_call, per_second (with
max_seconds, from 1 to 86400), or free=True. A number is a price in
dollars; pass currency="btc" to price in sats, or give a price for each
currency to sell in both:
@ouro_pricing(per_call=0.05) # $0.05 per successful call
@ouro_pricing(per_call=50, currency="btc") # 50 sats per call
@ouro_pricing(per_call={"usd": 0.05, "btc": 50}) # either; USD when the caller doesn't choose
@ouro_pricing(free=True) # make a paid route free againA price declared in code replaces one set in the UI each time the spec is synced. Routes without the decorator keep the pricing they have. See Runtime pricing for the headers your service receives on per-second routes, and the route input and output assets guide for a full service example.
Posts on Ouro are a way to share rich text-focused content with the Ouro community. Just like with every other kind of asset, you can create posts with a text editor on the web interface or use the SDK to create them programmatically.
Browse or search posts with optional filters.
posts = ouro.posts.list()
posts = ouro.posts.list(query="machine learning", scope="global", limit=5)The SDK exposes an Editor class that allows you to construct a document block by block.
Blocks are things like paragraphs, headings, lists, or references to other assets.
import pandas as pd
content = ouro.posts.Editor()
content.new_header(level=1, text="Hello World")
content.new_paragraph(text="This is a paragraph")
content.new_code_block(language="python", code="print('Hello, World!')")
content.new_table(pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}))
content.new_inline_asset(id="438e454b-cf9e-40d9-b53d-b9b250087179", asset_type="service", view_mode="card")
content.new_inline_asset(id="8891046c-b52c-432f-b9b0-ca9515bb1c20", asset_type="file", view_mode="preview")Valid methods of the Editor class are:
new_header: adds a header block of specified level (1-3)new_paragraph: adds a paragraph of textnew_code_block: adds a code blocknew_table: adds a table from a pandas DataFramenew_inline_asset: adds an Ouro asset with an optional visualization of the data (view_mode="preview"). For datasets, pass display_config={"visualizationId": view_id} to pin a saved view.Using the editor only creates a local object with your content.
You can view your content as JSON and Markdown text using the to_dict method.
Before you can create a post, you need to have the content you want to post.
The Editor class explained above is one way. You can also create an Editor directly from a markdown string:
markdown_string = """
# Hello World
This is a test post from the Python SDK
"""
content = ouro.posts.Editor(text=markdown_string)When the Editor is created via ouro.posts.Editor(), it's automatically connected to the Ouro client. Passing text to the constructor triggers server-side markdown parsing, which handles standard markdown as well as Ouro-specific syntax like @mentions and asset embeds.
You can also call from_markdown explicitly on an existing editor:
content = ouro.posts.Editor()
content.new_header(level=1, text="Hello World")
content.from_markdown("More content parsed from markdown")This is especially useful for converting LLM output. You can ask the model to return Ouro extended markdown with a prompt like this:
Write responses in extended markdown. Mention users as @username, prefer
typed asset links like [results](dataset:<uuid>), and use
assetComponent blocks when the reader needs a rich preview.
```assetComponent
{
"id": "<uuid>",
"assetType": "dataset",
"viewMode": "preview",
"displayConfig": {
"visualizationId": "<dataset-view-uuid-or-null>",
"actionId": "<route-action-uuid-or-null>"
}
}
```When you embed an asset, you don't need to provide the title, description, or
link; Ouro renders those from the asset. Set assetType to post, file,
dataset, route, or service. For files and datasets, prefer preview;
otherwise use card. For datasets, set
displayConfig.visualizationId to render a saved dataset view. For routes, set
displayConfig.actionId to pin a specific run.
Once you have your content, you save it to Ouro with the ouro.posts.create method.
post = ouro.posts.create(
content=content,
name="Hello World",
description="This is a post from the Python SDK",
visibility="public",
)
# Returns a Post object
print(post)If you don't need block-by-block control, you can skip the Editor entirely and pass markdown or a file path directly:
post = ouro.posts.create(
name="Hello World",
content_markdown="# Hello World\nThis is a test post from the Python SDK",
visibility="public",
)
# Or from a markdown file
post = ouro.posts.create(
name="Hello World",
content_path="./my-post.md",
visibility="public",
)Provide exactly one of content, content_markdown, or content_path.
A report usually comes with its data. Embed a dataset, file, or sub-post that doesn't exist yet and Ouro creates it together with the post, as the post's child:
editor = ouro.posts.Editor()
editor.new_paragraph("Formation energies for the candidates:")
editor.new_partial_asset(ouro.datasets.partial(df, name="Formation energies"))
editor.new_partial_asset(ouro.files.partial_from_file("./relaxed.cif", name="Relaxed structure"))
editor.new_partial_asset(ouro.posts.partial("## Method\n...", name="Method notes"))
post = ouro.posts.create(name="Screening report", content=editor)Children take their audience from the post, so they are visible to whoever can
see the report. List them with ouro.assets.children(post.id).
If your content starts with an H1 that matches the post name, Ouro keeps the two
in sync when it has to make the name unique (for example Hello World 1).
Any other leading H1 is left as written.
You can read a post with the ouro.posts.retrieve method.
You can find the ID of a post in the response of the ouro.posts.create method or from the web interface.
post_id = "0190ea44-bfef-7f8b-9e5f-503fc20a4d91"
post = ouro.posts.retrieve(post_id)
# Returns a Post object
print(post)You will find the post's content in the content property of the post object.
You can get a markdown representation of the content with the text property.
post_markdown = post.content.text
print(post_markdown)You can update a post with the ouro.posts.update method.
updated_post = ouro.posts.update(
post.id,
description="This is a post from the Python SDK that is now private",
visibility="private"
)
# Returns the updated post object
print(updated_post)You can update any of the post's properties, including the content, using the same approach used to create it.
You can delete a post with the ouro.posts.delete method. You must be an admin of the post to delete it.
ouro.posts.delete(post.id)Quests are team-scoped requests for help. Use ouro.quests to create quests,
manage items, submit entries, and review contributions. See
Quests on Ouro for the product model.
quest = ouro.quests.create(
name="CeO2 reference patterns",
description="Collect reference XRD patterns for CeO2 nanoparticles.",
visibility="organization",
org_id=org_id,
team_id=team_id,
type="closable",
status="open",
items=[
"Upload a clean .xy or .xye pattern",
{
"description": "Write a short methods note",
"submission_assets": {"note": {"asset_type": "post"}},
},
],
)submission_assets declares what contributors attach to an item, keyed by the
name they submit it under.
quest = ouro.quests.retrieve(quest_id)
ouro.quests.update(quest_id, status="closed")items = ouro.quests.list_items(quest_id)
ouro.quests.create_items(quest_id, ["Additional benchmark file"])
entry = ouro.quests.create_entry(
quest_id,
item_id=items[1].id,
assets={"note": post.id},
description="Methods note for the benchmark",
)
entries = ouro.quests.list_entries(quest_id, status="submitted")
ouro.quests.review_entry(quest_id, entry.id, status="accepted")
board = ouro.quests.list_leaderboard(quest_id, items[0].id)
# board[0].score ranks the row; .category_scores is an optional map
# of subcategory values from the eval route (default path $.categories).type)Set type when creating a quest ("closable" default, or "continuous"):
type | create_entry behavior |
|---|---|
closable | At most one active entry per (item_id, your user) while status is submitted or accepted. A second call raises an API error. After rejection, you may submit again. |
continuous | No per-user cap — each create_entry inserts a new row for the same item. |
# Ongoing benchmark — contributors can submit every week
ouro.quests.create(
name="Weekly structure upload",
type="continuous",
items=["Upload this week's relaxed structure"],
org_id=org_id,
team_id=team_id,
)
# One-shot bounty — one pending/accepted slot per contributor per item
ouro.quests.create(
name="Reference XRD pattern",
type="closable",
items=["Upload a clean .xy pattern"],
org_id=org_id,
team_id=team_id,
)Inspect quest.quest.type (nested on the retrieved asset) before retrying failed
submits. Use assets={key: asset_id} for multi-input items; see
Quests on Ouro.
ouro.quests.delete(quest_id)Conversations let users exchange messages. You can create, list, retrieve, update, and delete conversations, and create or list messages within a thread.
Start a conversation by passing member user IDs. Include yourself if you want the thread to appear in your conversation list.
conversation = ouro.conversations.create(
member_user_ids=[my_user_id, teammate_user_id],
name="Project Alpha",
org_id=org_id,
team_id=team_id,
)Start by listing your conversations to find threads you want to work with.
conversations = ouro.conversations.list()
for c in conversations:
print(c.id, c.name, c.metadata)Once you have an ID, load the conversation to inspect metadata and access message helpers.
conversation_id = "0190ea44-bfef-7f8b-9e5f-503fc20a4d91"
conversation = ouro.conversations.retrieve(conversation_id)
print(conversation.name, conversation.metadata)You can update top-level fields like name and summary.
updated = ouro.conversations.update(
conversation_id,
name="Project Alpha",
summary="Research thread"
)
print(updated)Send a message to a conversation as plain text or structured JSON. Use the
conversation.messages.create helper.
# Text message
msg = conversation.messages.create(text="Hello team!")
# JSON message (rich content)
editor = ouro.posts.Editor()
editor.new_paragraph(text="Hello team!")
msg2 = conversation.messages.create(json=editor.to_dict())Read the latest messages to understand the current context of the thread.
messages = conversation.messages.list()
for m in messages:
print(m.id, m.text)ouro.conversations.delete removes the conversation when you are the only
member. Otherwise it removes you from the member list (leave the thread).
ouro.conversations.delete(conversation_id)Comments are lightweight, rich-text notes attached to any asset (files, datasets, posts, routes, services, conversations). One-level threads are supported: top-level comments on an asset, and replies to those comments. Use the built-in Editor to compose content, then create, list, and update comments.
Compose with the Editor, then create a comment on a parent asset by ID.
parent_asset_id = "0190ea44-bfef-7f8b-9e5f-503fc20a4d91" # can be any asset id
editor = ouro.comments.Editor()
editor.new_paragraph(text="Great post! I especially liked the dataset example.")
comment = ouro.comments.create(
parent_id=parent_asset_id,
content=editor,
visibility="public",
)
print(comment.id)Fetch all top-level comments attached to an asset.
comments = ouro.comments.list_by_parent(parent_asset_id)
for c in comments:
# The content is stored as text + JSON structure
print(c.id, c.content.text)fetched = ouro.comments.retrieve(comment.id)
print(fetched.content.text)Rebuild the content with Editor or pass an updated Content object.
update_editor = ouro.comments.Editor()
update_editor.new_paragraph(text="Edited: adding one more note.")
updated_comment = ouro.comments.update(
comment.id,
content=update_editor,
visibility="private",
)
print(updated_comment)Replies are simply comments whose parent is a comment. Only one level of replies is supported.
# Create a reply to a top-level comment
reply_editor = ouro.comments.Editor()
reply_editor.new_paragraph(text="Replying here with more details.")
reply = ouro.comments.create(
parent_id=comment.id, # parent is the comment id
content=reply_editor,
visibility="public",
)
# List replies for a top-level comment
replies = ouro.comments.list_replies(comment.id)
for r in replies:
print(r.id, r.content.text)ouro.assets works across asset types. Use it when you have an ID but don't
know (or care) what kind of asset it is.
Pass a query for semantic and full-text search, or leave it empty to browse
recent assets. Passing an asset ID as the query looks up that asset directly.
# Search
results = ouro.assets.search("perovskite band gap", asset_type="dataset", limit=10)
# Browse your recent files and datasets in a team
recent = ouro.assets.search(
asset_type=["file", "dataset"],
scope="personal",
team_id=team_id,
sort="recent",
)
for asset in results:
print(asset.id, asset.asset_type, asset.name)Filters include asset_type, scope (personal, org, global, or all),
org_id, team_id, user_id, visibility, sort (relevant, recent,
popular, or updated), and metadata_filters. The server returns at most
200 results per request; larger limit values (or limit=None for every
match) are paginated for you. For exhaustive collection, browse with an empty
query — semantic search ranks a capped pool of candidates.
# Returns a Post, File, Dataset, Service, Route, Quest, or Comment
asset = ouro.assets.retrieve(asset_id)
# Give a teammate access to a private asset
teammate = ouro.users.get("ada")
ouro.assets.share(asset.id, teammate.user_id, role="read") # or "write", "admin"
# Preview, then delete
ouro.assets.delete(asset.id, dry_run=True)
ouro.assets.delete(asset.id)Private assets are invisible to other users until you share them. Mentioning a user or linking the asset in a post does not grant access.
saved = ouro.assets.download(asset_id, output_path="./downloads")
print(saved.path)Files download in their original format and datasets download as CSV.
To hand the download to something without Ouro credentials, such as an agent
running curl, ask for a link instead:
link = ouro.assets.create_download_url(asset_id)
print(link["download_url"], link["file_name"], link["expires_in"])
# Posts download as markdown, or as HTML
link = ouro.assets.create_download_url(post_id, format="html")The link works for anyone who has it until it expires. You can only make one for an asset you can open.
# Route actions that produced this asset, or used it as an input
actions = ouro.assets.actions(asset_id)
print(actions.created_by, actions.as_input)
# Routes that can take this asset as input
routes = ouro.assets.compatible_routes(asset_id)
# Or search them by what you want to do
routes = ouro.assets.compatible_routes(asset_id, query="relax the structure")
# References, derivatives, and other links to related assets
connections = ouro.assets.connections(asset_id)
# Views, comments, reactions, and downloads
counts = ouro.assets.counts(asset_id)Every asset lives in one organization and one team within it. See Teams for the product model.
orgs = ouro.organizations.list() # organizations you belong to
for org in orgs:
print(org.id, org.name, org.membership.role if org.membership else None)
org = ouro.organizations.retrieve(org_id)
open_orgs = ouro.organizations.list_discoverable()teams = ouro.teams.list(org_id=org_id, joined=True)
team = ouro.teams.retrieve(team_id, include_members=True)
team = ouro.teams.create(
name="xrd-benchmarks",
org_id=org_id,
description="Reference patterns and fitting benchmarks",
visibility="public",
)Team names are URL slugs: lowercase letters, numbers, and single dashes, unique
within the organization. Other names raise BadRequestError, and duplicates
raise ConflictError.
Pass join_policy="request" to review join requests, or "invite_only" to
close the team. source_policy can restrict creation to "api_only" or
"web_only".
ouro.teams.join(team_id) # submits a join request on request-only teams
ouro.teams.leave(team_id)
# Team admins
for request in ouro.teams.list_join_requests(team_id):
ouro.teams.approve_join_request(team_id, request.id)
ouro.teams.ban_member(team_id, user_id, reason="Spam")
ouro.teams.unban_member(team_id, user_id)feed = ouro.teams.activity(team_id, limit=20, asset_type="post")
unread = ouro.teams.unreads(team_id)ouro.teams.update(team_id, description="Now accepting .xye files")
ouro.teams.delete(team_id)Deleting requires team or organization admin permission. The team's assets move to the organization's default team rather than being deleted. An organization's default team can't be deleted.
me = ouro.users.me()
print(me.user_id, me.username)
profile = ouro.users.get("ada") # by username or user ID
matches = ouro.users.search("ada")Use ouro.users.me() for your profile. ouro.user is the raw auth user: it
has your id but not your username.
unread = ouro.notifications.unreads()
notifications = ouro.notifications.list(unread_only=True, category="mentions,comments")
for n in notifications:
print(n.type, n.asset_id, n.viewed)
ouro.notifications.read(n.id)Categories are mentions, comments, references, shares, money, and
actions.
Amounts are integers in the currency's smallest unit: sats for "btc" and
cents for "usd".
balance = ouro.money.get_balance(currency="usd")
transactions = ouro.money.get_transactions(currency="usd", limit=20)
# Buy access to a monetized asset. For an asset sold in both currencies,
# `currency` chooses which price you pay
ouro.money.unlock_asset("dataset", dataset_id, currency="usd")
# Tip another user $5
ouro.money.send(recipient_id=user_id, amount=500, currency="usd", message="Thanks!")Every SDK exception inherits from OuroError. Failed requests raise a subclass
of APIStatusError that matches the HTTP status, with status_code and the
response body attached.
| Exception | When |
|---|---|
BadRequestError | 400: invalid input, such as a malformed ID |
AuthenticationError | 401: missing or invalid API key |
PermissionDeniedError | 403: no access, or your plan doesn't allow the action |
NotFoundError | 404: the resource doesn't exist or isn't visible to you |
ConflictError | 409: a duplicate, such as a team name already in use |
RateLimitError | 429: too many requests |
InternalServerError | 5xx: server error |
APIConnectionError | The request never reached Ouro |
RouteExecutionError | A route run failed (with raise_on_error=True) |
ExternalServiceError | A RouteExecutionError where the API behind the route failed |
from ouro import NotFoundError, OuroError, PermissionDeniedError
try:
dataset = ouro.datasets.retrieve(dataset_id)
except NotFoundError:
print("No such dataset, or it's private")
except PermissionDeniedError as exc:
print(f"Not allowed: {exc}")
except OuroError as exc:
print(f"Ouro request failed: {exc}")A failed route run is not an APIStatusError: execute returns an errored
Action by default. Pass raise_on_error=True to raise instead; the exception
carries the action_id so you can inspect logs with
ouro.routes.get_action_logs. See Handle failed runs.
On this page