Skip to main content

Polls

The scheduling poll asked one question: which of these times can you do? The general poll is that question generalised — a poll is a list of typed questions, answered by ballots the box validates against each question's own declared vocabulary, and tallied by pure functions both ends can run.

It lives in the same three crates as everything else on the public surface: mecha-manifest holds the types and the tallies, factory-publish creates polls and reads them back, and mecha-factory — the box — serves the pages and stores the ballots.

Three rules carry the design

Only declared vocabulary, only legal shapes. For every kind but text, a ballot is enum values and small integers the question itself declared. That is what keeps poll pages out of the front door's quarantine: there is nothing a respondent can type into a likert answer. text is the deliberate exception, and it pays a toll — see the prose boundary below.

Ballots, never counters. Tallies are derived on demand from stored ballots. Every visibility mode, every edit, the CSV export and any future counting method fall out of that for free — instant-runoff and Borda are two reads of the same ranking ballots, not two ballot formats.

Absent is absent. An unanswered question is no answer — never a default, never a midpoint. The rule reaches all the way into the widget: an untouched VAS slider does not submit at all, because a slider parked at 50 that nobody touched would enter the data as a real opinion. required defaults to false for the same reason — forcing a respondent to invent an answer to reach the end is the midpoint-inventing bug wearing a UI.

The six question kinds

kindasks forthe tally leads with
choicebetween min_choices and max_choices of the optionscounts per option; 1/1 is single choice, 1/N is approval voting
rankingan ordering of the options, partial allowedinstant-runoff rounds — who held how many first preferences, who was eliminated, how many ballots exhausted
likertone point of a discrete labeled scalethe distribution and the median, because a Likert item is ordinal data
vasa continuous 0–100 position between two anchorsthe distribution over a continuous scale
textfree prose, max_length requirednothing on the projector but a word cloud; the sentences stay on the presenter's screen
timesthe tri-state answer over seeded candidate slotsthe ranking, and the auto-book verdict

Two shapes worth knowing:

  • A likert question is labeled either at every point (labels, length exactly points — a proper Likert item) or only at the ends (label_min/label_max), never both.
  • A vas question's anchor_min and anchor_max are required fields, because an unanchored visual analogue scale measures nothing.
  • A times question stands alone in its poll. Its candidates arrive from the freebusy pipeline and never from the spec — see the times poll below.

Writing a spec

A general poll is a TOML file handed to polls create --spec. This is the mid-semester course survey, which is also the specimen rendered in the gallery:

title = "Mid-semester feedback"
deadline = "2026-03-06T22:00:00-05:00"

[[questions]]
id = "paper"
prompt = "Which paper should the discussion section take on?"
kind = "choice"

[[questions.options]]
id = "world-models"
label = "World models are enough"
detail = "Chen et al., 2026"
link = "https://example.org/world-models"

[[questions.options]]
id = "affect-probes"
label = "Affective probes in fMRI decoding"

[[questions]]
id = "pace"
prompt = "The pace of the course so far is right."
kind = "likert"
points = 5
labels = ["Strongly disagree", "Disagree", "Neutral", "Agree", "Strongly agree"]

[[questions]]
id = "keep"
prompt = "What is working that we should keep doing?"
kind = "text"
max_length = 300

[results]
show = "after_vote"
identity = "anonymous"

[audience]
kind = "link"
max_ballots = 200

prompt is optional, because a single-question poll's title already is the prompt. An option's link is data to show, never a thing to fetch — it renders as a link and nothing on either end retrieves it.

A typo'd key is an error, not a no-op

serde cannot combine deny_unknown_fields with the flatten that gives us kind = "likert" inline, so the keys each kind accepts are checked by hand against the raw TOML. A misspelled anchor_mn fails at authoring time rather than silently doing nothing — the same arrangement, for the same reason, as request-type fields.

Who may answer

[audience]
kind = "roster" # the default

A roster poll mints one capability URL per participant. You name them at create time (--participant "Priya=priya@example.edu", repeatable, or --roster names.csv for a class section); the box learns the names and never the addresses. Each person's URL is their identity on the poll, which is what makes editing an answer, showing "2 of 6 answered", and named results possible at all.

