Repository navigation
v0.0.12 — adopt Loomio 3.8's API; make it efficient
Adopt Loomio 3.8's API; make it efficient. Every capability Loomio
3.8.1's b2 User API (and, opt-in, its b3 Server API) offers is now a
tool with a description written for an AI caller; every read costs as
few upstream calls and bytes as the API allows; and nothing a tool
returns is wrong (a hidden poll result, a thread in the wrong group) or
bloated (a 50-row list with full HTML bodies and avatar metadata). The
0.0.11 hotfix made the connector's failures loud; this release makes
its answers right. Tool count 8 reads + 4 writes + 2 b3 → 14 reads +
10 writes + 4 b3 (28 with everything enabled; 14 in read-only mode).
Tested against Loomio 3.8.1: every request shape and response field
described below was checked against Loomio's controllers, serializers,
services, routes and controller tests at tag v3.8.1, and against
sanitised live captures of every read endpoint from a 3.8.1 instance.
The write path was exercised live on 2026-09-20 in a sandbox group of
that instance (see Known limitations for exactly what was and was not
run). TESTED_LOOMIO_VERSION stays 3.8.1.
Added:
check_connection— "does the connector work and what can it
see", in one tool call and at most one request pair. Forces the
key-health probe (GET /b2/groups+ the publicGET /v1/boot/version)
and reuses the probe's own groups body, which the probe now keeps
beside its verdict (getCachedGroupsIndexin
src/loomio/health-cache.ts— stored besideLoomioHealth, never on
it, so/healthcannot grow a list of the user's groups). Returns
connector_version,tested_loomio_version,loomio_version,
key_status,readonly,b3_enabled(true iff the b3 tools are
registered: the secret set AND not read-only — on a read-only
deployment with the secret set it is false and a note says why,
matching whattools/listadvertises), the API key'suser{id,
name, username} (resolved from its own membership rows),groups[]
withmember_statemember|pending|parentandadmin,
groups_total, and fixed-wordingnotes[](rejected-key remediation,
valid key but no groups, pending invitations, non-member parents,
version drift, readonly / b3 flags). Never returns key material or
upstream error text. Registered first, so it is the first tool a
client reads.get_group—GET /b2/groups/{id|key|handle}(Loomio's
ModelLocator tries the three in that order). Full record minus
attachment / link-preview / cover metadata and minus the billing block
GroupSerializer adds for any member (subscription: plan, state, seat
caps, renewal / expiry dates — a serializer attribute no
exclude_typesprofile can drop;enabledalready says whether it is
active) plusnew_host/discarded_by, plusmember,
membership{accepted, admin, delegate, title},parent,
subgroups_count,url. Works for publicly visible groups the user
has not joined (can?(:show, group)); a hidden group's
403 "Not authorized to show Group."is classified as hidden group
— the id is right, the remedy is membership — and 404 as no such
group.list_threads—GET /b2/threads?limit&offsetwith
exclude_types=topic group parent membership reaction translation
(compact minustag, because Loomio gates the rows'tagsFIELD on
include_type?('tag')— plaincompact=1would strip it): every
thread visible to the user across all groups (member groups plus
public threads of public groups), newest activity first, with Loomio's
exacttotal. Rows carrytopic_id,type, the fronting record's
id/key/title/author_id,group_id, the topic counters
(tagsincluded) andurl.group_id/typefilter
client-side within the page (Loomio's route has no group filter)
andscope.notesays so.since(ISO-8601) is a client-side cutoff
too — Loomio's route reads onlylimit/offset— applied to the
newest-first rows: older rows are dropped, rows with no
last_activity_at(Postgres sorts those FIRST) are dropped and
counted inscope.undated_dropped, andscope.exhaustedturns true
when the page's last dated row already predates the cutoff (or the
page was short), so "what's new this week" is a bounded loop instead
of a guess;totalstays the whole visible set.list_thread_items— the successor tolist_events:
GET /b2/threads/{topic_id}/items. Addressed by exactly one of
topic_id(1 call),discussion_idorpoll_id(2 calls: one
compact=1GET to read the record'stopic_id). Loomio's items route
is unpaginated, so the whole thread is fetched once per call and
limit(default 200, max 1000) /offsetslice it here;kinds[]
filters client-side withotheras passthrough for unknown kinds;
total/matched/returnedsay what was counted. Side-loads are
narrowed to what the returned items reference and slimmed: comments
(body capped bybody_max_chars, default 4000), polls (identity,
schedule,hide_results, participation counts,poll_options[]{id,
name, priority} — the ids a stance'soption_scoresis keyed by, so
a vote is readable without aget_poll— andstance_countsonly
when visible, aligned with those options), stances (participant_id
null on anonymous polls), outcomes, users {id, name, username}.
max_total_chars(default 120000, -1 = none) budgets the WHOLE reply
in shaped characters:limit×body_max_charsbound each dimension
but not their product (200 comments at 4000 chars ≈ 900 KB, fifteen
times get_thread_markdown's document cap), so the slice stops before
the item that would overrun the budget — never before the first —
and returnstruncated_by_budget: trueplusnext_offset
(next_offsetis also set whenlimitended the slice;scope.chars
says what the budget counted). Three side-load profiles:compact=1for a bare
topic_id(the response must name the thread), compact plus
discussionwhen the thread's record is already in hand — a
discussion_idwas just resolved, orget_discussionpassed its
record through — so the opening post is not serialised a second time,
and, withinclude_reactions, the explicit list minusreaction
(keepingdiscussion, whose serializer carries the opening post's
reactions);scope.profilenames the one used. A thread the user
cannot see is 404, never 403, and the error says so; a 404 while
resolving adiscussion_id/poll_idsays "no such id or key"
instead (those show routes answer 403 for an invisible record), so
the two wordings never suggest a membership problem that is not one.get_thread_markdown—GET /b2/threads/{topic_id}/markdown:
Loomio's ownThreadMarkdownServicerendering (front matter, title,
body, every comment / poll / vote / outcome in order; Loomio applies
the poll vote-visibility rule itself here).max_chars(default
60000, -1 = all) caps from the end withtruncatedandchars. The
best single call for "summarise this thread".search_content—GET /b2/search?compact=1: full-text over
discussions, comments, polls, stances and outcomes the user can see
(member groups plus public groups' public threads).query(prefix
matching,!termnegation, trigram "did you mean" for short unquoted
queries),author_idalone for a user's 20 newest visible items, or
both;group_id,types[](sent as Loomio's comma-separated
types=),tag,order(authored_at_descdefault,
authored_at_asc,relevance). Each hit:type,id(the RECORD id,
not the pg_search row id),title,snippet(Loomio's<b>highlight
→**match**, HTML entities decoded, ≤ 400 chars),group{id,
full_name, handle} (full_namebecause the controller emits Loomio's
"Parent - Subgroup"full_name, not the shortname),author,
authored_at,discussion_key/poll_key,sequence_id, a
deep-linkurl. Vote reasons follow the poll-visibility rule below:
Loomio indexes the stances of openuntil_votepolls (its own web
search shows a member those reasons), so a Stance hit whose poll hides
results from the connector's user is gated from the response's own
polls/my_stanceroots (no extra call): in aquerysearch the
row is dropped and counted inscope.hidden_stance_hits_dropped
— with a term to probe, the hit's mere existence would confirm the
term occurs in the withheld reason, a word-by-word oracle over exactly
whatlist_thread_itemsstrips — while in author-only mode (nothing
to probe) it stays withsnippet: null+snippet_hidden_reason.
Loomio's hard cap: 20 results, no paging, no total —capped: true(computed over Loomio's rows, before the drop) says the match
set is larger.get_participation_report— ONEGET /b2/reports?section=users
for a whole group set (group_ids1–50 as a comma-separated string,
orgroup_scope: 'my'),start_month/end_monthasYYYY-MM
(default: the 12 months ending this month; validated client-side —
Loomio answers 500 to anything else),delegates_only. Every user's
threads,comments,polls,votes(=votes_cast),
votes_issued,votes_missed,all_votes_cast,outcomes,
reactionsandtotal(threads+comments+polls+votes+outcomes),
sorted bytotaldesc;countrydropped;groupsechoes what Loomio
counted andgroups_not_visiblenames requested groups it dropped
silently (the connector's user is not a member — Loomio's built-in
fence). THE tool for "who is most engaged" / "rank the members".update_discussion/delete_discussion,update_poll/
delete_poll,update_comment/delete_comment— the b2
updateanddestroyactions Loomio 3.1.0 added. PATCH bodies are
nested for discussions and polls and flat for comments (same rule as
create, below);update_*refuse an empty change set at the schema.
update_poll'soptionsADDS names honestly: Loomio'sPoll#options=
(alias ofpoll_option_names=) treats the array as the COMPLETE set
and marks every unlisted existing option for destruction — saved with
allow_destroy, cascading to the votes cast on it (PollOption has_many :stance_choices, dependent: :destroy) — so the tool first
GETs the poll's storedpoll_option_namesand PATCHes the union
(existing names first, then the new ones; one extra call; the result
echoesoptions_sent) — so it never sends a bare list Loomio would
read as the complete set. Removal is not offered. The two calls are
not atomic (see Known limitations).notify_on_openis exposed on
both create and update.delete_*are Loomio's
soft discard (discarded_atstamped, title / body nulled, records
kept, restorable by an admin in Loomio's UI) and return
discarded: trueplus a note saying so.delete_*ANDupdate_*
carrydestructiveHint: true(the MCP spec definesfalseas
additive-only; an overwrite is not) andidempotentHint: true.
Upstream tests
cited in each module: "update happy case" / "destroy soft deletes
discussion" (discussions), "update happy case" / "destroy soft deletes
poll" (polls), "update accepts a bearer token with flat parameters" /
"destroy soft deletes comment" (comments); the JSON shapes were also
exercised live (200) on 2026-09-20.get_user/list_users(b3, opt-in) —GET /b3/users/{id},
GET /b3/users/identity/{type}/{uid}andGET /b3/users[?is_admin=].
Rows as Loomio renders them: id, name, username, email, is_admin,
active, deactivated_at, identities[]. Registered only when
LOOMIO_B3_API_KEYis set AND the server is not read-only — the same
gate as the b3 writes, because every row carries an email — and
documented as single-tenant deployments only. Loomio 3.8.1's
identity route cannot resolve auidcontaining a dot (Rails reads
the suffix as a format); the description says to use the numeric id.- Server-level
instructions(SERVER_INSTRUCTIONSin
src/server.ts, delivered in the MCPinitializeresult): a ten-line
routing guide — check_connection first; list_threads for "what's new";
get_thread_markdown for summaries; search_content for keywords;
get_participation_report for rankings; get_user_activity per user;
trustresults_visible; never infer anonymous voters; writes replace,
deletes are soft; relaytotalandscope. - Response shaping layer —
src/loomio/types.ts(the wire shapes of
3.8.1's serializers, everything optional because Loomio hides fields
conditionally),src/loomio/shape.ts(joinTopics,slimUser/
slimGroup,truncateText/truncateField,highlightToMarkdown,
the canonical-URL builders) andsrc/loomio/visibility.ts(poll
result gating). All pure and unit-tested. - Topic join. Since Loomio 3.1 a thread's
items_count,
replies_count,last_activity_at,locked_at,pinned_at,tags,
members_count,seen_by_count,active_polls_countand
closed_polls_countlive on the side-loadedtopics[]row; every
discussion and poll read keeps that root and folds those fields onto
the record (reader_*state,rangesand the like are dropped). A
record whose topic Loomio withheld comes back without the fields, not
with zeros.tagis never in a topic-reading profile: Loomio gates the
rows'tagsFIELD oninclude_type?('tag'), so excluding the type
removes the field, not only the side-loaded root. - Poll result gating.
PollSerializeremitsresults,
stance_counts,total_scoreandstv_resultswhenever
results_available?— i.e. for every openuntil_votepoll too,
leaving the "vote first" rule to the browser client, which an API user
that never votes would bypass. The connector applies Loomio's full
results_visible?(voted:)predicate with "voted" = the API user's own
stance is cast, strips those fields (and other voters'option_scores
/reasonon stances) when it says no, and always emits
results_visible+results_hidden_reason(until_closed|
until_vote) so absent counts read as hidden, never as zero. In a
thread the user's own stance is recognised via the id the health probe
learned (cachedOwnUserId); with no cached identity it assumes "not
voted", which hides more, never less (scope.own_user_known). - Canonical URLs on every record, from Loomio's own route formats:
/d/{key}[/{slug}],/p/{key}[/{slug}],?comment_id=/
?sequence_id=deep links,/{handle}or/g/{key}for groups. The
site base isLOOMIO_API_BASE_URLwith its trailing/apiremoved. - Payload controls.
description_max_charsonlist_discussions
andlist_polls(default 1500) andbody_max_charson
list_thread_items(default 4000):0omits the field
(<field>_omitted: true),-1returns it whole,Nkeeps the first
N characters and marks the record<field>_truncated: truewith
<field>_chars(no ellipsis is appended — in a Markdown body it would
be indistinguishable from content; cuts never split a surrogate pair).
max_charsonget_thread_markdown(default 60000) caps the whole
document from the END with top-leveltruncatedandchars;-1
returns it whole and0is refused.get_*return full text. Tool
results are serialised as COMPACT JSON (10–37 % fewer bytes than the
indented form over the 3.8.1 fixtures);LOOMIO_MCP_PRETTY_JSON=1
restores indentation for humans reading a stdio session.
get_discussiongainsinclude_items(embeds thelist_thread_items
result; one extra call) so "read this whole thread" is one tool call,
withitems_limit/items_body_max_charsforwarded to it (the reply
budget applies there too, andthread_items.truncated_by_budget/
next_offsetsay when to page on withlist_thread_items).
HTML compaction under a cap (compactHtml/truncateBodyin
src/loomio/shape.ts): Loomio stores rich text with the attributes
its editor andHasRichTextadd on save —target="_blank" rel="nofollow ugc noreferrer noopener"on every link, anidon every
heading,class/data-mention-idon mentions — which were measured
at 17 % of a capped plain body and over half of a link-dense one. When
a list row's HTML body (description_format/details_format/
body_format=html) is LONGER than its cap, every tag attribute
except<a href>and<img alt>and the whitespace between tags are
stripped before the cap is applied, so the 1500 characters carry
words;<field>_charsstays the stored length. Bodies that fit,
Markdown bodies and everyget_*are returned byte-for-byte. - Client (
src/loomio/client.ts):loomioPatch/loomioDelete
(both refuse before any request underLOOMIO_MCP_READONLY, like
loomioPost),loomioGetB3,nestedBody/flatBody,csvParam,
and the read profilesEXCLUDE_TYPES/readParams— lists send
exclude_types=group parent membership reaction translation
(keepingtopics), showsexclude_types=parent membership reaction translation(keeping the group), the groups index and show
exclude_types=tag translation,list_threadscompact minustag,
rosters / search / thread items by baretopic_idcompact=1,
thread items for a known thread compact plusdiscussion,
items-with-reactionscompactminusreaction. Writes never carry
these parameters (Loomio's:raisemode would 400).
classifyForbiddenknows the new paths: on/b2/threads…,
/b2/search,/b2/reportsand the/b2/groupsindex Loomio applies
visibility as a filter and never as a 403, so the generic body there
means the key and nothing else;GET /b2/groups/:id's
"Not authorized to show Group."is explained as a hidden group. redactPathcovers/b2/threads/:id(/items|/markdown),
/b2/search,/b2/reports,/b2/groups/:key,/b2/groups/:handle
and/b3/users/identity/:type/:uid(the provider uid is frequently an
email address); query strings — search text, group id lists — never
reach a log line.strip_htmlon every read that carries rich text:list_discussions,
list_pollsandlist_thread_itemsdefault totrue,get_discussion
andget_polltofalse. When on and the stored format ishtml,
the body (description,details, commentbody, stancereason,
outcomestatement) is converted to plain text BEFORE the*_max_chars
cap — block boundaries become newlines, list items-, links keep
their text and lose the href, entities are decoded — and the record's
*_formatbecomes"text"; Markdown, discarded and format-less
bodies are untouched, write echoes return HTML as stored. With it on,
*_charscounts the text the caller received; off, the stored length
(the HTML-compaction path is unchanged).scope.strip_htmlechoes the
flag. Implementation:htmlToText/stripBodyHtmlin
src/loomio/shape.ts.get_participation_reportlimit/include_inactive: rows are
sorted bytotaldesc and cut tolimit(1–500, default 50), and
users withtotal0 in the period are dropped before ranking unless
include_inactive: true. The result gainstotal_users(ranked rows
before the cut),scope.limit,scope.include_inactive,
scope.inactive_dropped, and the note states the call's own numbers
("showing the top N of M ranked users", "K user(s) with no counted
activity were dropped"). Neither knob reaches the wire. Motivation: a
group set's report is a roster dump of everyone who EVER held a
membership, most rows zero.scripts/catalog-size.mjs: spawns the builtdist/index.jsover
stdio, runsinitialize+tools/list, and prints the catalogue's
bytes (total, descriptions, input schemas, instructions) with a
per-tool breakdown (--b3for the 28-tool set,--json). The number
tests/server.test.tspins comes from the same wire format.- Tests: 21 files, 571 tests (from 15 files). Fixtures
(tests/fixtures.ts) follow the roots, field names, nesting and
meta.totalpresence of the live 3.8.1 captures with every id, name,
handle, title and body anonymised. Request-shape assertions on every
tool (path, query includingexclude_types/compact/status,
method, body nesting) and response-shape assertions (topic join,
slimming, truncation flags, results gating, url building,total/
scope);tests/server.test.tsandtests/readonly.test.tspin the
advertised tool set per mode by name and by annotation, the
instructions text, the discovery order, that no description carries an
email address or an undocumented hostname, and — through a real
in-memory MCP client — that every cross-field schema rule (exactly one
thread reference, per-type option minimums,queryXORauthor_id,
…) is enforced ontools/callbefore any upstream request.
Changed:
list_groupsis one call on the nativeGET /b2/groups
(current_user.groups: every un-revoked membership, pending
invitations included) instead of the 0.0.11 id probe (one
GET /b2/polls?group_id=Nper id, 50–500 calls, blind to poll-less
groups, and — as the 3.8.1 review found — listing public groups the
user had never joined). Rows are the slim group record plusmember
and the user's ownmembership{accepted, admin, delegate, title}; each
subgroup's parent (Loomio side-loads it without a visibility check) is
appended flaggedmember: false; deduped by id, sorted byfull_name;
totalis Loomio'smeta.total.start_id/end_id/
stop_after_consecutive_missesare gone from the schema (an older
caller that still sends them is not rejected: unknown keys are dropped
and it gets the same one-call result). A 403 on this path can only be
the key, so the tool throws the
classified message instead of returning[]. The description says
what "visible" means and that a publicly visible non-member group is
readable by id with get_group / list_discussions / list_polls.get_user_activityreads Loomio's participation report
(GET /b2/reports?section=users&group_scope=custom&group_ids={id},
one call per group, at most 4 in flight) plus ONE author-mode search
forsample_events— N + 1 calls for N groups instead of the
0.0.11 walk over every discussion's event stream (~200 calls, and
impossible on Loomio ≥ 3.4). Same input contract (user_id,
group_ids1–50,since,until). The report is month-grained, so
since/untilare widened to whole calendar months and
scope.since_effective/until_effectivestate exactly what was
counted; there is no per-month breakdown per user (the service's
intervalshapes only thebasesection), soby_monthis gone and
the scope note says to call again with a narrower window.counts
are the nine report counters plustotal;by_groupcarries the
group's name, the user's delegate flag andlisted: falsewhen Loomio
has no row for the user there.scope.groups_not_visiblenames
requested groups Loomio dropped (not a member),groups_failedthe
report calls that errored,completeis false when either is
non-empty, and a call in which every report failed throws instead
of returning zeros. A 403 aborts at once — on/b2/reportsit can only
be the key — and the abort is shared across the lanes: the requests
already in flight are the last ones issued (a lane whose request
resolves after the abort pulls no further group), which the tests pin
with deferred responses, together with the cap itself. The scope
notes and both report descriptions state Loomio's vote-month rule in
full: a vote is counted in the month its counted stance ROW was
created — when the ballot was issued (the poll opened, or the voter
was added later) or, for a vote Loomio replaced on change
(StanceService.updatebuilds a new row and retires the old one when
the position changes after a reply or after 15 minutes;uncast
likewise), when it was changed — not necessarily the month it was
first cast.list_discussions/get_discussionjoin the topic counters,
drop attachment / link-preview /mentioned_usernamesbloat (keeping
attachments_count), cap the description on the list, slimusers
to {id, name, username} and the show'sgroupto identity + privacy +
counters, addurl, and surfacetotal+scope.statusis still
sent explicitly (defaultopen).list_polls/get_polljoin the topic counters, gate results
(above), fold the poll'spoll_options[]{id, name, priority,
meaning, prompt},current_outcomeand the API user's own
my_stanceonto the record, drop the chart / result-presentation
knobs Loomio's Vue client reads, capdetailson the list, addurl,
totalandscope. Alist_pollsROW is slimmer than the show: it
dropsresults[]/stv_results(per option: up to 50voter_ids,
avoter_scoresmap over every voter, colours, ranks, plus a
synthetic "undecided" row naming the non-voters — 30–40 % of a
visible poll's bytes and hundreds of KB of user ids over a 50-row
page) while keepingstance_counts+total_score, which are the
same per-option tallies aligned withpoll_options[]; drops
poll_option_nameswhenpoll_options[]names them; and drops the
type-specific voting knobs Loomio left null.get_pollhas the whole
record.status(activedefault,closed,all) is
sent explicitly and maps 1:1 onto howPollsController#indexreads
it.list_membershipssendscompact=1(the roster needs no topics
join and no group record;usersstill arrives) and reduces each row
to id, user_id, group_id, admin, delegate, title, inviter_id,
created_at, accepted_at anduser_emailwhere Loomio sent it; users
are slimmed to id / name / username with NO email — the only
users[].emailthis endpoint emits is the connector account's own
(AuthorSerializer#include_email?forcurrent_user_id), which is
not roster data.total,returnedand the 0.0.11 empty-roster
scope.note(now mentioning the past-the-end case whenoffsetwas
given) stay.create_discussionsends a NESTED body{"discussion": {…}}and
no longer makes theGET /v1/groups/{id}privacy lookup:privateis
sent only when the caller sets it, and Loomio's own
TopicService.private_defaultapplies otherwise (one call instead of
two, and no v1 request from a key-authenticated connector). After the
create, if Loomio's echo places the thread in a different group than
requested the tool throws naming the created id (misdirected-write
guard) instead of reporting success. The result is shaped like
get_discussion(record + counters +url+ group + users).create_pollsends a NESTED{"poll": {…}}body with the
recipient_*keys both nested and top-level (PollService.invite
reads them from the raw params;create_anonymous_poll_votersfrom
the resource hash). Attaching to a thread usestopic_id—
PermittedParams#poll_attributeshas nodiscussion_id(400) — so a
discussion_idis resolved with onecompact=1GET (resolveThread,
shared with the thread tools) and agroup_idgiven alongside
discussion_idORtopic_idis cross-checked BEFORE anything is
written (409 on a mismatch; withtopic_idthe check reads
GET /b2/threads/{topic_id}— one extra call only when both are
given, since Loomio derives the group from the thread and ignores a
group_idin the body). Post-
create guards throw naming the created id when the echo disagrees on
the thread / group or shows no options although options were sent.
PollTypeEnumgainscheck,questionandstv; per-type rules from
config/poll_types.yml(POLL_TYPE_RULES) refuse what Loomio's model
does not validate:optionsare required for every type except
question(Loomio has NO default options through the API — the 0.0.11
description's "proposal has built-in agree / disagree / abstain" was
wrong; those come from the web client's templates),ranked_choice/
stvneed ≥ 2,count/question/meetingcannot be anonymous,
an anonymous poll needsclosing_at(PollService.invite raises a bare
AccessDeniedfor an anonymous poll that is not open, AFTER the poll
was saved) and may not sethide_resultsother thanuntil_closed.
Every tuning knob Loomio permits is exposed (hide_results,
specified_voters_only,shuffle_options,notify_on_closing_soon,
stance_reason_required,reason_prompt, score / choice bounds,
stv_seats,meeting_duration,can_respond_maybe,tags) — and
notify_on_open, the Poll column (DB default TRUE) that makes Loomio
announce a poll to every eligible voter the moment it opens
(PollService.create→announce_poll_opened; on update,
open_poll_if_readywhen aclosing_atfirst opens a draft):
the common create_poll call (title + options + closing_at) notifies
the whole group unlessnotify_on_open: falseis sent, and the tool
says so instead of "nobody is notified" (which was true only of the
separaterecipient_*/notify_recipientspath). A poll created
WITHOUTclosing_atis saved by Loomio as a draft —opened_atnull,
nobody can vote,Poll.active(the list_polls default) omits it — with
HTTP 200, so the result carriesopenedand, when false, awarning
namingupdate_pollwith a futureclosing_atas the way to open it;
the description movesclosing_atinto the required clause. The
field descriptions state Loomio's API-side defaults, which differ from
its web form's:notify_on_closing_soonis 'nobody' (column default;
the form pre-selects 'undecided_voters'),meeting_durationis unset
(the form uses 60),can_respond_maybeis false (the form: true) —
config/poll_types.yml'sdefaults:reach only poll templates, while
min_score/max_score/dots_per_person/*_stance_choicesDO
fall back to them through the model's readers and need no client
default. Agroup_idgiven withtopic_idis cross-checked BEFORE the
POST (oneGET /b2/threads/{topic_id}), like one given with
discussion_id.create_commentsends FLAT JSON{discussion_id, body, body_format}— exactly what Loomio's own controller test posts —
instead of the form-encoded body with?discussion_id=in the query
(400 on 3.8.1). Replies viaparent_id+parent_type(Comment,
Poll,Stance,Outcome, orDiscussion);parent_typeis
required withparent_idat the schema, because without it Loomio
500s in the ability check.discussion_idtakes the numeric id OR the
short key a URL / search hit hands the caller:comments.parent_idis
an integer column (a key on the wire would be cast to 0 — a thread-less
comment), so a key is resolved with onecompact=1
GET /b2/discussions/{key}before the POST, ascreate_polldoes; a
numeric id costs nothing extra. The result carries the comment's
topic_idso the thread tools can show it in context.*_formaton every create refuses HTML without its format.
description_format(discussions),details_format(polls) and
body_format(comments) are alldefault: "md"columns in Loomio
3.8.1 (db/schema.rb); no service on the b2 write path assigns them and
there is no group-level format setting. An HTML body sent without its
format is therefore stored as Markdown — the browser happens to render
it, but every server-side rendering (notification emails, chatbot
posts, exports: Redcarpet withfilter_html) strips the tags. The
three create schemas now refuse a body that starts with an HTML block
tag unless the format is given (refuseHtmlWithoutFormat,
src/tools/_common.ts), the field descriptions say the default is
'md', and the update schemas say an omitted format keeps the stored
one. (update_poll'sdetailsgets the same guard.)- Every collection tool returns Loomio's exact
meta.totalas
total,returned, and ascopeblock (filters applied, caps, what
was dropped / not visible / truncated, upstream calls made). - Tool catalogue trimmed from ~83 KB to 36 KB (≈ 9 000 tokens) per
session; long-form guidance moved to HOWTO.md. The first 0.0.12
draft'stools/listfor the 24 full-mode tools weighed 82 892 bytes
(37 KB of descriptions, 41 KB of input schemas) plus 3.2 KB of
instructions — about 20 700 tokens every session paid before its
first question, more than most answers. Now 35 611 bytes (7.7 KB
descriptions, 23.0 KB schemas — two thirds of that structural JSON the
zod → JSON Schema conversion emits) and 1 747 chars of instructions;
the 28-tool b3 set is 39.0 KB. Every description is at most 700 chars
(350 fordelete_*/update_comment) in a fixed shape — what it
returns and costs, which sibling to prefer, the one caveat that
changes behaviour — and every field.describe()at most 120 chars
(self-naming fields such astitleorgroup_idcarry none). The
cut text — field-by-field outputs, permission rules, exact 403 / 404
texts, Loomio's counting rules, the 15 thread-item kinds, the per-type
poll option keys — lives in HOWTO.md under "Tool reference", one
subsection per tool. Shared poll fields are described per tool where
the rule differs:update_poll'sclosing_atreads "omitted =
unchanged" (the create-side "effectively REQUIRED" had been copied
onto it and made a rename invent a new deadline) and its
hide_resultsstates the one PATCH rule (until_closedcannot be
left);delete_polland the instructions send "close this poll" to
closing_at= the next full hour, since no close route exists.
tests/server.test.tspins the ceilings
(24 tools ≤ 36 000 bytes, 28 ≤ 40 000, instructions ≤ 1 800 chars,
per-description caps);scripts/catalog-size.mjsmeasures them. get_participation_reportno longer returns zero-activity rows by
default (see Added:limit/include_inactive). A caller that
relied on the all-zero rows to enumerate ever-members passes
include_inactive: true.- Every tool description rewritten for an AI caller: what it
returns, when to use it against its siblings, cost in upstream calls,
privacy notes, Loomio's limits, and the exact 403 / 404 it can hit.
All fourToolAnnotationshints on every tool: readsreadOnlyHint+
idempotentHint(check_joins the read prefixes),update_*
idempotent,delete_*destructive + idempotent,manage_memberships
anddeactivate_userdestructive,openWorldHinteverywhere.
Registration order is discovery → reading → analysis → writes → admin. - 429 message names the remaining fan-out (
get_user_activity's
one report per group) instead of the retired ones. - Health probe sends the groups read profile
(exclude_types=tag translation) so the once-a-minute probe does not
haul tag and translation side-loads it never reads. - Docs rewritten around the new surface: README (tool catalog with
upstream calls, efficiency before / after, deliberately-not-exposed
section, b3 section), NOTES-ON-LOOMIO-API (endpoint-by-endpoint notes
for groups, threads, search, reports, the write bodies; old gotchas
marked historical), DESIGN, HOWTO, SECURITY (search / report exposure,
b3 reads), DEPLOY, INSTALL, OPTIMIZATIONS (upstream cost per tool),
CONTRIBUTING, glama.json (28 tools, one line each).
Removed:
list_events(src/tools/events.ts,tests/events.test.ts). It
readGET /api/v1/events, which Loomio 3.4.0 removed; the
V1EventsRemovedErrorandMAX_SCAN_DISCUSSIONSbudget went with it.
list_thread_itemsis the replacement — and, because the b2 items
route runs as the key's user, it also sees the private threads the
user belongs to, which the anonymous v1 read never did.create_discussion'sGET /v1/groups/{id}privacy resolver
(resolveDiscussionPrivate). The connector now makes exactly one v1
request, the publicGET /v1/boot/versionin the health probe.- Form-encoded write bodies (
PostOptions.encoding: "form"). Every
b2 write is JSON; the 3.1.2-era double-wrapping bug the form path
worked around is gone and the form path itself 400s on 3.8.1. - The
list_groupsid probe and its per-call 500-id cap.
Fixed:
create_discussionandcreate_pollno longer misdirect. The
flat JSON bodies 0.0.11 sent reached Loomio 3.8.1 pre-wrapped by
Rails'wrap_parameterswith only the model's COLUMN names, so
group_id,private,optionsandrecipient_*were silently
dropped: a discussion landed as a group-less private thread and a poll
as a group-less poll with no options — both with HTTP 200 (confirmed
live on 2026-09-20). Nested bodies fix it; the post-create guards make
any recurrence loud.create_commentno longer 400s on Loomio ≥ 3.1.3 (form encoding
with?discussion_id=).list_groupsno longer misses poll-less groups, no longer lists
public groups the user is not in, and no longer costs one request per
candidate id.list_discussions/get_discussioncarryitems_count,
last_activity_at,locked_at,pinned_at,tagsand the other
counters again (they moved totopics[]in Loomio 3.1).- Open
until_votepolls no longer leak their tallies to a caller
whose user has not voted. get_user_activityworks again against Loomio ≥ 3.4 and never
returns zeros for a scan that counted nothing.create_poll's description no longer claims built-in options for
proposals, and the write descriptions no longer claim a format default
that does not exist: 0.0.11'sdescription_format/body_format
said "defaults to Loomio's group default" — Loomio has no such setting
and stores an omitted format asmd(see Changed).list_membershipsno longer discloses the connector account's own
email address (Loomio serialises it on the account's ownusers[]
row for every member group).
Known limitations:
- Write-path verification status. Exercised live on a Loomio 3.8.1
sandbox (2026-09-20, a private group, the connector's user a plain
member): nestedPOST /b2/discussionsandPOST /b2/polls
(standalone and viatopic_idinside a discussion), flat
POST /b2/comments, nestedPATCHon discussions and polls, flat
PATCHon comments, andDELETEon all three (soft discard); the
flat-body misdirection and the 400s for form-encoded / nested comment
bodies and fordiscussion_idon polls were reproduced. Verified
against Loomio's controllers and their tests but not run live:
therecipient_*/notify_recipientsvariants,specified_voters_only
and anonymous polls, replies viaparent_id+parent_type(ANY
parent type — only a top-level comment viadiscussion_idwas run
live),update_discussion'sprivatechange,update_poll's option
adding (the replacement semantics of Loomio'soptions=that make
the connector read-then-merge come frompoll.rb/poll_option.rb
and Loomio's model tests, not from a live run),notify_on_open
(its announce path comes frompoll_service.rband Loomio's
poll_service_test.rb, not from a live run), the draft-pollwarning
path (theopened_at: nullecho shape comes from a live capture of a
poll created withoutclosing_at, the warning itself was not exercised
live), a comment via a short-keydiscussion_id(the resolver is the
same GET the thread tools ran live; the POST after it was not), and
creates in apublic_onlygroup (Gotcha 3 in NOTES-ON-LOOMIO-API.md
is fixed upstream per source, unverified live). update_poll'soptionsread-then-write is not atomic. Loomio's
Poll#options=replaces the whole option set and b2 has no
add-option primitive and no version check (versions_countdoes not
move on an option-only change), so the connector reads the stored
names and PATCHes the union: it never removes an option it SAW, but an
option another editor adds between that read and the write is absent
from the union and Loomio destroys it — with any votes already cast
on it — answering 200 with no signal, and the echo cannot reveal it
(it reflects the post-destruction state). The window is one sequential
round-trip; Loomio's own web client submits the complete set the same
way over a minutes-long form. Avoid concurrent edits of one poll's
options; the descriptions say so.- Search drops, rather than annotates, hidden vote reasons in
query
mode. A caller looking for "the vote about X" will not see Stance
hits whose poll hides results from the connector's user;scope. hidden_stance_hits_droppedcounts them. Author-only mode still lists
them (snippet withheld). This is stricter than Loomio's own search,
which shows a member the full reason. - The tool catalogue is large.
tools/listfor the full 28-tool set
is ~89 KB (~46 KB in read-only mode) plus ~3 KB of server
instructions, paid once per session before the first call — 2.6× the
0.0.11 catalogue (14 tools, 31 KB, no instructions), half from
doubling the tool count and half from richer schemas and the caveats
this release's review added (announce-on-open, draft polls, the
format default, the options window,since, the reply budget). The
descriptions deliberately carry what the model must relay (DESIGN.md)
and every.describe()states the value's Loomio-side semantics; the
duplications that carried no information (the poll-type / option-key
list stated three times, the kinds list twice, theoptions
paragraph twice) were removed.tests/server.test.tspins a 92 KB
ceiling so further growth is a decision, not drift. - Search is capped at 20 results by Loomio (
SearchQuery::RESULT_LIMIT)
with no paging and no total;capped: trueis the only signal that
more exist. Typo tolerance depends on the instance'spg_search_words
vocabulary rebuild. - Thread items are unpaginated upstream: every
list_thread_items
call (andget_discussionwithinclude_items) fetches the whole
thread once; a 3000-item thread costs the same request whether one or
one thousand items are returned. get_user_activityis month-grained (whole calendar months, no
per-user per-month series), counts a vote in the month its counted
stance row was created (ballot issued at poll open or when the voter
was added; a vote Loomio replaced on change counts in the month of the
change), excludes anonymous polls from every vote column, and does
not count content posted as a non-member guest — all properties of
Loomio's report, stated inscope.note.get_participation_reportrows include everyone who ever held a
membership in the groups (revoked and deactivated accounts too);
all-zero rows mean "no counted activity", not "inactive member".until_votegating is client-side and depends on knowing the API
user's own id (from the health probe's groups body); until the first
successful probe the thread tools assume "not voted" and hide, and
scope.own_user_knownsays which case applied.list_threadsfiltersgroup_id/type/sinceclient-side
within the fetched page;totalstays instance-wide, andsince
needs the caller to page untilscope.exhausted(threads with no
last_activity_atare dropped and counted, not dated).- b3 identity lookups cannot resolve a
uidcontaining a dot on
Loomio 3.8.1 (Rails treats the suffix as a format); use the numeric
id. - Deliberately not exposed: b3
update/destroy/redactusers
(irreversible, and Loomio records no actor identity for b3 calls),
/b2/chatbots(group-admin only; the serializer returns the webhook
URL and credential to admins), the participation report'sbaseand
countriessections, andGET /b2/threads/{topic_id}as a tool of
its own (its row is whatlist_threadsreturns, and the items /
markdown tools fill the thread header from their own responses; the
connector reads it only forcreate_poll'stopic_id+group_id
cross-check).