ServicesPoll

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_vote unless the poll enables vote changing. Who may see what is covered in Integrity and visibility.
  • total_ballots and votes count different things. total_ballots counts ballots, options[].votes counts selections, so one multi-select ballot picking two options adds 1 to the total and 1 to each option.
  • Lifecycle transitions are lazy. scheduled becomes open and open becomes expired on the next read or vote that touches the poll, not on a background timer.
  • Polls are permanent. There is no update or delete endpoint; close is 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-stream

POST /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_c

single_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 rounds

For 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: unknown rather than echoing the id you asked for. The poll_not_found error code is still correct.
  • Percentages need the right denominator. Divide votes by total_ballots for single-choice, but a multi-select poll's option votes can exceed total_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

On this page