# Minion - documentation for language models Minion is an AI agent workspace: it manages agents ("minions") that run on your own hosts, and it carries the workspace tools those agents and their humans share - notes, forms, calendar, drive, accounting, assessments, and a headless CMS. This file contains only the pages needed to integrate an assessment hearing. Each page begins with its canonical URL. Full documentation: https://docs.minionworkspace.com/llms-full.txt Japanese: https://docs.minionworkspace.com/ja/llms-assessment.txt ## Rules that are easy to get wrong Read these before writing code. They are the points where a reasonable-looking implementation is the wrong one. 1. **Call the assessment API from a server, never from the browser.** It sends no CORS headers. The intake API key (`asm_...`) must not appear in client-side code or in a `NEXT_PUBLIC_` / `PUBLIC_` variable, and it is sent only as `Authorization: Bearer`. 2. **Ask questions in the order they are returned, and stop when `questions` is an empty array.** There is no done flag. Do not reorder, skip or add questions, and do not walk `/catalog/facts` from the top instead. 3. **An answer must match its question's type exactly.** `boolean` takes `true` / `false`, `number` a JSON number, `select` one option's `value` - not its label. A malformed body (not JSON, `answers` not an object, `unknown` not an array) is ignored: the response is `200` and nothing is recorded. 4. **Never guess an answer.** When a reply does not clearly fit the question, ask again or send the fact in `unknown`. A guess produces a wrong result with no visible gap. 5. **Starting a hearing is not idempotent.** Start it when the visitor starts, keep `session.id` (for example in an httpOnly cookie) and resume with `GET /sessions/{id}`. 6. **A site cannot finalize a hearing.** It closes it, and a person in the workspace finalizes it. Once it is closed or finalized, answers are rejected with `409`. --- # Assessment integration Source: https://docs.minionworkspace.com/guides/assessment-integration/ Summary: Everything needed to run an assessment hearing from your own website or bot, on one page. This page is deliberately self-contained. It repeats things that also appear in the [Assessment guide](https://docs.minionworkspace.com/guides/assessment/) 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`](https://docs.minionworkspace.com/llms-assessment.txt). ## 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 | 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 ``` 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. ## The loop ``` 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. ## 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 ```http 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. ```json { "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 | 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 ```http 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`. | `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. - **`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. ```json { "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: ```json { "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 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 ```http POST /api/public/assessment/sessions/{id}/close ``` No 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 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 - **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. ## 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 Every error is `{ "error": "", "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 | 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 ### The API client Shared by both examples below. Put it at `lib/assessment.ts` (Next.js) or `src/lib/assessment.ts` (Astro). ```ts // 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(method: 'GET' | 'POST' | 'DELETE', path: string, body?: object): Promise { 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('POST', '/sessions', { title, limit }), get: (id: string, limit = 1) => call('GET', `${session(id)}?limit=${limit}`), answer: (id: string, factId: string, value: AnswerValue, limit = 1) => call('POST', `${session(id)}/answers`, { answers: { [factId]: value }, limit }), unknown: (id: string, factId: string, limit = 1) => call('POST', `${session(id)}/answers`, { unknown: [factId], limit }), clear: (id: string, factId: string) => call('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) 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. ```ts // 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 { 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 } } ``` ```tsx // 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(null) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) const [sent, setSent] = useState(false) const [error, setError] = useState(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

Loading…

if (sent) return

Thank you. We will review your answers and get back to you.

if (!view) { return ( ) } // With limit=1 there is at most one question. An empty array means nothing is left to ask. const question = view.questions[0] return (
{question ? ( run({ action: 'answer', fact_id: question.fact_id, value })} onUnknown={() => run({ action: 'unknown', fact_id: question.fact_id })} /> ) : (

That is everything we need to ask.

)} {error &&

Something went wrong ({error}). Please try again.

} {worthShowing(view) && }
) } function QuestionForm({ question, busy, onAnswer, onUnknown, }: { question: Question busy: boolean onAnswer: (value: AnswerValue) => void onUnknown: () => void }) { const [raw, setRaw] = useState('') return (
{question.topic &&

{question.topic}

}
{question.label} {question.description &&

{question.description}

} {question.type === 'boolean' && ( <> )} {question.type === 'select' && question.options?.map((option) => ( // Send the option's value, never its label. ))} {question.type === 'number' && (
{ 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) }} > setRaw(e.target.value)} /> {question.unit && {question.unit}}
)}
) } function ResultPanel({ result }: { result: Result }) { return (
{result.measures.map((m) => (

{m.group ? `${m.group} · ` : ''} {m.label}: {m.primary ? {formatRange(m)} : formatRange(m)}

))} {result.verdict &&

{result.verdict.label}

} {result.pending.length > 0 && ( <>

Still to confirm

    {result.pending.map((p) => (
  • {p.label}
  • ))}
)}
) } ``` ```tsx // app/estimate/page.tsx import Hearing from './Hearing' export default function EstimatePage() { return (

