Authoritative contract for exposing the SDK's EverythingService aggregates
(basecamp/basecamp-sdk#435, #438) through the CLI. This supersedes the "open
design question" framing in #585 and the "deliberately not
wired" note in PR #584.
The aggregates are not a new command group. Everything is Basecamp web-UI
vocabulary; these are the account-wide variants of resources the CLI already
owns. Each aggregate lands on the group that owns its noun, reached by the
group's existing leaf list command.
All 16 methods, and the invocation that reaches each. The family was 17 through
SDK v0.10.0; Boosts was removed in v0.11.0, for the reasons under
"boost list — withdrawn" below.
| Command | SDK method | Payload | Paginated |
|---|---|---|---|
messages list --all-projects |
Messages |
.Recordings |
yes |
comments list --all-projects |
Comments |
.Recordings |
yes |
checkins answers --all-projects |
Checkins |
.Recordings |
yes |
forwards list --all-projects |
Forwards |
.Recordings |
yes |
files list --all-projects |
Files |
.Files |
yes |
todos list --all-projects |
OpenTodos |
.Groups |
yes |
todos list --all-projects --status completed |
CompletedTodos |
.Groups |
yes |
todos list --all-projects --unassigned |
UnassignedTodos |
.Groups |
yes |
todos list --all-projects --no-due-date |
NoDueDateTodos |
.Groups |
yes |
todos list --all-projects --overdue |
OverdueTodos |
flat []Todo |
no |
cards list --all-projects |
OpenCards |
.Groups |
yes |
cards list --all-projects --status completed |
CompletedCards |
.Groups |
yes |
cards list --all-projects --unassigned |
UnassignedCards |
.Groups |
yes |
cards list --all-projects --no-due-date |
NoDueDateCards |
.Groups |
yes |
cards list --all-projects --not-now |
NotNowCards |
.Groups |
yes |
cards list --all-projects --overdue |
OverdueCards |
flat []Card |
no |
reports overdue is not replaced or deprecated. It is a lateness-bucketed
report (under a week / over a week / over a month / over three months);
todos list --overdue is a flat oldest-first aggregate. Both stay, and the
distinction is documented rather than resolved.
Determine the scope first, then validate against that scope. Validating a flag under project rules before the account-wide branch is reached is a defect: the paginated aggregates accept any positive page, while several project-scoped commands only permit page 1.
Conversely --page must return ErrUsage against the unpaginated overdue
endpoints, which take no page argument at all.
Three inputs, in order:
- Explicit project —
--project,-p, or--in, accepted both at the root (basecamp --project X todos list, parsed ininternal/cli/root.gointoapp.Flags.Project) and after the group noun. Detection must not rely oncmd.Flags().Changedalone, which misses the root-level form. - Configured project —
app.Config.ProjectID. - Nothing — account-wide.
--all-projects overrides a configured project and conflicts with an
explicit one (ErrUsage). Without it, a user with a configured project could
never reach these listings, and identical scripts would change behavior with
ambient config.
The per-item groups (comments, checkins answers) list the children of one
recording, so a project alone cannot produce a listing — only an item ID can.
Their dispatch is therefore its own truth table, and it overrides the general
precedence above:
| ID/URL | Explicit project | --all-projects |
Result |
|---|---|---|---|
| present | any | absent | item-scoped |
| present | any | present | ErrUsage (conflict) |
| absent | present | present | ErrUsage (conflict) — the general I2 rule still applies |
| absent | present | absent | ErrUsage — ask for an ID |
| absent | absent (configured only) | absent | account-wide; ambient config ignored because it cannot scope this operation |
| absent | absent | present | account-wide, intent pinned |
The fourth row is the deliberate exception to "a configured project selects project scope": for these two commands a configured project is not a scope that can be honored, so it is ignored rather than turned into an error.
boost list is per-item in the same way but has no account-wide row at all
— the aggregate behind it was withdrawn server-side. Every absent-ID case
asks for an ID, and it carries no --all-projects. See "boost list —
withdrawn" under I5.
Every accepted flag must either affect the account-wide result or return an
actionable ErrUsage. This is the primary defect class.
Scope-child flags name a container inside one project and are meaningless
account-wide. Reject by name, including aliases:
--message-board, --inbox, --vault/--folder, --card-table, --column,
--list/--todolist, --todoset, --questionnaire, --event, --by.
A configured todolist is subject to the same rule as a configured project.
Filters with no aggregate equivalent (unsupported --status values) are
rejected, pointing at the command that does answer the question.
--assignee used to be the example here, and no longer is. It was rejected
account-wide because the aggregates had no assignee parameter to map it onto;
SDK v0.12.0 added one, so the flag is answerable in both scopes and the
rejection is gone. What it costs differs sharply by scope — see the filter table
below.
No new flags beyond --all-projects, the endpoint selectors the method
matrix names, the files list filters, and the pagination flags the two
flagless commands needed. The account-wide path reuses the filter and sort flags
a command already has; it does not grow a parallel flag surface for its own
sake.
Pagination is the one place where "reuse only" turned out to be wrong. A command
with no --limit/--page/--all has no way to recover from a server error
mid-crawl and no way to reach past a bounded default, so bare files list
gained all three — see I5. (boost list gained them too, then lost them along
with the account-wide boost feed itself — see below.)
The method matrix reaches eleven distinct todo/card endpoints, and the flags that pick among them do not all exist yet. This is the complete inventory of flags added by this work — anything not listed here is reuse:
| Command | New flag | Selects | Scope |
|---|---|---|---|
| all seven | --all-projects |
account-wide | — |
todos list |
--unassigned |
UnassignedTodos |
account-wide only |
todos list |
--no-due-date |
NoDueDateTodos |
account-wide only |
cards list |
--status (only completed) |
CompletedCards |
account-wide only |
cards list |
--unassigned |
UnassignedCards |
account-wide only |
cards list |
--no-due-date |
NoDueDateCards |
account-wide only |
cards list |
--not-now |
NotNowCards |
account-wide only |
cards list |
--overdue |
OverdueCards |
account-wide only |
todos list |
--due |
filter on the todo aggregates | account-wide only |
cards list |
--assignee |
filter on the card aggregates | account-wide only |
cards list |
--due |
filter on the card aggregates | account-wide only |
files list |
--kind, --person |
filters on Files |
account-wide only — see I5 |
files list |
--limit/-n, --page, --all |
pagination on Files |
account-wide only — see I5 |
Account-wide only means exactly what it means for the files list filters:
the project-scoped path has no equivalent, so passing one with a project in
scope is ErrUsage, not a silent no-op. todos list --status completed,
--completed, and --overdue already exist project-scoped and keep working
there; they merely gain an account-wide meaning.
The selectors are mutually exclusive — each picks one endpoint, and there is
no endpoint that combines two. Passing more than one is ErrUsage naming the
pair.
Sorting flags are reused where they exist and never added. Verified current
state: among these commands only messages list, todos list, and cards list
expose --sort/--reverse. comments list, checkins answers,
forwards list, and bare files list expose neither and gain
neither — an unsorted account-wide feed is the honest result, not a gap to
paper over.
--sort/--reverse are rejected for the grouped todo and card aggregates.
The payload is nested by project, and raw grouped machine output cannot
represent a globally sorted cross-project list; sorting within each group is a
different contract from the existing global --sort and must not be passed off
as it.
They are allowed for the flat results — overdue todos/cards (flat []Todo,
[]Card) and messages list --all-projects (flat []Recording) — where a
client-side sort helper exists or can be adapted.
Everywhere: --reverse without --sort is an error, and sorting precedes
truncation — truncating first would sort only the surviving window.
The aggregates take a single page number: 0 follows every page, N returns
exactly page N.
| Flag | Mapping |
|---|---|
--all |
page 0 — the whole account |
--page N |
page N (any positive N) |
--limit N |
bounds the fetch: walks positive pages until N items are collected, then trims to exactly N |
| default | a bounded cap, per the table below |
--limit bounding the fetch is the point. Fetching page 0 and then truncating
is correct and useless: it downloads the entire account to keep the first
hundred rows, which is what made cards list --limit 3 take 16s while
todos list --limit 3 took none. accountWideCollect is the shared walk.
Three deliberate exceptions, each with a reason that is not "we didn't get to it":
- Sorted
messages listfetches every page before capping. The cap applies after the sort (I4), so every page has to be in hand first. Pinned byTestMessagesListAccountWideSortsBeforeTruncating. - The overdue endpoints (
todos list --overdue,cards list --overdue) are unpaginated — one request returns everything — so--limittrims locally. There is no walk to bound. Withdrawn — there is no account-wide boost listing at all now. See below.boost listkeeps a first-page default.
Pinning each account-wide default to its project-scoped default was a mistake, and it is the mistake this section exists to correct. Project-scoped "all" is one project's items; account-wide "all" is the whole account. The same word describes two very different amounts of work, and on a ~80-project account the account-wide reading of it timed out.
The default is a uniform cap of 100 (accountWideDefaultLimit), with two
rows that differ for stated reasons. --all is how you ask for the account.
| Command | Account-wide default | Notes |
|---|---|---|
messages list |
cap 100 | unchanged |
comments list |
cap 100 | unchanged |
todos list |
cap 100 | unchanged |
cards list |
cap 100 | changed from "all pages" |
checkins answers |
cap 100 | changed from "all pages" |
forwards list |
cap 100 | changed from "all pages" |
files list |
cap 100 | changed from "all pages" |
todos list --overdue |
cap 100 | unpaginated endpoint; accepts --all, rejects --page |
cards list --overdue |
cap 100 | changed from uncapped; same rules as above |
bookmarks list |
cap 100 | personal feed — see below |
drafts list |
cap 100 | personal feed; server caps the full listing at 250 |
Project-scoped defaults are untouched throughout.
The two personal feeds. bookmarks list and drafts list are not
EverythingService methods, and they belong to no project-scoped group — they
are /my/ listings, private to the authenticated user, and there is no
--all-projects to pass because there is no project scope to leave.
They are recorded here anyway, because the invariants are this document's. Both
Bookmarks().List and Drafts().List take a page int32 where 0 means the
SDK follows the Link header across every page — the same spelling, and so the
same trap. A default of "fetch page 0, then trim to --limit" would reintroduce
fetch-everything-then-truncate through a door the aggregates no longer have.
So both reuse accountWideCollect unchanged, and follow the I5 flag table
exactly: bounded walk to 100 by default, --limit N walks to N, --page N is
exactly one request, and --all is the only path that reaches page 0. Leaving
them undocumented is how the contract erodes — the next /my/ feed would have
no precedent to copy.
checkins reminders is a third /my/ feed but sits outside this table: its
options struct does not honor a page number at all, so it takes --limit (a
real SDK-side bound) and deliberately registers no --page. Under I3 a flag
that cannot act on its value is worse than an absent one.
The two overdue rows. Both endpoints are unpaginated, so --page has
nothing to address and stays an error. --all is a different question: it means
"skip the cap", and since the complete array is already in hand it costs no
extra request. Previously todos --overdue capped at 100 and rejected --all,
which left item 101 unreachable by any flag combination — capped with no escape
hatch. cards --overdue was uncapped and also rejected --all.
Changing a default is allowed when the old one was wrong. The earlier version of this section forbade moving a default in either direction. That rule protected a contract that could not be honored at scale. What must not happen is a default changing silently: each row marked changed above is a documented behavior change and belongs in the release notes.
- Every account-wide listing needs a bounded default and a way to ask for
more. A listing with no escape hatch cannot recover from a server error
mid-crawl: when
files list --all-projectsreturned a 500 partway through the full-account crawl, there was no flag that could step around the failing page, because the command had none. A bounded default without a way past it is the same defect from the other side. - A registered flag must work on every path that accepts it. Where a path
genuinely has no pagination, it rejects all three by name rather than
accepting and ignoring them (
rejectScopedPaginationFlags):- Project-scoped
files listrejects--limit/--page/--all. That path passesniltoVaults().List,Uploads().List, andDocuments().List— three unpaginated calls with nowhere to put a page. boost listcarries none of the three at all. The SDK documentsBoostListOptions.Pageas not honoring the page number — settingPage=2does not fetch page 2 — and the account-wide feed they were added for is gone.
- Project-scoped
--limit Non grouped todo/card responses counts inner todos/cards, not outer project groups. Truncating groups would silently drop whole projects.Meta.TotalCountcounts groups, not items, for the grouped responses.accountWideRespOptstherefore must not be used for grouped item-count notices — the command computes its own item total and builds its own summary.- Explicit
--page 0, a negative--page, and a negative--limitreturnErrUsage.--page 0means "unset" to Cobra's default but "follow every page" to the SDK; accepting it silently would hand the user a full-account crawl they did not ask for, and that is the same no-silent-flags defect I3 names.--allis the spelling for every page.
There is no account-wide boost listing. boost list requires an item ID.
The /boosts.json aggregate this section used to document was an easter egg —
unlinked from the Basecamp web UI, ~2,250 requests per 30 days globally, and
this CLI its only known consumer. BC5 withdrew it (bc3#12464), and the path
now 404s on both the web and API hosts, so the CLI no longer calls it. The SDK
dropped Everything().Boosts() to match in v0.11.0 (basecamp/basecamp-sdk#504),
so the operation is not merely unused here — it no longer exists.
The cause, from BC3's own diagnosis rather than inference: the query's cost is proportional to the account's accessible recordings, not its boosts. That is why it measured ~44s on the largest account and why page 40 cost the same as page 1 (bc3#12458 has the timings).
The withdrawal is temporary. The feed is expected back on a
boost-proportional query, via a boosts.bucket_id denormalization, with the
design record in bc3#12463. Public notice: basecamp/bc-api#427. So this section
describes a listing that is gone for now, not one that was judged a bad idea.
What that means here:
boost listtakes an item ID, and rejecting a bare invocation is the honest answer rather than a fallback to an endpoint that will 404.- It carries no
--all-projects,--limit,--page, or--all. An item's boosts arrive in one unpaginated response, and the SDK documentsBoostListOptions.Pageas not honoring a page number, so there would be nothing for those flags to address even if the aggregate had survived. Everything().Boosts()is being removed from the SDK too. The initial brief asked that it stay — churning every generated client twice for a temporary withdrawal seemed worse than letting it 404 — but the endpoint is going away for real on the server, so the SDK drops it rather than ship an operation that cannot work. That is the SDK's call; this repo only has to not call it, which it already does. The CLI carries zero references to the aggregate, so the SDK bump that removes it needs no CLI change.
Everything else is untouched: the other Everything feeds, the bucket-scoped
boosts endpoints, and the boosts_count/boosts_url recording attributes.
Seven commands list account-wide, not eight — until the feed returns, at which point this is the section to revisit.
EverythingFilesOptions carries filters the project-scoped path has no
equivalent for: Vaults, Uploads, and Documents List() take neither a
kind nor a creator. Rather than leave adopted SDK surface unreachable, bare
files list gains two flags:
| Flag | Value | Maps to |
|---|---|---|
--kind |
all, images, pdfs, documents, videos |
EverythingFilesOptions.Kind |
--person |
repeatable; name, email, ID, or me, resolved via resolvePersonRoleID(ctx, app, person, "Person") |
EverythingFilesOptions.PeopleIDs |
Both are account-wide-only. Passing either with a project in scope —
explicit, configured, or via --vault/--folder — is ErrUsage, because the
project-scoped path would have to ignore them, and I3 forbids that. An
unrecognized --kind value is ErrUsage listing the accepted set.
These filters are account-wide-only by nature rather than by policy: the project-scoped path has nothing to map them onto.
EverythingTaskFilters (SDK v0.12.0) is a trailing parameter on 11 of the 16
aggregate methods — the nine paginated todo and card selectors plus the two
unpaginated overdue endpoints. Two flags map onto it:
| Flag | Value | Maps to |
|---|---|---|
--assignee |
repeatable, and comma-separated within a value; name, email, ID, or me, resolved via resolvePersonRoleIDs(ctx, app, input, "Assignee") |
EverythingTaskFilters.AssigneeIDs |
--due |
with, without, overdue |
EverythingTaskFilters.Due |
--due is account-wide-only on both groups, and --assignee is
account-wide-only on cards, which had none before. On todos, --assignee
already existed project-scoped and now works in both.
The same flag means two different things by scope, and that is worth saying
out loud rather than papering over. Account-wide it is a real assignee_ids[]
query parameter: the server narrows the listing before it paginates, so the
filter never turns the bounded walk into a full crawl — the walk stays bounded
by the item cap exactly as it is unfiltered.
It does not follow that the request count is identical. The cap counts items,
so a narrower filter returns fewer of them per page and can need one more page
to reach the same cap: soaked against account 2914079, todos list --all-projects took 2 requests for 100 todos and --assignee 3 took 3 for its
21. That is the walk working, not leaking. What must never happen is the filter
pushing the walk toward page 0, and a test pins that.
Project-scoped there is no server-side assignee parameter at all, so the filter runs client-side over an unlimited fetch — the project path already disables its own limit whenever an assignee is set. Same spelling, same semantics, very different cost.
The semantics are matched deliberately: project-scoped --assignee now matches
any of the named people, the way assignee_ids[] does. Before it took one
value; widening it from StringVar to StringArrayVar changes the .surface
type line, and since TestSurfaceSnapshot compares whole lines, the old line
reads as a removal and is acknowledged in .surface-breaking.
Carry the SDK's own caveat into help text: the filter matches the task's own assignees, and assignees on nested steps are not considered — a card whose step is assigned to someone does not match on that basis.
Two rejections, both before any request is issued:
--assigneewith--unassigned. The server builds that selector astodos_recordings.remaining.not_assignedover a relation the assignee filter has already narrowed (bc3:app/controllers/concerns/everything/todos/recordings.rb:24). The intersection is necessarily empty, so the combination would return zero rows that look like a real answer. Same rule for the card selector.--duewith--overdueor--no-due-date. Those two each select their own endpoint on the same axis--duenarrows, so combining them asks two endpoints for one answer.
internal/dateparse is deliberately not involved: these are category tokens,
not dates. An unrecognized --due value is ErrUsage naming the accepted set.
vaults (aliases vault, folders) and docs (alias documents) are
NewFilesCmd under different names, so they share this leaf. The account-wide feed
is not the same listing the project-scoped path returns: it carries Uploads,
Documents, and Attachments, and no folder variant at all.
Sharing the leaf unchanged would make folders list --all-projects return a
listing containing none of the thing the command is named for. Each spelling
therefore gets its own account-wide meaning:
| Spelling | Account-wide behavior |
|---|---|
files list |
the feed, --kind free, bounded by the cap and its pagination flags |
vaults / folders list |
ErrUsage — folders have no account-wide listing; points at the project-scoped form and at files list --all-projects |
docs / documents list |
the feed pinned to --kind documents; an explicit --kind is ErrUsage, since the command name already chose |
The project-scoped behavior of all three is unchanged.
Project-scoped paths return concrete types ([]Message, []Todo). Aggregate
paths return []Recording, []EverythingFile, or nested bucket groups.
Handing nested groups to the styled renderer produces unreadable nested cells.
Branch on app.Output.EffectiveFormat(), following the humanizeSearchResults
precedent in internal/commands/search.go:
- machine formats (
--json,--agent,--md): raw SDK payload, grouping preserved. - styled: flattened rows carrying at least project name, id, and title/subject, plus status and due date where applicable.
Which payloads actually need flattening, so the seven commands do not each answer this differently:
| Payload | Commands | Styled treatment |
|---|---|---|
[]Recording |
messages, comments, checkins answers, forwards | flatten — the generic renderer drops the nested bucket, and a project column is exactly what an account-wide row needs |
[]Todo, []Card (flat overdue) |
todos, cards --overdue |
flatten — same reason as []Recording; the items come from every project and bucket is skipped by name |
[]EverythingFile |
files | flatten — all-pointer superset, too wide to render raw |
[]BucketTodosGroup, []BucketCardsGroup |
todos, cards | flatten — nested groups render as unreadable cells |
Every account-wide payload flattens. The rule is not about nesting depth —
it is that internal/output/render.go skips bucket by name in both generic
renderers, so any aggregate handed over raw loses the one column that makes a
cross-project row attributable. "Renders fine project-scoped" is never the test:
project-scoped output needs no project column. recordings list hands
[]Recording over raw for exactly that reason, and it is not a precedent here.
The same goes for WithEntity. A schema that renders a task list has no column
for a project, so the account-wide overdue todo listing drops the entity rather
than lose the attribution.
Mechanism. Flattening is supplied through output.WithDisplayData, not by
branching on EffectiveFormat(). Data stays the raw SDK payload so --json
and --agent keep it; DisplayData carries the flat rows that styled,
--md, --ids, and --count all read. Branching on the format instead leaves
the other three reading the nested payload — where --count counts project
groups and --ids finds no ids at all, both silently.
EverythingFile is an all-pointer superset over the Upload, Document, and
Attachment variants — every field read during flattening must be nil-checked.
ensureProject() is unreachable on the account-wide path. The bare command
group still shows help; only the leaf list invocation lists account-wide.
- The interactive project prompt no longer fires on these list commands when no project is configured; they list account-wide instead.
todos listwith no project and--overdue/--assigneepreviously errored with a redirect. Both now return results:--overduesince the bounded-walk work, and--assigneesince SDK v0.12.0 gave the aggregates an assignee parameter.todos list --assigneeis now repeatable and matches any of the named people. It was single-valued; the.surfacetype line changes fromstringtostringArray, acknowledged in.surface-breaking.cards listgains--assignee(account-wide only). The group's agent note used to say cards do not support assignee filtering at all; that is now true only of the project-scoped path.- Both groups gain
--due with|without|overdue, account-wide only, rejected alongside--overdueand--no-due-date. --assigneewith--unassignedis now a usage error on both groups. The combination was previously reachable oncardsonly by not having the flag; it is refused because the server makes it necessarily empty.