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.
What you need before you start
Section titled “What you need before you start”| Value | Looks like | Where |
|---|---|---|
| API base URL | https://minionworkspace.com/api/public/assessment | The origin you open Minion on, plus /api/public/assessment |
| Intake API key | asm_… | The catalog’s Integration view → Intake API keys. Shown once, when it is issued |
| Catalog identifier (optional) | web-production | The 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.
Where the calls happen
Section titled “Where the calls happen”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: Bearerheader only. A key in the query string is ignored and the request fails withapi_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.
The loop
Section titled “The loop”1. POST /sessions → session.id, questions, result2. 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 empty5. POST /sessions/{id}/close- An empty
questionsarray means nothing is left to ask. There is nodoneflag, 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.
Endpoints
Section titled “Endpoints”All paths are under the base URL, and every request needs the header.
| Method | Path | Returns |
|---|---|---|
POST | /sessions | 201 { 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.
Starting a hearing
Section titled “Starting a hearing”POST /api/public/assessment/sessionsAuthorization: Bearer asm_…Content-Type: application/json
{ "title": "Acme Corp — site rebuild", "limit": 1 }Both fields are optional.
titleis 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 /sessionscreates a new hearing, including a retry after a timeout that had in fact succeeded. Storesession.idand resume withGET /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 }}Questions
Section titled “Questions”| Field | |
|---|---|
fact_id | Send it back as the key of the answer |
label | The question, as written in the catalog |
description | Optional help text |
type | boolean, number or select |
unit | number only, optional. Show it next to the input |
options | select only. [{ "value": "opt_new_design", "label": "New design" }] |
topic | Optional. 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.
Answering
Section titled “Answering”POST /api/public/assessment/sessions/{id}/answersContent-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.
type | Send | Not |
|---|---|---|
boolean | true / false | "true", "yes", 1 |
number | a JSON number: 12 | "12", "10-20", null |
select | one 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.
unknownmeans “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 inresult.pendingfor the reviewer. Answering the fact later takes it out ofunknown, 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_failedand nothing is saved. - A malformed body is ignored, not rejected. If the body is not JSON, or
answersis not an object, orunknownis not an array, that part is treated as absent: the response is200and nothing is recorded. Always send a JSON object withContent-Type: application/json, and check thatprogressmoved 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_fact | No fact with that ID in this catalog |
archived_fact | The fact has been deleted from the catalog |
type_mismatch | The wrong JSON type for the question |
unknown_option | Not one of the question’s options[].value |
archived_option | The option has been deleted from the catalog |
not_finite | NaN or Infinity |
The result
Section titled “The result”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, .group | Measures that share a group belong together: “Monthly · Expenses”, “Monthly · Our services” |
measures[].primary | The main figure. Show it largest |
measures[].display | currency: 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[].settled | true when min equals max |
verdict | null, 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.
Ending a hearing
Section titled “Ending a hearing”POST /api/public/assessment/sessions/{id}/closeNo body. The response is { session } with state set to awaiting_review.
session.state | Means |
|---|---|
in_progress | Open. Answers are accepted |
awaiting_review | Your site closed it. Waiting for a person in the workspace |
fixed | A 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.
Resuming
Section titled “Resuming”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.
Other ways to lay out the questions
Section titled “Other ways to lay out the questions”- Every question on one page.
GET /sessions/{id}/factsreturns every fact in catalog order with itsstate:answered(withvalue),unknown,unanswered, orinactive— it does not affect the result given the current answers, so hide it or grey it out; anything already entered is kept.unansweredfacts that would narrow the primary measure carry arank,1being the most useful to ask. - Before a hearing exists.
GET /catalog/factsandGET /catalog/measuresreturn 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 preferquestionswhen you can.
Deleting a hearing
Section titled “Deleting a hearing”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.
Errors
Section titled “Errors”Every error is { "error": "<code>", "message": "…" }; validation_failed also carries errors[].
Branch on error, and never show message to visitors — it is not localized.
| Status | error | When |
|---|---|---|
| 400 | validation_failed | An answer does not fit its question. Nothing was saved |
| 401 | api_key_required | No Authorization: Bearer header |
| 401 | invalid_api_key | No such key. Deleting a catalog deletes its keys |
| 401 | api_key_revoked | The key was revoked |
| 404 | not_found | No such session, or it was not started by this key |
| 404 | catalog_not_found | The catalog is no longer available, or Assessment is off for the workspace |
| 409 | catalog_unpublished | The catalog has not been published |
| 409 | session_closed | The hearing was closed |
| 409 | assessment_fixed | A person finalized the hearing |
| 409 | conflict | Two writes reached the same hearing at the same moment. Read it again and retry once |
| 429 | rate_limited | Too many requests. There is no Retry-After header; back off |
| 500 | internal_error | Try again later |
| 503 | unsupported_engine | The catalog cannot be used right now. Ask the workspace |
Rate limits
Section titled “Rate limits”| Applies to | Limit |
|---|---|
| Every request | 300 per minute per key (per IP address when there is no key) |
POST /sessions | 60 per minute per key |
| Writes to one hearing: answer, take back, close, delete | 20 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.
Complete examples
Section titled “Complete examples”The API client
Section titled “The API client”Shared by both examples below. Put it at lib/assessment.ts (Next.js) or src/lib/assessment.ts
(Astro).
//// 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)),}Next.js (App Router)
Section titled “Next.js (App Router)”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.
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 }}'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> )}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.
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' }] },})---// 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)?.valueconst id = cookie && UUID.test(cookie) ? cookie : nulllet 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 = nullif (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>With a language model in front
Section titled “With a language model in front”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 approachType: 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 rangeDo 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:
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.
Do not do this
Section titled “Do not do this”| ❌ | ✅ | Why |
|---|---|---|
Call the API from browser JavaScript, or put the key in a NEXT_PUBLIC_ / PUBLIC_ variable | Call it from your server | The key would be readable by anyone. The API sends no CORS headers anyway |
Send the key as ?apiKey= | Authorization: Bearer | Query-string keys are not accepted, and they end up in logs |
| Forward any path or session ID the browser sends | Act only on the session ID in the visitor’s cookie | Anyone holding an ID could read or delete someone else’s hearing |
| Start a hearing on page load | Start when the visitor presses Start | Every hearing is a record the workspace sees |
Start a new hearing on reload, or retry POST /sessions after a timeout | Keep 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 top | Ask questions[0] | Asking in the returned order is what prevents missed questions |
| Decide the hearing is over by counting questions | Stop when questions is [] | The number of questions depends on the answers |
Send "12", an option’s label, or "yes" | 12, the option’s value, true | 400 validation_failed |
Send "unknown": "fct_x" or a form-encoded body | "unknown": ["fct_x"] in a JSON body | Malformed fields are ignored: 200, and nothing is recorded |
| Let a model guess an unclear reply | Ask again, then send unknown | A guess gives a wrong result with no visible gap |
| Show the range from the first question | Decide when figures are worth showing | An early range can be many times wide |
Show a non-primary measure as final when questions is empty | Ask its remaining facts from /facts, or show the primary measure only | Questions are ranked by the primary measure alone |
Show message to visitors | Map error to your own wording | Messages are not localized |
| Try to finalize from the site | Close; a person finalizes | Finalizing is a person’s decision, by design |
| Use one catalog for a site in several languages | One catalog, and one key, per language | Catalog text is in a single language |