Everything needed to decide
- —The value being proposed
- —The exact source and locator
- —Competing candidates side by side
- —Reviewer notes, actor, and time
- —Accept, keep current, reject, or could not confirm
Developers New to Kontour? Start with Flow Agents.
review facts without losing where they came from
Every "verified" value in your product had a story: which document, which sentence, what else it might have been, who signed off. Most systems throw that away the second someone clicks approve. Survey turns approval from a disappearing click into a record that stays attached to the value — so anyone can re-check it later without taking your word for it.
# the document the reviewer signed off on
source msa-renewal-refresh [aligned]
Prepared artifact identity verified. Exact source spans are available.
candidate renewalDate chars:262-347 page 4 [aligned]
"This agreement renews annually on March 31 unless either
party gives 60 days' notice."
candidate renewalDate chars:71-96 page 1 [aligned]
"Effective March 31, 2026."
# the same extraction, after the document was re-fetched
source msa-renewal-refresh [digest-mismatch]
Prepared artifact digest does not match the extraction artifact.
Candidates are not grounded.
candidate renewalDate chars:262-347 page 4 [digest-mismatch]
(no highlight — source not grounded)
candidate renewalDate chars:71-96 page 1 [digest-mismatch]
(no highlight — source not grounded)
Real output from @kontourai/survey 2.2.1 against an example contract. The row labels belong to the demo script; every span, page number, alignment state, and message comes from the library. Long excerpts wrapped to fit the column.
Where did this value come from?
Survey highlights the exact sentence in the document —"This agreement renews annually on March 31 unless either party gives 60 days' notice." — page 4, characters 262 to 347. Next to it: the extractor that read it, the 0.82 confidence it stated, the other date it also found (March 31, 2026, off the cover page, confidence 0.41), and the reviewer who accepted it, with the time they did it.
The highlight is not a search. Before a single character lights up, Survey checks that the document in front of you is exactly the one the extractor read: same digest, same length, and the recorded excerpt still sitting at the recorded span. If the document has since been re-fetched and any of that has moved, the highlight does not appear at all. The value shows as not grounded rather than pointing you at a sentence that may no longer be there — the second half of the run above.
That is the whole product in one interaction. Everything else on this page is the plumbing that makes it hold: a record shape where the source, the excerpt, the alternatives, the decision, and the actor travel together, and a set of checks that refuse to show evidence they can no longer stand behind.
It doesn't fetch pages, parse PDFs, rank candidates, or decide your review policy — and it never says a value is true. You bring the crawler, the parser, the ranking, and the rules about what matters. Survey is what keeps the trail intact once you have them, so the answer that comes out the far end can still show its sources.
Want the finished application rather than the contracts? Fieldwork is the ready-made front door: extract, inspect, decide, recheck, and export, running locally with no integration work. Survey is what you reach for when you already have a product and a queue and need the evidence to survive into what you publish.
See it
The review console runs locally against a session file — one row per proposed change, each with the value it would replace, the excerpt behind it, and a link to the source. Try the live demo →
$ npx -p @kontourai/survey survey-review-console --session your-session.json