[audience]
kind = "link"
max_ballots = 200 # required for a link poll

A link poll is one shared URL. Dedup is a cookie and an honor system, and the page says so rather than implying a guarantee it cannot make. max_ballots is required because an open write endpoint has to be priced in advance: a bot run costs the poll its remaining capacity, never the box its disk.

A link poll takes no --participant and no --roster; the CLI refuses the combination rather than quietly ignoring the flags.

Who sees what

Two enums, both enforced server-side, and both promises made before the vote — so they are fixed at creation. The store refuses edits once a ballot exists, and there is no setter anywhere.

[results]
show = "after_vote" # live | after_vote | after_close | creator
identity = "anonymous" # named | creator | anonymous

show decides when a voter sees results. after_vote is the default by decision — independent ballots first, the summary as the reward for voting. live is the live-response-product behaviour; after_close is the course-eval behaviour; creator sends results to the organizer alone, which is the one that does presentation choreography (see Live polls on a slide).

identity decides whose names ride the results. Left absent it resolves from the audience — named for a roster, anonymous for a link — rather than defaulting blindly. A link poll has no names to show, so anonymous is both its default and its only legal value; a spec that says otherwise is refused rather than silently rewritten, because rewriting a promise is worse than failing to make it.

Anonymous has a floor

Under three respondents an anonymous poll withholds the per-option breakdown and reports only the count. "The one person who strongly disagreed" is not an aggregate, and a class of five where four have answered is exactly where anonymity stops being real. Every emitter asks the same question of the same number.

The commands

The polls' verbs live in factory-publish, the home-side binary that holds the gate address and the key.

# A general poll from a spec.
factory-publish polls create seminar feedback-mar --spec survey.toml \
--roster section-a.csv

# Where it stands. --json is the shape an agent reads.
factory-publish polls status seminar feedback-mar
factory-publish polls status seminar feedback-mar --json

# Freeze the answers, and say what happened.
factory-publish polls close seminar feedback-mar \
--resolution "Replication it is — projects due the last week of classes."

# One row per ballot, one column per question.
factory-publish polls export seminar feedback-mar --out feedback.csv

create prints each participant's own URL (or, past twelve people, points at the CSV), writes a record at ~/.mecha/factory/polls/<poll>.json, and writes <poll>.links.csvname,email,url, which is what an LMS mail-merge eats. Addresses never leave your machine: the box mints the URLs, home holds the roster, and mailing the links is your act or your agent's outbox-reviewed one.

close takes an optional --resolution, rendered at the top of the closed page. It is Loomio's outcome statement rather than an accountability requirement, which is why it is optional where the front door's close reason is not: the links people are holding should be able to answer "so what happened?".

export is injection-hardened CSV and is nameless when the poll is anonymousanonymous is a serving policy that reaches the drain and the export, not just the page.

Watching one without leaving the session

mecha tui has a /polls modal, built on the same pattern as /triggers, /outbox and /frontdoor: every mutation shells out to factory-publish polls …, so there is one implementation per verb and nothing the TUI can do that the command line cannot.

keydoes
move between polls
Enterthe detail view — the CLI's own output, verbatim
rrefresh this poll from the gate
cclose it, with an optional resolution typed inline
eexport ballots to ~/.mecha/factory/polls/<poll>.csv
sshow the projector URL
esc / qback

One honest difference from the other modals: the store of record is on the gate, not on this machine. The list is drawn from the local creation records — who was invited, which the box never learns — and everything live arrives by driving the CLI. So the modal states its staleness ("as of 14:03:22") and an unreachable gate is a labelled condition on the row rather than a blank panel.

Text answers surface here on purpose. The presenter's own screen is where prose belongs; a person reading it in a terminal is the safe context, and nothing drawn in a modal reaches a model.

The prose boundary

text is the one kind whose answers are prose, and it is treated as prose everywhere downstream: capped at authoring time by a required max_length, capped again at 10,000 characters whatever the manifest says (an unauthenticated endpoint plus an uncapped text field is an unbounded write), and carried as free_text — the same class of value the front door hands to an extractor rather than to a privileged run.

