---
title: "Designing routes for agents"
description: "Return a small JSON summary, push full output into assets, and let agents drill down only when they need to."
date: "2026-09-08"
last_updated: "2026-09-08"
---

Most routes on Ouro are called more often by agents than by people. A person runs a route from the route page, skims the result, and scrolls past the parts they don't need. An agent has no scrollbar. When an agent calls your route through [MCP](/docs/developers/mcp), `execute_route` inlines the entire action response into the model's context window, and every byte you returned competes with the rest of the agent's reasoning for the same budget.

That single fact drives the whole design. Your response body is not a transcript of what happened — it's a summary the agent reads in full, plus pointers to everything it might want to read later.

<Callout type="info">
This guide assumes you already know how routes declare and produce assets. If you don't, start with [route input and output assets](/guides/route-input-output-assets) and come back.
</Callout>

## The context budget

Rough arithmetic: one token is about four characters of JSON, so a 200 KB log dump costs roughly 50,000 tokens and a 10 MB trajectory costs more than any current model can hold. And agents rarely call one route once — they chain routes, retry after errors, and keep earlier results in context while they work. A response that is merely large is fine on its own and fatal three calls into a pipeline.

Nothing in the stack will save you from this. Ouro strips `base64` payloads from the stored response once it materializes them into assets, and MCP clients apply a soft size cap to some tools, but `execute_route` returns your response body verbatim. If you return it, the agent reads it.

A good target: the response body should be small enough that you'd be willing to read it out loud. Somewhere under a few kilobytes, or roughly a hundred lines of JSON.

## Summary in JSON, everything else in assets

Take a structure relaxation. The run produces a relaxed structure, an optimizer trajectory with hundreds of frames, a few thousand lines of solver log, and a handful of numbers that answer the question the caller actually asked.

Only the numbers belong in the response body. Declare the rest as output assets:

```json
{
  "relaxed_structure": {
    "asset_type": "file",
    "file_extensions": ["cif"],
    "primary": true
  },
  "trajectory": {
    "asset_type": "file",
    "file_extensions": ["xyz"]
  },
  "run_log": {
    "asset_type": "file",
    "file_extensions": ["log"]
  }
}
```

Hang that declaration on the endpoint as `OUTPUT_ASSETS` and return the summary alongside the asset payloads. Ouro saves each declared key as an asset and replaces it with an asset reference:

```python showLineNumbers
@ouro_field("x-ouro-output-assets", OUTPUT_ASSETS)
@app.post("/relax")
async def relax_structure(
    request: dict,
    ouro_route_org_id: Optional[str] = Header(None, alias="ouro-route-org-id"),
    ouro_route_team_id: Optional[str] = Header(None, alias="ouro-route-team-id"),
):
    result = run_relaxation(request["structure"]["url"])

    return {
        # The summary: bounded, scalar, and enough to decide what to do next.
        "converged": result.converged,
        "final_energy_ev": round(result.energy, 4),
        "max_force_ev_per_ang": round(result.fmax, 4),
        "steps": len(result.trajectory),
        "wall_time_seconds": round(result.wall_time, 1),
        # The bulk: saved as assets, referenced by id.
        "relaxed_structure": {
            "name": f"Relaxed {result.formula}",
            "type": "chemical/x-cif",
            "extension": "cif",
            "base64": encode_base64(result.cif_bytes),
            "org_id": ouro_route_org_id,
            "team_id": ouro_route_team_id,
        },
        "trajectory": {
            "name": f"Relaxation trajectory ({len(result.trajectory)} frames)",
            "type": "chemical/x-xyz",
            "extension": "xyz",
            "base64": encode_base64(result.trajectory_bytes),
            "org_id": ouro_route_org_id,
            "team_id": ouro_route_team_id,
        },
        "run_log": {
            "name": f"Relaxation log ({result.log_lines} lines)",
            "type": "text/plain",
            "extension": "log",
            "base64": encode_base64(result.log_bytes),
            "org_id": ouro_route_org_id,
            "team_id": ouro_route_team_id,
        },
    }
```

The agent sees a few hundred bytes of numbers and three asset IDs. If the relaxation converged and the energy is all it needed, it never opens any of them.

### What belongs in the summary

The test for each field is whether an agent would act differently depending on its value.

| Include | Leave out |
| --- | --- |
| Scalars that answer the question: energies, scores, counts, verdicts | Anything whose size grows with the input |
| Status the agent must branch on: `converged`, `n_failed`, `warnings` | Per-item records, per-step traces, per-frame data |
| Shape metadata: row counts, frame counts, log line counts | The rows, frames, and log lines themselves |
| Top-N when a ranking is the point, with the full set as a dataset | Full rankings, full candidate lists |
| Asset IDs for everything large | Base64 blobs the agent didn't ask for |

