Skip to content

Assessment integration

This page is deliberately self-contained. It repeats things that also appear in the Assessment guide so that it can be handed to someone — or to a coding assistant — as the only thing they need to read to put a hearing on a website or in a bot.

A plain-text version for language models is at /llms-assessment.txt.

The shape of the integration, in three sentences

Section titled “The shape of the integration, in three sentences”

Your server holds an intake API key and calls the API. Minion decides which question to ask next and works out the result; your site shows the question, turns the visitor’s reply into a value and sends it back. When nothing is left to ask, your site closes the hearing and a person in the workspace reviews it.

Minion never receives free text and never runs a language model on your behalf, so the same API serves a plain form, a chat bot, or a person typing. What your site does not decide is the order of the questions: asking them in the order they come back is what stops questions from being missed.

ValueLooks likeWhere
API base URLhttps://minionworkspace.com/api/public/assessmentThe origin you open Minion on, plus /api/public/assessment
Intake API keyasm_…The catalog’s Integration view → Intake API keys. Shown once, when it is issued
Catalog identifier (optional)web-productionThe same Integration view, above the keys. Also under each name in the catalog list

Keep them in server-side environment variables, for example ASSESSMENT_API_BASE and ASSESSMENT_API_KEY. Never give them a NEXT_PUBLIC_, PUBLIC_ or VITE_ prefix — those are bundled into the JavaScript that ships to browsers.

The catalog must be published, and Assessment must be enabled for the workspace.

The key decides the catalog. No request names a catalog. A key pasted from another integration does not fail — it silently asks another catalog’s questions. Call GET /catalog once at startup and check that catalog.key is the one you expect.

The catalog identifier is what that check compares against, which is the only reason to configure it. Treat it as optional. No request carries it, so a missing identifier should skip the check rather than stop your app from booting — a correct key works without it, and making it required only adds a way to be down. An identifier is fixed when the catalog is created and never changes, so it is safe to pin in an environment variable.

browser ──(your own routes + a cookie)──▶ your server ──(Authorization: Bearer asm_…)──▶ Minion
  • The API sends no CORS headers. A browser cannot call it, and that is on purpose: the key would have to be in the page. Static hosting alone is not enough — you need something that runs on a server: SSR, route handlers, serverless or edge functions.
  • The key goes in the Authorization: Bearer header only. A key in the query string is ignored and the request fails with api_key_required.
  • Your routes act on the visitor’s own hearing only. Keep the session ID in an httpOnly cookie and expose specific actions — start, answer, don’t know, send. Do not forward paths or session IDs that come from the browser: anyone holding an ID could then read or delete someone else’s hearing.
1. POST /sessions → session.id, questions, result
2. show questions[0]
3. POST /sessions/{id}/answers → the next questions, the new result
(an answer, or "don't know")
4. repeat 2–3 until questions is empty
5. POST /sessions/{id}/close
  • An empty questions array means nothing is left to ask. There is no done flag, and the number of questions changes with the answers: an answer can make other questions irrelevant, and those drop out.
  • Ask in the order returned. Do not reorder, skip, or add questions.
  • The list is recomputed after every answer. If you show several questions at once, the later ones may stop applying once the first is answered — so show one, or a few, and send them before asking more.
  • You can close at any point. Whatever is still open is listed for the reviewer.

limit sets how many upcoming questions come back: the default is 3, and 0 means all of them. Use limit: 1 for one question per screen or per chat message.

All paths are under the base URL, and every request needs the header.

MethodPathReturns
POST/sessions201 { session, catalog, questions, result, progress }
GET/sessions/{id}?limit={ session, catalog, questions, result, progress } — everything, for resuming
POST/sessions/{id}/answers{ questions, topic_changed?, result, progress }
GET/sessions/{id}/answers{ answers, unknown }
DELETE/sessions/{id}/answers/{factId}{ questions, result, progress }
GET/sessions/{id}/questions?limit={ questions, progress }
GET/sessions/{id}/facts{ facts } — every fact, with its state in this hearing
GET/sessions/{id}/result{ result }
POST/sessions/{id}/close{ session }
DELETE/sessions/{id}{ ok: true }
GET/catalog{ catalog: { key, name, description, fact_count, measure_count } }
GET/catalog/facts{ facts } — every question, in catalog order
GET/catalog/measures{ measures } — every measure, in catalog order

A key only reaches the hearings it started. Any other session ID — one started by another key, or an assessment created inside Minion — returns 404, the same as an ID that does not exist.

