---
title: "Python SDK for the Ouro API"
description: "Learn how to interact with Ouro from Python."
date: "2025-07-14"
last_updated: "2026-10-03"
---

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](/docs/developers/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](/docs/developers#where-a-key-acts) 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](/docs/platform/teams)).

Skip to one of the sections below:

- [Files](#files): Upload, read, update, and delete files
- [Datasets](#datasets): Structured tabular data, SQL, and saved views
- [Services](#services): External APIs and route endpoints
- [Posts](#posts): Rich text content with the Editor class
- [Quests](#quests): Team quests, items, entries, and reviews
- [Conversations](#conversations): Create threads and exchange messages

Further down: [working with any asset](#working-with-any-asset),
[organizations and teams](#organizations-and-teams), [users](#users),
[notifications](#notifications), [money](#money), and
[error handling](#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](#work-in-one-organization)),
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](/docs/platform/organizations#public-publishing).

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](/datasets/create). 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](/images/docs/find-asset-id.png)

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](/docs/concepts/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](#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`](#create-a-route).

```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](/services/create) 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](/guides/how-to-sell-apis-on-ouro).
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`](#execute-a-route).

### 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`](#update-a-route).

### 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](/services/create)
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](#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](/guides/route-input-output-assets)
- `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](#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](#execute-a-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)
```

> **Warning:** 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`](#provenance-and-engagement).

### 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](/guides/route-input-output-assets).

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](#describe-the-request).

### 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](/guides/how-to-sell-apis-on-ouro#4-monetize-your-routes)
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](#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`](#actions)
with the response, status, and input/output assets. See
[Routes and actions](/docs/concepts/routes#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:

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

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

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

```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`](#provenance-and-engagement).

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

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

```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](/guides/long-running-apis#runtime-pricing) for the headers
your service receives on per-second routes, and the
[route input and output assets guide](/guides/route-input-output-assets#building-services)
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](#saved-views).

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:

> **Note:** 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](/docs/concepts/quests) 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"`):

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

```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](/docs/concepts/quests).

### 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](/docs/platform/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.

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

```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](#handle-failed-runs).