The producer pipeline
The observed material — a page, a document, an API record, a manual entry — with Source Reference, time, and checksum.
The value pulled from that Raw Source, with a locator, an excerpt, the extractor, and a confidence.
Competing values for the same target, kept side by side instead of silently dropped.
The outcome a human or policy assigns — verified, assumed, rejected, or proposed.
A Surface-ready claim with its full provenance trail attached, ready to be reported on.
What teams gain
Reviewers see everything the decision depends on. The decision stays tied to the exact snapshot they saw. And the result keeps its evidence when it's published.
Review anywhere
npx survey-review-mcp drives the queue from any MCP host. The review card carries four outcomes, not two: accept the proposal, keep what's there, reject it — or say you couldn't confirm it. Every one of them goes through the same server check as a decision made in a browser.
A local dashboard over the same session file, for when you want a browser and not a chat window. An SSE stream keeps it and any concurrent MCP agent converged on one event queue — no refresh, no divergence. For a full application rather than a queue viewer, that's Fieldwork.
When reviewers keep overturning the same kind of value, that pattern becomes a written proposal to change how it's extracted — and the proposal gets reviewed too. Producer profiles can auto-accept what has earned it, and provenance records how each value was resolved. Nothing durable changes because a metric moved.
Every reviewed candidate is a labeled sample: the extractor said 0.82, the reviewer said yes or no. Survey keeps the score and derives its calibration curve from the review chain it owns, so over time you learn what your 0.82 is actually worth. It can raise the bar for what gets auto-accepted — as a proposal someone approves, not a threshold that quietly moves.
The fourth answer
Review tools usually give a person two doors and a fire escape: approve, reject, escalate. Real reviewers hit a fourth case constantly — the sources disagree, or the page moved, or the evidence just isn't there — and every one of those decisions gets squeezed into an approval or a rejection it doesn't deserve.
Survey gives it its own outcome. Could not confirm ends the review round without changing the value, rejecting it, or passing it to someone else, and it will not record without a written reason. The claim stays exactly where it was, now with a note saying why nobody could move it.
$ npx -p @kontourai/survey survey-review-mcp
(tool call) survey_review_decide { itemName: "public-directory-phone",
decision: "could-not-confirm" }
survey_review_decide requires a non-empty reason for could-not-confirm
(tool call) survey_review_decide { … decision: "could-not-confirm",
reason: "Directory page and the county
record disagree; neither is newer." }
Decision recorded: Could not confirm
Effect: The review round ends without changing or escalating
the proposed claim.
Item: public-directory-phone
Target: phoneNumber
Status: could-not-confirm
Note: Directory page and the county record disagree; neither is newer.
… candidate values and excerpts elided
--- Updated queue ---
Review queue: 3/6 resolved
Active item: public-directory-hours (hours)
Next unresolved: public-directory-address
Session summary: accepted=1 keptCurrent=0 rejected=0
couldNotConfirm=1 escalated=1 unresolved=3
Your server stays in charge of the write
The reviewer's browser never decides anything. It records what the reviewer did; your server replays those events against the snapshot the reviewer actually saw and derives the result from it. If the underlying data moved in between, the apply fails instead of writing a decision made against a stale screen.
Your product keeps the parts that are yours. You supply the labels reviewers read, the links they follow, the ranking and materiality rules, and the storage — Survey supplies the record shape and the checks. Nothing gets published as reviewed until that record can answer: which source, which sentence, what else was on the table, who decided, and when.
Read the integration guide →import { deriveServerReviewSessionApplyResult } from "@kontourai/survey/review-workbench/server-review-session"; const current = await loadProductRecord(recordId); const session = await loadServerReviewSession(reviewId); const events = await loadPersistedReviewEvents(reviewId); const apply = deriveServerReviewSessionApplyResult({ record: session, currentSnapshot: await rebuildCurrentReviewSnapshot(recordId), events, requiredResolvedItems: "all", }); if (!apply.ok) throw new Error(apply.issues[0]?.message); for (const result of apply.results) { assertTargetStillMatches(current, result); await applyProductPolicy({ selectedCandidateId: result.selectedCandidateId, selectedValue: result.selectedValue, actor: auth.user.id, }); }
Example use case
A crawler reads a registration status off a public record and needs to preserve the extraction before it becomes a claim. With fieldObservation, the source, the sentence it was read from, the extraction, the review outcome, and the claim travel together — so Surface can report not just the value, but where it came from and how fresh it is.
Conflicting candidates don't disappear. The value that lost keeps its own source and excerpt, and its claim stays on the trail marked superseded. A conflict nobody resolved projects to Surface as disputed. For a worked example of this architecture end to end, read the reference story: The AI read your tax form. Nothing checked it. →
import { fieldObservation, SurveyInputBuilder } from "@kontourai/survey"; const input = new SurveyInputBuilder({ source: "crawler:run-1" }) .addObservation(fieldObservation({ id: "entity-123.status.current", field: "registrationStatus", value: "ACTIVE", rawSource: { kind: "web-page", sourceRef: "https://records.test/123", observedAt: now(), locatorScheme: "html", }, extraction: { confidence: 0.97, locator: "css:#registration-status", // the sentence a reviewer can click back to excerpt: "Registration status: Active as of 12 Mar 2026.", extractor: "example-crawler", extractedAt: now(), }, reviewOutcome: { status: "verified" }, claim: { subjectType: "public-record.entity", subjectId: "entity-123", facet: "public-record.profile", claimType: "public-data.field", fieldOrBehavior: "registrationStatus", impactLevel: "medium", collectedBy: "example-crawler", }, })) .build();
Built for Surface
buildSurveyTrustBundle projects Survey records into a Surface TrustBundle. From there, Surface reports claim status, evidence, dependencies, freshness, and the trust state humans and agents inspect.
Survey records
Raw Source → Source Reference → extraction → candidate → review → claim
buildSurveyTrustBundle()
───────────────────────────────
Surface TrustBundle
verified registrationStatus = ACTIVE
evidence web-page · css:#registration-status
freshness owned by Surface policy