POST /api/public/assessment/sessions
Authorization: Bearer asm_…
Content-Type: application/json
{ "title": "Acme Corp — site rebuild", "limit": 1 }

Both fields are optional.

  • title is what the reviewer sees in the list, and it cannot be changed afterwards. Without it the title is the catalog name plus the date and time, which is hard to tell apart. It is also the only free text a hearing stores.
  • Start the hearing when the visitor starts, not when the page loads. Every hearing is a record in the workspace.
  • Starting is not idempotent. Each POST /sessions creates a new hearing, including a retry after a timeout that had in fact succeeded. Store session.id and resume with GET /sessions/{id} instead of starting again.
{
"session": {
"id": "0b8f4c7e-6d0a-4f5e-9d61-2c3a1e7b9f10",
"title": "Acme Corp — site rebuild",
"state": "in_progress",
"closed_at": null,
"created_at": "2026-09-16T04:12:30.000Z"
},
"catalog": { "key": "web-production", "name": "Web production quote" },
"questions": [
{
"fact_id": "fct_pages",
"label": "Number of pages",
"description": "Including the top page.",
"type": "number",
"unit": "pages",
"topic": "Design"
}
],
"result": {
"measures": [
{ "measure_id": "msr_initial", "label": "Initial cost", "group": null, "primary": true,
"display": "currency", "value": { "min": 1130000, "max": 3300000 }, "settled": false },
{ "measure_id": "msr_monthly_expense", "label": "Expenses", "group": "Monthly", "primary": false,
"display": "currency", "value": { "min": 0, "max": 14900 }, "settled": false },
{ "measure_id": "msr_monthly_service", "label": "Our services", "group": "Monthly", "primary": false,
"display": "currency", "value": { "min": 0, "max": 25000 }, "settled": false }
],
"verdict": null,
"pending": [
{ "fact_id": "fct_pages", "label": "Number of pages" },
{ "fact_id": "fct_design", "label": "Design approach" }
]
},
"progress": { "answered": 0, "unknown": 0 }
}
Field
fact_idSend it back as the key of the answer
labelThe question, as written in the catalog
descriptionOptional help text
typeboolean, number or select
unitnumber only, optional. Show it next to the input
optionsselect only. [{ "value": "opt_new_design", "label": "New design" }]
topicOptional. The group the question belongs to

topic_changed appears on the response to POST /answers when the request contained at least one answer. It is true when the next question belongs to a different topic from the one just answered — use it for a section heading or a “next, about…” transition, or ignore it. No other response includes it.

Questions are in the language the catalog was written in. There are no translations.

A fact that no contribution item uses appears in GET /catalog/facts but never in questions, because answering it cannot change the result. That is a gap in the catalog, not in your site: the catalog editor marks such facts as Unused.

POST /api/public/assessment/sessions/{id}/answers
Content-Type: application/json
{
"answers": { "fct_pages": 12, "fct_design": "opt_new_design" },
"unknown": ["fct_hosting"],
"limit": 1
}

Every field is optional. answers is an object keyed by fact_id; unknown is an array of fact_id.

typeSendNot
booleantrue / false"true", "yes", 1
numbera JSON number: 12"12", "10-20", null
selectone option’s value: "opt_new_design"its label, "New design"
  • Answers merge. Only the facts you send change, and answering a fact again overwrites it.
  • unknown means “asked, but they do not know or would rather not say”. It is an answer: the fact is not asked again, and it is listed in result.pending for the reviewer. Answering the fact later takes it out of unknown, and marking an answered fact unknown removes the answer. If a request puts the same fact in both, the answer wins.
  • To take an answer back entirely, so the fact is neither answered nor unknown, use DELETE /sessions/{id}/answers/{factId}.
  • All or nothing. If any entry is invalid, the request fails with 400 validation_failed and nothing is saved.
  • A malformed body is ignored, not rejected. If the body is not JSON, or answers is not an object, or unknown is not an array, that part is treated as absent: the response is 200 and nothing is recorded. Always send a JSON object with Content-Type: application/json, and check that progress moved when you expect it to.
  • Never guess. When a reply does not clearly fit the question, ask again or send it as unknown. A guessed answer gives a result with no visible gaps that is simply wrong.
  • A number is one number. “10 to 20 pages” is not an answer the API accepts: ask for a single number, or send it as unknown.
{
"questions": [
{ "fact_id": "fct_multilang", "label": "Multi-language support", "type": "boolean", "topic": "Build" }
],
"topic_changed": true,
"result": {
"measures": [
{ "measure_id": "msr_initial", "label": "Initial cost", "group": null, "primary": true,
"display": "currency", "value": { "min": 1930000, "max": 2640000 }, "settled": false }
],
"verdict": null,
"pending": [
{ "fact_id": "fct_multilang", "label": "Multi-language support" },
{ "fact_id": "fct_hosting", "label": "Hosting" }
]
},
"progress": { "answered": 2, "unknown": 1 }
}

