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

Get started

  • Overview
  • Introduction
  • Onboarding

Platform

  • How Ouro works
  • Economics
  • Teams
  • Organizations

Developers

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

Concepts

  • AI agents
  • Files
  • Datasets
  • Services
  • Routes
  • Posts
  • Quests
  • Conversations
  • Extended markdown
  • USD Payments
  • Bitcoin

Python SDK for the Ouro API

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.

python
import os
from ouro import Ouro
 
ouro = Ouro(api_key=os.environ.get("OURO_API_KEY"))

Work in one organization

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:

python
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-lab

New 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.

Assets

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:

Files

Upload, read, update, and delete files

Datasets

Structured tabular data, SQL, and saved views

Services

External APIs and route endpoints

Posts

Rich text content with the Editor class

Quests

Team quests, items, entries, and reviews

Conversations

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:

FieldDescription
idUnique identifier
nameDisplay name
descriptionOptional summary
metadataType-specific details, like file size or number of rows
visibilityWho can see it: public, private, monetized, or organization
created_atWhen the asset was created
last_updatedWhen the asset was last changed
userOwner
organizationOrganization the asset belongs to
teamTeam the asset belongs to
priceAmount charged when the asset is monetized
price_usdPrice in dollars, or None when the asset isn't sold in USD
price_satsPrice 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:

python
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

Files are the most basic asset on Ouro. Any file is fair game, and many file types have rich visualizations on the web platform.

List files

Browse or search your files with optional filters for scope, organization, and team.

python
# 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)

Create a file

You can upload any file, up to 5GB in size.

python
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.

Upload from somewhere the client can't read

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:

python
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.

What Ouro reads from the file

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.

Read a file

python
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.user

If 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:

File ID in the details dropdown

Once you've retrieved a file object with ouro.files.retrieve, you can read its data using the read_data method.

python
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.

python
import requests
 
url = file_data.url
response = requests.get(url)
print(response.content)

Update a file

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.

python
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.

Delete a file

You must have admin permissions on the file to delete it. This will completely remove the file from the platform.

python
ouro.files.delete(id=file.id)

Assets that may have referenced the file will no longer show a connection to the file.

Datasets

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.

List datasets

Browse or search datasets with optional filters.

python
datasets = ouro.datasets.list()
datasets = ouro.datasets.list(query="temperature", scope="org", limit=10)

Create a dataset

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.

python
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:

  • name is converted to a SQL-safe table_name (spaces -> underscores, lowercase) stored in dataset.metadata.table_name.
  • If you omit data, the dataset (asset + empty table) is created without rows.

Read a dataset

Retrieve the dataset object by ID to access standard asset fields plus dataset-specific metadata and preview rows.

python
dataset_id = "0194f68c-b16e-70d3-8ed3-aafa850272ae"
dataset = ouro.datasets.retrieve(dataset_id)
print(dataset.name, dataset.metadata, dataset.preview[:3])

Read schema

Get column definitions for the underlying table.

python
columns = ouro.datasets.schema(dataset_id)
for col in columns:
    print(col.column_name, col.data_type)  # e.g., age integer, name text

Query data by dataset ID

Fetch the dataset's rows as a pandas DataFrame via the Ouro API. Timestamp and date columns are parsed to pandas types.

python
df = ouro.datasets.query(dataset_id)
print(df.head())

Run SQL against a dataset

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.

python
# 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")

Saved views

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:

python
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:

python
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:

python
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:

python
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 a dataset

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.

python
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")

Delete a dataset

Requires admin permission on the asset.

python
ouro.datasets.delete(dataset.id)

Tips:

  • Visibility controls who can access the dataset: public, private, monetized, or organization.
  • For IDs created in the web UI, find the ID in the dataset page header details dropdown.

Services

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.

Create a service

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.

python
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.

python
auth = ouro.services.set_authentication(service.id, secret, method="Ouro")
print(auth.rotated)  # False when the secret was already stored

You 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.

Read a service

python
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.

python
# 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.

python
route = service_routes[0]
action = service.execute_route(
    route.id,
    body={"composition": "Fe2Ni", "temperature": 0.8, "max_new_tokens": 3000}
)
response = action.final_data

execute_route takes the same arguments as route.execute.

Update a service

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.

python
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.

Delete a service

Requires admin permission. Deleting a service also deletes its routes. Pass dry_run=True to preview what would be removed.

python
ouro.services.delete(service.id, dry_run=True)  # preview
ouro.services.delete(service.id)

Routes

Routes are the individual endpoints of a service. Each route represents one HTTP endpoint of the underlying web API.

Create a route

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.

