monday.com backlog
Walking-skeleton workflow for talking to the product backlog on monday.com.
The primary path is dependency-free CLI scripts in
${CLAUDE_PLUGIN_ROOT}/scripts/monday/ — they only need MONDAY_TOKEN (and
Node 18+ for the global fetch). They run identically inside Claude Code,
opencode, CI, or a plain bash shell, and don't pay any MCP handshake / npx
startup cost.
The monday-api-mcp MCP server registered by this plugin's .mcp.json is
still available as an optional path — use it only if you need rich
behaviour outside the scripts' surface (e.g. apps-framework mutations, board
schema introspection across many boards).
Prerequisites
MONDAY_TOKENmust be set in the environment (see the plugin README). When it is missing, every script exits 1 with a pointer to the docs — same failure mode as the MCP launcher.- A
mondayBoardIdshould be configured in.claude/scrum-context.json(seehooks/scrum-context.example.json, or run thesetup-scrum-contextskill to write it). When set, it is injected at session start as<scrum-context><monday-board id="..." /></scrum-context>, and thelist-itemsscript also falls back to it when invoked with no argument. Check the current context for this before asking the user for a board ID.
Scripts
Run from any cwd with node "${CLAUDE_PLUGIN_ROOT}/scripts/monday/<script>".
Output is JSON Lines (one compact JSON object per line for list
operations, a single object for item reads and writes) — stop after one
scripted call rather than looping unless the user asks for more.
| Script | Args | Returns |
|---|---|---|
list-items.mjs |
[boardId] |
one item per line: {id, name, group, status} |
get-item.mjs |
<itemId> |
one object: {id, name, group, board, column_values} |
update-item-status.mjs |
<itemId> "<StatusLabel>" |
one object: {itemId, boardId, columnId, label, index, updated} |
save-item.mjs |
<itemId> [outPath] |
one object: {itemId, outPath, bridge, bytes, saved}. Persists a pretty-printed JSON snapshot; supports Windows drive paths (G:\\...) on WSL via an automatic powershell.exe bridge when /mnt<g> is not mounted. |
save-doc.mjs |
<docId|objectId> [outPath] |
one object: {docId, name, outPath, bridge, bytes, saved}. Resolves either a doc-column objectId or a doc id, renders the doc's blocks as Markdown. |
save-updates.mjs |
<itemId> [outPath] |
one object: {itemId, outPath, bridge, count, bytes, saved}. Persists the item's updates (comments) + replies as JSON. |
save-all.mjs |
[boardId] "<groupName>" [epicFolderPath] [--full] |
one object summarizing a bulk snapshot of every item in one board group ("Epic") — see Epic snapshot layout below. boardId/epicFolderPath fall back to mondayBoardId/mondayEpics[groupName] in <scrum-context> when omitted. Incremental by default (skips re-fetching docs/updates for items whose updated_at hasn't changed); pass --full to force a full re-fetch. |
init-task.mjs |
--item <itemId> [epicFolder] or --subitem <subitemId> [epicFolder] |
one object describing the PBI markdown files created and the monday link column(s) written — see Epic snapshot layout below. epicFolder falls back to mondayEpics[<item's monday group>] when omitted. |
set-link.mjs |
<itemId> "<columnTitle>" <url> [text] |
one object: {itemId, boardId, column, columnId, url, text, updated}. Overwrites one link-typed column by title — used to replace an interim file:// value with a resolved real link, see Resolving real Drive links for pbi link columns below. |
check-stale-links.mjs |
<itemId> |
one object: {itemId, boardId, staleLinks, ok}, where staleLinks is [{column, columnId, url}] for every link column still holding a file:// value. Exit code 4 when staleLinks is non-empty — run this after set-link.mjs to mechanically confirm no link column was missed, instead of relying on remembering to repeat the recipe for every link column on the item. |
The walking-skeleton end-to-end check is: get-item.mjs <someId> prints one
item's name + status. You don't need to dump the whole board.
Epic snapshot layout
save-all.mjs and init-task.mjs manage a per-Epic folder (an Epic = one
board group) with this fixed layout — this is the authoritative reference:
<epicFolder>/
design/ # manual, whole-Epic design docs — save-all.mjs never touches this
.pm/ # dot-prefixed: single root for all machine-managed
# Epic data (backlog snapshot, repo sync data,
# AI reports, summary) — see scripts/lib/layout.mjs
backlog/
items/<itemId>.json # save-all.mjs, always overwritten, id-only
docs/<docId>.md # save-all.mjs, always overwritten, id-only
updates/<itemId>.json # save-all.mjs, always overwritten, id-only
progress-history.json # generate-progress-report.mjs
repo/ # sync-repo.mjs — see README's "Repository sync" section
reports/
progress/progress-report-<date>.html # generate-progress-report.mjs
prd/
prd.md # save-all.mjs, generated once, never overwritten
# (a folder, since more than one PRD can accumulate)
pbi/<itemId>-<name>/
acceptance.md # init-task.mjs --item, generated once
tasks.md # init-task.mjs --item, only when the item has no subitems
evidence/README.md # init-task.mjs --item, generated once — acceptance-test evidence
sub-tasks/<subitemId>-<name>.md # init-task.mjs --subitem, one per subitem
A legacy Epic that still has .snapshots/ instead of .pm/ needs a one-time
migration — run node "${CLAUDE_PLUGIN_ROOT}/scripts/setup/migrate-epic-layout.mjs" "<groupName>".
save-all.mjs and generate-progress-report.mjs refuse to run (a JSON error,
not a silent partial write) against an Epic that still has .snapshots/ and
no .pm/ yet.
<name> is only appended for pbi/ / sub-tasks/ (human-curated,
create-once files) — .pm/backlog/* stays id-only since it is fully
overwritten every save-all.mjs run, and embedding a mutable name there
would leave stale orphaned files behind after a monday rename. For pbi/ /
sub-tasks/, <name> is resolved once via resolveIdPrefixedEntry (in
monday-client.mjs): a later rename in monday does not rename the
folder/file — only the id prefix is relied on to find it again, so re-running
init-task.mjs after a monday rename still reuses the original entry instead
of creating a duplicate.
init-task.mjs writes each generated file's path back into the item's/
subitem's link-typed column, resolved by column title ("Acceptance
Criteria" / "Tasks" on the item; the sole link column on a subitem) —
monday generates a random id suffix per board (e.g. link_mm4y5g57), so
never assume a column id is stable across boards.
Resolving real Drive links for pbi link columns
init-task.mjs only ever writes a best-effort file:///G:/... URI (built by
its local toFileUri()) into the link column — this is clickable only on
a machine where that exact Drive-letter/sync path exists, so it is not a
real, portable link. Once the generated file has synced up to Google Drive,
it has a real https://drive.google.com/... link that should replace the
file:// one.
Resolve it with the (already-connected) Google Drive MCP connector's
search_files tool — this needs only the monday itemId, no local path
parsing:
search_files({query: "title contains '<itemId>' and mimeType = 'application/vnd.google-apps.folder'"})→ finds the pbi folder (e.g. title"12445363659-Item 2"). Filter the results so the folder'stitlestarts with<itemId>-or equals<itemId>exactly — plain substringcontainscan false-positive when one itemId is a numeric substring of another (e.g. searching445363would also match a folder prefixed12445363658). If more than one folder still matches after that filter, stop and ask the user rather than guessing.search_files({query: "parentId = '<folderId>' and title = 'acceptance.md'"})(or'tasks.md', or a subitem's<subitemId>-<name>.md) → take the returnedviewUrldirectly as the real link. Noget_file_metadatacall is needed. If more than one file matches, surface both to the user instead of picking one arbitrarily.node "${CLAUDE_PLUGIN_ROOT}/scripts/monday/set-link.mjs" <itemId> "Acceptance Criteria" "<viewUrl>" "pbi/<itemId>-<name>/acceptance.md"to overwrite the column with the real link. Repeat steps 2–3 for every otherlinkcolumninit-task.mjswrote — in item mode this is normally twice (once for"Acceptance Criteria"/acceptance.md, once for"Tasks"/tasks.md); a subitem has exactly onelinkcolumn, matched by type rather than title.node "${CLAUDE_PLUGIN_ROOT}/scripts/monday/check-stale-links.mjs" <itemId>to confirm none of the item's link columns still hold afile://value. A non-zero exit lists the offending columns instaleLinks— repeat steps 1–3 for those columns and re-run this check before considering the item done. Don't skip this: it is the deterministic way to catch a missed column instead of relying on memory.
Fallback: if step 1 returns zero folders, the file hasn't synced to
Google Drive yet (Drive for desktop mirrors asynchronously), or the Drive
MCP connector isn't reachable. Leave the existing file:// value in place,
tell the user it's interim, and retry this resolution later — don't fail
the whole init-task.mjs workflow on this. This step is optional/best-effort
and depends on the Google Drive MCP connector being connected — the same
"reach for MCP only for the one thing the dependency-free scripts can't do"
posture as the monday-api-mcp escape hatch below, just naming the Drive
connector instead (the same read-only connector referenced in the
manage-drive-docs skill).
Steps
- Read the board:
node "${CLAUDE_PLUGIN_ROOT}/scripts/monday/list-items.mjs"— pass a<boardId>only if none is present in<scrum-context>. The script emits{id, name, group, status}per item; render that summary to the user. - Read a single item:
get-item.mjs <itemId>— for the walking-skeleton minimum, fetching and displaying one item's name + status is enough. - Update status: confirm the target item and the new status label with
the user before running
update-item-status.mjs <itemId> "<Label>"— monday.com changes are visible to the whole workspace. If the label isn't found, the script prints the board's accepted labels on stderr; pass one of those back to the user and retry. - Persist an item snapshot to Google Drive or another location:
save-item.mjs <itemId> "<outPath>". Falls back to apowershell.exebridge for Windows drive paths on WSL where the drive is not mounted at/mnt/<drive>. The output JSON is pretty-printed (multi-line) so it is easy to diff across runs. - Set up or refresh an Epic folder:
save-all.mjs "<groupName>"(add<boardId>/<epicFolder>explicitly only if not already configured inmondayBoardId/mondayEpicsin.claude/scrum-context.json) bulk-fetches every item in that group plus attached docs/updates into.pm/backlog/, and generatesprd/prd.mdonce (never overwritten afterwards — safe to re-run repeatedly to refresh snapshots; add--fullto force re-fetching docs/updates for every item instead of only changed ones). - Start work on a PBI: confirm the item/subitem with the user, then
init-task.mjs --item <itemId>(or--subitem <subitemId>) to scaffold itsacceptance.md/tasks.md/evidence//sub-tasks/<id>-<name>.mdand link them back on the board —epicFolderis only needed explicitly ifmondayEpicsdoesn't have an entry for the item's monday group. Likeupdate-item-status.mjs, this writes to monday.com without prompting again — confirm with the user first. Afterwards, follow up with Resolving real Drive links for pbi link columns (above) to replace the interimfile://value with a real Drive link once the file has synced — don't leave the stalefile://link as the final state if a real one can be resolved. That recipe's final step runscheck-stale-links.mjs <itemId>for you — treat a non-zero exit there as the signal that a column (e.g. "Tasks") was missed, not just the prose reminder in this step. - Fix a stale
file://link on an already-created item: ifget-item.mjs <itemId>(orlist-items.mjs) shows a link column'scolumn_valuesholding afile:///...value, the itemId alone is enough to resolve a real link — run the same Resolving real Drive links recipe above (both queries use only the itemId, not the stale URL) and callset-link.mjsto overwrite it. No need to parse the stalefile://path at all.
Optional: the monday-api-mcp MCP server
Only reach for MCP if a request genuinely exceeds the scripts above (rich mutations, apps/atp mode tooling). In that case:
- Confirm
monday-api-mcpis connected (/mcp). - The exposed tool names differ by startup mode (
api/apps/atp); this plugin launches it in default mode. List the currently availablemcp__monday-api-mcp__*tools before assuming a name — don't hardcode one from memory.
Failure modes
MONDAY_TOKENnot set: every script exits 1 with the doc URL. Point the user to https://developer.monday.com/api-reference/docs/authentication.- No board id available: pass one explicitly, or use the
setup-scrum-contextskill to addmondayBoardIdto.claude/scrum-context.json.list-items.mjsexit code is 2 here. - Unknown status label: exit code 3; the accepted labels are listed on stderr — pass them back to the user.
- HTTP / GraphQL error: surfaced verbatim on stderr with exit code 1.
- Drive file not found for
search_files: the pbi folder/file hasn't synced from local disk to Google Drive yet (Drive for desktop mirrors asynchronously), or the Drive MCP connector isn't reachable. Leave the existingfile://(or prior) link in place and retry later — don't fail the whole workflow on this. check-stale-links.mjsreports stale links: exit code 4;staleLinksin the printed JSON lists the offending{column, columnId, url}entries. Re-run the Resolving real Drive links recipe for each listed column, then re-runcheck-stale-links.mjsuntil it exits 0 — don't consider the item done while it's still non-zero.set-link.mjs"no link-typed column titled X": the board's column was renamed away from "Acceptance Criteria"/"Tasks" (or another title was passed). The script lists the board's actuallink-typed column titles on stderr (exit code 3) — pass the correct title back.