(Shortened: a real response lists every measure and every pending fact.)

A validation failure names each problem:

{
"error": "validation_failed",
"message": "",
"errors": [
{ "fact_id": "fct_pages", "code": "type_mismatch", "message": "" },
{ "fact_id": "fct_design", "code": "unknown_option", "message": "" }
]
}
code
unknown_factNo fact with that ID in this catalog
archived_factThe fact has been deleted from the catalog
type_mismatchThe wrong JSON type for the question
unknown_optionNot one of the question’s options[].value
archived_optionThe option has been deleted from the catalog
not_finiteNaN or Infinity

Every response that carries result has one — from the very first question.

Field
measures[]One per output of the catalog, in catalog order
measures[].label, .groupMeasures that share a group belong together: “Monthly · Expenses”, “Monthly · Our services”
measures[].primaryThe main figure. Show it largest
measures[].displaycurrency: an amount of money. There is no currency code in the response; Minion shows these amounts in yen. number: a plain number, with unit when there is one
measures[].value{ min, max }. Round for display
measures[].settledtrue when min equals max
verdictnull, or { level, label }. level is pass, borderline, fail or info; label is text the catalog author wrote. It stays borderline while anything that matters is still open
pending[]Facts still open that explain the range: ones that would narrow it if answered, and ones recorded as unknown. Show them as “still to confirm”

Decide when to show figures. Early on the range can be wide: in our web production sample the initial cost starts at ¥1,130,000 – ¥3,300,000, and a larger quote catalog with 21 questions started with the upper end 15 times the lower. A wide range on screen reads as “they have no idea”. Show the figures at the end, after a few answers, or once the primary measure’s max / min is small enough — and guard against min being 0, which is common for monthly measures.

Questions are ranked by the primary measure only. A fact that moves only other measures never appears in questions, so when questions runs out, a non-primary measure can still be a range with nothing in pending to explain it. In the web production sample, the monthly maintenance contract is never asked, and “Monthly · Our services” ends at ¥0 – ¥25,000. If you show such a measure, fetch GET /sessions/{id}/facts after questions is empty and ask the facts still unanswered; otherwise show the primary measure alone.

Contribution amounts, rates, the breakdown and hours are deliberately never in the response. Do not try to work them out from the figures.

POST /api/public/assessment/sessions/{id}/close

No body. The response is { session } with state set to awaiting_review.

session.stateMeans
in_progressOpen. Answers are accepted
awaiting_reviewYour site closed it. Waiting for a person in the workspace
fixedA person checked and finalized it. The result is frozen
  • After closing, answering and taking answers back fail with 409 session_closed. Reads keep working, and so does deleting the hearing.
  • The workspace is notified when a hearing with at least one answer is closed.
  • A person can finalize at any time — even while the visitor is still answering. The next answer then fails with 409 assessment_fixed. Treat it the same as a closed hearing.
  • Your site cannot finalize. That is a person’s decision, by design.
  • A hearing that is never closed stays In progress in the workspace. Close when the visitor sends their answers; a visitor who simply leaves needs nothing.

Keep the session ID — in an httpOnly cookie, or in your own database for a bot — and call GET /sessions/{id}?limit=1 when the visitor comes back. It returns everything the start did.

If it returns 404, or session.state is no longer in_progress, forget the ID and offer to start again.

  • Every question on one page. GET /sessions/{id}/facts returns every fact in catalog order with its state: answered (with value), unknown, unanswered, or inactive — it does not affect the result given the current answers, so hide it or grey it out; anything already entered is kept. unanswered facts that would narrow the primary measure carry a rank, 1 being the most useful to ask.
  • Before a hearing exists. GET /catalog/facts and GET /catalog/measures return the questions and the measures, to lay out a page in advance. A fixed order gives up the point of the feature — asking the most useful thing first — so prefer questions when you can.

DELETE /sessions/{id} removes a hearing your key started — when a visitor asks for their data to be removed, for example. A finalized hearing cannot be deleted through the API (409 assessment_fixed); ask the workspace to delete it.

