review_form composes one human-review card that aggregates the structured outputs of several prior steps (for example, an invoice extraction plus a per-row GL-code lookup) into a single form with sections and tables, then pauses the task for review.
It is part of the Nanonets data-extraction tool group (data_extraction, document_processing, validation, review).
When to use it
Use
review_form instead of chaining multiple review_data_extraction calls when a reviewer needs to see data from several blocks together — for example, invoice header fields plus line items with a looked-up GL code column.
Architecture
The agent does not design the form layout. It passes:- variables — which prior outputs to include (
{name, ref}pairs whererefis a${VAR_N}reference) - layout_guidelines — plain-English description of how the reviewer should see the data
review_form then:
- Resolves each variable to JSON (markdown fences from
ai_call_responseare stripped automatically) - Generates a layout spec via a focused LLM call (cold path), or reuses a frozen spec from
configured_tools.bindings - Applies the spec deterministically — citations (
word_id_groups) and__metadataon source leaves are preserved - Optionally runs a validation LLM pass when
validation_rulesis set - Creates a validation
task_awaitand transitions the task towaiting_for_input
Layout freezing
On the first successful run for a configured tool instance, the generated layout spec is saved toconfigured_tools.bindings.layout_spec. Subsequent runs reuse that spec — the layout LLM is not called again.
To force regeneration, pass regenerate_layout: true (or clear the binding).
Inputs
layout_spec is injected from configured_tools.bindings at execution time and is not LLM-visible in the input schema.
One further config-only toggle (bound in configured_tools.bindings, not LLM-visible): auto_proceed_on_pass (skip the human pause when validation rules are set and none fail).
Frozen variable names
A frozen layout references data as$<name>.<path>, but the agent re-authors the variables names every task, and they drift. The tool defends in layers:
- Published names: a configured tool with a frozen layout advertises
variablesas an object of the layout’s fixed names (each anx-var-refstring property), so the deciding LLM never invents names — it only picks which${VAR_N}holds each one. Names are deliberately not JSON-schema-required: forcing a key when the task lacks that data would make constrained decoding stuff a best-guess ref (a plausible-but-wrong form) instead of failing loudly below. - Sanitization: variable names are trimmed of edge punctuation on intake (
"sold_to_match,"→sold_to_match); spec aliases match through the same fallback. - Recovery by id: a dropdown field whose alias doesn’t resolve recovers its value from the task’s match/lookup variable carrying the same
match_config_id/csv_id(unambiguous single-row only). A present named variable is always authoritative — an empty match result renders an empty dropdown, never a row from another variable. - Naming contract (always on): with a pre-authored layout, a call missing a referenced name that has no id fallback fails with a retryable error listing the exact expected/passed/missing names, instead of rendering those fields blank — the backstop for array-shape calls mimicked from history and for providers without constrained decoding. The error deliberately does not suggest
regenerate_layout— regenerating overwrites the admin-configured layout and is an admin decision, not a naming-mistake workaround. (Aregenerate_layout: truecall on a frozen-layout tool can only reuse the already-published names — adding a new data source to a frozen form is an admin action in the tool config.)
Match dropdowns without match data
Amatch_config dropdown whose task has no match data — the matcher failed, never ran, or returned an empty matches array — renders an empty, functional dropdown (options are served live from the config’s source) so the reviewer picks manually. Such a pick fills the value but does not teach the matcher (no search term to key a learning on). This requires value_column declared on the dropdown config.
Raw passthrough
raw_passthrough: true bypasses the layout compiler entirely: resolveLayoutSpec / generateLayoutSpec are never called, so there’s no LLM cost and no risk of a hallucinated $path. Use it when the upstream extraction’s own JSON shape is already what the reviewer should see and you don’t need to combine sources, add a looked-up column, or otherwise reshape the data — e.g. reviewing a single structured_data_extraction output as-is. validation_rules, reviewers, and the stage fields all still work normally; only the layout step is skipped.
Variable resolution rules
- Pass the whole variable reference (e.g.
"${VAR_2}"), not a JSON sub-path. - Each variable must resolve to structured JSON (object or array). Raw text or unresolved references fail the step.
ai_call_responsevariables may be markdown-fenced (```json … ```); the tool strips fences on consume, matching orchestrator path resolution.
Layout spec (internal)
The layout LLM outputs JSON of the form{"sections": [ ... ]}. Sections reference source data with $<name>.<path> (e.g. $invoice.header.total). Data values are never inlined.
Section shapes
Whole source (preferred for simple cases){"label": "Line items", "source": "$invoice.line_items"} renders as a table.
Explicit fields
gl_coding outputs, publish the tool result as a ${VAR_N} and pass it into review_form as e.g. gl. Join $gl.line_items onto extraction lines (by id or positionally) and expose account codes via gl.codings.<dimension>.code. Those paths are auto-stamped with __metadata.editor = "select" and a shared select_option_groups entry from $gl.dimensions so the review UI renders a searchable COA dropdown. Reviewer edits create agent memories/rules that the next gl_coding run consumes via InjectLearnedRules.
- Omit
onfor a positional (by-index) join. - Omit
columnsto auto-derive columns from the first row (plus joined keys). - Column
pathnames a field (e.g."amount"or"gl.codings.account.code") — never"amount.value". Trailing.valueon paths is stripped so citations survive. - Keyed joins normalize
[cite:N]markers in join-key strings soai_calloutputs can match extraction leaves.
Dropdown (select) editors
When a field’s value must be one of a known candidate list — agl_coding dimension, a csv_lookup match, any prior step output carrying an enum — the reviewer should correct it via a constrained dropdown, not free text. A dropdown correction still flows through the normal edit path (/api/steps/edit-simple), so it diffs the artifact and creates learned rules exactly like a typed edit.
Explicit spec — mark the column (or a fields entry, using the object form) with editor: "select" and an options locator:
optionsstarting with$resolves once against the variables and becomes a shared option group: stored a single time in the resultmetadata.select_option_groupscatalog (capped at 500 entries,truncatedflag set when clipped) and referenced from each cell via__metadata.options_group.optionswithout$is a row-relative path (e.g."match.additional_candidates"): each row’s own candidate list is stamped inline on that cell as__metadata.select_options(capped at 50).- Option items may be strings or objects. Objects are read via conventional keys (
value/code/id/matchfor the value;label/name/match/titlefor the label;description;confidence_score/confidence). Override withoption_value/option_label/option_description— gjson paths into the option element, so nested picks like"product.Item Code"work.
csv_lookup_tool’s additional_candidates (per match) is only the LLM matcher’s top-K close matches — good for a “did you mean one of these” dropdown, but it isn’t the full CSV. When the reviewer should be able to pick any row (e.g. any approver, any GL account), reference that lookup’s all_products array — every row of the CSV.
An LLM csv_lookup variable is published as a wrapper object (exact-match lookups stay a bare matches array):
$lookup.matches and the full option list via $lookup.all_products:
all_products rows are raw {header: value} maps straight from the CSV (no value/code/match key of their own), so set option_value/option_label to the actual column headers. all_products is populated only for matching_strategy: "llm". Unlike other shared option groups (which are capped at 500 entries), the all_products catalog is not capped — every CSV row renders as a choice, so the reviewer can always pick any row. The trade-off is size: a very large CSV produces a very long dropdown, and publishing all_products into the variable also inlines it wherever that ${VAR_N} is referenced, inflating prompt/token size for downstream steps — use return_columns/pre-filtering upstream to keep large catalogs reasonable.
Auto-detection — no spec changes needed for the two known dropdown-capable sources:
gl_codingvariables ({line_items, dimensions: [{name, candidates}]}): any column/field resolving to acodings.<dimension>.codeleaf is stamped with that dimension’s candidates as a shared group (<var>:<dimension>).csv_lookupLLM-match variables (the published matches array): rows carryingadditional_candidatesget the matcher’s shortlist stamped inline on the matched (embedding) column’s cell — the column is identified deterministically bycandidate.product[col]equaling the candidate’s identity (match, falling back tomatch_identifierwhen the matcher leavesmatchempty).
editor: "select" whose path lands on a whole match row or its values map (including the case where the matched column is literally named value, which a $row.values.value ref can’t reach because trailing .value is stripped for citation safety) is narrowed to a canonical leaf holding just the matched column’s value, with the row’s candidates attached. Stamping refuses non-canonical leaves outright — an object with extra keys renders as nested rows where no dropdown can attach. Narrowing requires explicit select intent; without editor: "select", objects pass through untouched.
Leaves stamped either way carry __metadata.editor: "select"; the frontend (SelectValueCell) renders a searchable dropdown with inline candidates first (confidence dots), then the shared group, plus the current value and a clear option. Sections rendered as whole-source passthrough ({"source": "$gl"}) and raw_passthrough forms are not stamped — enum data must be laid out via table columns or fields for dropdowns to attach (the layout compiler prompt steers this).
Output
structured_datauses the same leaf shape asstructured_data_extraction:{"value": ..., "word_id_groups": [...], "__metadata": {...}}.- When
validation_rulesorstatic_checksis set, flagged cells receive__metadata.validation.{failed, reason}, plussource: "static"for a deterministic (static-check) verdict — an LLM finding is left unmarked (absentsource== LLM). The provenance lets a round-2 review recover a carried-through deterministic verdict rather than downgrade it. await_idis always present on success. The orchestrator detects it and suppressesdecide_next_action, moving the task towaiting_for_input.
structuredContent is {"status": "error", "message": "..."} and isError is true.
Applied memory rules
applied_memories surfaces the learned rules (agent_memories) that fired on the source steps this form aggregates — review_form itself has no extraction LLM call, so it never applies rules on its own account. Each entry is tagged with the source variable’s name (e.g. invoice.header.invoice_number, gl.codings.department.code) so rules from different sources are distinguishable even when merged into one list; a rule that fired across multiple rows of a table can appear once per affected row. The key is omitted entirely when no source step recorded any rules. This feeds the same “N learned rules applied” chip already shown under structured_data_extraction output — no separate UI for review_form.
Task pause and review flow
review_form always creates a validation task_await — unlike review_data_extraction, which only pauses when validation findings exist.
The orchestrator treats review_form the same as structured_data_extraction and review_data_extraction for pause detection: an await_id in structuredContent.result triggers waiting_for_input.
Do not call ask_user after review_form. The pause is embedded in the tool result.
When reviewers are assigned:
ask_user.PerformAssignmentwrites task assignments and sends notifications.- Assignment failure is logged but does not cancel the pause.
- Task assignees are returned on
GET /api/tasks/:id/feedasassigneesand shown in the approval composer.
Approving or rejecting
review_form uses a chat-composer approval surface (ChatInput in reviewApproval mode), not the inline Proceed button on the extraction card.
On approve, the frontend saves pending cell edits first (via a
reviewFormSaveRef callback registered by the active pause card), then calls the respond endpoint. Reject does not auto-save edits.
The backend records an attributed message_sent feed row on respond (decision, optional comment, reviewer name) for the timeline audit trail.
Edit persistence
Edits saved on the review card are written to thereview_form step result in the task feed (via submitEditsSimple / EditService), updating the combined structured_data artifact on that card. They do not write back to the upstream source variables (${VAR_2}, etc.) that were only read to compose the form.
review_form does not yet register its own ${VAR_N} on step completion — downstream steps cannot reference reviewer edits via a variable until variable publication is added. Design intent: a future review_form output variable would hold the combined artifact; upstream sources stay read-only inputs.
Multi-stage review
Callreview_form once per stage (one reviewer/stage per call). Each call creates an auditable feed card.
review_form feed result (or a future published output variable once wired). The UI shows a stage progress banner from metadata.stage_* in the approval composer (stage_name, stage_index / stage_total as “Stage N of M”).
Human-readable UI
In the task feed,review_form renders as structured_data_extraction content (same JsonTreeTable extraction UI) with:
- Editable cells and citation highlights (clicking a cited cell opens the PDF preview; citation visibility follows
showCitationForCellrules) - Validation flags (yellow cells) when
validation_rulesflagged fields - Full-width card layout —
StepActionMenuis positioned outside the content column so the table uses the full feed width
Pause UX (two surfaces)
When the task iswaiting_for_input / awaiting_review and a review_form step holds an await_id, TaskFeed picks the latest matching feed entry as the active pause.
1. Data card — edit bar only
The sticky bottom bar on the extraction card is for edits, not approval:
- Shown for
review_formonly when the reviewer has unsaved changes (or while saving / after a save flash) - Offers Discard Edits and Save Changes
- Does not show Proceed — approval lives in the chat composer (below)
review_data_extraction, the same bar still includes Proceed.
2. Chat composer — approve / reject
When a review_form pause is active, the normal message input is replaced by ChatInput in reviewApproval mode:
- Stage label from
metadata.stage_*(e.g. “Finance Review · Stage 2 of 3”) - Segmented Approve / Reject toggle
- Comment field (optional on approve, required on reject)
- ReviewAssigneePicker (task assignees from feed
assignees) - Send button (blue on approve, red on reject)
reviewApprovalSingleLine), offset for the PDF preview panel width. The former ReviewFormSidebarPanel in the sections sidebar was removed — approval is always in this bottom bar.
TaskFeed passes activeReviewFormEntryId and reviewFormSaveRef so the active card registers saveEditsIfNeeded before approve.
Agent instruction guidance
- Prior steps must produce structured JSON. Typical sources:
structured_data_extraction,ai_call(with JSON output), database/query tools, or anotherreview_form/review_data_extractionoutput. - Name variables logically in
variables(e.g."invoice","gl") — the layout compiler uses these names in$invoice,$glreferences. - Describe joins in
layout_guidelineswhen combining sources: which key to match on, which columns to show, and which source supplies each column. - Multi-stage: after stage N approves, stage N+1 should compose from the prior stage’s saved review data (not re-extract from scratch). Until output variables exist, the agent must reference the corrected artifact from the prior
review_formstep result. - Do not chain
ask_userfor review —review_formhandles the pause.
Example workflow
Comparison with review_data_extraction
Notes
- Layout generation uses the default LLM tier. Optional
validation_rulesuses the same validation pass asreview_data_extraction. - Off by default — enable it in the agent’s Tools panel.