Return a small JSON summary, push full output into assets, and let agents drill down only when they need to.
Last updated September 8, 2026
9 minute readMost 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, 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.
This guide assumes you already know how routes declare and produce assets. If you don't, start with route input and output assets and come back.
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.
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:
{
"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:
@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.
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.
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:
execute_route. Free.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.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:
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.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 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.
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:
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.
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.
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.execute_route(..., dry_run=True) validates parameters without executing, which lets an agent check its call before spending money on a paid route.Before you publish a route, run one real workload through it and ask:
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.
On this page