Every error is { "error": "<code>", "message": "…" }; validation_failed also carries errors[].

Branch on error, and never show message to visitors — it is not localized.

StatuserrorWhen
400validation_failedAn answer does not fit its question. Nothing was saved
401api_key_requiredNo Authorization: Bearer header
401invalid_api_keyNo such key. Deleting a catalog deletes its keys
401api_key_revokedThe key was revoked
404not_foundNo such session, or it was not started by this key
404catalog_not_foundThe catalog is no longer available, or Assessment is off for the workspace
409catalog_unpublishedThe catalog has not been published
409session_closedThe hearing was closed
409assessment_fixedA person finalized the hearing
409conflictTwo writes reached the same hearing at the same moment. Read it again and retry once
429rate_limitedToo many requests. There is no Retry-After header; back off
500internal_errorTry again later
503unsupported_engineThe catalog cannot be used right now. Ask the workspace
Applies toLimit
Every request300 per minute per key (per IP address when there is no key)
POST /sessions60 per minute per key
Writes to one hearing: answer, take back, close, delete20 per minute per hearing

The limits are approximate. Do not poll: nothing changes on Minion’s side unless your site or a person in the workspace changes it.

Shared by both examples below. Put it at lib/assessment.ts (Next.js) or src/lib/assessment.ts (Astro).

lib/assessment.ts
//
// Server-side only: it reads the intake API key. Never import it from code that runs in
// the browser. Importing its *types* from a client component is fine — types are erased.
const BASE = process.env.ASSESSMENT_API_BASE ?? 'https://minionworkspace.com/api/public/assessment'
export type AnswerValue = boolean | number | string
export interface Question {
fact_id: string
label: string
description?: string
type: 'boolean' | 'number' | 'select'
/** number only */
unit?: string
/** select only */
options?: { value: string; label: string }[]
topic?: string
}
export interface MeasureValue {
measure_id: string
label: string
group: string | null
primary: boolean
display: 'currency' | 'number'
unit?: string
value: { min: number; max: number }
settled: boolean
}
export interface Result {
measures: MeasureValue[]
verdict: { level: 'pass' | 'borderline' | 'fail' | 'info'; label: string } | null
pending: { fact_id: string; label: string }[]
}
export interface Progress {
answered: number
unknown: number
}
export interface Session {
id: string
title: string
state: 'in_progress' | 'awaiting_review' | 'fixed'
closed_at: string | null
created_at: string
}
/** POST /sessions, GET /sessions/:id */
export interface SessionView {
session: Session
catalog: { key: string; name: string }
questions: Question[]
result: Result
progress: Progress
}
/** POST /sessions/:id/answers, DELETE /sessions/:id/answers/:factId */
export interface AnswerView {
questions: Question[]
/** Present only when the request answered at least one fact. */
topic_changed?: boolean
result: Result
progress: Progress
}
export class AssessmentApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly errors: { fact_id: string; code: string; message: string }[] = [],
) {
super(message)
}
}
async function call<T>(method: 'GET' | 'POST' | 'DELETE', path: string, body?: object): Promise<T> {
const key = process.env.ASSESSMENT_API_KEY
if (!key) throw new Error('ASSESSMENT_API_KEY is not set')
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${key}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
cache: 'no-store',
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new AssessmentApiError(
res.status,
data?.error ?? 'http_error',
data?.message ?? `HTTP ${res.status}`,
data?.errors,
)
}
return data as T
}
const session = (id: string) => `/sessions/${encodeURIComponent(id)}`
export const assessment = {
/** Which catalog the key points at. Call it once at startup to catch a wrong key. */
catalog: () =>
call<{
catalog: { key: string; name: string; description: string | null; fact_count: number; measure_count: number }
}>('GET', '/catalog'),
start: (title: string, limit = 1) => call<SessionView>('POST', '/sessions', { title, limit }),
get: (id: string, limit = 1) => call<SessionView>('GET', `${session(id)}?limit=${limit}`),
answer: (id: string, factId: string, value: AnswerValue, limit = 1) =>
call<AnswerView>('POST', `${session(id)}/answers`, { answers: { [factId]: value }, limit }),
unknown: (id: string, factId: string, limit = 1) =>
call<AnswerView>('POST', `${session(id)}/answers`, { unknown: [factId], limit }),
clear: (id: string, factId: string) =>
call<AnswerView>('DELETE', `${session(id)}/answers/${encodeURIComponent(factId)}`),
close: (id: string) => call<{ session: Session }>('POST', `${session(id)}/close`),
remove: (id: string) => call<{ ok: true }>('DELETE', session(id)),
}

