Structured output
How outputSchema becomes a live, validated answer store with per-field coverage tracking, a completeness gate on done, and refusal of values without evidence.
Pass an outputSchema and OpenBrowse does more than validate the final result. The schema is compiled into a validation model before the run, an empty answer store in that shape is created, and the agent fills it in place with writes that are each checked live. A value that does not fit is rejected at the moment of writing, with a message the agent can act on, rather than surfacing as a malformed blob at the end.
This page covers what the schema may contain, how the store behaves, and why the result you get back does not contain invented data.
What the schema may contain
outputSchema is a JSON Schema (2020-12) object. The supported subset is what Zod and Pydantic emit in practice:
object,arrayand the primitivesstring,integer,number,boolean- Optional fields as
anyOf/oneOfwith a{"type": "null"}branch, or as a type array["string", "null"] - String
enums, enforced as literal types - Nested objects and arrays,
$refand$defs,additionalProperties(falseforbids unknown keys, anything else allows them), anddescriptionon any field defaulton a top-level field, and a top-levelif/then/else. These two say something about a run rather than a shape, and both are covered under telling the schema what you already know
A genuine multi-branch union (more than one non-null type for a field) cannot be represented. In that case the run falls back to prose mode: the schema is appended to the task text, the agent works without the store, and the final answer is validated once at the end, with a single reformat pass if it does not parse.
Field names carry constraints
String fields gain automatic shape guards from their format, or failing that from their name:
| Field | Guard |
|---|---|
format: "uri", "url", or a name ending in Url, Uri, Href, Link | Must be an absolute http(s) URL. Relative links are rejected with an instruction to resolve them against the page URL |
format: "email" or a name ending in Email | Must be an email address |
format: "uuid" or a name ending in Uuid | Must parse as a UUID |
A name ending in Id | Must be a single token, no whitespace, at most 128 characters |
Values are kept byte-identical rather than normalised, because scraped data should round-trip exactly. Declaring any other explicit format opts a field out of the name guards.
Schema design tips
- Write
descriptionfields; they are instructions attached to exactly the field they govern, such as"seniority as stated on the page, null if not stated". - Prefer
enumfor categorical fields. Enum writes are evidence-checked (below), which is a stronger guarantee than free text. - Make fields the site might not publish nullable. A required non-nullable field the site never shows can only end in a failed run.
- If you want unmapped observables kept rather than dropped, give items a field shaped as a list of
{key, value}objects (or a plain object field); the draft mapper routes leftovers there.
Telling the schema what you already know
Two standard JSON Schema 2020-12 keywords are read for what they say about a run. Both are ignored by hosted Browser Use, so a schema carrying them works against either backend; it simply costs less here.
A field that only applies on one branch. A reason that exists only when a page could not be retrieved is not missing work, but without saying so the completeness gate treats it as an unfilled field and the agent spends steps settling something the schema already considers inapplicable.
"if": { "properties": { "found": { "const": false } } },
"then": { "required": ["reason"] },
"else": { "not": { "required": ["reason"] } }The condition is evaluated against the answer as it stands, so a field stops being excused the moment the run moves to the branch that needs it. Excused fields appear in the coverage summary as not applicable. Only the {"properties": {"field": {"const": value}}} form is read; anything richer is ignored rather than rejected, so an unusual schema gets the ordinary behaviour instead of a failed run.
A value you already hold. If you have already fetched the page for your own purposes, there is no reason to pay an agent to read its <title> back to you. A default on a top-level field pre-fills the store before the run starts:
"title": { "anyOf": [{ "type": "string" }, { "type": "null" }],
"default": "Arize AI | Agent Observability" }Seeded values go through the same validated writer an agent write uses, so a seed cannot put the store into a state the agent could not have reached; anything that fails validation is skipped and left for the agent rather than failing the run. The feed records what was prefilled and what was rejected. Seeds are hints, not locks: the agent can overwrite one, which matters when the value came from a pre-JavaScript fetch of a client-rendered page.
Because a seeded field holds a value, the completeness gate already treats it as filled. Nothing else in the schema changes.
How the store behaves during a run
The agent writes through a fixed set of actions, and every one validates before it lands:
| Action | What it does |
|---|---|
add_item | Appends one item to the answer array |
update_item / update_items | Merges fields into one item, or many in one call, reporting per-entry failures without aborting the rest |
set_field | Sets a top-level, non-list field |
remove_items | Deletes items by index, for duplicates or rows that should never have been records |
mark_absent | Settles a field the source genuinely does not publish, with a reason. Accepted once you have read the page you are claiming about; reading your own output back does not count, since it shows what you recorded rather than what the page publishes |
read_output / search_output | Reads back or searches what has been built, windowed and elided so a large store never floods the context |
add_items_from_file / update_items_from_file | Bulk-loads items or merges from a saved JSON file in one step |
The same operations are exposed inside the code sandbox, with identical validation, so a script cannot bypass the store's rules. After every successful write the store is mirrored to output.json, and the write's response includes a one-line coverage summary: item count, then item fields grouped as filled on all, partial, empty on all, and marked absent. That summary is the agent's verification; it never needs to dump the whole store to know what is missing.
The guards that stop plausible nonsense
- Evidence-checked enums. Text from every page read this session accumulates into an evidence corpus. Writing an enum value that no read page states is rejected:
rejected: no read page states it. Enum values must be observed on a page, never inferred or defaulted. This is aimed directly at the classic failure of fillingseniority: "Senior"because it is a plausible default. - The stub throttle. An item whose detail URL has not been visited and which carries no substantial content is a bare list-row stub. The store holds at most two of those; a third
add_itemis refused with an instruction to read the items' own pages first. This stops a listing page being batch-loaded as the finished answer. - Earned absence.
mark_absentis refused while item pages remain unread, because absence must be observed, not assumed. It is also refused for a field that has a value on some items once every page has been read: a partial field is already complete, and the honest state of the remaining rows is null. - URL, email, UUID and id guards apply at the store boundary too, so a malformed link never becomes part of the answer.
The bulk-read path
For list-shaped extractions, the intended golden path takes four steps regardless of how many records there are:
find_links(...)collects the listing's links, bare or narrowed with a selector, scrolling the page (and any embedded panel) until the count is stable. It is the only tool that reads links inside a cross-origin embed.read_pages()opens every found link in parallel tab waves (up to 48 URLs per call, visible in the live view), waits for each page's real content to render, and saves{url, title, text, jsonld, links}per page topages.json. It also prefillsrows_draft.json: one schema row per page, deterministically mapped, the page URL into the item's own-URL field, JSON-LD scalars token-matched onto schema fields (datePublishedontopublishedAt), labelled specs harvested from the visible text, the description HTML-stripped, and every value validated against its field before inclusion. What the page does not state stays empty.add_items_from_file('rows_draft.json')loads all rows in one validated step.- One
update_itemscall fixes the judgement fields,mark_absentsettles what no page publishes, anddonefinishes.
read_pages defends the integrity of step 2 aggressively. If several pages come back with near-identical text while cross-origin embeds exist, they were shell reads (the embedding page instead of each page's real content); they are flagged as failures and automatically retried inside the panel. If sibling pages rendered their embedded panel and one did not, that lone fallback is flagged too. A page that fails because the embed never attached is recorded separately from a genuinely dead URL, and never unlocks mark_absent.
The completeness gate
The first time the agent calls done while schema fields are still empty, the call is bounced with the exact list of deficiencies: unset top-level fields, item fields empty on N of M items, an empty answer array, or a link deficit (find_links captured more usable links than the store holds items, with the unmatched URLs listed). The bounce includes shortcuts when they exist, such as a captured raw key that appears to fill an empty schema field, or a reminder that published dates usually live in each page's JSON-LD rather than its visible text.
The gate is one-shot: the second done is always accepted, so it can never loop. Fields settled with mark_absent do not count as deficiencies, fields the schema's own conditional excuses are never asked for at all, and a field that is partial once every item page has been read is treated as finished. When the gate passes, the feed shows a schema event whose action is completeness with the final coverage summary.
Two consequences worth knowing:
- The judge reviews the store's actual content (with long values elided for display, never truncated mid-record), so a complete store cannot be failed for looking abbreviated.
- A run that ends before
donebut leaves a schema-valid store is still recorded as delivered, with a feed entry saying so. Null or partial values may be valid; usefulness remains the caller's judgement.
Getting the result
The final output on the session is the store's content, validated once more against the schema. isTaskSuccessful says whether the run delivered the requested shape, not whether every value is useful: a completed run with schema-valid null or partial values is true, while an early stop, exhausted step budget or schema-invalid result is false. When the agent doubts a delivered result, its reason appears alongside the successful completion in lastStepSummary instead of overriding the delivery flag.
curl http://<your-host>:8420/v3/sessions/<session-id> \
-H "Authorization: Bearer $OPENBROWSE_API_KEY" | jq .outputThe dashboard's session page can export the same thing (output scope), or the full step log with it; see the live view.
Fields the site never published come back as null, with the absence recorded deliberately during the run. If you expected values there, check the feed for the mark_absent reasons before assuming the run failed; the reason names where the agent looked.
Writing tasks
How the agent reads your prompt, and the prompt shapes that measurably change what comes back: completion targets, record definitions, and scope.
How OpenBrowse works
What actually happens between POST /v3/sessions and a finished result: the display slots, the step loop, the goal pre-flight, the answer store, and the reviewer.