The distinction that matters most is bounded versus unbounded. `"steps": 412` is bounded — it's four characters no matter how long the run was. `"trajectory": [...]` is unbounded, and a route that returns unbounded fields works beautifully in testing and blows up the first time someone hands it a real workload.

Shape metadata is what makes the omission safe. Telling the agent the log has 3,412 lines and the dataset has 1,280 rows lets it decide whether opening them is worth it, without opening them to find out.

## Let the agent drill down

Trimming the response only works if the detail is still reachable. Ouro gives agents three levels of access, and each one costs more context than the last, so let the agent choose:

1. **The summary.** Always in context, from `execute_route`. Free.
2. **A targeted slice.** `query_dataset` runs read-only SQL against a dataset output; `get_action_logs` filters progress logs by level. The agent pays only for the rows it asked for.
3. **The whole artifact.** `download_asset` writes the file to local disk and returns the path — not the contents. The agent then greps, parses, or scripts against it with its own tools, and none of it enters context unless the agent quotes it.

That third point is the one people miss. Downloading a 40 MB file costs an agent almost nothing, because the bytes land on disk. Returning 40 KB of that file inline costs it 10,000 tokens. Push size into assets and the ceiling effectively disappears.

Which artifact type you choose decides how cheaply the agent can drill down:

- **Dataset** for anything row-shaped — candidate lists, per-item scores, sweep results. An agent can run `SELECT formula, score FROM {{table}} ORDER BY score DESC LIMIT 10` and read ten rows instead of ten thousand. This is the best drill-down surface Ouro has, so prefer it whenever your output is tabular.
- **File** for logs, trajectories, archives, plots, and raw domain formats. Downloaded whole, inspected locally.
- **Post** for narrative meant for a person. Agents can read it, but prose is an expensive way to convey numbers you could have returned as fields.

Action logs are a fourth surface, and they're for progress and diagnosis rather than results. Log milestones during the run — see [long-running APIs](/guides/long-running-apis) for the pattern — and the agent can pull them with `get_action_logs` when something looks wrong. Don't use them to smuggle output; a result that a caller needs belongs in the response or in an asset.

## Errors are outputs too

An agent that gets a clear error fixes its call and moves on. An agent that gets a 200-line traceback burns context, guesses, and often retries the exact same request.

Put the traceback where tracebacks belong — the action log, or a file output — and return something the agent can act on:

```python showLineNumbers
raise HTTPException(
    status_code=400,
    detail={
        "error": "unsupported_file_extension",
        "message": "Input 'structure' must be a .cif file; received .pdb.",
        "field": "structure",
        "accepted": ["cif", "xyz"],
    },
)
```

Three things make this work: it names the field at fault, it says what would be accepted instead, and it fails before the expensive part of the run. Validate inputs up front and reject fast — a 400 in two seconds is far more useful to an agent than a failure after forty minutes of compute.

## Make the route legible before it's called

Everything above is about the response. The request side matters too, because agents pick routes by reading descriptions, and they call routes by filling in schemas.

- **Write descriptions that say when to use the route**, not just what it does. "Relaxes a crystal structure to its local energy minimum with a machine-learned potential; use before computing formation energies" tells an agent something that "Relax endpoint" does not.
- **Name outputs as stable workflow handles.** `relaxed_structure` and `trajectory` survive being passed between routes; `result_1` and `output_file` do not. Agents chain on names — see [chaining routes into a pipeline](/guides/chaining-routes-into-a-pipeline).
- **Declare input assets with concrete extensions.** This is what lets Ouro tell an agent which routes are compatible with an asset it's already holding, instead of making it guess.
- **Give every optional parameter a default.** An agent should be able to call your route with the one input that matters and get sensible behavior. Every required knob is a chance to get it wrong.
- **Support a dry run.** `execute_route(..., dry_run=True)` validates parameters without executing, which lets an agent check its call before spending money on a paid route.

## Checklist

Before you publish a route, run one real workload through it and ask:

- Is the response body under a few kilobytes?
- Does every field in it stay the same size when the input gets ten times bigger?
- Is every large artifact an output asset with a name the next route could use?
- Does the summary say how big those artifacts are?
- Is tabular output a dataset rather than a CSV file?
- Do failures return a field name and an accepted value, not a stack trace?
- Could an agent that has never seen your service pick this route and call it correctly from the description alone?

The routes that agents use well aren't the ones that return the most. They're the ones that return the least while leaving everything else one call away.

## Next steps

- [Route input and output assets](/guides/route-input-output-assets) — declaring and producing assets
- [Chaining routes into a pipeline](/guides/chaining-routes-into-a-pipeline) — passing named outputs between routes
- [Aggregate computed results](/guides/aggregate-computed-results) — collecting many runs into one dataset
- [Long-running APIs](/guides/long-running-apis) — webhooks and progress logging
