Skip to main content
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:
  1. variables — which prior outputs to include ({name, ref} pairs where ref is a ${VAR_N} reference)
  2. layout_guidelines — plain-English description of how the reviewer should see the data
review_form then:
  1. Resolves each variable to JSON (markdown fences from ai_call_response are stripped automatically)
  2. Generates a layout spec via a focused LLM call (cold path), or reuses a frozen spec from configured_tools.bindings
  3. Applies the spec deterministically — citations (word_id_groups) and __metadata on source leaves are preserved
  4. Optionally runs a validation LLM pass when validation_rules is set
  5. Creates a validation task_await and transitions the task to waiting_for_input

Layout freezing

On the first successful run for a configured tool instance, the generated layout spec is saved to configured_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 variables as an object of the layout’s fixed names (each an x-var-ref string 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. (A regenerate_layout: true call 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

A match_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_response variables 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)
Renders all fields from the source. Nested arrays become tables. An array source like {"label": "Line items", "source": "$invoice.line_items"} renders as a table. Explicit fields
Table with optional join Use when adding a looked-up column from a second source:
For 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 on for a positional (by-index) join.
  • Omit columns to auto-derive columns from the first row (plus joined keys).
  • Column path names a field (e.g. "amount" or "gl.codings.account.code") — never "amount.value". Trailing .value on paths is stripped so citations survive.
  • Keyed joins normalize [cite:N] markers in join-key strings so ai_call outputs can match extraction leaves.
When a field’s value must be one of a known candidate list — a gl_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:
  • options starting with $ resolves once against the variables and becomes a shared option group: stored a single time in the result metadata.select_option_groups catalog (capped at 500 entries, truncated flag set when clipped) and referenced from each cell via __metadata.options_group.
  • options without $ 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/match for the value; label/name/match/title for the label; description; confidence_score/confidence). Override with option_value / option_label / option_description — gjson paths into the option element, so nested picks like "product.Item Code" work.
Every row in the CSV, not just the matcher’s shortlist: 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):
so reference the rows via $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_coding variables ({line_items, dimensions: [{name, candidates}]}): any column/field resolving to a codings.<dimension>.code leaf is stamped with that dimension’s candidates as a shared group (<var>:<dimension>).
  • csv_lookup LLM-match variables (the published matches array): rows carrying additional_candidates get the matcher’s shortlist stamped inline on the matched (embedding) column’s cell — the column is identified deterministically by candidate.product[col] equaling the candidate’s identity (match, falling back to match_identifier when the matcher leaves match empty).
Match-row narrowing: a field/column marked 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_data uses the same leaf shape as structured_data_extraction: {"value": ..., "word_id_groups": [...], "__metadata": {...}}.
  • When validation_rules or static_checks is set, flagged cells receive __metadata.validation.{failed, reason}, plus source: "static" for a deterministic (static-check) verdict — an LLM finding is left unmarked (absent source == LLM). The provenance lets a round-2 review recover a carried-through deterministic verdict rather than downgrade it.
  • await_id is always present on success. The orchestrator detects it and suppresses decide_next_action, moving the task to waiting_for_input.
On error, 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:
  1. ask_user.PerformAssignment writes task assignments and sends notifications.
  2. Assignment failure is logged but does not cancel the pause.
  3. Task assignees are returned on GET /api/tasks/:id/feed as assignees and 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 the review_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

Call review_form once per stage (one reviewer/stage per call). Each call creates an auditable feed card.
Subsequent stages that need reviewer-corrected data must read from the prior stage’s saved 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 showCitationForCell rules)
  • Validation flags (yellow cells) when validation_rules flagged fields
  • Full-width card layout — StepActionMenu is positioned outside the content column so the table uses the full feed width

Pause UX (two surfaces)

When the task is waiting_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_form only 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)
For 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)
Fullscreen extraction overlay: the composer is hidden; a single-line approval bar is portaled to the bottom of the viewport (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

  1. Prior steps must produce structured JSON. Typical sources: structured_data_extraction, ai_call (with JSON output), database/query tools, or another review_form / review_data_extraction output.
  2. Name variables logically in variables (e.g. "invoice", "gl") — the layout compiler uses these names in $invoice, $gl references.
  3. Describe joins in layout_guidelines when combining sources: which key to match on, which columns to show, and which source supplies each column.
  4. 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_form step result.
  5. Do not chain ask_user for review — review_form handles the pause.

Example workflow

Comparison with review_data_extraction

Notes

  • Layout generation uses the default LLM tier. Optional validation_rules uses the same validation pass as review_data_extraction.
  • Off by default — enable it in the agent’s Tools panel.