Get an estimate

) } ``` ### 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. ```js // 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' }] }, }) ``` ```astro --- // 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 } ---

Get an estimate

{sent &&

Thank you. We will review your answers and get back to you.

} {error &&

{error}

} {!view && !sent && (
)} {question && (
{question.topic &&

{question.topic}

}
{question.label} {question.description &&

{question.description}

} {question.type === 'boolean' && ( <> )} {question.type === 'select' && question.options?.map((option) => ( ))} {question.type === 'number' && ( <> {question.unit && {question.unit}} )}
)} {view && !question && (

That is everything we need to ask.

)} {view && worthShowing(view) && (
{view.result.measures.map((m) => (

{m.group ? `${m.group} · ` : ''}{m.label}: {m.primary ? {formatRange(m)} : formatRange(m)}

))} {view.result.verdict &&

{view.result.verdict.label}

} {view.result.pending.length > 0 && ( <>

Still to confirm

    {view.result.pending.map((p) =>
  • {p.label}
  • )}
)}
)}
``` ### 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: ```text 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 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: ```ts // 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. ## 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 | --- # Assessment Source: https://docs.minionworkspace.com/guides/assessment/ Summary: Turn a hearing into a working answer, and let visitors to your own website or bot answer the questions Assessment turns the facts you collect in a hearing into a result — a quote, a fit score — and tells you which unanswered question would change that result the most. It is built for hearings that never run top to bottom. What is worth asking next depends on what you already know, so the questions are not a fixed list: - **You get an answer from the first question on.** While anything is still open the result is a range, and it narrows as you fill facts in. - **"Unknown" is an answer.** The question stops being asked, and it is listed as something to confirm. - **The order of questions is worked out for you.** The next question is the one whose answer would narrow the result the most. Questions that stop mattering because of earlier answers drop out. - **What you know stays with you.** How much each thing adds to the result, and your rates, live in the catalog. None of it leaves Minion — including through the integration API. Assessment is experimental and off by default. Ask us to enable it for your workspace if you do not see it in the sidebar. :::tip[Running a hearing from your own site or bot?] [Assessment integration](https://docs.minionworkspace.com/guides/assessment-integration/) is a single self-contained page with the API, the JSON shapes, complete Next.js / Astro examples, how to put a language model in front of it, and the mistakes to avoid. It is written to be handed to a developer — or to a coding assistant — on its own. A plain-text version for language models is at `/llms-assessment.txt`. ::: ## The pieces | Term | What it is | |---|---| | **Catalog** | A reusable model for one kind of decision: its facts, contribution items, measures and, optionally, a verdict. Found under **Assessment → Catalogs** | | **Fact** | Something you find out. Yes / No, a number, or a choice | | **Contribution item** | What goes into the result, and when it applies — "multi-language routing: 16 hours, when multi-language is needed". This is your know-how, and it stays inside Minion | | **Measure** | An output of the catalog: initial cost, monthly fee, fit score. Shown as currency or as a number | | **Verdict** | Optional. A pass / borderline / fail call made from a measure, for uses such as screening | | **Assessment** | One case: the answers so far and the result they give | The quickest way in is **Add a sample**, which copies a finished catalog into your workspace — a logo design quote, a web production quote, deal triage, or hiring screening. Read it, then change it into your own. A catalog is edited as a draft and **published** before it takes effect. Only the published version is used — by assessments in Minion and by integrations alike. **Starting from blank asks for an identifier** — `web-production`, say — alongside the name. It is derived from the name when the name is written in English, and you fill it in yourself otherwise. It is worth a moment's thought: an identifier **cannot be changed after the catalog is created**, because integrations pin it to check they are talking to the catalog they expect, and letting it move would turn a rename into a false alarm. You can see it later under **Integration**, and under each name in the catalog list. Samples bring their own. ## Running a hearing from your own site A catalog can be driven from outside Minion: a quote form on your website, a chat bot, or your own back office. Visitors answer the questions there, and each hearing lands in your assessment list for someone to review. ### What an integration can and cannot see | Leaves Minion | Never leaves Minion | |---|---| | The questions: label, description, type, unit, choices, topic | Contribution items and how much each one adds | | The result as presented: a range for each measure | The conditions that decide when an item applies | | The verdict's label and level | Hourly rate, overhead rate, target margin | | Which facts are still open | The breakdown, and hours before they are turned into a price | One thing to plan around: **whoever holds the key can read every question in the catalog**, and the questions alone show what you price. The key belongs on the server of the site you build, never in a browser. ### Issue an intake API key 1. **Publish** the catalog. A key cannot be issued for a catalog that has never been published. 2. Open the catalog and choose the **Integration** view. 3. Under **Intake API keys**, name the key after where it will be used — "Estimate form on the corporate site" — and press **Issue key**. 4. **Copy the key now.** It is shown only once; Minion keeps nothing but a hash of it. If the key comes with a warning that some numeric facts have no sample values, the key still works, but the order of questions will be less accurate. Set **Sample values** on those facts and publish again. How keys behave: - **A catalog with at least one active key can be used from outside. Revoke every key and it is closed again.** There is no separate switch. - **Issue one key per place it is used.** A key only reaches the hearings it started, so your website and your chat bot never see each other's hearings — and nothing created inside Minion can be reached through any key. - **Revoking a key stops that integration immediately.** The hearings it started stay in your list. - **Deleting a catalog deletes its keys.** The confirmation tells you how many are still active. - **Turning Assessment off for the workspace stops every key.** A key cannot be issued while the catalog's verdict points to an implementation that does not exist; the catalog editor flags this. ### Reviewing what comes in A hearing started through a key is an ordinary assessment in your list, marked **External**. You can narrow the list by catalog, by origin (**Created here** / **From integration**) and by status. Assessments without a single answer — mostly visitors who left at the first question — are hidden by default; **Show unanswered** brings them back. | Status | Means | Who acts next | |---|---|---| | **In progress** | Still being filled in | The visitor, or whoever is filling it in | | **Awaiting review** | The site has ended the hearing | You: check the answers and finalize | | **Finalized** | A person checked it, and the result is frozen | — | - **You are notified when a site ends a hearing** that has at least one answer. Every workspace member gets it in the app; email is off by default and can be turned on in your notification settings. - **Finalizing is always a person's decision.** An integration cannot finalize. Once you finalize, the site can still read the hearing, but it can no longer change or delete it. - **Reopening for editing** puts a hearing the site had ended back to **Awaiting review**, not In progress, because it still needs a person's check. - An assessment awaiting review is the same record as any other — open it, correct answers, add what the visitor did not know, then finalize. ### Changing a catalog while hearings are running Until it is finalized, an assessment follows the catalog's **published** version. When you publish a change, hearings in progress pick it up on their next request: new facts can be asked, deleted facts stop being asked, and answers already given stay. A finalized assessment keeps the version it was finalized with, including when it is read through the API. Unpublished draft edits affect nothing. If you delete a choice that a visitor has already picked, their answer is kept as it was. ### Personal information Answers are yes / no, numbers and choices; there is no free-text answer. The only text an integration stores is the **title** it gives a hearing when it starts it. If your site also collects names or email addresses, it keeps them itself. To match them up, the site can put something recognisable in the title — a company name, say — as long as you intend that to be stored in your workspace. To remove a hearing, the integration can delete the ones it started, unless they are finalized. Anyone in the workspace can delete an assessment from the list. ### Limits - **Catalog text is in one language** — the one you wrote it in. For a site in several languages, create a catalog per language and give each its own key. - **The API is for servers.** It sends no CORS headers, so a browser cannot call it directly, and the key must never be shipped to a browser. - **Rate limits**, per minute: 300 requests per key, 60 new hearings per key, and 20 changes to any one hearing.