A route handler owns the session cookie and talks to the API; a client component renders one question at a time. @/* maps to the project root.

app/api/hearing/route.ts
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { assessment, AssessmentApiError, type AnswerValue } from '@/lib/assessment'
// The visitor's session id is kept in an httpOnly cookie. This route only ever acts on
// that one session, so it is not a general-purpose proxy to the API.
const COOKIE = 'assessment_session'
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
type Action =
| { action: 'start' }
| { action: 'answer'; fact_id: string; value: AnswerValue }
| { action: 'unknown'; fact_id: string }
| { action: 'close' }
async function currentSessionId(): Promise<string | null> {
const id = (await cookies()).get(COOKIE)?.value
return id && UUID.test(id) ? id : null
}
/** Resume after a reload. */
export async function GET() {
const id = await currentSessionId()
if (!id) return NextResponse.json({ view: null })
try {
const view = await assessment.get(id)
// Closed, or finalized by someone in Minion: start over instead of showing a dead form.
if (view.session.state !== 'in_progress') {
;(await cookies()).delete(COOKIE)
return NextResponse.json({ view: null })
}
return NextResponse.json({ view })
} catch (err) {
if (err instanceof AssessmentApiError && err.status === 404) {
;(await cookies()).delete(COOKIE)
return NextResponse.json({ view: null })
}
throw err
}
}
export async function POST(request: Request) {
const body = (await request.json()) as Action
const jar = await cookies()
try {
if (body.action === 'start') {
// Put something the reviewer will recognise in the title. It cannot be changed later.
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ')
const view = await assessment.start(`Website estimate ${stamp}`)
jar.set(COOKIE, view.session.id, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return NextResponse.json({ view })
}
const id = await currentSessionId()
if (!id) return NextResponse.json({ error: 'no_session' }, { status: 409 })
switch (body.action) {
case 'answer':
return NextResponse.json({ view: await assessment.answer(id, body.fact_id, body.value) })
case 'unknown':
return NextResponse.json({ view: await assessment.unknown(id, body.fact_id) })
case 'close':
await assessment.close(id)
jar.delete(COOKIE)
return NextResponse.json({ view: null, closed: true })
default:
return NextResponse.json({ error: 'unknown_action' }, { status: 400 })
}
} catch (err) {
if (err instanceof AssessmentApiError) {
// Hand the page the error code, nothing else. A bad key (401) or an outage (5xx)
// is our problem, not the visitor's, so it becomes a plain 502.
console.error('[assessment]', err.status, err.code, err.message)
const status = [400, 404, 409, 429].includes(err.status) ? err.status : 502
return NextResponse.json({ error: err.code, errors: err.errors }, { status })
}
throw err
}
}
app/estimate/Hearing.tsx
'use client'
import { useEffect, useState } from 'react'
import type { AnswerValue, MeasureValue, Progress, Question, Result } from '@/lib/assessment'
interface View {
questions: Question[]
result: Result
progress: Progress
}
/** These mean the session can no longer be written to. Start again. */
const GONE = ['no_session', 'not_found', 'session_closed', 'assessment_fixed']
async function send(body: object): Promise<{ view: View | null; closed?: boolean }> {
const res = await fetch('/api/hearing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json()
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`)
return json
}
const numberFormat = new Intl.NumberFormat('ja-JP')
function formatRange(m: MeasureValue): string {
// `currency` carries no currency code. Minion shows these amounts as yen.
const one = (n: number) =>
m.display === 'currency'
? `¥${numberFormat.format(Math.round(n))}`
: `${numberFormat.format(Math.round(n))}${m.unit ? ` ${m.unit}` : ''}`
return m.settled ? one(m.value.min) : `${one(m.value.min)}${one(m.value.max)}`
}
/**
* Whether the figures are worth showing yet. Early on the range is very wide, and a wide
* range on screen reads as "they have no idea". The rule here is an example — tune it.
*/
function worthShowing(view: View): boolean {
if (view.questions.length === 0) return true
const primary = view.result.measures.find((m) => m.primary) ?? view.result.measures[0]
if (!primary) return false
if (primary.settled) return true
const { min, max } = primary.value
return view.progress.answered >= 3 && min > 0 && max / min <= 2
}
export default function Hearing() {
const [view, setView] = useState<View | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('/api/hearing')
.then((res) => res.json())
.then((json) => setView(json.view))
.catch(() => setError('load_failed'))
.finally(() => setLoading(false))
}, [])
async function run(body: object) {
setBusy(true)
setError(null)
try {
const next = await send(body)
setView(next.view)
if (next.closed) setSent(true)
} catch (err) {
const code = err instanceof Error ? err.message : String(err)
if (GONE.includes(code)) setView(null)
else setError(code)
} finally {
setBusy(false)
}
}
if (loading) return <p>Loading…</p>
if (sent) return <p>Thank you. We will review your answers and get back to you.</p>
if (!view) {
return (
<button type="button" disabled={busy} onClick={() => run({ action: 'start' })}>
Start
</button>
)
}
// With limit=1 there is at most one question. An empty array means nothing is left to ask.
const question = view.questions[0]
return (
<div>
{question ? (
<QuestionForm
key={question.fact_id}
question={question}
busy={busy}
onAnswer={(value) => run({ action: 'answer', fact_id: question.fact_id, value })}
onUnknown={() => run({ action: 'unknown', fact_id: question.fact_id })}
/>
) : (
<div>
<p>That is everything we need to ask.</p>
<button type="button" disabled={busy} onClick={() => run({ action: 'close' })}>
Send
</button>
</div>
)}
{error && <p role="alert">Something went wrong ({error}). Please try again.</p>}
{worthShowing(view) && <ResultPanel result={view.result} />}
</div>
)
}
function QuestionForm({
question,
busy,
onAnswer,
onUnknown,
}: {
question: Question
busy: boolean
onAnswer: (value: AnswerValue) => void
onUnknown: () => void
}) {
const [raw, setRaw] = useState('')
return (
<div>
{question.topic && <p>{question.topic}</p>}
<fieldset disabled={busy}>
<legend>{question.label}</legend>
{question.description && <p>{question.description}</p>}
{question.type === 'boolean' && (
<>
<button type="button" onClick={() => onAnswer(true)}>Yes</button>
<button type="button" onClick={() => onAnswer(false)}>No</button>
</>
)}
{question.type === 'select' &&
question.options?.map((option) => (
// Send the option's value, never its label.
<button key={option.value} type="button" onClick={() => onAnswer(option.value)}>
{option.label}
</button>
))}
{question.type === 'number' && (
<form
onSubmit={(e) => {
e.preventDefault()
const n = Number(raw)
// Number('') is 0, so an empty field must be rejected explicitly.
if (raw.trim() !== '' && Number.isFinite(n)) onAnswer(n)
}}
>
<input type="number" step="any" value={raw} onChange={(e) => setRaw(e.target.value)} />
{question.unit && <span>{question.unit}</span>}
<button type="submit">Next</button>
</form>
)}
<button type="button" onClick={onUnknown}>Not sure</button>
</fieldset>
</div>
)
}
function ResultPanel({ result }: { result: Result }) {
return (
<section>
{result.measures.map((m) => (
<p key={m.measure_id}>
{m.group ? `${m.group} · ` : ''}
{m.label}: {m.primary ? <strong>{formatRange(m)}</strong> : formatRange(m)}
</p>
))}
{result.verdict && <p>{result.verdict.label}</p>}
{result.pending.length > 0 && (
<>
<p>Still to confirm</p>
<ul>
{result.pending.map((p) => (
<li key={p.fact_id}>{p.label}</li>
))}
</ul>
</>
)}
</section>
)
}
app/estimate/page.tsx
import Hearing from './Hearing'
export default function EstimatePage() {
return (
<main>
<h1>Get an estimate</h1>
<Hearing />
</main>
)
}

Astro (server output, no client-side JavaScript)

Section titled “Astro (server output, no client-side JavaScript)”

Plain HTML forms, one question per page load. Every answer is a POST followed by a redirect, so a reload never sends an answer twice.

astro.config.mjs
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
// List the host names the site is served on. Astro does not trust the request's host
// otherwise: Astro.url falls back to localhost, and every form POST is then rejected
// with 403 "Cross-site POST form submissions are forbidden".
security: { allowedDomains: [{ hostname: 'www.example.com', protocol: 'https' }] },
})
src/pages/estimate.astro
---
// Needs a server adapter (e.g. @astrojs/node). No client-side JavaScript: plain forms.
import {
assessment,
AssessmentApiError,
type AnswerValue,
type MeasureValue,
type SessionView,
} from '../lib/assessment'
export const prerender = false
const COOKIE = 'assessment_session'
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** These mean the session can no longer be written to. Start again. */
const GONE = ['not_found', 'session_closed', 'assessment_fixed']
const cookie = Astro.cookies.get(COOKIE)?.value
const id = cookie && UUID.test(cookie) ? cookie : null
let error: string | null = null
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData()
const action = String(form.get('action') ?? '')
try {
if (action === 'start') {
// Put something the reviewer will recognise in the title. It cannot be changed later.
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ')
const started = await assessment.start(`Website estimate ${stamp}`)
Astro.cookies.set(COOKIE, started.session.id, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return Astro.redirect('/estimate', 303)
}
if (id && action === 'answer') {
const factId = String(form.get('fact_id'))
if (form.get('unknown') === '1') {
await assessment.unknown(id, factId)
return Astro.redirect('/estimate', 303)
}
// Form values are always strings. Convert to the question's type before sending.
const type = String(form.get('type'))
const raw = String(form.get('value') ?? '')
let value: AnswerValue | null = raw
if (type === 'boolean') value = raw === 'true'
if (type === 'number') value = raw.trim() !== '' && Number.isFinite(Number(raw)) ? Number(raw) : null
if (value === null) {
error = 'Please enter a number.'
} else {
await assessment.answer(id, factId, value)
return Astro.redirect('/estimate', 303)
}
}
if (id && action === 'close') {
await assessment.close(id)
Astro.cookies.delete(COOKIE, { path: '/' })
return Astro.redirect('/estimate?sent=1', 303)
}
} catch (err) {
if (!(err instanceof AssessmentApiError)) throw err
if (!GONE.includes(err.code)) {
console.error('[assessment]', err.status, err.code, err.message)
error = 'Something went wrong. Please try again.'
}
}
}
let view: SessionView | null = null
if (id) {
try {
view = await assessment.get(id)
// Closed, or finalized by someone in Minion: start over instead of showing a dead form.
if (view.session.state !== 'in_progress') view = null
} catch (err) {
if (!(err instanceof AssessmentApiError) || err.status !== 404) throw err
}
if (!view) Astro.cookies.delete(COOKIE, { path: '/' })
}
// With limit=1 there is at most one question. An empty array means nothing is left to ask.
const question = view?.questions[0]
const sent = Astro.url.searchParams.has('sent') && !view
const numberFormat = new Intl.NumberFormat('ja-JP')
function formatRange(m: MeasureValue): string {
// `currency` carries no currency code. Minion shows these amounts as yen.
const one = (n: number) =>
m.display === 'currency'
? `¥${numberFormat.format(Math.round(n))}`
: `${numberFormat.format(Math.round(n))}${m.unit ? ` ${m.unit}` : ''}`
return m.settled ? one(m.value.min) : `${one(m.value.min)}${one(m.value.max)}`
}
/** Early on the range is very wide. The rule here is an example — tune it. */
function worthShowing(v: SessionView): boolean {
if (v.questions.length === 0) return true
const primary = v.result.measures.find((m) => m.primary) ?? v.result.measures[0]
if (!primary) return false
if (primary.settled) return true
const { min, max } = primary.value
return v.progress.answered >= 3 && min > 0 && max / min <= 2
}
---
<main>
<h1>Get an estimate</h1>
{sent && <p>Thank you. We will review your answers and get back to you.</p>}
{error && <p role="alert">{error}</p>}
{!view && !sent && (
<form method="post">
<button name="action" value="start">Start</button>
</form>
)}
{question && (
<form method="post">
<input type="hidden" name="action" value="answer" />
<input type="hidden" name="fact_id" value={question.fact_id} />
<input type="hidden" name="type" value={question.type} />
{question.topic && <p>{question.topic}</p>}
<fieldset>
<legend>{question.label}</legend>
{question.description && <p>{question.description}</p>}
{question.type === 'boolean' && (
<>
<button name="value" value="true">Yes</button>
<button name="value" value="false">No</button>
</>
)}
{question.type === 'select' &&
question.options?.map((option) => (
<button name="value" value={option.value}>{option.label}</button>
))}
{question.type === 'number' && (
<>
<input type="number" step="any" name="value" required />
{question.unit && <span>{question.unit}</span>}
<button>Next</button>
</>
)}
<button name="unknown" value="1" formnovalidate>Not sure</button>
</fieldset>
</form>
)}
{view && !question && (
<form method="post">
<p>That is everything we need to ask.</p>
<button name="action" value="close">Send</button>
</form>
)}
{view && worthShowing(view) && (
<section>
{view.result.measures.map((m) => (
<p>
{m.group ? `${m.group} · ` : ''}{m.label}: {m.primary ? <strong>{formatRange(m)}</strong> : formatRange(m)}
</p>
))}
{view.result.verdict && <p>{view.result.verdict.label}</p>}
{view.result.pending.length > 0 && (
<>
<p>Still to confirm</p>
<ul>{view.result.pending.map((p) => <li>{p.label}</li>)}</ul>
</>
)}
</section>
)}
</main>

A chat bot uses the same loop. The model does two jobs only: phrasing the question for the visitor, and turning the visitor’s reply into a value. The order stays with Minion.

Three rules for the prompt that talks to the visitor:

  • Ask only the question you were given. Do not add questions of the model’s own.
  • One question per message. Several questions in one message make the replies impossible to map.
  • Never fill in an answer the visitor did not give. A guess gives a result with no visible gaps that is wrong; “unknown” leaves the gap where the reviewer can see it.

To turn a reply into a value, give the model the question and ask for JSON:

You are recording one answer in a structured hearing. Do not ask anything.
Question: Design approach
Type: select — answer with exactly one of these values:
opt_new_design: New design
opt_reuse: Keep the current design
The visitor replied: """We'd like to keep what we have, mostly."""
Return only JSON, one of:
{"value": <the answer>} the reply clearly answers the question
{"unknown": true} they do not know, or would rather not say
{"unclear": true} you cannot tell, or they gave a range
Do not guess.

For boolean, ask for true or false; for number, one number, in unit when there is one.

Then check the model’s output against the question before sending it. Models return labels instead of values and strings instead of numbers, and the API rejects both:

lib/check-reply.ts
import type { AnswerValue, Question } from './assessment'
/**
* Check what the model extracted against the question before sending it.
*
* - `{ value }` — fits the question's type: send it as an answer
* - `{ unknown }` — the person does not know or declined: send it as `unknown`
* - `null` — the model could not tell: ask the person again, do not guess
*/
export function checkReply(
question: Question,
reply: unknown,
): { value: AnswerValue } | { unknown: true } | null {
if (!reply || typeof reply !== 'object') return null
if ('unknown' in reply && reply.unknown === true) return { unknown: true }
if (!('value' in reply)) return null
const { value } = reply
switch (question.type) {
case 'boolean':
return typeof value === 'boolean' ? { value } : null
case 'number':
return typeof value === 'number' && Number.isFinite(value) ? { value } : null
case 'select':
// The value must be one of the option values. A label, or anything close to one, is not.
return typeof value === 'string' && question.options?.some((o) => o.value === value)
? { value }
: null
}
}

When checkReply returns null, ask again in other words. If the second reply is still unclear, send the fact as unknown and move on. Minion has nowhere to store the conversation itself; keep transcripts on your side if you need them.

Why
Call the API from browser JavaScript, or put the key in a NEXT_PUBLIC_ / PUBLIC_ variableCall it from your serverThe key would be readable by anyone. The API sends no CORS headers anyway
Send the key as ?apiKey=Authorization: BearerQuery-string keys are not accepted, and they end up in logs
Forward any path or session ID the browser sendsAct only on the session ID in the visitor’s cookieAnyone holding an ID could read or delete someone else’s hearing
Start a hearing on page loadStart when the visitor presses StartEvery hearing is a record the workspace sees
Start a new hearing on reload, or retry POST /sessions after a timeoutKeep the ID and resume with GET /sessions/{id}Starting is not idempotent; you get duplicates
Reorder, skip or add questions, or walk /catalog/facts from the topAsk questions[0]Asking in the returned order is what prevents missed questions
Decide the hearing is over by counting questionsStop when questions is []The number of questions depends on the answers
Send "12", an option’s label, or "yes"12, the option’s value, true400 validation_failed
Send "unknown": "fct_x" or a form-encoded body"unknown": ["fct_x"] in a JSON bodyMalformed fields are ignored: 200, and nothing is recorded
Let a model guess an unclear replyAsk again, then send unknownA guess gives a wrong result with no visible gap
Show the range from the first questionDecide when figures are worth showingAn early range can be many times wide
Show a non-primary measure as final when questions is emptyAsk its remaining facts from /facts, or show the primary measure onlyQuestions are ranked by the primary measure alone
Show message to visitorsMap error to your own wordingMessages are not localized
Try to finalize from the siteClose; a person finalizesFinalizing is a person’s decision, by design
Use one catalog for a site in several languagesOne catalog, and one key, per languageCatalog text is in a single language