Skip to main content
research() behaves differently enough to be worth a section. A search answers in a second. A research run reasons over several turns (searching, reading, cross-checking) and answers in minutes.
The line between this and search is not “does it reason”. It is whether the work fits inside one request/response. A written answer that comes back in one call is answer(). A run that takes minutes, can be polled, can be cancelled and bills by the unit of effort is a job, and it needs a different shape.

Asking for structure instead of prose

Pass a schema and the answer comes back as an object. Either JSON Schema or the pydantic model to take one from:
Add records on top of a schema and the run becomes an enrichment: the same schema, filled in once per row you already hold: structured comes back as one row per record. Nimble, the backend that maps it, refuses one at effort="low"; its default effort runs one, as the example below does (checked live).

Effort

effort is research’s equivalent of search_depth: four levels mapped onto each backend’s own vocabulary, which disagree more here than anywhere else in the package: A backend with fewer tiers maps more than one level onto the same setting rather than inventing a distinction it does not have. Nimble’s own max is not open yet, so max sends its deepest tier, x-high. Parallel’s base has no normalized level of its own and is reachable through provider_options, as is You.com’s frontier. Parallel is the one backend that requires an effort, so leaving it unset sends medium rather than omitting the field, the same deliberate exception to “unset means unset” that extract makes for format, and for the same reason: the alternative is a value that depends on a vendor default nobody declared.

Not waiting for it

research() blocks, which is wrong anywhere a minutes-long wait is: a request handler, a UI, an MCP server. submit_research() hands back the job instead:
Running out of time raises rather than returning an empty answer, and leaves the run going rather than cancelling it; the exception carries the job id, so picking the same run back up costs nothing where starting it again would cost the whole run. job.cancel() stops one where the backend supports it, and returns False where it does not rather than pretending. The async router is the same call with await, which is what makes several research runs at once reasonable there and not on the sync one.

What comes back

Citation.field says which part of the answer a source backs (a JSON path into structured, or a marker in answer) for the backends that attribute per field. None means it cites the answer as a whole. outcome is "partial" only when the backend itself reported stopping early: an exhausted budget, a truncated generation. A run that simply found little is "complete", because it finished the work it was asked to do. Nothing here is inferred.