Canonical contract: the binding decisions about command surface, output envelope, error codes, and divergences from Monday's API live in
cli-design.md. This file describes the internal module structure that implements that contract.
- AI-agent ergonomics. Output should be predictable, parseable
(default
--output json), and stable across versions. Errors should carry enough structured context that an agent can recover or escalate without scraping prose. Side-effecting commands should be idempotent where the API allows. - Type safety. Every Monday entity that crosses a module boundary has an explicit type. Parsing is done at the edge (zod for env/argv, the SDK's generated types for GraphQL responses) so internal code only handles validated data.
- Composability. A user (human or agent) should be able to chain
commands with shell pipes — e.g.
monday items list ... | jq ... | xargs monday items update .... This shapes I/O design more than any single command. - Human ergonomics, second. Pretty tables, colour, spinners — but
only when stdout is a TTY and
--output jsonis not set.
┌──────────┐
│ cli/ │ argv parsing, signal/abort plumbing, envelope emission
└────┬─────┘
│ resolves Config, dispatches to a command module via the registry
▼
┌──────────┐
│ commands/│ one CommandModule per subcommand: parses argv, calls
└────┬─────┘ api/, validates output, emits the §6 envelope
│
▼
┌──────────┐
│ api/ │ MondayClient over an injectable Transport: typed
└────┬─────┘ errors, retries, abort threading, complexity surfacing
│
▼
┌──────────┐
│ Monday │ network — never reached directly from commands/
└──────────┘
cli/index.ts— 5-line shebang entry; thinrunWithSignalswrapper.cli/run.ts— testable runner: argv → commander parse → action body → envelope → exit code. Combines a caller-supplied abort signal with an internal one so SIGINT cancels in-flight work.cli/program.ts(M2.5; profile-resolution preAction hook added at v0.3-M21 Part 1 ina4cb5b0) — commander wiring: program metadata, global flag declarations, command registration. Pulled out ofrun.tsso the runner stays focused on I/O plumbing. The preAction hook (originally M2.5 for the api-version commit, extended at M21 Part 1) runs before any subcommand action and resolves the active profile when--profile <name>orMONDAY_PROFILEenv is set OR aconfig.tomlwithdefault_profileexists; injects the resolved token intoctx.env.MONDAY_API_TOKENso downstreamloadConfig/resolveClientcalls Just Work via the standard env-var path. Auth verbs (auth login,auth logout) are exempt — they're the source of credentials, not consumers. Profile-levelapi_versionoverride (cli-design §7.2) threads through the same hook: global--api-versionflag wins, but profile-setapi_versionlands inctx.env.MONDAY_API_VERSION+ctx.meta.setApiVersion(...)when the flag is absent.cli/envelope-out.ts(M2.5) —MetaBuilder+writeErrorEnvelope. The error path's source of truth formeta.api_version/meta.sourceso a thrown action error carries the same meta a success would.
commands/types.ts—CommandModule<I, O>interface + theensureSubcommandidempotent noun-creator.ensureSubcommand(program, name, summary?)looks the description up inNOUN_DESCRIPTIONS(seecommands/noun-descriptions.ts) — the single source of truth across the registered command surface. The explicitsummaryoverride is retained for ad-hoc / test injections (exercised bytests/unit/commands/types.test.ts).commands/noun-descriptions.ts(v0.10-M53) — single source of truth for noun-level command descriptions. One entry per registered top-level noun + group-tier noun (~18 entries total). Replaces ~100 duplicate'<noun>', '<desc>'literals that previously lived in every verb file'sattach().lookupNounDescription(name)throwsInternalErrorwithdetails.reason: 'unknown_noun'on a missing entry — caught at registration walk, not silently at first--help.commands/index.ts— static registry.cli/program.tswalks it to attach commands;commands/schema/index.tswalks the same registry to emit JSON Schema 2020-12.commands/emit.ts—emitSuccessbuilds the §6 success envelope. Owns format selection (json / table / text / ndjson), the collection-meta passthrough (nextCursor,hasMore,totalReturned,columns), and final-byte token redaction.commands/parse-argv.ts(M3) — wrapsschema.safeParseso positional/flag failures land asusage_error(exit 1), never the runner's catch-allinternal_error(exit 2). Mandatory at every argv parse boundary pervalidation.md.commands/<noun>/<verb>.ts— the action. Parses argv viaparseArgv, calls intoapi/, projects to the strict output schema, callsemitSuccess({ ...toEmit(result) }).commands/run-by-id-lookup.ts(M4 R7) — shared get-by-id action helper covering theparseArgv → resolveClient → client.raw → not_found-on-empty → emitshape used byworkspace get,board get,user get,update get,item get. Optionalprojectcallback for shapes that need a parse-then-project step (item get uses it for the column-value projection).
-
api/transport.ts—Transportinterface +FetchTransport. Owns header lockdown (Authorization,API-Version,Content-Typeare transport-controlled; caller headers can't override) and timeout + abort signal combination. -
api/client.ts—MondayClientoverTransport. Typed wrappers for the M2 account queries;client.raw<T>(query, vars, opts)is the escape hatch for queries the SDK doesn't type (hierarchy_type/is_leafin M3, futureBatteryValueetc.). -
api/resolve-client.ts(M2.5) —resolveClient(ctx, programOpts)returns{ client, globalFlags, apiVersion, toEmit }. Every network command calls this once at the action's top. -
api/errors.ts— maps Monday HTTP / GraphQL responses to the 26 stable CLI error codes from §6.5. -
api/retry.ts— exponential backoff + jitter, honoursretry_after_secondsand the abort signal. -
api/cache.ts— disk-backed cache primitives: per-board metadata, per-account user directory, schema version pin.0600mode + atomic writes (tmp + rename) + permission verification on read. -
api/board-metadata.ts(M3) — cache-awareloadBoardMetadatashared byboard describe/columns/groups/views(v0.9-M52 added the views surface). Returns{ metadata, source, cacheAgeSeconds, complexity }so verbose-mode complexity flows through cache-aware commands. -
api/columns.ts(M3) — read-side §5.3 column resolver:resolveColumn(metadata, token, options) → ColumnMatch(pure) +resolveColumnWithRefresh({...})(auto-refreshes once oncolumn_not_foundafter a cache hit). M5a's value translator will reuse this for--set <token>=<value>. -
api/resolvers.ts(M3) —findOne(haystack, query, project, options)for thefindverb (NFC + case-fold +--first); plususerByEmailwith directory-cache +users(emails:)fallback for M5a's people-column writes. -
api/walk-pages.ts(M3) — page-based pagination walker with--limit-pagescap +pagination_cap_reachedwarning. Used by every page-based list command (workspace/board/user/update). Cursor-based pagination has a fundamentally different contract (§5.6 stale-cursor fail-fast, no silent re-issue) and ships in its own module — seeapi/pagination.tsbelow. -
api/pagination.ts(M4) — cursor-based pagination walker foritems_page/next_items_page.paginate({fetchInitial, fetchNext, extractPage, getId, all, limit, pageSize, onItem, now})honours the §5.6 contract: 60-min cursor lifetime, fail-fast onstale_cursorwith enricheddetails.cursor_age_seconds / items_returned_so_far / last_item_id, never silently re-issued. Per-call effective limit =min(pageSize, remainingBudget)so Monday's cursor advances over exactly the rows the walker emits — no silent skip on--limit < pageSizeresume.onItemis the streaming hook foritem list --output ndjson. Used byitem list / search / find. -
api/filters.ts(M4) —--whereparser +--filter-jsonpassthrough.parseWhereSyntax(raw)is pure (testable without network);buildFilterRules({metadata, resolveMe, clauses, onColumnNotFound})resolves tokens via M3's column resolver and emits Monday'squery_params.rules.onColumnNotFoundfires once on a cache-miss to honour §5.3 step 5; the result'srefreshedflag drives the caller'smeta.source: 'mixed'decision. Operator allowlist:=,!=,~=,<,<=,>,>=,:is_empty,:is_not_empty.--where/--filter-jsonare mutually exclusive. -
api/sort.ts(M4) — per-page numeric-ID-asc sort (compareNumericId+sortByIdAsc). Length-then-lex tuple handles IDs past 2^53 correctly (string-lex sort fails:"9" > "10";Number.parseIntloses precision on large IDs). Centralised here so the rule applies identically acrossitem list / search / find / subitemsper §3.1 #8. -
api/item-projection.ts(M4) —rawItemSchema(parse boundary for items_page item shapes) +projectItem({raw, columnTitles?, omitColumnTitles?})(canonical §6.2 single-item shape). Single- resource calls keep per-celltitleinline (§6.2); collection calls drop per-celltitleand letmeta.columnscarry the canonical view (§6.3). Typed inline fields (label/index,date/time,people: [...]) for the v0.1-allowlisted writable types; other types surfacetext + value.idFromRawItemexposes the defensive id-reader the cursor walker needs for the per-page sort. -
api/column-types.ts(M5a R8 + M8) — single source of truth for the writable allowlist + roadmap-category gates.WRITABLE_COLUMN_TYPES(frozenas constarray — 10 entries post-M8; order is part of the contract; tests iterate it),isWritableColumnType(type guard narrowing to theWritableColumnTypeunion),parseColumnSettings(defensivesettings_strJSON parser that returnsnullon null/empty/ malformed input). M8 additions:isReadOnlyForeverType/isFilesShapedType(gate--set-raw's post-resolution reject lists per cli-design §5.3 escape-hatch contract);getColumnRoadmapCategory(drives the category-accurateunsupported_column_typehint — read-only-forever / future). M19 close graduated the full v0.2 tentative row (tags/board_relation/dependency) intoWRITABLE_COLUMN_TYPES; thev0_2_writer_expansioncategory branch is now unreachable through the runtime classifier (kept as documented dead code for future tentative-row revival). Three consumers:commands/board/describe.ts(writable + example_set),api/column-values.ts(friendly writer),api/raw-write.ts(M8 escape hatch). -
api/column-values.ts(M5a + M8 + M9) — write half of §5.3. Two entry points: the synctranslateColumnValue({ column, value, dateResolution? }) → TranslatedColumnValuecovers the locally-resolvable types; the asynctranslateColumnValueAsync({ column, value, dateResolution?, peopleResolution? }) → Promise<TranslatedColumnValue>is the unified wrapper the command layer always calls (delegates to sync for non-people; dispatchesparsePeopleInputforpeople, which needs network/cache lookup for email→ID resolution). The result type returnscolumnId,columnType,rawInput, a discriminatedpayload, and parallel echo slots (resolvedFromfor relative dates;peopleResolutionfor people inputs). Payload variants:{format:'simple', value:string}for the bare-string form (change_simple_column_value) or{format:'rich', value:JsonObject}for the JSON-object form (change_column_value/ per-column entry ofcolumn_valuesmaps). Ten types translate (7 v0.1 + 3 M8 firm-row):text/long_text/numbers(simple) andstatus/dropdown/date/people/link/email/phone(rich).selectMutationdispatches per cli-design §5.3 step 5: 1 simple →change_simple_column_value; 1 rich →change_column_value; N →change_multiple_column_values(atomic).bundleColumnValues(M9 lift) — projects a list ofTranslatedColumnValues into thecolumn_values: JSON!map shape every Monday mutation that takes one accepts (change_multiple_column_values,create_item,create_subitem). Thelong_textre-wrap ({text:<value>}inside the map vs. bare-string inchange_simple_column_value) applies uniformly across every wire surface;selectMutationdelegates for its multi case so the rule can't drift between update and create. MondayJSONscalar discipline: every payload is a plain JS value typed asJsonObject(R-JsonValue refactor — catches non-JSON shapes likeundefined/ symbols at compile time); the SDK / fetch layer stringifies at the wire boundary. The translator neverJSON.stringifys — pinned by regression tests per (count × type) cell, including the create_item.column_values shape pin (M9). -
api/raw-write.ts(M8) —--set-raw <col>=<json>escape-hatch helpers.parseSetRawExpression(raw)is argv-parse-time: splits<col>=<json>on first=, validates the JSON parses to aJsonObject(string / number / array / null at top level rejected withusage_error+ the parse error indetails).translateRawColumnValue(column, value, rawJson)is post-resolution: rejects read-only-forever types (unsupported_column_typewithread_only: true) and files- shaped types (unsupported_column_type, rejection STAYS at v0.6-M38 per D3 closure — PERMANENT for--set-rawbecauseadd_file_to_columnis multipart-only andchange_column_valuehas no JSON-shape for file columns; the hint names BOTH M38 friendly--set <file-col>=<path>(monday item set/monday item update) AND M31 verb-shapedmonday item uploadwrite paths reaching the multipart wire); otherwise builds aTranslatedColumnValuewithpayload: {format: 'rich', value: <parsed>}soselectMutation/bundleColumnValueshandle it uniformly. Per cli-design §5.3 line 949–960: no per-type schema validation — Monday's server-side rejection surfaces asvalidation_failedwith Monday's message. Mutual exclusion with--setis the caller's concern (resolution-time enforcement per §5.3 step 2; the cross-token duplicate-resolved-id check indry-run.ts/ each mutation command catches it). -
api/set-expression.ts(M9.5 R22) —splitSetExpression(raw) → {token, value}, the--set <col>=<val>argv splitter shared byitem set/item update(single + bulk) /item create. Lifted from three identical 12-line copies; the M8 siblingparseSetRawExpressioninraw-write.tskeeps its own copy of the first-=rule because its JSON-parsing concerns aren't duplicated. -
api/links.ts/api/emails.ts/api/phones.ts(M8) — the firm-row friendly translators.parseLinkInput("url|text")→{url, text}(pipe-form; no auto-extraction of hostnames as text).parseEmailInput(pipe-formemail|textor bare email — bare emails settextto the email).parsePhoneInputiso-country-codes.ts— E.164 input with explicitphone:countryCodepayload; the country-code allowlist comes from a frozen ISO 3166-1 alpha-2 list.
-
api/dates.ts(M5a) — pure date helpers powering the date translator.parseDateInputaccepts ISO date / ISO date+time / relative tokens (today/tomorrow/+Nd/-Nw/+Nh) and resolves relative inputs against an injected clock + IANA timezone (defaults to system clock + system tz; M5b's command layer plumbsMONDAY_TIMEZONE).+Nd/+Nwuse calendar-component arithmetic in the resolution tz so DST is irrelevant;+Nhuses instant arithmetic so wall- clock hour shifts ±1 across a DST boundary day, matching industry-standardInstant.addsemantics. Relative offsets are bounded to ±100 years magnitude (MAX_RELATIVE_DAYS/MAX_RELATIVE_HOURS) so unsafe inputs surface as typedusage_error.formatNowInTimezonebuilds the local-time ISO + longOffset string cli-design §5.3 line 786 pins (2026-04-25T14:00:00+01:00). -
api/people.ts(M5a) — pure people-resolution helpers powering the people translator.parsePeopleInputaccepts comma-split tokens (emails or case-insensitiveme), trim+filter empty segments, then resolves each through an injectedPeopleResolutionContextcarryingresolveMe(mirrorsfilters.ts's slot — samemerule across--where Owner=meand--set Owner=me) andresolveEmail(M5b wires this toresolvers.userByEmail). Wire shape{personsAndTeams:[{id:N,kind:'person'},...]}withidas JS number;kindliteral'person'only (teams deferred to v0.2).meresolution cached within a single call (inputme,me,meresolves once). Defence-in-depth ID validation via the sharedDECIMAL_USER_ID_PATTERN(R16, exported fromsrc/types/ids.ts) matched against the same shapeuserByEmail's schema enforces — malformed IDs surface asinternal_errorrather than silently corrupting the wire payload viaNumber(). Numeric tokens (--set Owner=12345) rejected withusage_error; the hint points at the email form first and surfaces the M8--set-rawescape hatch as the path for raw IDs. M18 release-prep dropped thedeferred_toslot from this error —--set-rawexists today, so the rejection isn't a deferral; it's an input-grammar limitation with a documented escape. Unknown emails bubbleuser_not_foundfrom the resolveEmail callback per cli-design §5.3 line 733.parsePeopleInputalso returns aresolution: PeopleResolutionecho (one{input, resolved_id}entry per non-empty input token) the dry-run engine renders asdetails.resolved_fromon the diff cell. M19→M20 cleanup-window widening (2cbf0d3):resolveEmailwas widened to returnResolveEmailResult = {id, source, cacheAgeSeconds}— transparent passthrough ofuserByEmail's public shape, mirroring the M19 tags translator'sresolveTagscallback.parsePeopleInputaggregates per-leg viaSourceAggregator(R21) —merecords'live'(me { id }is always a network call), email tokens forwarduserByEmail's{source, cacheAgeSeconds}verbatim — and exposes the aggregate onParsedPeopleInput's newsource+cacheAgeSecondsslots. The people translator threads the aggregate intotranslatorResolutionfor envelope-level merge by the post-translate aggregation loop inresolution-pass.ts. Closes the v0.3-plan §11 M19 post- mortem parity gap where cache-hit email lookups didn't contribute tometa.source. -
api/resolution-context.ts(M9.5 R24) —buildResolutionContexts({client, ctx, globalFlags}) → {dateResolution, peopleResolution, tagResolution, relationResolution}. Builds the four contexts every mutation surface that calls intotranslateColumnValueAsync/planChanges/planCreateneeds, closing overctx.clock+ctx.env.MONDAY_TIMEZONE(date side),client+ctx.env+globalFlags.noCache(people side viaresolveMeFactory+userByEmail— M19→M20 forwards the full{id, source, cacheAgeSeconds}provenance, not just the id),client+env+noCache(tags side viaresolveTagsagainst the per-account directory), andclient+env+noCache(relation side viavalidateBoardRelationItemsfor bothboard_relationanddependency— the per-noun divergence is thecontextdiscriminant the translator passes through, not a second callback). Lifted from four identical 12-line copies inset.ts/update.ts(single + bulk) /create.ts; widened to four contexts at M19 (Commits 2 / 3 / 4). Pure function; the natural seam forme-token caching across translate calls in one command run (still candidate work — see v0.3-plan §22). -
api/item-board-lookup.ts(M9.5 R23) — sharedITEM_BOARD_LOOKUP_QUERY+ITEM_PARENT_LOOKUP_QUERYGraphQL strings, their zod parse-boundary schemas (boardLookupResponseSchema/parentLookupResponseSchema), plus three helpers:lookupItemBoard({client, itemId, label?, detailKey?})(M5b shape — id + board),lookupItemBoardWithHierarchy(M9 shape — addshierarchy_type; originally drove the M9 subitem gate, now read-but-unused after M50's unified dispatch and retained as a regression-guard affordance — M51 surfaceshierarchy_typevia the separate board projection, not this fetch),resolveBoardId({client, itemId, explicit})(the--board?? lookup wrapper).set/clear/update/create.tseach shed ~70 LOC of duplicated GraphQL + schema + resolver. Error labels caller-supplied (Item/Parent item/--relative-to item) preserve consumer voice; null-board / not-found error codes (not_found) and detail keys (item_id/parent_item_id/relative_to_id) match every original site byte-for-byte. -
api/me-token.ts(M5a R15) — sharedisMeToken(token)recogniser plus the frozenME_TOKENSarray. Three consumers:filters.ts(--where Owner=me),commands/item/search.ts(search filter), andapi/people.ts(--set Owner=me). One rule across all three surfaces per cli-design §5.3 step 3 line 704-707; lifted out of three verbatim copies that had drifted across the people-session Codex passes. v0.2 grammar extensions (i/@me) land by adding to the array. -
api/dry-run.ts(M5a + M9) — two orchestrators behind a shared resolution discipline.planChanges({client, boardId, itemId, setEntries, rawEntries?, nameChange?, dateResolution?, peopleResolution?, env?, noCache?})(M5a) powers M5b's mutation surfaces (item set/item clear/item update) when--dry-runis set. Ties together the M3 cache-aware column resolver (withincludeArchived: trueso archived targets surface ascolumn_archived, notcolumn_not_found),translateColumnValueAsync+translateRawColumnValue+selectMutationfor the wire shape, and a fresh item-state read for the difffromside.planCreate({client, mode, name, setEntries, rawEntries?, dateResolution?, peopleResolution?, env?, noCache?})(M9) powersmonday item create's--dry-runpath. Same three- pass resolution + same all-or-nothing semantics, but no item-state read (the item doesn't exist;fromis alwaysnull) and the difftoside projects throughbundleColumnValues(the shared map shape, not single-column variants) so the long_text re-wrap surfaces in the diff exactly as it would on the wire. TheCreateModediscriminator ({kind: 'item', boardId, groupId?, position?}vs{kind: 'subitem', parentItemId, subitemsBoardId}) drives theCreatePlannedChangeshape, which hoistsname/group_id/position/parent_item_idto top-level slots per cli-design §6.4 item-create variant. Subitem variant omitsboard_idbecause Monday derives the subitems board server-side. Output matches cli-design §6.4'splanned_changes[]shape byte-for-byte for both orchestrators, pinned via envelope-snapshot tests (the load-bearing exit gate). All-or-nothing: any resolution failure (column_not_found,ambiguous_column,column_archived,unsupported_column_type,user_not_found, itemnot_found(planChanges only), item-on-wrong-board (planChanges only), duplicate token, duplicate resolved id) aborts the batch before any further work. Resolver warnings on acolumn_archivedthrow fold intoerror.details.resolver_warningsviafoldResolverWarningsIntoErrorso a stale-cache-then- archived flow keeps both signals. The engine's ownrawItemSchema.parseboundary (planChanges) uses safeParse +ApiError(internal_error)per validation.md (mirrors R17 + the userByEmail wrap + R18 across every later parse boundary). -
api/source-aggregator.ts(M9.5 R21) — the §6.1meta.source+meta.cache_age_secondsmerge rules as three exports:mergeSource(current, next)(3-state aggregator: first leg seeds,mixedis contagious,cache + live → mixed),mergeSourceWithPreflight(planner, preflight)(4-state variant collapsing'none'to the preflight when any preflight leg fired — used byitem createfor parent-lookup + parent-board metadata + relative- to legs that fire pre-planner),mergeCacheAge(current, next)(null-awareMath.maxfor worst-case staleness). Lifted from four sites: dry-run.ts (private), update.tsmergeSourceForRemap+ five inlineMath.maxcache-age folds, create.tsmergeSourceLeg+mergeSourceWithPreflight+mergeCacheAgeWithPreflight. Every multi-leg envelope now folds source/age through this module so the rule is one source of truth. -
api/resolution-pass.ts(M9.5 R20) —resolveAndTranslate({client, boardId, setEntries, rawEntries, dateResolution?, peopleResolution?, env?, noCache?, initialSource?, initialCacheAgeSeconds?}) → {resolved, translated, resolvedIds, warnings, source, cacheAgeSeconds}. Lifts the ~80-90 LOC three- pass discipline from five sites (planChanges,planCreate,update.tssingle,update.tsbulk,create.ts) into one helper: pass (a) resolve every--settoken, then every--set-rawtoken, with archived-column gate + same-token duplicate gate; pass (b) cross-token duplicate-resolved-id check (cli-design §5.3 line 961- 972 mutual-exclusion contract); pass (c) translate friendly entries throughtranslateColumnValueAsync, raw entries throughtranslateRawColumnValue, with each catch folding cumulative resolver warnings (M5b R19 contract —instanceof MondayCliErrorcatches both translator UsageErrors and ApiErrors).initialSourceinitialCacheAgeSecondsseed the aggregator from upstream legs (e.g. update.ts's bulk path passes the board-metadata leg's source/age). Two consistency normalisations landed alongside the lift: archived-column message wording unified across all callers; translate-time catch unified to MondayCliError (pre-fix the dry-run paths only caught ApiError, missing translator UsageErrors that the M5b R19 contract says should fold).
-
types/json.ts(M5a R-JsonValue) —JsonValue/JsonObjecttypes narrowing the rich-payload slot to JSON-shaped values. ReplacesReadonly<Record<string, unknown>>for column-values' rich-payload type, theselectMutationdiscriminated union's rich variant, andMultiColumnValue. Catches non-JSON values (undefined, symbols, functions, class instances) at compile time; documented limitations include NaN/Infinity (silently becomenullviaJSON.stringify), cycles, symbol keys, and BigInt (none of which TypeScript can structurally exclude). -
api/item-helpers.ts(M5b R9) — shared item-command helpers + GraphQL fragments. Exports:COLUMN_VALUES_FRAGMENT— the §6.2 column-value selection (id type text value column { title }) used by every item-shape query.ITEM_FIELDS_FRAGMENT— the full §6.2 item shape (id / name / state / url / created_at / updated_at / board { id } / group { id title } / parent_item { id } / column_values).collectColumnHeads(metadata)— per-board column heads formeta.columnsconsolidation.titleMap(metadata)— column-id → titleReadonlyMap.resolveMeFactory(client)— closure-cachedmetoken resolver viaclient.whoami().parseRawItem(raw, details?)— R18-wrapped raw item schema parser; safeParse +ApiError(internal_error).projectFromRaw(raw, titles, options)— parse + project- apply §6.3 same-board title de-dup.
Six consumers:
item get / list / find / search / subitems+ the dry-run engine +item set. R9 eliminated 10 verbatim fragment copies + 4 per-command scaffolding duplicates pre-M5b.
- apply §6.3 same-board title de-dup.
Six consumers:
-
utils/parse-boundary.ts(M5b R18) —unwrapOrThrowhelper centralising the safeParse +ApiError(internal_ error, ..., { details: { issues } })pattern per validation.md "Never bubble raw ZodError out of a parse boundary". Applied atapi/board-metadata.ts(live-fetch responseSchema + projectBoard),api/item-helpers.ts parseRawItem(4 item commands + dry-run engine),commands/emit.ts schema.parse(drift catch),commands/item/set.ts boardLookupResponseSchema(the implicit board-lookup boundary). Cache-read parses (board-metadata parseCacheEntry,resolvers.ts readDirectoryCache) intentionally don't use this — the surrounding cache-miss try/catch swallows them as misses (corrupt cache → re-fetch live). -
=) [--board ]`. Two argv shapes (mutually exclusive at the schema boundary): friendly positional `=` runs through `translateColumnValueAsync`; `--set-raw =` (M8) runs through `translateRawColumnValue` (read-only-forever / files-shaped reject lists). Both shapes share `resolveColumnWithRefresh` + `selectMutation` + the Monday `change_*_column_value` mutation. `--dry-run` delegates to `api/dry-run.ts planChanges` (engine handles both `setEntries` + `rawEntries` uniformly). Live mutation envelope echoes `resolved_ids: { token: column_id }` per cli-design §5.3 step 2. Resolver-warning preservation via `foldResolverWarningsIntoError` covers every typed post-resolution failure (UsageError translators, ApiError `unsupported_column_type` / `user_not_found`, mutation- time `validation_failed`). `maybeRemapValidationFailedTo Archived` remaps `validation_failed` → `column_archived` on cache-sourced resolution if a forced refresh confirms the archived state.commands/item/set.ts(M5b + M8) — first M5b mutation surface. `monday item set (= | --set-raw -
commands/item/clear.ts(M5b session 2 + M12) — dedicated per-column clear verb across two argv shapes:- Single-item (M5b):
monday item clear <iid> <col> [--board <bid>]. Reuses the resolution / mutation selection / projection pipeline; the only divergence fromitem setis the value source:translateColumnClearinapi/column-values.tsreturns the per-type clear payload (text →"", status →{}, etc.) rather than translating user input. Per-type table is the canonical "what's a clear" mapping and lives alongside the writer's set-payload table. - Bulk (M12):
monday item clear --board <bid> <col> (--where <c>=<v>... | --filter-json <json>) [--yes] [--dry-run]. Same cursor-walk +confirmation_required- per-item-failure decoration as bulk
item update --where(M5b). The dispatch (parseBulkOrSingleArgv) checks whether--where/--filter-jsonis present — bulk path requires--board, single path keeps the positional<iid>. Output schema is now az.union([projectedItemSchema, bulkLiveDataSchema])somonday schema item.clearadvertises both branches (Codex round-1 F4 pinned the union schema). Bulk envelope:{summary: {matched_count, applied_count, board_id}, items: [...]}matching bulk update's shape. Per-item failure error decorates withdetails.applied_count/details.applied_to/details.failed_at_item/details.matched_count. The bulk leg shares almost every behaviour with bulk update — same gating, same walker, same dry-run aggregation — and is documented as R35 in v0.2-plan §18 as a defer-until- third-consumer lift candidate.
- per-item-failure decoration as bulk
- Single-item (M5b):
-
commands/item/update.ts(M5b + M8) — multi-column atomic update + bulk path.monday item update <iid> --set <col>=<val> [--set ...] [--set-raw ...] [--name <n>]for single-item;monday item update --where <expr> --set ... [--set-raw ...] --board <bid> --yesfor bulk. Single- item usesselectMutationto pick simple / rich / multi mutation;--namesynthesises anametranslated value that bundles intochange_multiple_column_valuesalongside real columns (Monday's multi mutation acceptsnameas a key). M8 widened both single + bulk paths to accept--set-rawinterleaved with--set; the resolution-time cross-token duplicate-resolved-id check (§5.3 step 2) catches a--set X=...+--set-raw X={...}collision pre-translation. Three-pass resolution discipline (resolve every token first, then dedupe + cross-token check, then translate) — pinned post-M8 so a malformed--setvalue doesn't pre-empt the mutual-exclusion error. Bulk walksitems_page/next_items_pagecursor pagination, fail- fast onstale_cursor. Bulk without--yes/--dry-run→confirmation_requiredwithmatched_count+ filter shape. Bulk live: per-item sequential mutation; per-item failure decorates error withapplied_count/applied_to/failed_at_item/matched_count. Bulk dry-run: aggregates per-itemplanChangesresults into one N-elementplanned_changes; deduplicates resolver warnings bycode+message+token. Cache-source aggregation across metadata + page walk + mutation legs (cli-design §6.1). -
=]... [--parent ] [--position before|after --relative-to ] [--dry-run]`. Two argv shapes the dispatch picks between: top-level (calls `create_item` against `--board`) and subitem (`--parent` triggers `create_subitem`; v0.9-M50 unified dispatch — both classic and multi-level boards, no `hierarchy_type`-keyed rejection). **Single round-trip is the hard exit gate** (cli-design §5.8): every translated `--set` / `--set-raw` value bundles into the single `create_item.column_values` (or `create_subitem.column_values`) parameter via `bundleColumnValues`. The CLI does NOT fall back to `create_item` + `change_multiple_column_values` on partial failure — partial-state risk by design. Resolution mirrors `item update`'s three-pass pattern (resolve all tokens → cross-token dup check → translate); `resolveCreateMode` returns the dispatch-ready `CreateMode` plus pre-planner source aggregation for the parent-lookup + parent-board metadata + relative-to legs that fire before `planCreate` / live mutation (Codex round-1 P2 — `meta.source` reflects every wire leg). For subitem with `--set` / `--set-raw`, the subitems-board ID derives from the parent's `subtasks` column's `settings_str.boardIds[0]`; column resolution targets that board, not the parent's. F4 remap wired: cache-sourced resolution + Monday `validation_failed` → forced refresh → if archived, `column_archived` with `details.remapped_from`. Mutation envelope: `data: {id, name, board_id, group_id, parent_id?}` + top-level `resolved_ids` echo. **Idempotent: false** — re-running creates a duplicate item; `monday item upsert` (M12) is the idempotent variant.commands/item/create.ts(M9) — first item-lifecycle verb. `monday item create --board --name [--group ] [--set =]... [--set-raw -
commands/item/archive.ts(M10 Session A) —monday item archive <iid> --yes [--dry-run]. The smaller of M10's two destructive verbs. Calls Monday'sarchive_itemmutation behind the cli-design §3.1 #7 confirmation gate (--yesmandatory;--dry-runexempts the gate per §10.2). Single round-trip per path: live callsarchive_itemdirectly (the mutation returns the archivedItem, so no pre-mutation read fires); dry-run reads viaItemArchiveReadand emits the §6.4 envelope withoperation: "archive_item",item_id, anditem: <projected snapshot>so the agent verifies the ID before re-running with--yes. Both paths shareITEM_FIELDS_FRAGMENT+parseRawItem+projectItemso the response shape matchesitem getbyte-for-byte. Null result on the live mutation surfaces asnot_foundwithdetails.item_id(mirrors the dry-run path's null-handling so the error shape stays identical across both paths). Idempotent: true — re-archiving an already-archived item is a no-op on Monday's side per cli-design §9.1; safe to retry on transient transport failures. Theconfirmation_requiredhint anchors at Monday's 30-day recovery window + cli-design §5.4 (nounarchivemutation). -
commands/item/delete.ts(M10 Session A) —monday item delete <iid> --yes [--dry-run]. Sibling ofitem archive— same argv shape, same confirmation contract, same projection, one knob different:idempotent: false. Callsdelete_iteminstead ofarchive_item; post-mutation state flips to"deleted". Why non-idempotent despite Monday'sdelete_*being idempotent past the first call (cli-design §9.1 — re-deleting →not_found): re-running with the same<iid>after an interimmonday item createwould delete the new item, so agents can't safely retry without verifying the ID still names the same record. Theconfirmation_requiredhint anchors at cli-design §5.4 (Monday retains deleted items in the trash for 30 days but exposes nounrestoremutation; recreating is lossy). Pinned via an integration-levelidempotentknob assertion importing both archive + delete CommandModules so a copy- paste regression that flips the wrong knob fails loud. -
commands/item/duplicate.ts(M10 Session B) —monday item duplicate <iid> [--with-updates] [--dry-run]. Third sibling of M10's lifecycle cluster, closing the four-verb set Monday's API exposes (archive/delete/duplicatehere;move_item_to_*lands in M11). Unlike its M10 siblings duplicate is creative (not destructive), so it skips the--yesgate per cli-design §3.1 #7 —monday item createsets the precedent for creative verbs without confirmation;resolveClientruns first since there's no pre-network gate to be masked (Session A's round-1 P2 ordering trap doesn't apply). Two-leg live path: Monday'sduplicate_item(item_ id, board_id: ID!, with_updates)requiresboard_id(verified at SDKindex.d.ts:2131), so the CLI callslookupItemBoardfirst to derive it, then fires the mutation. Both legs are guaranteed live — no cache participates — someta.source: "live"directly withoutmergeSource(the aggregator is for cache + live combinations). Single-leg dry-run: onlyItemDuplicateReadfires; the source-item snapshot is the preview. Mutation envelope (cli-design §6.4 line 1827-1831 precedent — upsert'sdata.createdflag): output schema isprojectedItemSchema.extend({ duplicated_from_id: ItemIdSchema }), echoing the source ID alongside the new item'sidso v0.2-plan §3 M10's "Returns the new item's ID- the source item's ID" commitment lands as one self-
describing field on
data. Dry-run shape carries the additionalwith_updates: true | falseslot insideplanned_changes[0]so the preview tells the agent whether re-running without--dry-runwould copy the source's updates; otherwise identical to archive's + delete's dry-run shapes. Idempotent: false — every call creates a new item, mirroringmonday item create's semantics per cli-design §9.1 (duplicate_itemsharescreate_item's inheritance; the table doesn't list it separately). Pinned via an integration-level idempotency knob assertion againstitemCreateCommand.idempotent(the parallel-false pin — archive'struepin is already covered by Session A's delete test).--with-updatesplumbing pinned via fixturematch_variables(true + false cases) so a regression where commander'sundefinedleaked through would surface as a cassette mismatch.
- the source item's ID" commitment lands as one self-
describing field on
-
commands/item/move.ts(M11) —monday item move <iid> --to-group <gid> [--to-board <bid>] [--columns-mapping <json>] [--dry-run]. Fourth and final lifecycle verb closing the four-verb set Monday's API exposes (archive/delete/duplicatehere;move_item_to_*is the M11 closer). Two transports under one verb: same-board (--to-group <gid>alone) callsmove_item_to_group— single-leg live, single- leg dry-run viareadSourceItemForDryRun(operationName: 'ItemMoveRead')(R27 helper). Cross-board (--to-group <gid> --to-board <bid>) callsmove_item_to_board(item_id, board_id, group_id, columns_mapping)— four-leg live (source-item read + sourceBoardMetadata+ targetBoardMetadataparallel + the mutation), three-leg dry-run (no mutation).--to-groupis required for both forms because Monday'smove_item_to_board(group_id: ID!)is mandatory (verifiable in SDK 14.0.0 atindex.d.ts:2156);--to-boardalone (no--to-group) →usage_error. The mutex now lives between--columns-mappingand--to-board's absence (passing the mapping without the cross-board flag is a user mistake).--columns-mapping <json>parses throughsrc/api/column-mapping.ts parseColumnMappingJson— the simple{<source_col_id>: <target_col_id>}form (string-to-string), matching Monday'sColumnMappingInput = { source: ID!, target?: ID }exactly. The richer{id, value?}value-translation form is deferred to v0.3 (the wire shape carries no value slot; supporting it requires a non-atomic post-movechange_multiple_column_valuesmutation; v0.2-plan §15 captures the SDK-shape discovery that drove the deferral). Strict default per cli-design §8 decision 5. The planner (planColumnMappings) enumerates every source column with actual content (thecellHasDatapredicate — recursive semantic-empty check onvalue+ non-emptytext; Codex round-1 F1 + round-2 F1 escalation pinned the populated-cells filter so cleared rich shapes like{}/{personsAndTeams: []}/{label: null, index: null}drop out). Each populated source column needs (a) a verbatim ID match on target OR (b) an agent-supplied mapping entry; both branches validate againsttargetColumnIds(Codex round-2 F2 added the typo-target check —--columns-mapping '{"status_4":"typo"}'raisesusage_errorwithdetails.invalid_mappingsrather than reaching Monday and silently dropping). Unmatched →usage_errorcarryingdetails.unmatched: [{source_col_id, source_title, source_type}]+details.example_mappingso agents copy-paste the seed into their retry.--columns-mapping {}(empty object) is the explicit "drop everything (Monday's permissive default)" opt-in that bypasses the unmatched check. Live wire mirrors dry-run echo (Codex round-2 F3) — the live mutation always sendsplan.columnsMapping(the same array dry-run echoes), so the preview describes what Monday will receive byte-for-byte.meta.sourceaggregation for cross-board mixesmergeSource+mergeCacheAge(R21) across the source-item read (always live), source + target metadata loads (cache or live), and the mutation (always live); same- board paths are pure-live single-leg so the source aggregator doesn't fire.idempotent: falseat the verb level — same-board (move_item_to_group) is wire-level no-op when already in target group per cli-design §9.1, but cross-board (move_item_to_board) re-running on the target board is undefined SDK behaviour; conservative bound across all paths mirrorsmonday item create. Inherited helpers: R23 (lookupItemBoardfor the projection-board-id-null fallback — defensive, deliberately uncovered per v0.2-plan §15), R27 (readSourceItemForDryRun(operationName: 'ItemMoveRead')), R28 (projectMutationItem(errorCode: 'not_found')). -
commands/item/upsert.ts(M12) —monday item upsert --board <bid> --name <n> --match-by <col>[,<col>...] [--set <col>=<val>]... [--set-raw <col>=<json>]... [--create-labels-if-missing] [--dry-run]. The idempotency- cluster verb (cli-design §5.8). Three-state branch decision viadecideBranch({matchCount, cursor}): 0 matches with null cursor →{kind: 'create'}→ routes throughrunCreateBranch(M9 surface reused viacreate_item); 1 match →{kind: 'update', itemId}→ routes throughrunUpdateBranch(M5b surface reused viachange_multiple_column_valueswith syntheticname); 2+ matches →{kind: 'ambiguous', error: ApiError}→ fails fast withambiguous_matchcarryingdetails.candidates: [{id, name}](up to 10 from thelimit: 11lookahead — the planner can distinguish "exactly 2..10 matches" from "11+ matches" in one page) +details.match_by/details.match_values/details.matched_count. Empty-page-with-non-null-cursor raisesinternal_errordirectly (Codex round-1 F3 — Monday's items_page returnscursor: nullwhen no more pages exist, so non-null cursor with empty items is API-side malformation, not "no matches"). Lookup leg routes throughbuildQueryParams(Codex round-1 F1) — the sameitems_page(query_params)filter pipelineitem list --whereand bulkitem update --whereuse, some-resolution + cache-miss-refresh + collision warnings inherit automatically.namepseudo-token entries skip column resolution and feed Monday's built-incolumn_id: "name"filter directly. v0.2 match-by safe-list (cli-design §5.8 — narrowed across Codex rounds 2–4): always-safe (name/text/long_text/numbers/ external_id-shaped hidden text), safe-via-label-text (status/dropdown), restricted (people→meonly), not-v0.2-safe (date/link/email/phone). Recommended canonical pattern: stable hidden text / external_id column as the synthetic key.data.operation: "create_item" \| "update_item"exposes the branch on the success envelope (cli-design §6.4); dry-run encodes the same viaplanned_changes[0].operation. Sequential-retry idempotent only (idempotent: trueat the verb level — the flag is a coarse boolean, the nuanced contract lives in cli-design §9.1) — concurrent agents observing zero matches both branch tocreate_item; the next call surfaces the duplicate asambiguous_match. Concurrent-write protection via lock-resource semantics is a v0.4 candidate (cli-design §9.3). Inherited helpers:SourceAggregator(R30) for the lookup + create-or-update aggregation across both branches;mergeSourceWithPreflight(R21) for the dry-run create branch;splitSetExpression(R22) for the--match-by-into-where-clause translation;projectMutationItem(R28) for both branches' live-mutation projection. Net new ~1345 LOC; per-file branch coverage 78.12 — notably below sibling files because of the state-machine fan-out, flagged in v0.2-plan §17 as follow-up. Five Codex review rounds during M12 (vs M11's two) all narrowed the v0.2 round-trip contract; the load-bearing lesson ("wire-shape claims need empirical proof, not translator-input-shape reasoning") lives in v0.2-plan §17. -
commands/update/create.ts(M5b session 2) — posts a Monday update (comment) on an item viacreate_update. Body sources:--body <md>inline,--body-file <path>, or--body-file -for stdin (whitespace-only / empty input →usage_error).idempotent: falsebecause re-running produces duplicate comments.--dry-runis supported despite that; the dry-run shape diverges from column-mutation shape (noboard_id/resolved_ids/diff— insteadoperation: "create_update"+body+body_length);meta.source: "none"because no API call fires. -
api/resolver-error-fold.ts(M5b R19, session 2 + M9.5 R26) —foldResolverWarningsIntoError+mergeDetails+maybeRemapValidationFailedToArchived+foldAndRemap(M9.5 R26 — async wrapper composing fold then remap with the empty- columnIds short-circuit). Two integration patterns:- Translate-time catches (fold-only, in
resolution-pass.tsanddry-run.ts'scolumn_archivedthrow) callfoldResolverWarningsIntoErrordirectly — no remap probe needed. - Post-mutation catches (the five mutation surfaces:
item set,item clear,item updatesingle + bulk,item create) callfoldAndRemaponce per mutation site, replacing the 25-LOC inlinefold + columnIds.length === 0 ? folded : maybeRemappattern. Bulk's per-item-progress decoration stays bulk-specific: the bulk path callsfoldAndRemapfirst, then attachesapplied_count/applied_to/failed_at_item/matched_countbefore re-throwing.
M5b cleanup generalised the remap from a single
columnIdto acolumnIds: readonly string[]array so multi-column updates probe every translated column for the archived flag, not justtranslated[0]. The Codex M9 P1 finding (cache-stale archived columns surfacing asvalidation_failedfromitem create) was caused by item create skipping this exact catch arm — R26 reduces the surface where a future command can forget to wire it. - Translate-time catches (fold-only, in
-
commands/raw/index.ts(M6) — generic GraphQL escape hatch. Six argv shapes (positional<query>/--query-file <path|->×--vars <json>/--vars-file <path|->/ no vars), each with empty-after- trim rejection.--query-file -and--vars-file -are mutually exclusive (one stdin reader per call). Variables must parse to a plain JSON object — null / array / primitive surface asusage_error. Result wrapped in the §6 envelope; raw GraphQL/HTTP errors map via the existingapi/errors.tschain so error codes are stable even on raw queries. M6 close-arc additions: delegates toapi/raw-document.ts analyzeRawDocumentfor the AST walk that drives the mutation gate (--allow-mutationrequired for anymutationop in the doc; subscriptions rejected unconditionally) andoperationNameselection (1 anon → omit; 1 named → pass; N → require--operation-name <name>). Honours cli-design §9.2's universal--dry-runbinding when the selected op is a mutation — emits the §6.4 raw-GraphQL planned-change shape (operation: 'raw_graphql',operation_kind,operation_name,query,variables) and skips the wire call; read-only selections ignore--dry-run.resolveClientis deferred until after the analyser succeeds so a pre-networkusage_errorreportsmeta.source: 'none'per §6.1. -
api/raw-document.ts(M6 close) —analyzeRawDocument({query, explicitOperationName, allowMutation})walks the parsed GraphQL AST via thegraphqlreference parser (a direct dep at 16.8.2 — was already a transitive via@mondaydotcomorg/api'sgraphql-request). Returns{operationName, selectedOperationKind, hasMutation, hasSubscription, operations[]}. Two predicates ship side-by-side and must stay distinct:hasMutation(document-wide; drives the--allow-mutationopt-in gate) vsselectedOperationKind === 'mutation'(the op Monday will actually execute; drives the--dry-runshort-circuit). The split is load-bearing — Codex M6 pass-5 P2 caught a bug where dry-run was keyed off the document-wide check and mis-fired on mixed docs whose--operation-nameselected a query. -
commands/board/doctor.ts(M6) — three diagnostic kinds:duplicate_column_title(NFC + case-fold + whitespace- collapse, same as the §5.3 column resolver, so doctor's "duplicate" matches runtime's "ambiguous"),unsupported_column_type(per non-allowlisted column, keyed bycolumn-types.ts getColumnRoadmapCategory's three-category split — agents reading doctor output get the sameread_only_forever/v0.2_writer_expansion/futureclassification the runtime'sunsupportedColumnTypeErrorships), andbroken_board_relation(parsessettings_str.boardIdsfor everyboard_relationcolumn, queries linked boards viaboards(ids:)aggregated into one round-trip, flags any archived/deleted/unreachable target). Force-refreshes metadata so diagnostics describe live state.
The mutation surface that grew M10–M17 (item lifecycle / update mutations / workspace + board + column + group lifecycles) settled into a stable set of cross-cutting helpers. Each helper is a per-noun (or cross-noun) consolidation of a pattern that recurred across ≥3 mutation sites; the lift-when-three-consumers heuristic v0.2-plan §22 documents.
-
api/item-source-read.ts(M10 R27) —readSourceItemForDryRun ({client, itemId, operationName}). Single-item read againstITEM_FIELDS_FRAGMENT+ null-result throw withdetails.item_id+parseRawItem+projectItem. Three M10 consumers (item archive/delete/duplicatedry-run); M11item movejoins later. The namedoperationNameparameter pins the per-verb GraphQL operation name without forcing each call site to re-declare the fragment. -
api/item-mutation-result.ts(M10 R28) —projectMutationItem ({raw, itemId, errorCode, errorMessage}). First per-noun projection helper. Owns the null-check +details: { item_id }envelope + projection-schema parse. Each call site supplies its own typed error code + message (M10 verbs usenot_found; M12 upsert's create + update branches diverge on theerrorCodeparameter so the helper stays per-noun-shaped, not per-verb). -
api/source-aggregator.ts(M9.5 R30) —mergeSource/mergeCacheAge/mergeSourceWithPreflight. The §6.1meta.sourcefour-state aggregator ('live' | 'cache' | 'mixed' | 'none') lifted from inlineMath.maxcache-age folds + per-legcurrentSourceaccumulators across dry-run.ts / update.ts / create.ts. -
api/destructive-gate.ts(M14 R29) —enforceDestructiveGate({globalFlags, verb, target, detailKey, action, hint, extraDetails?}). Sharedconfirmation_requiredgate for every destructive verb:item archive/item delete/update delete/update clear-all/workspace delete/board archive/board delete/ M16column-delete/ M17group-archive/group-delete(10 consumers post-M17). The M16extraDetailsslot extension carries a secondaryboard_idfor two-tuple destructive verbs (column-delete/group-archive/group-delete) per cli-design §6.5 single- target shape;extraDetailsmerges FIRST so the canonical[detailKey]: target+hintalways win on key collision. Gate fires BEFOREresolveClient()so a missing token still surfacesconfirmation_requirednotconfig_error(the M10 round-1 P2 ordering invariant — preserved by accepting already-parsedglobalFlagsrather than re-resolving inside the helper). -
api/items-page-walker.ts(M13 R34) —items_pageGraphQL helper for the cursor-walk pagination shape. Five consumers post-M14. -
api/update-mutation-result.ts(M14 R37) +UPDATE_FIELDS_FRAGMENT(R38) —projectMutationUpdate({raw, updateId, errorCode, errorMessage})for the four full-projection M13 update verbs (create/reply/edit/delete) + the like-cluster + the dedicatedassertUpdateMutationPresentforupdate clear-all's partial-success{id}-only wire shape. Two-export seam handles both shapes; five consumers across M13. -
api/partial-success-mutation.ts(M13/M14) — the dispatch helper for the §6.4 partial-success envelope shape. M13update clear-all+ M14workspace add-users/remove-users+ M15board add-usersconsume; per-target outcomes ({<target_id_field>: string, ok: boolean, error?}) ride indata.results: [...]with the id-field name parameterised per verb (update_id/user_id). The envelope isok: truewhenever the dispatch ran — top-levelok: falseis reserved for whole-call failure. -
api/users-fan-out-mutation.ts(M15 R40) — partial-success--usersresolver-fronted dispatch shared by M14workspace add-users/remove-usersand M15board add-users. Mixes numeric IDs + emails (resolved throughuserByEmail); per-token resolution failures land as records indata.resultswith the input token echoed verbatim. Whole- calluser_not_foundfires only when no dispatchable user_id remains after parsing/resolution. -
api/response-root.ts(M15 R41) —assertResponseFieldPresent(data, fieldName, {mutationName, details}). The shared missing-root-key guard surfacinginternal_errorwith a schema-drift hint (distinguishes Monday's contract-drift signal from null-payload). Used by M14/M15 inline at call sites; R42 (deferred — see §22) consolidates the ~32 pre-M14 + post-M14 mutation sites onto this helper for one uniform shape. -
api/workspace-projection.ts(M14/M15 R39) +WORKSPACE_FIELDS_FRAGMENT+workspaceProjectionSchema. The R28-sibling for Workspace; six M14 consumers (the four workspace mutation verbs +workspace get). -
api/board-projection.ts(M15 R43) +BOARD_FIELDS_FRAGMENT+boardProjectionSchema. The read-side projection for Board; six M15 consumers. -
api/board-mutation-result.ts(M15 R43) —projectMutation Board({raw, errorCode, errorMessage, detailKey, detailValue}). R28-sibling for Board; six M15 consumers (the six board mutation verbs). -
api/column-mutation-result.ts(M16 R45) +COLUMN_FIELDS_FRAGMENT+columnProjectionSchema+projectMutationColumn({raw, errorCode, errorMessage, boardId, columnIdKey: 'column_id' | 'title', columnIdValue}). Per-noun projection helper for Column. Three M16 consumers (column-create/column-update/column-delete); thecolumnIdKeyparameter pins the per-verb divergence between create's pre-id'title'shape and update / delete's'column_id'shape. -
api/board-mutation-invalidation.ts(M16→M17 cleanup R46) —withBoardInvalidationSingleLeg({boardId, env, perform})andwithBoardInvalidationFanOut({boardId, env, runFanOut}). Pin the cli-design §8 leg-count split in the type system: single- leg verbs invalidate AFTER the success-envelopedataprojection and skip on the error path; fan-out verbs invalidate ONCE after the per-attribute loop settles iff at least one leg succeeded (high-water-mark counter via the closure-capturedrecordLegSuccess()callback so partial- application failure still invalidates). 11 consumers post-M17 (8 single-leg: column-create + column-delete + M15-retrofit board-archive + board-delete + M17 group-create- group-archive + group-duplicate + group-delete; 3 fan-out: column-update + M15-retrofit board-update + M17 group-update).
-
api/group-mutation-result.ts(M17 R48) +GROUP_FIELDS_FRAGMENT+groupProjectionSchema+projectMutationGroup({raw, errorCode, errorMessage, boardId, idKey: 'group_id' | 'name', idValue}). Per-noun projection helper for Group. Five M17 consumers (all five group verbs); theidKeyparameter pins create's pre-id'name'shape vs update / archive / duplicate / delete's'group_id'shape. Fifth per-noun projection helper post-M17 — sets up a hypothetical R44 (genericprojectMutationResource) lift pending a sixth-noun trigger. -
api/group-color.ts(M17 round-1 fix-up) —GROUP_COLOR_VALUESpalette: 41 names covering Monday's documented group colours. Bothgroup-createandgroup-updateconsume viaz.enum(GROUP_COLOR_VALUES)so invalid colour names surface asusage_errorBEFORE any network call. The M17 implementation owns the field set per cli-design §4.3 (the contract pins the shape; the implementation owns the values, mirrors M16column-types.ts). -
api/board-child-finder.ts(M17→M18 cleanup R51) —findBoardChildOrThrow<K extends 'columns' | 'groups'>({ metadata, kind, id, boardId}). Discriminated lookup-and-throw helper for the M16/M17 dry-run preflight "board-level read succeeded but the child ID isn't on the board" carve-out: finds a child entity by ID insidemetadata[kind], throwsnot_foundwithdetails: { board_id, [<kind-singular>_id]: id }if absent. Conditional return type (K extends 'columns' ? BoardColumn : BoardGroup) preserves type-narrowing at every call site withoutascasts. Three consumers post-M17 (column-update+group-update+group-archivedry-run preflights). Future v0.3 fourth child kind (e.g. board subscribers) extends theKunion without re-touching the helper — the noun derivation is mechanical (kind.slice(0, -1)). -
api/tag-directory.ts(v0.3 M19 — pre-flight atd822982, runtime body filled at M19 implementation19801f8). Per-account tag-directory lookup helper for thetagsfriendly translator. Two surfaces:loadAccountTags(full directory reader, cache-then-live with on-diskaccount_tags/index.jsonat mode 0600 per theaccountTagscache key kind) andresolveTags(comma-split name-list → tag-id resolver with NFC + case-fold matching, refresh-on-miss, residual-miss surfacing astag_not_foundper cli-design §6.5's array shape). Mirrors theuserByEmailuser-directory pattern inresolvers.ts:191–402verbatim — there is no standaloneuser-directory.tsmodule; the user-directory cache / lookup / refresh-on-miss machinery lives insideresolvers.ts. Two consumers post-M19:column-values.ts translateTags(friendly translator dispatch) +commands/account/tags.ts(themonday account tagsread verb that closes the §6.5 hint forward-reference). -
api/board-relation-validation.ts(v0.3 M19 — pre-flight atd822982, runtime body filled at M19 implementationa569590). Validatesboard_relation/dependencylinked- item references against the linked-board'sitems(ids: ...)query. Returns{ ok: true, items: ValidatedRelationItem[] } | { ok: false, code, details }(theitemswidening was a Codex round-2 P1-3 catch — the dry-run echo needs per-item home boards to renderdetails.resolved_from.items). Single batched GraphQL call per validation pass to stay inside Monday's complexity envelope. Two consumers post-M19:column-values.ts translateRelation(board_relation + dependency friendly translators share the helper — verbatim shape per the M19 R45/R48 ship-the-helper-with-the-first-new-consumer cadence). -
api/oauth.ts(v0.3 M21 — pre-flight at5c07840, runtime bodies landed at M21 Part 1 in81eec03). OAuth wire wrapper for themonday auth loginflow per cli-design §7.3. Constants pin the wire URLs (OAUTH_AUTHORIZE_URL,OAUTH_TOKEN_URL,OAUTH_DEFAULT_PORT = 9876,OAUTH_CALLBACK_PATH = '/callback',OAUTH_DEFAULT_LISTENER_TIMEOUT_MS,OAUTH_SCOPES,OAUTH_DEFAULT_REQUESTED_SCOPES) plus the public-OAuth-client app credentials (OAUTH_CLIENT_ID+OAUTH_CLIENT_SECRET— shipped as<UNREGISTERED_PENDING_OAUTH_APP>placeholders that the user swaps with the registered Monday OAuth app's values pre-publish; tests don't depend on the real values since cassettes intercept/oauth2/token). Type surface splits raw-vs-normalized:RawTokenResponseis the snake_case/oauth2/tokensuccess body (Monday's wire shape);TokenResponseis the camelCase normalized shape thatexchangeCodereturns to its callers. Four runtime bodies:generateOAuthState(32-byte CSPRNG → base64url CSRF token);verifyCsrf(length-guard beforecrypto.timingSafeEqualso length-mismatched buffers route tooauth_failed.reason: "csrf_mismatch"rather thaninternal_error);bindOAuthListener(node:httplistener on127.0.0.1:<port>, single-request handler matching${OAUTH_CALLBACK_PATH}?code=…&state=…or?error=…&state=…, 404 on other paths, 400 on/callbackwith neither code nor error, 5-min timer armed insideawaitRedirect's promise executor withretryable: trueoverriding the umbrellaoauth_failedfloor forreason: "timeout", EADDRINUSE mapping tooauth_failed.reason: "port_in_use"withdetails.port, server.address() readback so callers passingport: 0get the OS-assigned port back);exchangeCode(POST /oauth2/tokenwithapplication/x-www-form-urlencodedbody per Monday's documented shape + the empirical-probe finding that PKCE-only is rejected withMissing client_secret param; 4xx surfaces asoauth_failed.code_exchange_failedwithmonday_code+monday_description; 5xx surfaces asnetwork_error; fetch rejection becomesnetwork_error; malformed 200 JSON or schema-mismatch surfaces asinternal_error). Single consumer:commands/auth/login.ts. PKCE not shipping in v0.3 — documented as a v0.4+ amendment per the empirical-probe pin. -
api/oauth-test-helper.ts(v0.3 M21 Part 1 —81eec03). Test seam for the__test_oauth_helperenv var per cli-design §7.3.4 (separate file because the helper + fixture-schema surface crosses ~165 LOC, above the inline threshold). Two exports:readTestOAuthFixture(path)parses the JSON fixture (zod-validated; rejects withconfig_erroron missing / malformed / schema-mismatched files) andbuildTestOAuthListener( fixture, generatedState)returns a syntheticOAuthListenerHandlewhoseawaitRedirectresolves (or rejects) based on the fixture'sforce_*flags (force_csrf_mismatchsubstitutes a different state;force_user_deniedsimulates?error=access_denied;force_authorization_failedsimulates a non-access_deniedredirect error withmonday_code+ optionalmonday_description;force_listener_timeoutrejects withoauth_failed.reason: "timeout"). The default fixture path (noforce_*flags) succeeds with the CLI's own state echoed back, so CSRF verification passes — the network-side/oauth2/tokenexchange still goes through cassettes forcode_exchange_failedcoverage. Production callers never see this module; the test seam is opt-in viactx.env. __test_oauth_helper. -
api/probes.ts(v0.3 M22 — pre-flight atfbab6b0; implementation at3a1b465). Per-probe primitives for themonday statusverb per cli-design §11.5. Type surface pins the {@link ProbeName} enum (dns | tcp | tls | auth | cache_writability | redaction_self_test | env_var_pickup), theProbeResultdiscriminated union (ok | fail | skipped), theStatusOutputenvelope (derived fromstatusOutputSchema— schema-as-source-of-truth per.claude/rules/validation.md). Constants pinSTATUS_PROBE_ORDER,NO_PROBE_SKIP_SET(the four network-probe names skipped under--no-probe),ENV_VAR_PICKUP_KEYS,REDACTION_SELF_TEST_FIXTURE_TOKEN(the leak-scan canary). Seven probe runners (runDnsProbe,runTcpProbe,runTlsProbe,runAuthProbe,runCacheWritabilityProbe,runRedactionSelfTest,summariseEnvVarPickup) bind tonode:dns/promises.lookup+node:net.createConnection+node:tls.connect(production defaults wrapped inc8 ignore start/stopblock-wraps — unit tests injectlookupImpl/tcpConnectImpl/tlsConnectImplseams) + theme { id }GraphQL auth probe +fs.stat / access (W_OK) / probe-writecache-writability dance + the canary-fold redaction self-test + the pure env-var-pickup summariser. The mockable-seam pattern mirrors the M21__test_oauth_helperenv-seam: each probe accepts an injectable helper so tests don't bind real sockets / open real fetch handles. Per-probe abort context (createProbeAbortContext) combines the per-probetimeoutMsdeadline with the outer abort signal so every probe seam aborts cleanly on Ctrl-C or per-probe timeout. Auth probe scans ALLerrors[]entries forNOT_AUTHENTICATED/UNAUTHENTICATEDcodes (Codex M22 W2 ratification — not justerrors[0]).RedactionSelfTestInputstakes BOTHenvandruntimeSecretsper cli-design §7.4.3 (Codex M21 P1 ratification) — the probe exercises the same two-layer redaction the production emission paths use viacollectSecrets(env, runtimeSecrets). Single consumer:commands/status.ts. -
api/usage.ts(v0.3 M22 — pre-flight atfbab6b0; implementation at3a1b465). Daily-operation budget for themonday usageverb per cli-design §11.5.3 / §13 v0.3 entry. Empirical-probe finding (2026-05-10, API2026-01): Monday's daily-budget surface lives atplatform_api.daily_limit { base total }+platform_api.daily_analytics.by_day [{ day, usage }]— NOTaccount.complexity(the field doesn't exist on theAccounttype; the cli-design pre-M22 wording was loose). The unit Monday tracks underplatform_api.daily_*is operations per day (200/day on free tier), NOT complexity points; per-minute complexity is a SEPARATE quota system already surfaced by v0.1'saccount complexity. Type surface pinsRawDailyLimit { base, total },RawDailyAnalyticsByDay { day, usage }, theUsageOutputenvelope{daily_limit, usage_today, usage_remaining_today, last_updated}(additive- only per Decision 8 closure — v0.4 may extend with per-minute complexity + concurrency cap WITHOUT breaking), and theUSAGE_QUERYGraphQL document.fetchUsage(inputs)builds a per-callMondayClientover the injectedTransport(retries=0, verbose=false — the usage path doesn't need retry / complexity injection because the verb is reporting on the same budget the retry layer would consume) and projects viaprojectUsageOutput. Parse-failure path surfacesinternal_errorwithdetails.issuesso a Monday-side amendment to theplatform_api.daily_*surface fails loudly rather than silently. Theday-field timezone pin is UTCYYYY-MM-DD(commands/usage.ts formatTodayKeyderives it viatoISOString().slice(0, 10)); re-probe if a live-activity account reveals account-local semantics. Two pure helpers branch-tested:sumUsageForDay(byDay, today)(sum-where-day- equals, opaque string equality on thedayfield),projectUsageOutput(parsed, today)(clampsusage_remaining_todayat zero — Monday's reportedusageis best-effort and may briefly exceedtotalon a near-cap account). Single consumer:commands/usage.ts. -
api/time-tracking.ts(v0.3 M20 — pre-flight ata702af2, runtime body landed at M20 implementation as documentation- only verbs). Verb-shaped column-type extension primitives for themonday item time-track start / stopsurface (cli-design §5.2 carve-out 2). Two consumers:commands/item/time-track/start.ts+... /stop.ts(three- level command depth via nestedensureSubcommand). The api primitivesstartTimeTracking/stopTimeTrackingreject every invocation today withusage_errorcarrying theAPI_UNSUPPORTED_HINTconstant — empirical probe at M20 implementation kickoff (2026-05-10, against API version2026-01) confirmed Monday's public GraphQL API does not currently expose any mutation for writing to time_tracking columns:change_simple_column_valuerejects withCorrectedValueExceptionfor every candidate value;change_column_valuerejects withInvalidColumnTypeExceptionfor every candidate JSON shape; the mutation root has zero time-tracking-related mutations. The four pre-flight*Inputs/*Resultinterfaces stay verbatim so the runtime body swap is one-sided when Monday ships API support (replace the rejection with the wire call; no consumer change).StopTimeTrackingResult.durationSeconds: number | null— null when Monday omitsstarted_aton the just- closed session (per-session duration uncomputable; SDK 14.0.0 exposes only the column-levelTimeTrackingValue.durationtotal). No cache surface, no R46withBoardInvalidation*wrapping —time_trackingmutations don't affect board structure.
M10–M17 add ~25 command files following the patterns established
in M5b/M9 (commands//.ts each registered in
commands/index.ts, parsing argv via parseArgv, calling into
the helpers above, projecting through the per-verb output schema,
emitting the §6 envelope). The cli-design §4.3 contract section
is the per-verb spec; the v0.2-plan §3 milestone entries
document the verb cluster post-mortems. Briefly:
-
commands/item/{archive,delete,duplicate,move,upsert}.ts— M10 / M11 / M12 lifecycle verbs. Already documented above. -
commands/update/{reply,edit,delete,like,unlike,pin,unpin, clear-all}.ts— M13's eight new update mutation verbs.clear-allis the first §6.4 partial-success consumer (the envelope isok: truewhen dispatch ran; per-target failures land indata.results[i].error). -
commands/workspace/{create,update,delete,add-users, remove-users}.ts— M14 lifecycle.add-users/remove- usersare the first resolver-fronted partial-success consumers (mixed numeric IDs + emails; per-token resolution failures land indata.results). -
commands/board/{create,update,archive,delete,duplicate, add-users}.ts— M15 lifecycle.board duplicateintroduces the wrapped envelope shape (data: { board: <projection>, is_async }) because Monday'sBoardDuplicationcarries anis_asyncslot the projection doesn't model.board updateis per-attribute fan-out acrossupdate_board(board_attribute, new_value)with a force-live final read leg (Monday's per- attribute calls return only the changed slice). -
commands/board/{column-create,column-update,column-delete}.ts— M16 column lifecycle + the §8 eager-invalidation contract. Every verb callsinvalidateBoard(boardId)post-success via R46's wrappers.column-updateis per-attribute fan-out across two wire surfaces (change_column_titlefor--title,change_column_metadata({column_property: description})for--description); the trailing per- attribute response is authoritative fordata(no force-live read leg — distinguishes column-update from board-update).column-deleteis the first two-tuple destructive verb (R29'sextraDetailsslot extension carriesboard_idalongside the canonicalcolumn_iddetailKey per cli-design §6.5). -
commands/board/{group-create,group-update,group-archive, group-duplicate,group-delete}.ts— M17 group lifecycle. All five adopt R48'sprojectMutationGroup+ R46's invalidation wrappers from day one.group-updateis per- attribute fan-out across the singleupdate_group( group_attribute, new_value)surface; trailing response is authoritative fordata(no force-live read leg).group- archive+group-deleteare the 2nd + 3rd two-tuple destructive consumers (after M16 column-delete).group- duplicatedeliberately omits--with-updatesbecause Monday'sduplicate_groupwire signature has nowith_updatesargument (unlikeduplicate_item/duplicate_board). -
commands/{status,usage}.ts(v0.3 M22 — pre-flight atfbab6b0; implementation at3a1b465). The diagnostics- cluster verbs per cli-design §11.5.statusis a top-level verb (not a noun-verb shape) — mirrorsschema/raw; the §5.2 two-level rule permits single-level verbs. Argv surface:statustakes--no-probe;usagetakes no flags beyond globals. Both action bodies delegate through dedicated helpers for testability —commands/status.tsextractsorchestrateStatusProbes(probe-matrix iteration with short-circuit on first network failure: DNS fail → TCP/TLS/auth surfaceupstream_failed; cache_writability + redaction_self_test- env_var_pickup always run) +
deriveOverall(§11.5.2 rules:redaction_self_testfailure ALWAYS promotes to'down'; network fail →'down'; auth-success + soft-local-fail →'degraded') +resolveStatusTransport(env → flag → SDK-pin precedence, withisMissingTokenConfigErrordiscriminator that downgrades ONLY the token-onlyConfigErrorto ano_tokenauth-probe result — Codex M22 F2 ratification; other config-validation failures re-throw verbatim asconfig_error).commands/usage.tsexportsformatTodayKey(now)returning UTCYYYY-MM-DD(pinned at M22 close; revise if Monday's runtimedayfield turns out to be account-local).status'smeta.sourceis committed BEFORE the orchestrator call (Codex M22 F1 ratification) so error envelopes onoverall='down'report the accuratesource: 'live'rather than the runner's'none'default.statusimportsstatusOutputSchema+StatusOutputfromsrc/api/probes.ts(single source of truth for the envelope shape — R-NEW-4 lift at0b5af57);usagedefines its ownusageOutputSchemalocally (verb-specific envelope, no shared helper).
- env_var_pickup always run) +
-
api/cross-board-search.ts(v0.3 M23 — pre-flight at1fefdb1, Codex round-1 fixes at9b93f15, Codex round-2 fixes atfa27b60; runtime body landed at1f09a25). The cross-board fan-out walker formonday item search's v0.3 cross-board path (--boardomitted;--workspace/--favorites/ no-scoping-lever scopes the board set). Per the empirical-probe findings (2026-05-11, API2026-01,scripts/probe/m23-cross-board.ts+scripts/probe/m23-cross-board-search-2.ts), the walker fans out viaboards(ids: [...]) { items_page(query_params: { rules }) }— DIFFERENT shape from the v0.1 single-boarditems_page_by_column_values(which takes a singularboard_id: ID!). Inaccessible board IDs are silently omitted by Monday'sboards(ids:)resolver; the walker detects the count delta and surfaces aninaccessible_boardswarning. Per-board column resolution is required because passing a column token (e.g.status) that doesn't resolve on ONE board errors the WHOLE cross-board query; the walker resolves tokens per-board independently, skips boards where the column doesn't resolve (with acolumn_not_found_on_boardwarning). v0.3 cross-board search is single-call-only (Codex round-1 P1-2 resolution): no resumable cross-board cursor; aggregate--limitshort-circuit or per-board pagination state surfaces across_board_truncatedwarning with per-boardstatebreakdown (exhausted/has_more/not_started) for agent introspection. v0.4 may add an opaque-token resumable cursor envelope-additively. Cap pinned atDEFAULT_MAX_BOARDS=25/HARD_CAP_MAX_BOARDS=100per Decision 5 closure (3a2f1db); the cap is wall-clock-latency-based, not complexity-budget- based — empirical probe measured ~25-30 complexity points per board against a ~999_950 per-call budget (complexity supports ~30,000+ boards) but per-call latency scales linearly at ~0.5-1.5s/N, putting N=25 at ~12-18s under the 30sMONDAY_REQUEST_TIMEOUT_MSdefault and N≥60 at the ceiling. TakesMondayClient(Codex round-1 P1-1 resolution — notTransportdirectly, so the walker inherits--retry+--verbose-complexity injection from MondayClient.raw). Walker result is pure-live (source: 'live'constant; Codex round-2 P2-1 resolution); the command-action aggregates with the per-board column-resolution pre-pass's cache state viaSourceAggregator. -
api/board-favorites.ts(v0.3 M23 — pre-flight at1fefdb1, Codex round-1 fixes at9b93f15, Codex round-2 fixes atfa27b60; runtime body landed at1f09a25). The 2-stage favorites resolver formonday board favorites+monday item search --favorites. Per the empirical-probe findings (2026-05-11, API2026-01,scripts/probe/m23- favorites*.ts+scripts/probe/m23-hierarchy-*.ts), Monday surfaces favorites at the TOP-LEVELQuery.favorites: [GraphqlHierarchyObjectItem!](NOTUser.favorites/Board.is_starred). The element is POLYMORPHIC —object: { id, type }withtype: GraphqlMondayObjectenum (Board | Folder | Dashboard | Workspace). Stage 1 fetchesFAVORITES_LIST_QUERYand filters viafilterFavoritesToBoardstoobject.type === Board, sorting byposition(Float — Monday's UI sidebar order ascending). Stage 2 hydrates surviving board IDs viaBOARDS_HYDRATE_QUERY(boards(ids: [...])for name + workspace_id + state + url;complexityNOT selected per Codex round-1 P1-1 — MondayClient.raw injects it under--verboseautomatically). The 2-stage shape mirrors M22'smonday usage(platform_api.daily_limit+platform_api.daily_analytics); one round-trip per stage. Stage-1/Stage-2 count delta surfaces aboard_favorites_stalewarning (a favorited board was deleted or had access revoked). Resolver result is pure-live (source: 'live', cacheAgeSeconds: nullper Codex round-1 P1-1 — no per-call cache). -
commands/board/favorites.ts(v0.3 M23 — pre-flight at1fefdb1; runtime body landed at1f09a25). Themonday board favoritesverb. Argv-empty shape (no command- specific flags beyond globals). Action body drivesfetchBoardFavorites(the 2-stage filter+hydrate above) and emits the §6.1 collection envelope sorted by Monday-UIposition(Float). Integration tests attests/integration/commands/board-favorites.test.tscover Stage-1 short-circuit, happy path, stale-favorites warning, parse-failure shape + envelope contract. -
commands/item/search.ts(v0.3 M23 extension; pre-flight at1fefdb1; cross-board runtime body landed at1f09a25). The v0.1 single-board path (items_page_by_column_values) remains UNCHANGED; the v0.3 extension adds--workspace <wid>/--favorites/--max-boards <n>flags + a.superRefinemutual-exclusion rule (at most one of--board/--workspace/--favorites; supplying two surfacesusage_errorat the parse boundary withparams.conflicting_flagscarrying the named levers — Codex round-2 P2-3 fix toparse-argv.ts:summariseIssuespreservesZodIssue.paramsthrough to the envelope'sdetails.issues[].params).--max-boardshard-cap enforcement is CONDITIONAL on the cross-board path (Codex round-1 P2-2 fix); the single-board path accepts any positive integer (the flag is documented as ignored when--boardis set). Command-registry-facingoutputSchemaisz.union([itemSearchOutputSchema, crossBoardSearchOutputSchema])(Codex round-2 P1-1 fix) somonday schema item.searchreflects both branches. Cross-board action body dispatches on the scoping lever:--favoritescallsfetchBoardFavoritesfor the ID set;--workspace <wid>runsCrossBoardEnumerateagainstboards(workspace_ids:[wid], limit:N, state:active); no scoping lever runs the same enum withoutworkspace_idsfor all-accessible mode. Per-board column-resolution pre-pass callsloadBoardMetadataper resolved board; the walker fans out viacrossBoardSearch. Source aggregation viaSourceAggregatormerges per-board cache state with the walker's'live'constant. NDJSON streaming branch wraps the walker'sonItemhook throughstartNdjsonStream. Cassette tests attests/integration/commands/m23-cross-board.test.tscover each scoping lever + each warning code (workspace / favorites / all-accessible happy paths, inaccessible_boards, column_not_found_on_board, cross_board_truncated, empty-plans short-circuit, NDJSON streaming, plus parse-boundary mutual exclusion + --max-boards validation). -
commands/auth/{login,logout}.ts(v0.3 M21 — pre-flight at5c07840, runtime bodies landed at M21 Part 1 ina4cb5b0). The OAuth flow + credentials cache verbs per cli-design §7.3 / §7.4. Both verbs read--profile <name>from the global flag layer (cli-design §4.4 — auth verbs do NOT redeclare--profileat the command level; commander attaches global flags to the parent program); both throwusage_errorif the flag is missing.auth loginruns the 8-step flow per cli-design §7.3.1 (generateOAuthState→bindOAuthListenerorbuildTestOAuthListenerif__test_oauth_helperis set → consent-URL build + best-effort browser open + stderr URL print fallback →awaitRedirect→verifyCsrf→ kind-mapping [code→ exchange,error: access_denied→user_denied, other error →authorization_failedwithmonday_code+ optionalmonday_description] →exchangeCode→ post-exchangeaccount { id }GraphQL via the just-obtained token [single consumer of the fresh-transport pattern; R-watch-item logged formonday auth refreshv0.4+] →setProfileCredentials) and emits{profile, account_id, scopes}(token NEVER indataper §7.4.3).auth logoutcallsdeleteProfileCredentials(profileName)and emits{profile, was_present}(idempotent per §7.3.2; the post-delete profiles map is preserved as{schema_version: '1', profiles: {}}rather than the file being deleted outright). -
api/dev-conventions.ts(v0.3 M26 — pre-flight at1620220, M26a IMPL runtime fetchers at19755e3, M26b IMPL workflow-verb helpers cluster at10cd1c5+34a5bc1round-1 P2-3 lift). Owns themonday dev …namespace's convention-to-board-ID resolution per cli-design §2.7 (Monday Dev is convention, not API). Three layers:- Discover heuristic + doctor diagnostics (M26a):
matchBoardByConvention+groupCandidatesByDevNoun+buildDiscoverMappingFromMatches(pure helpers seeding auto-detection from stock English board names:Tasks/Sprints/Epics/Releases/Bugs).discoverDevBoardswalks accessible boards viaboards(state: all, workspace_ids:)with aBoard.type === 'board'filter (dropssub_items_boardvirtual entries — empirical-probe finding pinned at 2026-05-11).runDevDoctorvalidates a mapping via 10 checks pinned inDEV_DOCTOR_CHECK_NAMES(tasks_board_exists/tasks_status_column_present/tasks_status_labels_canonical/sprints_board_exists/sprints_date_columns_present/epics_board_exists/releases_board_exists/bugs_board_exists/tasks_to_sprints_relation/tasks_to_epics_relation) with per-status detail surfaced via the structuralz.discriminatedUnion('status', [ok, warn, fail])schema + the closedDEV_DOCTOR_REASONS11-value enum (z.toJSONSchemaexposure formonday schema dev.doctorintrospection — M26a IMPL round-2 P2-1 refactor). - Per-profile dev-block IO:
loadDevMapping(profile, options)reads[profiles.<name>.dev]from~/.monday-cli/config.toml; throwsdev_not_configuredwithdetails.reason ∈ {no_config_file, profile_absent, no_dev_block}when absent.saveDevMapping(profile, mapping, options)writes atomically via tmp-rename with mode0o600.smol-tomlstringifyproduces canonical TOML (comments discarded — M26a IMPL contract correction). - M26b workflow-verb helpers cluster (R-NEW-36):
walkDevBoardItems(7 consumers across read verbs; composes paginate + fetchItemsPage + parseRawItem + projectItem; narroweddev_board_misconfiguredrewrap of empty-boardsparse failures viaisEmptyBoardsArrayIssue— Codex round-2 P2-1 fix),hydrateDevBoardColumns(4 consumers; singleboards(ids:)+ column projection),findRelationColumnIdToBoard+extractLinkedItemIds(3 consumers; the sprint-items / epic-items / task-list-with- sprint walkers filter tasks-board items by board_relation column value),resolveStatusColumn+resolveCanonicalLabel(3 consumers; the task mutation verbs case-insensitively resolve the canonical "Working on it" / "Done" / "Stuck" label against the configured status column's labels),flipTaskStatus(3 consumers; composes hydrate + resolve + executeItemMutation into the start/done/block flip preamble),fireDevCreateUpdate(2 consumers; thetask done --message+task block --reasonside-effect with a strict zod parse boundary on Monday'screate_updatemutation + dynamic operation-name builder so the doc named-operation + wireoperationNamealways agree — Codex round-2 P1-1 fix).
commands/dev/_shared.tsowns the per-verb preamble:resolveActiveDevProfile(ctx, programOpts)resolves the active profile name via the same flag / env / default_profile precedencecli/program.ts's preAction hook uses for token resolution (throwsconfig_errorin implicit-v1 mode); andrequireDevBoard(mapping, slot, profile)(R-NEW-35, 10 consumers) pulls a noun-specific board ID off the loaded mapping or throwsdev_not_configuredwithdetails.slot. - Discover heuristic + doctor diagnostics (M26a):
config/load.ts(M0) — env-var-only config loader. ParsesMONDAY_API_TOKEN/MONDAY_API_URL/MONDAY_API_VERSION/MONDAY_REQUEST_TIMEOUT_MSthrough a zod schema; rejects withconfig_error(exit 3) on any failure.config/credentials.ts(v0.3 M21 — pre-flight at5c07840, runtime bodies landed at M21 Part 1 in5c1f7ac). Mode-0600per-profile credentials cache primitive at~/.monday-cli/credentialsper cli-design §7.4. Closes v0.3-plan §8 Decision 3. Zod schemas pin the §7.4.1 file shape (schema_versionliteral-pinned to"1"at the security floor — defends against future-version files silently passing through;profileEntrySchemacovers{access_token, obtained_at, expires_at: string | null, scopes, account_id}). Six runtime helpers:resolveCredentialsPath,readCredentials(withfs.fstat-against-open-descriptor permission check; refuses onmode & 0o077 !== 0withchmod 600hint),writeCredentials(atomic-replace mirroringsrc/api/cache.ts writeJsonFileverbatim —mkdir 0o700+chmod 0o700on parent dir;writeFile(tmp, payload, {mode: 0o600})+ re-chmod 0o600+rename; secure-file primitive R-candidate stays below the 3-consumer threshold per cli-design §7.4.2's in-design backstop),setProfileCredentials(read-modify- write convenience forauth login),deleteProfileCredentials(idempotent forauth logout— returns{wasPresent}; preserves the file with emptyprofiles: {}map after the last delete per §7.3.2),resolveProfileToken(per-profile source-order resolver:credentials_cache>api_token_envconfig_error).config/profiles.ts(v0.3 M21 — pre-flight at5c07840, runtime bodies landed at M21 Part 1 in5c1f7ac). TOML loader for~/.monday-cli/config.tomlper cli-design §7.2. Zod schemas pin the §7.2 file shape with two layers of defense for the no-token-in-config rule: structural exclusion via.strict()rejects unknown keys (access_token,api_token,secret,token); value-level shape check via/^[A-Z_][A-Z0-9_]*$/uregex onapi_token_envrejects token-looking values. Three runtime helpers:resolveProfilesConfigPath,loadProfilesConfig(parses TOML viasmol-toml— the dep added at Part 1 per the v0.3-plan §3 M21 leaning, minimal footprint + ESM-native + zero runtime deps),selectProfile(§7.2 source-order resolver:--profile flag>MONDAY_PROFILE env>default_profile in config> implicit-v1 mode). One contract widening vs the M21 pre-flight:selectProfilereturns a synthetic emptyProfileEntrywhen--profile workis set but noconfig.tomlexists — credentials-cache-only flows (runmonday auth login --profile workthen any other command with--profile work) work without requiring aconfig.tomledit. Theconfig_errorpath stays for the genuine "named profile missing from the listed profiles" case.
commands/never imports the SDK directly — always goes throughapi/. Keeps the SDK upgrade surface small and lets the test stack swapTransportfor aFixtureTransportwithout monkey-patching.api/is pure I/O — no console output, no exit codes. It throws typed errors;commands/'s action handler decides how to render them viaemit.ts/ the runner's catch-all.utils/has no knowledge of Monday — it's generic formatting, redaction, logging, output renderers.config/runs once perrun()call. The resolvedConfigis passed explicitly to anything that needs it (no globals, no module-level singletons).
Every command must support:
--output json(default for non-TTY, or when set): a single JSON envelope on stdout, nothing else. Errors go to stderr as a §6.5 envelope with a stableerror.code.--output table/--output text(default for TTY, single-resource only): human-friendly, may include colour. NDJSON is collection-only.- Exit codes:
0success,1usage error,2API/network error,3config error,130SIGINT,>3reserved.
The full envelope contract — meta skeleton, error fields, the
29 stable error codes (v0.1 froze 26; v0.2-M12 added
ambiguous_match → 27; v0.3-M19 added tag_not_found → 28; v0.3-M21
added oauth_failed → 29 at the M21 pre-flight contract diff per
§7.3.3; v0.3-M22 added NO new codes — probe failures map to existing
codes per cli-design §11.5.1's per-probe mapping table) — lives in
cli-design.md §6 and is enforced by the
integration suite's envelope-contract assertion
(assertEnvelopeContract) on every M2+ command's output. Adding
fields to meta or data is non-breaking; removing or renaming
is a major version bump.
Thrown errors are instances of the typed family in src/utils/errors.ts,
all sharing a base class with a stable code from the registry
(29 codes today; v0.1 froze the initial 26 + v0.2/v0.3 milestones
extended it):
UsageError— bad flags / missing required positionals / mutually exclusive inputs. Exit 1.ConfirmationRequiredError— destructive op without--yes. Exit 1 (grouped with usage per §3.1 #5).ConfigError— invalid env / missing token / malformed config. Exit 3.ApiError— non-2xx GraphQL, network failure, timeout, validation. Most M3 cassette-driven failures land here. Exit 2.CacheError— local cache I/O failure. Auto-retried without cache; exit 2 if it surfaces.InternalError— last-resort code for unknown thrown values (signals a CLI bug). Exit 2.
The runner's catch-all in cli/run.ts converts any unknown thrown
into one of these via toMondayError (in envelope-out.ts) and emits
the §6.5 error envelope on stderr before exiting. Two layers of
redaction (key-based + value-scan over the live token) run on the
envelope before bytes hit the stream — see .claude/rules/security.md
for the canary discipline.
A raw ZodError reaching the catch-all becomes internal_error /
exit 2; argv schemas wrap with parseArgv, env / config schemas wrap
in loadConfig, response schemas wrap inside api/ so a parse
failure at the right boundary surfaces as usage_error / config_error.
| Layer | Lives in | What it tests | Network? |
|---|---|---|---|
| Unit | tests/unit/ |
Pure logic — config parsing, error mapping, formatters, redaction, helper modules | No |
| Integration | tests/integration/ |
run({ transport: FixtureTransport }) end-to-end — argv → commander → action → envelope, with a cassette replacing the network |
No (mocked) |
| E2E | tests/e2e/ |
The compiled binary — spawn node dist/cli/index.js, assert stdout / stderr / exit code against an in-process HTTP fixture server |
No (mocked) or yes (gated by RUN_LIVE_TESTS=1) |
tests/fixtures/load.ts— cassette format (Cassette= orderedInteraction[], each withoperation_name/match_query/match_variables/expect_headers/response/repeat) + theFixtureTransportintegration tests inject viarun({ transport }).tests/e2e/fixture-server.ts— an in-process HTTP server that replays the same cassette format over real HTTP, so spawned-binary E2E tests exercise the productionFetchTransportend-to-end.tests/e2e/spawn.ts—spawnCli({ args, env })helper that fails fast with a clear hint ifdist/cli/index.jsis stale.tests/integration/redaction-hardening.test.ts— adversarial token-leak suite (Codex M0 §1 / M2 §4 follow-up). Asserts the literal canary token is absent from every emitted byte across 9 failure shapes (Error.message, Error.stack, cause chains, redirected URLs, retry-decorator details, etc.).tests/integration/helpers.ts(M4 R6) — sharedbaseOptions / EnvelopeShape / parseEnvelope / assertEnvelopeContract / drivescaffolding extracted from the M3 per-noun integration test files. New M5+ test files start at one import line.
E2E tests that hit the real Monday API must be gated behind
RUN_LIVE_TESTS=1 and use a dedicated test workspace — the default
CI run never touches the network. Coverage thresholds live in
vitest.config.ts (lines/functions/statements: 95, branches: 94 —
ratchet upward as new code lands; never lower).
Loaded at v0.4-M31 pre-flight (R-NEW-41 lift, 3rd-consumer trigger). v0.3-plan §22 R-NEW-41 entry pinned this section's placement.
The CLI surfaces a stable, agent-keyed envelope contract (§6
in cli-design.md). Monday's wire surface evolves at its own
cadence — and at API 2026-01 the wire surface carries four
documented asymmetries where the CLI's argv shape OR transport
shape diverges from a 1:1 mirror of Monday's wire. Each
asymmetry needs documentation discipline so future agents
(and future maintainers) understand WHY the divergence exists
and where to look when the wire surface evolves.
This section enumerates the four asymmetries known at v0.6-M38 pre-flight, the per-asymmetry documentation cadence, and the expansion rule for future asymmetries.
1. Webhook.config JSON-input / String-output asymmetry
(v0.3-M27). Monday's create_webhook mutation accepts
config: JSON (any valid JSON value); Monday's read-side
Webhook.config returns String (the stored JSON-encoded
text). The CLI's read-shape treats config as string | null;
webhook create parses --config <json> once at the parse
boundary and threads the resulting JS value to Monday's
JSON scalar (sending the raw string would result in Monday
seeing a JSON-string-of-a-string). Documented inline in
src/api/webhooks.ts's module docstring.
2. NotificationTargetType wire enum collapse (v0.3-M27).
Monday's NotificationTargetType wire enum has TWO values
(Post / Project); Project covers both items and boards
at the wire level. The CLI surfaces a TWO-value --target-type item|board argv that collapses to wire Project; the CLI
preserves the item-vs-board distinction at the parse boundary
for argv-validation discipline + envelope echo, but neither
the CLI nor Monday cross-validates that the supplied --target <id> actually names the declared kind. Documented inline in
src/api/notifications.ts's module docstring + cli-design.md
§4.3 notification row.
3. Multipart-vs-JSON transport asymmetry (v0.4-M31).
Monday's add_file_to_column + add_file_to_update mutations
cross the wire via multipart/form-data per the standard
GraphQL multipart-request specification (jaydenseric) — the
operations + map + file parts wire envelope is incompatible
with the JSON-only client.request seam's
body: JSON.stringify(...) + Content-Type: application/json
invariants. The CLI carries TWO sibling transports rather than
one transport with a multipart branch:
src/api/transport.ts—Transportinterface + JSON body- hard-coded
Content-Type: application/json.
- hard-coded
src/api/multipart-transport.ts—MultipartTransportinterface + binaryfile: Blobslot +filenameslot +Content-Typeset byfetchfrom FormData boundary.
The two interfaces share the Authorization + API-Version
header lockdown discipline + the timeout-aware combined-signal
threading; they DIFFER on Content-Type ownership (JSON
transport owns it; multipart transport delegates to fetch).
Documented inline in src/api/multipart-transport.ts's module
docstring + src/api/assets.ts's module docstring + cli-design
§6.4 asset-upload sub-section.
4. Translator-boundary dispatch asymmetry — --set <file-col>=<path> vs --set <value-col>=<val> (v0.6-M38).
The CLI's --set <col>=<value> syntax is type-uniform from the
agent's view — same flag, same argv shape, same column-token
resolution. But for file-typed columns, the dispatch leg
transitions silently from the JSON change_column_value /
change_multiple_column_values wire path to Monday's
multipart add_file_to_column wire path at the COMMAND ACTION
BODY level (NOT inside the translator). The translator
(translateColumnValueAsync in src/api/column-values.ts)
stays JSON-output-shaped for the 13 writable allowlisted types;
the file-column dispatch is a SIBLING branch routed at
src/commands/item/{set,update,create}.ts that fires AFTER column
resolution when resolution.match.column.type === 'file'.
Routed via executeFileColumnSet in
src/api/file-column-set.ts, which itself wraps M31's
addFileToColumn fetcher verbatim (a stdin source — v0.8-M47
<file-col>=- — sources the Blob from readStdinFileSource
in src/utils/file-source.ts and calls addFileToColumn
directly, the Blob being already in hand).
This asymmetry is at the AGENT-INPUT boundary, NOT the wire
boundary (M31's asymmetry #3 above is wire-boundary-only —
the multipart wire shape diverges from JSON; M38's asymmetry
is that the agent's --set token transitions across two
wire shapes based on column type). The mutex rules at the
column-resolution boundary preserve the per-call atomicity
guarantee. Multi-file --set per call CARVED OUT at v0.8-M46
for the 3 reachable callShapes ('item_update_single' /
'item_update_bulk' / 'item_create') — N sequential
add_file_to_column legs per item (× parallel across items for
bulk); only 'item_set' keeps the defensive (argv-unreachable)
'multi_file_set_unsupported' throw. Duplicate resolved
file-column IDs reject 'duplicate_resolved_file_columns'.
Mixing with value --set / --set-raw / --name rejects on
'item_set' / 'item_update_single' / 'item_update_bulk'
(universal mixed mutex), SUPPRESSED on 'item_create' per
v0.7-M43 D6 asymmetry (create_item natively bundles non-file
column_values atomically into leg-1). Single-item ships at
v0.6-M38; bulk ships at v0.7-M42 as a per-item multipart fan-out
under --concurrency; create-time ships at v0.7-M43 as a
two-leg create_item + add_file_to_column dispatch under the
§5.8 orphan-warn atomicity envelope; multi-file per call ships at
v0.8-M46; the stdin source (<file-col>=-, single-file /
single-target) ships at v0.8-M47. Documented inline in
src/api/file-column-set.ts's module docstring +
cli-design.md §5.3 step 4 / step 5 "File-column dispatch
leg" + §5.8 atomicity discipline + the §13 v0.6 / v0.7 entries.
Each per-asymmetry inline prose lives in the relevant module
docstring (one per asymmetry). The module docstring is the
load-bearing source — it carries the empirical-probe finding
(e.g., "introspected via scripts/probe/m31-asset-upload.ts
2026-05-13, API 2026-01") + the rationale for the CLI's
chosen shape + the failure-mode mapping. The architecture.md
section (this one) is the canonical cross-link target
for any other prose that needs to reference the asymmetry:
- the relevant cli-design.md subsection (e.g., §6.4 asset-upload sub-section, §4.3 verb-row prose) — points HERE rather than re-explaining the asymmetry.
- per-verb prose in cli-design.md §4.3 — points HERE.
- the v0.3-plan + v0.4-plan §22 R-class entries — point HERE.
- per-asymmetry module docstrings — are the source-of-truth; carry the empirical-probe finding + rationale; point at THIS section for the cross-cutting "why we document this pattern" discussion.
The cadence is inline-first. An agent diagnosing a wire mismatch reads the module docstring first (it's the closest to the code path); the architecture section exists to explain WHY the documentation lives where it does and to flag the pattern for future maintainers seeing a 5th asymmetry.
When a 5th wire-vs-CLI asymmetry surfaces in a future milestone, the discipline is:
- Document the asymmetry inline in the relevant
src/api/<noun>.tsmodule docstring (empirical-probe finding + rationale + failure-mode mapping). - Extend THIS section's "The four asymmetries" enumeration to five — same prose shape as the existing four.
- Update the per-affected cli-design + plan-doc references to point HERE.
- File an R-NEW entry in the relevant plan doc's §22 if the asymmetry has a code-shape implication (a new transport/interface; a new wire-enum bridge; a new translator-boundary dispatch leg), or skip the R-class entry if it's purely a documentation lift.
Anti-pattern to avoid. Re-explaining the asymmetry in every consumer's docstring + cli-design + plan-doc prose + test description. The M27 webhook + notification surfaces shipped with three+ inline copies of the per-asymmetry prose because no canonical cross-link target existed; the R-NEW-41 lift at M31 pre-flight closes that gap.