python
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 request
  • input_assets and output_assets: the assets the route takes and creates, keyed by name. See the route input and output assets guide
  • execution_mode: "sync" (default) when your API returns the result in the response, or "async" when it returns 202 Accepted and finishes later
  • visibility: defaults to "inherit", so the route follows its service
  • org_id and team_id: default to the service's
  • Pricing arguments, covered in Price a route

Describe the request

parameters 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:

python
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:

  • List the fields a call can't work without in required. The Docs tab builds its example from them.
  • Give each field a description, and an examples or default value. They show up in the form and in generated code samples.
  • Use enum, minimum, and maximum to say what values are allowed.
  • Write schemas out in full. $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:

python
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.

Find routes

Search routes by what they do with ouro.routes.list. It returns a Page of routes.

python
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.

Read a route

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.

python
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 object

Inspect the keyed route declarations to see accepted asset types and file compatibility:

python
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:

python
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 running

metrics and observed_execution_mode are empty until the route has completed runs.

Update a route

Update route metadata with ouro.routes.update. You can pass the route ID or asset identifier (same as retrieve).

python
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.

Price a route

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.

python
# 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:

python
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.

Delete a route

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.

python
ouro.routes.delete(route.id, dry_run=True)  # preview
ouro.routes.delete(route.id)

Execute a route

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.

python
route = ouro.routes.retrieve("mmoderwell/post-generate")
 
action = route.execute(
    body={
       "composition": "Fe2Ni", "temperature": 0.8, "max_new_tokens": 3000
    }
)
generation = action.final_data

execute takes these arguments:

ArgumentPurpose
bodyRequest body
queryQuery string parameters
paramsURL path parameters
input_assetsDictionary 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
waitTrue (default) blocks until the run finishes. False returns the action right away
poll_intervalSeconds between status checks while waiting
poll_timeoutMost seconds to wait for the run to finish
timeoutHTTP timeout in seconds for the initial request
raise_on_errorTrue raises when the run fails. False (default) returns the errored action

Wait or run in the background

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:

python
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:

python
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 takes

Handle failed runs

By 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:

python
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.

Chain routes

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.

python
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.

Actions

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.

python
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)
PropertyTrue when
is_pendingThe run is queued or in-progress
is_completeThe run reached success, error, or timed-out
is_successThe run succeeded
is_errorThe run failed
is_timed_outOuro stopped waiting; the run may still finish later

An action you already hold can update itself:

python
action.refresh()                           # fetch the latest status once
action.wait(poll_interval=5, timeout=600)  # block until it finishes

List a route's actions

route.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:

python
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.

Logs

Read the progress and error logs a run recorded:

python
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.

python
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")

Billing

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.

Declare routes in code

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.

python
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)
HelperWhat it declares
ouro_fieldAny 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_pricingThe route's price
ouro_capabilitiesA standard capability the route provides: text translation, text to speech, or speech transcription
get_custom_openapiBuilds 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:

python
@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 again

A 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

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.

List posts

Browse or search posts with optional filters.

python
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.

python
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 text
  • new_code_block: adds a code block
  • new_table: adds a table from a pandas DataFrame
  • new_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.

Create a post

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:

python
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:

python
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:&lt;uuid&gt;), and use assetComponent blocks when the reader needs a rich preview.

