Poll
A voting service with real ballot integrity: four tally strategies (single-choice, yes/no, multi-select, ranked-choice with instant-runoff), one ballot per identity enforced atomically, optional vote weighting from reputation or treasury balances, quorum-triggered auto-finalization, and a live WebSocket result stream. Every example on this page was executed against a live server.
When to reach for it: feature prioritization polls, community proposal votes with instant-runoff tabulation, governance decisions weighted by stake or standing, and agent swarms that must reach a bounded quorum before acting.
When not to: an approval gate with named approvers and a deadline is Human-in-the-Loop; validator attestation of a factual claim is Consensus; a questionnaire with typed fields and drafts is Form.
Concepts
- Creating a poll requires a platform admin token. Voting, reading
results, and fetching your own receipt are open to any authenticated
caller; a non-admin creating a poll gets
403 Poll creation requires admin role or owner grant. - Options get server-assigned ids. You supply labels at create time and vote by the returned option id, never by label.
- One ballot per identity. The voter is the JWT subject and nothing in
the request body can change it; a second ballot is
409 duplicate_voteunless the poll enables vote changing. Who may see what is covered in Integrity and visibility. total_ballotsandvotescount different things.total_ballotscounts ballots,options[].votescounts selections, so one multi-select ballot picking two options adds 1 to the total and 1 to each option.- Lifecycle transitions are lazy.
scheduledbecomesopenandopenbecomesexpiredon the next read or vote that touches the poll, not on a background timer. - Polls are permanent. There is no update or delete endpoint;
closeis the only way to stop a poll early.
Create a poll
snug poll create --title "Which feature should ship next?" \
--options "Feature A,Feature B,Feature C"
snug poll create --title "Rank the proposals" --strategy ranked_choice \
--options "Alpha,Bravo,Charlie" --live-streamPOST /api/v1/polls returns the poll id and the generated option ids - keep
them, since voting needs them:
curl -X POST -H "Authorization: Bearer $SNUG_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "docs-example", "options": ["Feature A", "Feature B"]}' \
http://localhost:4000/api/v1/polls{
"status": 201,
"msg": "Created",
"data": {
"poll_id": "RYxWTAEEmSMxZJtfacFe",
"status": "open",
"options": [
{ "id": "rjtdGUdMxWmPgEZyhdUV", "label": "Feature A" },
{ "id": "TqKHdDbPGwnUwTSnWbEr", "label": "Feature B" }
],
"created_at": "2026-08-29T12:46:54.337283Z"
}
}A poll opens immediately unless opens_at is in the future, in which case it
is created scheduled. closes_at must be later than the opening time and
within the maximum duration; both violations are 400 invalid_options.
Weighting, identity guard, before_close, opens_at, and
quorum_min_agents have no CLI flags, so send those with curl.
Vote
snug poll vote <poll-id> --option rjtdGUdMxWmPgEZyhdUV
snug poll vote <poll-id> --options opt_a,opt_c
snug poll vote <poll-id> --ranked opt_a,opt_b,opt_csingle_choice (the default) and yes_no take exactly one option id.
yes_no is not special cased - it uses whatever labels you supply and
accepts more than two, so treat it as single-choice with a naming
convention. multi_select takes an options array bounded by
max_selections, rejecting an over-long list or a repeated id.
ranked_choice takes an ordered ranked array. Selection violations are
400 invalid_selection; an accepted ballot returns 202 with a receipt
hash committing to the selections.
For write-ins, create the poll with write_ins_enabled and send write_in
alongside a selection - on single-choice polls it must accompany a real
option. The text is recorded on the ballot and does not become votable.
Promoting one is a separate owner-or-admin call,
POST /polls/{id}/options, which requires that same write_ins_enabled
flag; on any other open poll it is
409 Options can only be added to draft polls or polls allowing write-ins.
Read results
snug poll results --poll-id <poll-id>
snug --output json poll results --poll-id <poll-id> # includes IRV roundsFor a ranked poll the votes column counts every appearance at any rank, so
it is not a ranking. The answer is irv_rounds plus winner, reported as
the option label:
{
"total_ballots": 3,
"irv_rounds": [
{ "round": 1, "counts": { "MEux...": 1, "gryU...": 1 }, "eliminated": "MEux..." },
{ "round": 2, "counts": { "gryU...": 2, "QDKP...": 1 }, "eliminated": null }
],
"winner": "Bravo"
}The CLI's table view shows only the vote counts, so use --output json for
the rounds or the winner. By default non-owners see both as null until the
poll closes; that is the before_close policy, described in
Integrity and visibility.
Weighted voting
weighting.kind is none (default), static, reputation, or
treasury. The weight resolves at vote time, is bounded by max_weight,
and is recorded on the ballot for audit. Reputation and treasury weighting
read the voter's score or balance from the named domain, divide by
scale, and floor to at least 1. A poll created with
{"kind": "static", "static_weight": 5} turns one ballot into
"votes": 1, "weighted_votes": 5.
Closing, quorum, and finalization
close is owner-or-admin only and takes effect immediately. Closing twice
is 409 poll_closed, as is a later ballot. A poll whose closes_at has
passed reports expired and rejects ballots with 409 poll_expired.
A poll created with quorum_required and quorum_min_agents is watched by
the decision engine: once both thresholds are met, it finalizes itself
exactly once. POST /polls/{id}/finalize runs the same check on demand and
fails closed, which makes it a safe readiness probe - an unmet quorum is
409 conditional_not_met with the shortfall spelled out
(Quorum not met: 0/3 ballots, 0/2 agents). Finalizing an already-finalized
poll returns 200 with quorum_met: true, and a poll with no quorum policy
cannot be finalized at all.
Live stream
GET /api/v1/polls/{id}/stream upgrades to a WebSocket carrying tally,
quorum, and finalization events for polls created with
live_stream_enabled. The handshake enforces result visibility, so a caller
who cannot read the tally is rejected with 403 before upgrading.
snug poll stream <poll-id> --raw{"event":"tally_update","poll_id":"JvsK...","total_ballots":3}
{"event":"quorum_met","poll_id":"JvsK...","total_ballots":3,"agents":3}
{"event":"finalized","poll_id":"JvsK...","status":"finalized","total_ballots":3}Finding polls
snug poll list accepts the shared search
grammar: -q over title and description,
--filter on status, strategy, results_visibility,
ballots_visibility, option_count, and closes_at, and --sort-by on
created_at, total_ballots, option_count, title, or closes_at.
snug poll list --filter "status:eq:open" --sort-by total_ballots --sort-order desc
curl -H "Authorization: Bearer $SNUG_API_TOKEN" \
"http://localhost:4000/api/v1/polls/<poll-id>?include=options"Behaviors and gotchas
- A missing poll reports
Poll not found: unknownrather than echoing the id you asked for. Thepoll_not_founderror code is still correct. - Percentages need the right denominator. Divide
votesbytotal_ballotsfor single-choice, but a multi-select poll's option votes can exceedtotal_ballots. - There is no way to withdraw a ballot. Vote changing must be enabled at create time; otherwise the first ballot is final.
Limits and configuration
Titles up to 200 characters, 100 options per poll, 25 ranked selections per
ballot, 140-character write-ins, and a 90-day maximum poll duration by
default - all tunable through the POLL_* variables in the
Poll CONFIG reference.
Reference
- Poll API - every endpoint, callable
- Integrity and visibility - who can vote, vote twice, or see a ballot
- Related: Human-in-the-Loop for deadline-bound approvals, Consensus for validator-attested facts, Leaderboard for ranking a continuous metric rather than a fixed slate