Developers New to Kontour? Start with Flow Agents.

v2.2.3npm install @kontourai/survey @kontourai/surface

Survey

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.

node inspect-renewal.mjs

# 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?

A renewal date says March 31, 2027. Click it.

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.

What Survey doesn't do

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 →

review console

$ npx -p @kontourai/survey survey-review-console --session your-session.json

Survey review console. The Hours row proposes changing Weekdays 9am-5pm to Weekdays 8am-6pm, with confidence 0.82 and the excerpt it came from: "Extended schedule for the fall term — program hours are now Weekdays 8am-6pm." linked to its source. Below it, Phone Number proposes +1-555-0199, quoting "Our enrollment line has moved. Call +1-555-0199 to reach the office." Each row offers Keep current, Use proposed, and Could not confirm.
Read the excerpt under each proposed value and check it yourself: the hours change quotes the sentence announcing it, the phone change quotes the line that gives the new number. That is the whole point — a value you cannot trace back to a sentence is a value you are taking on faith. Captured against 2.2.2 on 2026-07-25.

The producer pipeline

One path from observed source to a claim someone can re-inspect.

01Raw Source

The observed material — a page, a document, an API record, a manual entry — with Source Reference, time, and checksum.

02Extraction

The value pulled from that Raw Source, with a locator, an excerpt, the extractor, and a confidence.

03Candidate

Competing values for the same target, kept side by side instead of silently dropped.

04Review

The outcome a human or policy assigns — verified, assumed, rejected, or proposed.

05Claim

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.

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

Fits the review you already run

  • Use your domain labels and summaries
  • Apply your ranking and materiality rules
  • Embed the workbench in your product
  • Persist decisions on your server
  • Replay every decision against the reviewed snapshot

Reviewed data stays useful

  • Publish source-linked claims
  • Show evidence and support strength
  • Catch a decision whose source moved before it is applied
  • Build calibration from labeled outcomes
  • Carry the review trail into reports and downstream products

Review anywhere

The queue meets the reviewer where they already are.

Review MCP

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.

Review Console

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.

Reviewed learning updates

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.

Calibrated confidence

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

"I couldn't confirm this" is a real outcome.

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.

survey-review-mcp

$ 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

Survey derives the review result; your product applies the change.

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 →
server-apply.ts
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 public-record status changes from rumor to reviewed claim.

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. →

field-observation.ts
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

Survey produces.
Surface makes it inspectable.

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.

Explore Surface →
survey → surface

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