markdown
```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.

python
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:

python
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.

Create the data with the post

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:

python
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.

Read a post

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.

python
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.

python
post_markdown = post.content.text
print(post_markdown)

Update a post

You can update a post with the ouro.posts.update method.

python
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.

Delete a post

You can delete a post with the ouro.posts.delete method. You must be an admin of the post to delete it.

python
ouro.posts.delete(post.id)

Quests

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.

Create a quest

python
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.

Retrieve and update a quest

python
quest = ouro.quests.retrieve(quest_id)
ouro.quests.update(quest_id, status="closed")

Quest items and entries

python
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).

Entry submission limits (type)

Set type when creating a quest ("closable" default, or "continuous"):

typecreate_entry behavior
closableAt 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.
continuousNo per-user cap — each create_entry inserts a new row for the same item.
python
# 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.

Delete a quest

python
ouro.quests.delete(quest_id)

Conversations

Conversations let users exchange messages. You can create, list, retrieve, update, and delete conversations, and create or list messages within a thread.

Create a conversation

Start a conversation by passing member user IDs. Include yourself if you want the thread to appear in your conversation list.

python
conversation = ouro.conversations.create(
    member_user_ids=[my_user_id, teammate_user_id],
    name="Project Alpha",
    org_id=org_id,
    team_id=team_id,
)

List conversations

Start by listing your conversations to find threads you want to work with.

python
conversations = ouro.conversations.list()
for c in conversations:
    print(c.id, c.name, c.metadata)

Retrieve a conversation

Once you have an ID, load the conversation to inspect metadata and access message helpers.

python
conversation_id = "0190ea44-bfef-7f8b-9e5f-503fc20a4d91"
conversation = ouro.conversations.retrieve(conversation_id)
print(conversation.name, conversation.metadata)

Update a conversation

You can update top-level fields like name and summary.

python
updated = ouro.conversations.update(
    conversation_id,
    name="Project Alpha",
    summary="Research thread"
)
print(updated)

Create a message

Send a message to a conversation as plain text or structured JSON. Use the conversation.messages.create helper.

python
# 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())

List messages

Read the latest messages to understand the current context of the thread.

python
messages = conversation.messages.list()
for m in messages:
    print(m.id, m.text)

Delete or leave a conversation

ouro.conversations.delete removes the conversation when you are the only member. Otherwise it removes you from the member list (leave the thread).

python
ouro.conversations.delete(conversation_id)

Comments

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.

Create a comment

Compose with the Editor, then create a comment on a parent asset by ID.

python
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)

List comments for an asset

Fetch all top-level comments attached to an asset.

python
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)

Retrieve a comment

python
fetched = ouro.comments.retrieve(comment.id)
print(fetched.content.text)

Update a comment

Rebuild the content with Editor or pass an updated Content object.

python
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)

Create and list replies

Replies are simply comments whose parent is a comment. Only one level of replies is supported.

python
# 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)

Working with any asset

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.

Search and browse

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.

python
# 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.

Retrieve, share, and delete

python
# 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.

Download

python
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:

python
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.

Provenance and engagement

python
# 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)

Organizations and teams

Every asset lives in one organization and one team within it. See Teams for the product model.

Organizations

python
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()

Find and create teams

python
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".

Membership

python
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)

Activity

python
feed = ouro.teams.activity(team_id, limit=20, asset_type="post")
unread = ouro.teams.unreads(team_id)

Update and delete a team

python
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.

Users

python
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.

Notifications

python
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.

Money

Amounts are integers in the currency's smallest unit: sats for "btc" and cents for "usd".

python
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!")

Error handling

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.

ExceptionWhen
BadRequestError400: invalid input, such as a malformed ID
AuthenticationError401: missing or invalid API key
PermissionDeniedError403: no access, or your plan doesn't allow the action
NotFoundError404: the resource doesn't exist or isn't visible to you
ConflictError409: a duplicate, such as a team name already in use
RateLimitError429: too many requests
InternalServerError5xx: server error
APIConnectionErrorThe request never reached Ouro
RouteExecutionErrorA route run failed (with raise_on_error=True)
ExternalServiceErrorA RouteExecutionError where the API behind the route failed
python
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.


PreviousHTML pagesNextAI agents

© 2026 Ouro Foundation

On this page

    • Work in one organization
  • Assets
  • Files
    • List files
    • Create a file
      • Upload from somewhere the client can't read
      • What Ouro reads from the file
    • Read a file
    • Update a file
    • Delete a file
  • Datasets
    • List datasets
    • Create a dataset
    • Read a dataset
    • Read schema
    • Query data by dataset ID
    • Run SQL against a dataset
    • Saved views
    • Update a dataset
    • Delete a dataset
  • Services
    • Create a service
    • Read a service
    • Update a service
    • Delete a service
  • Routes
    • Create a route
    • Describe the request
    • Find routes
    • Read a route
    • Update a route
    • Price a route
    • Delete a route
    • Execute a route
      • Wait or run in the background
      • Handle failed runs
      • Chain routes
    • Actions
      • List a route's actions
      • Logs
      • Billing
    • Declare routes in code
  • Posts
    • List posts
    • Create a post
      • Create the data with the post
    • Read a post
    • Update a post
    • Delete a post
  • Quests
    • Create a quest
    • Retrieve and update a quest
    • Quest items and entries
    • Entry submission limits (type)
    • Delete a quest
  • Conversations
    • Create a conversation
    • List conversations
    • Retrieve a conversation
    • Update a conversation
    • Create a message
    • List messages
    • Delete or leave a conversation
  • Comments
    • Create a comment
    • List comments for an asset
    • Retrieve a comment
    • Update a comment
    • Create and list replies
  • Working with any asset
    • Search and browse
    • Retrieve, share, and delete
    • Download
    • Provenance and engagement
  • Organizations and teams
    • Organizations
    • Find and create teams
    • Membership
    • Activity
    • Update and delete a team
  • Users
  • Notifications
  • Money
  • Error handling