On a projector it does not render as sentences. Anonymous prose on a lecture screen is an incident with a countdown, so a text question projects as a word cloud with a structural guard:

  • words are counted once per ballot, so one answer repeating a word fifty times scores 1 and nobody can shout their way to 72pt;
  • a word reaches the wall only when two or more different ballots chose it — which keeps a lone troll's word off the screen with no profanity list to maintain;
  • stopwords and short tokens drop, sizes are five discrete buckets with the count in text beside them, and the list is sorted and capped so both ends render the same cloud.

The full sentences stay on the presenter's screen and in status.

An agent reading a poll through poll_status does get the text answers, in a text_answers field kept separate from the typed tallies. An earlier version withheld them and returned counts, on the front door's reasoning; that was wrong here, because in a poll the prose is the data — "what did people say" is most of why anyone runs one. What makes returning it safe is the mechanism mecha already has for other people's words, which is not silence: the tool carries openWorldHint, so the answers arrive marked untrusted_input and arm the interlock exactly as a mail body does. The typed and the written stay in separate fields, which is what lets an answer summarise the prose without treating any of it as an instruction.

Layout, and questions about pictures

Two things are presentation and are deliberately not part of what a question means — a tally must never change because somebody rearranged a page.

Which way the controls run is layout on the question:

[[questions]]
id = "format"
layout = "horizontal" # or "vertical"; omit for each kind's own default
kind = "choice"

The default is auto, which reproduces what each kind always rendered: a scale runs across the page, a list of options runs down it. A scale becomes a grid of equal columns with each label under its control, and collapses to one point per line on a narrow screen.

A question can be about a picture, and so can each option:

[[questions]]
id = "figure"
prompt = "Which version of Figure 2 should go in the paper?"
media = { src = "/f/fig-all.png", alt = "All three panels side by side" }
kind = "choice"

[[questions.options]]
id = "scatter"
label = "Scatter with a fitted line"
media = { src = "data:image/png;base64,…", alt = "A scatter plot with a fitted line" }

Options render as cards you press rather than dots you aim at, with the picture inside the card and picture options side by side — comparing two figures means seeing both at once. The radio is still in the markup and in the tab order, because it is what the form posts, what a screen reader announces, and what works with the script blocked.

Two rules, both enforced at authoring time:

  • alt is required. A question that asks people to choose between pictures is unanswerable without it for anyone using a screen reader, and a poll is a thing you send to a group whose eyesight you do not know.
  • src is a data: URI or a path this origin serves — nothing else. Every page here sends img-src 'self' data:, so an image from anywhere else is blocked by the browser. That includes your own artifact subdomain, which is a different origin. An off-origin src is refused when the spec is parsed, because the alternative is discovering it from sixty people looking at a page with a hole in it that cannot be recalled.

Inline images are capped at 512 KB before base64: a spec travels as one request body and is stored whole. There is no upload channel for poll assets yet, so today "poll a set of images" means figures small enough to embed. Photographs from a phone are not, and closing that gap needs an asset endpoint on the box.

The times poll is still its own flow

kind = "times" is the scheduling poll, unchanged. Its candidates are the availability engine's earliest feasible slots — already minus your real busy time — so they arrive from the freebusy pipeline and never from a spec:

mecha-mail freebusy --days 60 --json | \
factory-publish polls create book lab-feb --policy book.toml \
--title "Lab meeting" --duration 60 \
--participant "Priya=priya@example.edu" --participant "Tal=tal@w.edu"

polls status on a times poll prints the ranking and the auto-book verdict rather than per-question tallies, and polls export refuses it by name — the ranking is what there is to read.

The candidate list is capped small on purpose: a poll a colleague answers in ten seconds is the one that gets answered.

Where to go next

  • Live polls on a slide — the projector page, and the PowerPoint content add-in.
  • Component gallery — the survey rendered by the code that serves it, open and closed.
  • The front door — where prose from strangers goes, and why a poll's typed answers do not have to.