engineering — full tool reference
One endpoint, several tool groups: diagrams, spec-builder, task-assigner, documents, entity-connections, ux-map, sheets.
| Endpoint | https://engineering.mcp.devfellowship.com/mcp |
| Package | packages/dfl-mcp-engineering |
| Tools | 53 |
| Tool | Description |
|---|---|
create_diagram | THE way to make a diagram anywhere in DFL. When you are asked for a diagram in a plan, an ADR, a handoff document, a spec or a PR description, call this INSTEAD OF writing a Mermaid code block into the body. It persists a row in public.diagrams — the store behind the dfl-diagrams app at diagrams.devfellowship.com/diagrams/<id> — and returns that row, whose id (a uuid) is what every other DFL app references. A pasted code fence is dead text: it cannot be opened in the editor, edited, versioned, exported or searched, and the plan that holds it never updates when the design changes. TO PUT IT IN A PLAN, do not hand-write the token: call the plans MCP (plans.mcp.devfellowship.com) attach_entity with the plan slug, type “diagram” and this uuid as the locator. It inserts the reference token — {{dfl-diagram:<uuid>}}, whose long form {{dfl-entity:diagram:<uuid>}} is equivalent — which the plans-app resolves at render time, so later edits to the diagram reach the plan with no new plan version. The same token works in a document (see write_document diagram_entity_id). Use export_diagram only for places that cannot resolve a token, such as a GitHub PR body. epic_id is OPTIONAL. Pass it to file the diagram under a work.epics entity (stored as entity_id) and a non-epic id is still rejected, so resolve or create the epic (work MCP) first; check list_diagrams for that epic before creating a second diagram of the same thing. Omit it for a stand-alone diagram — the case for a diagram whose subject is a PLAN, which has no epic. Never invent an epic to fill the field: a wrong-but-valid id files the diagram under someone else’s epic. VISIBILITY, and it is not a formality: on public.diagrams the SELECT policy for authenticated users is entity_id IS NOT NULL, so entity_id doubles as the “shared” flag. A diagram created WITHOUT epic_id is therefore readable by ITS CREATOR ALONE, and the plans-app resolves a {{dfl-diagram:<uuid>}} token under the VIEWER’S own RLS with no service-role fallback — so an epic-less diagram embedded in a plan renders for you and for nobody else. Omit epic_id when the diagram is genuinely yours or is a draft; pass an epic when other people must read it. Either pass mermaid_text (a Mermaid flowchart/erDiagram/sequenceDiagram, optionally fenced in ```mermaid blocks) to have type/nodes/edges derived and auto-laid-out automatically, or pass nodes/edges directly in the native @xyflow/react shape plus an explicit type. PlantUML import is not supported yet. NODE LABELS ARE CAPPED AT 60 CHARACTERS and the cap is enforced on both paths (native nodes AND mermaid_text, where the node text becomes data.label): a diagram with a longer data.label or data.name is rejected outright, naming the offending nodes — nothing is written and nothing is truncated. Long prose (requirements, open questions, rationale) belongs in that node’s data.description, which has no length limit and renders as node detail rather than as the box label. A label that reads like a sentence is a label in the wrong field. |
update_diagram | Update an existing diagram in public.diagrams by id — the right way to change a diagram that a plan or a document already references. The uuid does not change, and plans/documents hold only the {{dfl-diagram:<uuid>}} reference token, so every one of them shows the new version on the next render and NO plan version is created. Never re-create a diagram to change it, and never paste an updated Mermaid block into the plan body instead. Pass any subset of name, description, type, nodes, edges — omitted fields are left untouched. Alternatively pass mermaid_text to replace type/nodes/edges wholesale from Mermaid source (auto-detected and auto-laid-out; explicit type/nodes/edges are then ignored). Set auto_layout: true to re-run the dagre layout over explicitly supplied nodes instead of keeping their positions. NODE LABELS ARE CAPPED AT 60 CHARACTERS — exactly the same enforced validation as create_diagram, on both the nodes path and the mermaid_text path: an update whose data.label or data.name is longer is rejected, naming the offending nodes; nothing is written and nothing is truncated. Long prose belongs in that node’s data.description, which has no limit. Writes are RLS-scoped to the caller: you can only update diagrams you created. There is no delete tool by design. By default this snapshots the diagram’s pre-update state into public.diagram_versions before writing (see snapshot / commit_message) — a prior state is only recoverable via get_diagram_version if it was snapshotted first. |
list_diagrams | Find an EXISTING diagram and, above all, its uuid — the id you need to reference it from a plan or a document (plans MCP attach_entity, or the {{dfl-diagram:<uuid>}} token). Filter by epic_id to get every diagram of a work.epics entity, or by name/type. Run this BEFORE create_diagram when a diagram of the same thing may already exist: a duplicate diagram splits the references and the two copies then drift apart. With no epic_id the list is NOT epic-scoped: it also includes STAND-ALONE diagrams, created without an epic (entity_id null) — the shape a diagram takes when it belongs to a plan. Those are returned under the caller’s RLS, so a stand-alone diagram is listed only for the user who created it, while an epic-bound one is listed for any authenticated user. |
get_diagram | Get one diagram by id, including its full nodes/edges in the normalised @xyflow/react shape (the same shape whether it was created from mermaid_text or from explicit nodes). Use it to read what a {{dfl-diagram:<uuid>}} token in a plan or a document actually points at, and to inspect the current content before calling update_diagram. For a rendered picture or Mermaid source instead of the raw graph, use export_diagram. |
export_diagram | Export any stored diagram to a shareable artifact by id. Formats: svg (a standalone vector image rendered from the persisted node positions — use this when you need a picture; rasterise to PNG on the client at whatever DPI you want, since SVG has no fixed resolution), mermaid (Markdown-fenced Mermaid source) and plantuml. Works for erd, flowchart and sequence diagrams. Returns the artifact text plus a suggested filename and mime type. This is for surfaces that CANNOT resolve a DFL reference token — a GitHub PR body, a README, a slide, an e-mail. Do NOT export to Mermaid just to paste the fence into a plan or a document: those resolve {{dfl-diagram:<uuid>}} themselves (plans MCP attach_entity), and a pasted copy stops tracking the diagram the moment it is edited. |
snapshot_diagram_version | Save the CURRENT nodes/edges of a diagram as a new row in public.diagram_versions, so this state is recoverable later via get_diagram_version. Generic and reusable — not tied to any one diagram or incident. version_number is COALESCE(MAX(version_number), 0) + 1 for that diagram_id (concurrent snapshots are retried on the UNIQUE (diagram_id, version_number) race, not left to fail opaquely). Reads are RLS-scoped: only the diagram’s owner can snapshot it. update_diagram already calls this automatically before applying changes (default snapshot: true) — call this tool directly when you want a checkpoint WITHOUT also changing the diagram right now. The revision this creates is also what lets a plan PIN a diagram: the plans MCP attach_entity honours mode “pinned” only when it is given a rev, and that rev is a public.diagram_versions id (read it back from list_diagram_versions). Snapshot first when an ADR or a decision block must keep showing the state the decision was taken against; leave the embed live everywhere else. |
list_diagram_versions | List the revision history of a diagram from public.diagram_versions, ordered by version_number DESC (newest first). Deliberately omits the full nodes/edges payload — each row carries node_count / edge_count instead, so the response stays small even for a diagram with many revisions. Use get_diagram_version to fetch the full nodes/edges of one specific revision. Each row’s id is also the rev the plans MCP attach_entity needs to PIN a plan embed to a fixed state — a pin without a rev is not honoured and renders live content instead. |
get_diagram_version | Get one specific revision of a diagram from public.diagram_versions, including its full nodes/edges — this is what makes a prior state actually recoverable, as opposed to list_diagram_versions which only returns counts. Identify the revision either by its own id, or by diagram_id + version_number. |
generate_tasks | ⚠️ DEPRECATED — prefer create_spec_run followed by promote_spec_run_items. THIS IS THE ONLY TOOL IN THE DFL MCP FLEET THAT WRITES UNREVIEWED AI OUTPUT STRAIGHT INTO A REAL BACKLOG: it generates tasks from a spec and inserts them into work.tasks immediately, with NO review gate, no versioning, no durable candidate items, and no way to adjust points before they become real — the dfl-spec-builder frontend has always staged the same output for approval, and this tool does not. Its output is also invisible in the Spec Builder history. create_spec_run writes CANDIDATES (work.ai_spec_inputs + work.ai_spec_tasks) as a versioned, commentable, linkable spec run; promote_spec_run_items turns the approved ones into work.tasks behind an explicit dry_run: false. It also needs no epic, no project and no business unit, so it works for plan-only client projects that this tool cannot run at all. Kept only so existing callers do not break. Pass an existing epic_id, OR epic_name + project_id to create a new epic. Restricted to projects under the devfellowship/Revera business units (MVP). Uses the calling user’s JWT (RLS applies normally). |
create_spec_run | Generate a pack of CANDIDATE tasks from spec prose and store it as a spec run — a first-class, versioned, linkable entity with its own URL. A spec run holds CANDIDATES, not tasks. This writes work.ai_spec_inputs + work.ai_spec_tasks and NEVER work.tasks. Nothing reaches the real backlog until promote_spec_run_items is called with dry_run: false. Creates version 1 of the run (a work.spec_run_versions snapshot with a points_total), and gives every item a stable short_ref (SR-01, SR-02, …) that survives every later re-generation and anchors every comment. Bind it to a plan with plan_slug — unlike the deprecated generate_tasks, this needs NO epic, no project and no business unit, which is exactly what lets a client project that lives only as a plan be run at all. A run does NOT store its plan binding and has no plan_slug column. The binding IS the {{dfl-entity:spec_run:<run_id>}} token in the plan body; work.entity_connections is only the index the plans-app derives from it. So creating a run does NOT make it appear in the plan’s Entidades rail — call attach_entity on the PLANS MCP (plans.mcp.devfellowship.com) with { slug, type: "spec_run", locator: <run_id> } to do that. One link per RUN, never one per item. Writing an entity_connections row directly would be reconciled away on the next publish, because the body is the source of truth. NOT idempotent: every call generates a new pack and a new run id, so a retry after a timeout can leave two runs — check list_spec_runs before retrying. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error. |
get_spec_run | Read a spec run: its items with points, short_ref anchors, review status and point provenance, its points_total, both version counters, and the OPEN comment batch. Reads LIVE by default. Pass version_number or version_id to read an exact historical version, which returns that version’s stored JSONB snapshot — so it keeps resolving even after items were removed or re-pointed. ⚠️ AN UNRESOLVABLE VERSION IS AN ERROR (version_not_found), NEVER a silent fallback to live: a quote pinned to v3 that quietly renders v7 is a wrong number that looks right. Read-only — bumps nothing, writes nothing, and is the side-effect-free way to inspect the open comment batch (handoff_spec_run_comments would bump it). Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. |
list_spec_runs | Find spec runs and get the run_id every other spec-run tool needs. Filter by plan_slug (resolved through the work.entity_connections index the plans-app derives from plan bodies — a run does not store its own plan binding), by epic_id (the optional pipeline entity), or by status; omit all three to list the most recent runs. Default 20 results, max 50. Read-only. Next steps by exact name: get_spec_run to read one, attach_entity (PLANS MCP) to link one into a plan, comment_spec_run to review it. A run does NOT store its plan binding and has no plan_slug column. The binding IS the {{dfl-entity:spec_run:<run_id>}} token in the plan body; work.entity_connections is only the index the plans-app derives from it. So creating a run does NOT make it appear in the plan’s Entidades rail — call attach_entity on the PLANS MCP (plans.mcp.devfellowship.com) with { slug, type: "spec_run", locator: <run_id> } to do that. One link per RUN, never one per item. Writing an entity_connections row directly would be reconciled away on the next publish, because the body is the source of truth. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. |
update_spec_run_items | Apply HUMAN edits to the items of a spec run — rename, re-describe, re-point, re-stage, re-tag, approve or reject. THIS BUMPS THE RUN VERSION (work.ai_spec_inputs.current_version) and writes a new work.spec_run_versions snapshot with a recomputed points_total. That is correct and intended: a quote is derived from the points, so every content change has to be pinnable to an exact version or the number stops being reproducible. It does NOT touch the comment version. Points carry provenance. points_set_by is ai until a human sets the value, then human forever, and the field name lands in human_edited_fields. A human-set point is never overwritten by a generator: regenerate_spec_run REJECTS an update touching it unless the item id is listed in repoint. Points use the Fibonacci scale 1/2/3/5/8/13/21. SEPARATELY from points_set_by, every item records HOW its number was produced: points_source (one of engine, human, generator_legacy, imported), plus engine_version and rule_id for the audit trail. The database enforces that an engine row NAMES its engine_version — a writer cannot claim engine provenance and leave the trail unfalsifiable. generator_legacy is the column DEFAULT and means “produced before provenance was recorded”, NOT “produced by the current generator”. Every field you set here is recorded in that item’s human_edited_fields, and any estimated_points you set flips points_set_by to human — which is what later makes regenerate_spec_run refuse to overwrite it — AND stamps points_source human in the same write. To correct provenance WITHOUT changing a number, use set_spec_run_points_provenance. Items keep their id and short_ref — this never replaces a row. All-or-nothing: if ANY edit names an item that is not in this run (unknown_item_id) or was soft-removed (item_already_removed), NOTHING is written and no version is cut. Approving an item does NOT create a task — promote_spec_run_items does that. Get item ids from get_spec_run. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error. |
set_spec_run_points_provenance | Record HOW the estimated_points of one or more spec-run items were produced — write points_source, engine_version and rule_id on work.ai_spec_tasks. SEPARATELY from points_set_by, every item records HOW its number was produced: points_source (one of engine, human, generator_legacy, imported), plus engine_version and rule_id for the audit trail. The database enforces that an engine row NAMES its engine_version — a writer cannot claim engine provenance and leave the trail unfalsifiable. generator_legacy is the column DEFAULT and means “produced before provenance was recorded”, NOT “produced by the current generator”. This is the ONLY sanctioned write path for those three columns: they are DATA, so they are never corrected by a dfl-schema migration. It does NOT change estimated_points, points_set_by or points_needs_review — use update_spec_run_items to change a number, this to record where the number came from. It therefore does NOT bump the run version and cuts no work.spec_run_versions snapshot: a provenance edit cannot move points_total, and a snapshot identical to its predecessor makes the history a quote is pinned to harder to read, not easier. Idempotent — re-sending the same values rewrites the same values. All-or-nothing: if ANY entry names an item that is not in this run (unknown_item_id), was soft-removed (item_already_removed), or claims points_source: "engine" without an engine_version (invalid_arguments), NOTHING is written. Get item ids from get_spec_run. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error. |
score_spec_run_items | Price one or more spec-run items with the points_v2 decision table, and record the provenance of every number it produces. YOU classify each item — pass work_type and complexity; the TABLE decides the number. The table is 14 rules of reviewable data living in devfellowship/dfl-flows-definitions -> policies/work/points_v2.dmn.json, not logic in this repo: three hard rules from the founder (documentation → 0, migration → 0.5, duplicate → 0) evaluated FIRST, then eleven trusted cells whose values are corpus MEDIANS of historical tasks. Outside a trusted cell it emits NO NUMBER and escalates to a human — there is no catch-all rule and no default. On the historical corpus it declines ~61% of rows and ~71% of points, which is the designed behaviour, not a failure: a loud gap beats a confidently wrong price. The score is CLIENT-BLIND. No client, project or business-unit input exists anywhere in this path. The client premium belongs on the rate card, charged once — a fellow’s pay must not depend on who the invoice goes to. ⚠️ Accuracy caveat you must not restate as more than it is: the table reproduces Tainan’s own historical scores (MedAE 1.00, 69.4% within ±1pt on covered rows, versus 1.50 / 49.6% for always-guess-the-median). There is NO independent oracle — work.tasks has no effort column — so this is fidelity to past judgement, never a claim of correctness. Sell it as guard-rail plus audit trail. Writes estimated_points, and stamps points_source: "engine", engine_version and rule_id in the SAME write so a number and its provenance can never disagree. SEPARATELY from points_set_by, every item records HOW its number was produced: points_source (one of engine, human, generator_legacy, imported), plus engine_version and rule_id for the audit trail. The database enforces that an engine row NAMES its engine_version — a writer cannot claim engine provenance and leave the trail unfalsifiable. generator_legacy is the column DEFAULT and means “produced before provenance was recorded”, NOT “produced by the current generator”. DEFAULTS TO A DRY RUN: the plain call reports every verdict and writes nothing. Pass dry_run: false to apply. When applied and at least one point VALUE changed, THIS BUMPS THE RUN VERSION (work.ai_spec_inputs.current_version) and writes a new work.spec_run_versions snapshot with a recomputed points_total. That is correct and intended: a quote is derived from the points, so every content change has to be pinnable to an exact version or the number stops being reproducible. It does NOT touch the comment version. If every number the engine produced already matched what was stored, only provenance moved and NO version is cut — a snapshot identical to its predecessor makes the history a quote is pinned to harder to read. Human-set points are protected exactly as in regenerate_spec_run: an item with points_set_by: "human" is rejected (human_points_not_repointed) unless its id is listed in repoint. All-or-nothing on validation: if ANY entry names an item outside this run (unknown_item_id) or one that was soft-removed (item_already_removed), NOTHING is written. Get item ids from get_spec_run. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error. |
regenerate_spec_run | TWO PATHS, chosen by whether you pass operations. ① OMIT operations → THE GENERATOR RUNS: the spec generator is re-invoked over spec (or, if you omit it, the prose already stored on the run), a spec you pass is PERSISTED to the run so the stored input stops describing a spec nobody works from any more, and you get back the freshly generated pack plus a ready-to-send proposed_operations list already keyed to the live item ids — outcome generated_proposal. It writes NO item and cuts NO version: the generator emits tasks with no ids, so applying them against existing rows could only guess at identity or destroy it. Edit the proposal and send it back through path ②. ② PASS operations → they are applied exactly as they always were (and a spec passed alongside is persisted in the same all-or-nothing call). Path ② re-works the item set AS OPERATIONS ON THE EXISTING ITEMS — never as a fresh list of tasks. Items are DURABLE: they keep their id and their short_ref across every re-generation, which is what lets a comment written three rounds ago still point at the right line. Operations: keep(item_id) · update(item_id, fields) · add(item) · remove(item_id, reason) · split(item_id, new_items) · merge(from_ids, into_id). A SPLIT IS NOT TWO NEW ITEMS. split(item_id, new_items) CONTINUES item_id — same row, same id, same short_ref, same points, removed_at still NULL — and only births new_items, each stamped lineage_ref = item_id, lineage_kind = "split_from" and points_needs_review = true. A human point is NEVER divided, copied or re-estimated across a split. A MERGE is symmetric: merge(from_ids, into_id) KEEPS into_id (modified) and SOFT-REMOVES from_ids, each stamped lineage_ref = into_id, lineage_kind = "merged_into". Removal is always soft — a hard delete would make an older pinned version stop resolving and dangle the short_ref in an already-sent clipboard batch. ⚠️ FULL COVERAGE IS REQUIRED: every live item must be named by exactly one operation. Use keep for the ones that do not change — an operation list that omits live items is rejected with unreferenced_item, because it is a fresh list in disguise. Call get_spec_run first to get the ids. ⚠️ HUMAN POINTS ARE GATED: an update (or a merge rewrite) that changes estimated_points on an item whose points_set_by is human is REJECTED with human_points_not_repointed unless that item id is listed in repoint. Points carry provenance. points_set_by is ai until a human sets the value, then human forever, and the field name lands in human_edited_fields. A human-set point is never overwritten by a generator: regenerate_spec_run REJECTS an update touching it unless the item id is listed in repoint. Points use the Fibonacci scale 1/2/3/5/8/13/21. ALL-OR-NOTHING: any rejection means NOTHING is written and no version is cut; the response names every rejection so you can fix the operation list and resend. THIS BUMPS THE RUN VERSION (work.ai_spec_inputs.current_version) and writes a new work.spec_run_versions snapshot with a recomputed points_total. That is correct and intended: a quote is derived from the points, so every content change has to be pinnable to an exact version or the number stops being reproducible. It does NOT touch the comment version. Never writes work.tasks. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error. |
promote_spec_run_items | Turn APPROVED spec-run items into real work.tasks rows — the only tool here that writes the real backlog, and the only irreversible one (there is no task-delete tool in the DFL MCP fleet, by policy). ⚠️ dry_run DEFAULTS TO TRUE: the default call reports exactly what WOULD be promoted and writes nothing. Pass dry_run: false to actually create the tasks. Only items with status = "approved", no removed_at and no existing work_task_id are eligible; everything else is reported skipped with a reason (skipped_not_approved, skipped_removed, skipped_already_promoted). IDEMPOTENT — re-running promotes only the remainder, which is what makes a retry after a partial failure safe. Each item carries its own outcome, so a batch where some succeeded and one failed (failed) reads as exactly that. The DFL-XXXXX identifier is minted by the database trigger, never by this tool. On a real run it writes work_task_id/work_task_identifier back onto each item and THIS BUMPS THE RUN VERSION (work.ai_spec_inputs.current_version) and writes a new work.spec_run_versions snapshot with a recomputed points_total. That is correct and intended: a quote is derived from the points, so every content change has to be pinnable to an exact version or the number stops being reproducible. It does NOT touch the comment version. Approve items first with update_spec_run_items({ status: "approved" }). Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error. |
comment_spec_run | Add a review comment to a whole spec run, or to ONE item of it, into the currently OPEN hand-off batch. ⚠️ THIS NEVER BUMPS THE RUN VERSION and never bumps the comment version either — commenting changes no content and sends nothing. The COMMENT version (work.ai_spec_inputs.comment_version) is a hand-off cursor, not an archive: it counts “batches I have copied and sent”, nothing else. Posting a comment bumps NEITHER axis — a comment is a request, and the edit that satisfies it is the change. Only handoff_spec_run_comments advances it. The comment is stamped with its batch_number and with context_version (the run version it was written against) SERVER-SIDE, so this tool and the Spec Builder UI cannot disagree about which batch a comment belongs to. Comment on an ITEM whenever the remark is about one line — the item comment carries the short_ref anchor that makes handoff_spec_run_comments produce an unambiguous agent prompt, whereas “quebra em duas” on the whole run is useless the moment it leaves the UI. Read the open batch with get_spec_run (side-effect free); send and close it with handoff_spec_run_comments (which DOES bump). Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error. |
handoff_spec_run_comments | Take the OPEN batch of review comments on a spec run, format it as a ready-to-paste agent prompt with a short_ref anchor per item, mark the batch as sent (handed_off_at) and BUMP THE COMMENT VERSION. The programmatic twin of the UI’s “copy all + bump”. ⚠️ RETURNS ONLY THE OPEN BATCH, never the comment history — a hand-off that re-sends already-delegated comments is the exact failure the bump exists to prevent. A second call with no new comments returns an EMPTY batch and bumps NOTHING. ⚠️ THE DEFAULT CALL BUMPS. Pass dry_run: true to preview the exact prompt without sending or bumping. To merely READ the open batch with no side effect at all, use get_spec_run instead — that is what it is for. This NEVER touches the run version: handing comments off changes no content. The COMMENT version (work.ai_spec_inputs.comment_version) is a hand-off cursor, not an archive: it counts “batches I have copied and sent”, nothing else. Posting a comment bumps NEITHER axis — a comment is a request, and the edit that satisfies it is the change. Only handoff_spec_run_comments advances it. The stamp and the counter move together inside one SECURITY DEFINER function, so a batch can never end up half-sent. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error. |
delete_spec_run | PERMANENTLY delete one spec run and everything scoped to it: the run row (work.ai_spec_inputs), its items (work.ai_spec_tasks, via CASCADE), its version snapshots (work.spec_run_versions, via CASCADE) and its comments (work.comments keyed spec_run / spec_run_item, which have NO foreign key and would otherwise be orphaned). There is no undo and no soft-delete: this is the tool for discarding a run that should never have existed (a smoke test, a mis-generated pack), not for retiring one — a real run that is over gets status: "discarded" via update_spec_run_items, which keeps the history. ⚠️ dry_run defaults to TRUE: the first call always reports what WOULD go and deletes nothing. REFUSES, without deleting anything, when the run is still referenced by a plan (work.entity_connections.target_type = "spec_run") or when any item was already promoted into work.tasks — see the outcome strings run_attached_to_plan and run_has_promoted_items. The plan-reference check counts through work.entity_target_reference_count(), which is NOT row-filtered, so it also refuses for a plan you cannot read: reference_count can exceed the plan_slugs it names, and that gap means “ask the plan owner to detach it”, not “retry”. It NEVER deletes a work.tasks row: promoted tasks are real backlog and are not this tool’s to remove. Not idempotent in the useful sense — a second call on a deleted run returns run_not_found_or_not_entitled. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error. |
list_spec_run_packages | List the scope layers (the “onion”) of a spec run, each with its item count, point total, and — the number that actually sells — what that layer ADDS over the previous one. layer is the depth from the CORE: 1 is the innermost package (the MVP), and the package of layer N contains every item whose package has layer <= N. So assigning an item to layer 1 puts it in EVERY package. It is NOT the number the client reads — that is client_label. An item with no package is NOT in layer 1: it is in no package at all. Assign package_id null to take an item out of every package. A run with no layers is not broken: it is one package nobody has split yet. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. |
upsert_spec_run_package | Create or rename ONE scope layer of a spec run, keyed by (run_id, layer). layer is the depth from the CORE: 1 is the innermost package (the MVP), and the package of layer N contains every item whose package has layer <= N. So assigning an item to layer 1 puts it in EVERY package. It is NOT the number the client reads — that is client_label. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error. |
assign_spec_run_layers | Assign items to scope layers in ONE batch. Each item points at the INNERMOST package that contains it. layer is the depth from the CORE: 1 is the innermost package (the MVP), and the package of layer N contains every item whose package has layer <= N. So assigning an item to layer 1 puts it in EVERY package. It is NOT the number the client reads — that is client_label. An item with no package is NOT in layer 1: it is in no package at all. Assign package_id null to take an item out of every package. Items you do not list are left alone. The write is REFUSED if another writer changed the run after this call read it — nothing is written, and you re-read and retry. Get item ids from get_spec_run and package ids from list_spec_run_packages. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. |
delete_spec_run_package | Delete a scope layer. REFUSED while any item still points at it — reassign those items first with assign_spec_run_layers. The refusal is deliberate: the foreign key is ON DELETE SET NULL, so a direct delete would silently un-package every item of that layer, and the run would quietly lose scope nobody removed. |
list_spec_run_feature_groups | List the feature groups of a spec run with their item count and point total, PLUS the items that are outside every group. A feature group is the unit the BUYER has an opinion about (“I want the AI copilot”), not a unit of work. It does NOT price anything — the scope layer sells, and a package total is a plain sum. feature_group_id: null means the item is OUTSIDE every group — the base of the project that exists in any version of it (CI/CD, deploy, database, handover). That is a classification, not a missing value, and on b17c6fd2 it is 32% of the points. The two numbers always add up to the run total — if they do not, an item points at a group that no longer exists. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. |
upsert_spec_run_feature_group | Create or rename one feature group of a run, keyed by (run_id, code). A feature group is the unit the BUYER has an opinion about (“I want the AI copilot”), not a unit of work. It does NOT price anything — the scope layer sells, and a package total is a plain sum. Groups are PER RUN — there is no shared catalog, because a group’s composition is run-specific. Without an explicit sort_order the group goes to the end, never tied with another: two rows with the same order make the list non-deterministic, and a proposal that reorders itself between two reads does not inspire confidence. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error. |
assign_spec_run_feature_groups | Assign items to feature groups in ONE batch. feature_group_id: null means the item is OUTSIDE every group — the base of the project that exists in any version of it (CI/CD, deploy, database, handover). That is a classification, not a missing value, and on b17c6fd2 it is 32% of the points. Items you do not list are left alone. The write is REFUSED if another writer changed the run after this call read it — nothing is written, and you re-read and retry. Get item ids from get_spec_run and group ids from list_spec_run_feature_groups. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. |
delete_spec_run_feature_group | Delete a feature group. REFUSED while any item still points at it. The refusal matters more than it looks: the foreign key is ON DELETE SET NULL, and in this model “outside every group” is a CLASSIFICATION — it says “this is the base of the project”. A direct delete would make the run assert that about a dozen items without anyone deciding it. Move the items explicitly first, with assign_spec_run_feature_groups. |
assign_developer | Suggests and assigns the best-matched developer for a work.tasks row via the dfl-ai-task-assigner-matching-task Edge Function (deterministic scoring + optional AI re-rank). Always writes the top-scored candidate to work.tasks.owner_id and returns the full ranked suggestion list for transparency. |
seed_template | Create or update a documents.template + its documents.template_variable rows, without a dfl-schema migration. Idempotent by template name: re-running with the same name updates the existing template in place and replaces its full variable set with the one passed in this call. Reusable for any template (comercial, handoff, future ones) — not handoff-specific. |
get_handoff_document | Read documents.document rows. Pass “id” to fetch one document, or “epic_id” to list every document linked to a work.epics id (entity_id), most recently updated first. |
write_document | Create or update a documents.document row from a template category, an explicit template id, or a literal body. Generic: any category seeded via seed_template works — nothing here is specific to one document kind. TO PUT A DIAGRAM IN THE DOCUMENT, do not paste a Mermaid code block: create the diagram with create_diagram, put the diagram sentinel in the body and pass diagram_entity_id, and every diagram of that entity is injected as a live {{dfl-diagram:UUID}} reference token that keeps following the diagram as it is edited. Writes run on your user JWT, so documents.document RLS (iam.is_member()) decides whether they land; a write that matches no row is reported as a failure, never as an empty success. Does not compute rendered_content (a frontend concern, same as the dfl-documents wizard) and cannot set signature-lifecycle statuses. |
write_handoff_document | Convenience wrapper over write_document for the “handoff” template category: resolves a work.epics id to its name, instantiates the active handoff template, and injects {{dfl-diagram:UUID}} tokens for every diagram already created for that epic. Everything else (RLS, duplicate handling, audit log) is the same shared writer — use write_document directly for any other category. |
link_epic_to_plan | Bind a work.epics epic to a plan, so the plan shows which epic carries its work and the dfl-learn epics panel can find the plan from the epic. Writes ONE row in work.entity_connections with target_type “epic”. Idempotent: re-linking the same pair reports the existing row instead of creating a second one. It is epic-scoped ON PURPOSE and there is no target_type argument. The plans-app owns and rewrites the diagram / document / image / ux_path / spec_run rows of that table from the plan body on every publish, and the task rows on every set_plan_tasks, so a row of those types written here would report success and then vanish at the next publish. Attach a diagram, a document, an image, a ux_path or a spec_run with the plans MCP attach_entity; bind tasks with the plans MCP set_plan_tasks. Authorisation follows the plans-app: you must be able to READ the plan (an unreadable or absent plan both answer plan_not_found — the tool never confirms that a stranger’s personal plan exists), and you must be the plan OWNER or an admin (IAM level 80+). |
list_entity_connections | Read the plan-to-entity links in work.entity_connections — the join that binds a plan to its tasks, epics, diagrams, documents, images, ux_paths and spec runs. READ-ONLY across every target type. Use it to inspect what a plan is bound to, or, with target_id, to find which plans reference one entity. It exists so nobody needs psql to answer that question. Results are filtered by the caller’s own RLS: you see the links of a plan you can read, and you see no link of a plan you cannot. An empty list can therefore mean either “no links” or “not your plan” — when you filter by plan_slug the response says which one it was, in plan_readable. To WRITE a link: epic links use link_epic_to_plan on this server; task links use set_plan_tasks on the plans MCP; diagram, document, image, ux_path and spec_run links are derived from the plan body by the plans-app, so use attach_entity on the plans MCP. |
annotate_ux_map_region | Pin a design note to a REGION of a captured screen in engineering.ux_map_annotations. The anchor comes straight from a region of the screen’s regions.json — the file, the component, the tag and the occurrence — so nothing here is typed by hand or guessed. Written with YOUR user-JWT: the row records you as the author and the database refuses an author_id that is not yours, because a design opinion attributed to the wrong person is worse than no opinion. THE ANCHOR IS IMMUTABLE. An annotation is anchored to (app, screen_id, source_file, component_name, element_tag, occurrence_index). Those columns cannot be updated: authenticated holds UPDATE on six columns only, and a BEFORE UPDATE trigger refuses an anchor change even for a privileged role. If the component is gone, the annotation is BROKEN and must be reported as broken — never moved to another element, and never re-created silently under the old id. Write a NEW annotation on whatever replaced it. “broken” is deliberately not a status, because brokenness is a property of the capture you are LOOKING AT, not of the row. Every coordinate is a 0..1 FRACTION, never a pixel — a stored pixel goes silently wrong the moment the viewport changes. pin_rel_* and region_rel_* are fractions of the SCREEN; pin_in_region_* is the fraction of the REGION BOX, and it is the pair a surviving pin is redrawn from against the region box in the CURRENT capture. CHECK constraints reject anything outside 0..1. There is NO viewport argument, and none may be added. The database copies the viewport from the capture you named, for the same reason it takes the author from your JWT: what you were LOOKING AT is not a claim the writer gets to make. VIEWPORT is a lane BESIDE the role, never inside it. role says WHO was looking; viewport says ON WHAT. Known values: “desktop” (the default, and every capture taken before 2026-09-02) and “mobile”; anything else follows the “<W>x<H>” convention in CSS pixels, lower case, e.g. “390x844”. It is free text and lower-cased by the database, so “Mobile” and “mobile” are one lane. NEVER encode it in the role as “<role>@mobile” — that is the design the column replaced, and it would make a viewport look like a role. Requires the engineering.ux_map_annotations migration (devfellowship/dfl-schema#793), which is at a human gate. Until it merges this call fails with a database error naming the missing relation — it does NOT silently succeed. |
list_ux_map_annotations | Read design notes pinned to regions of captured screens. Every filter is optional, so this answers “what is open on this screen”, “what is open on this FILE” — the reverse lookup an engineer opens a pull request from — and “what did this person raise”. READ-ONLY. This tool does NOT tell you whether an annotation still points at anything: that depends on the capture you are looking at, and is decided by resolving the anchor against that capture’s regions.json (resolveAnchor in @devfellowship/ux-paths-spec). A row here with status “open” may well be BROKEN against the newest capture. THE ANCHOR IS IMMUTABLE. An annotation is anchored to (app, screen_id, source_file, component_name, element_tag, occurrence_index). Those columns cannot be updated: authenticated holds UPDATE on six columns only, and a BEFORE UPDATE trigger refuses an anchor change even for a privileged role. If the component is gone, the annotation is BROKEN and must be reported as broken — never moved to another element, and never re-created silently under the old id. Write a NEW annotation on whatever replaced it. “broken” is deliberately not a status, because brokenness is a property of the capture you are LOOKING AT, not of the row. VIEWPORT is a lane BESIDE the role, never inside it. role says WHO was looking; viewport says ON WHAT. Known values: “desktop” (the default, and every capture taken before 2026-09-02) and “mobile”; anything else follows the “<W>x<H>” convention in CSS pixels, lower case, e.g. “390x844”. It is free text and lower-cased by the database, so “Mobile” and “mobile” are one lane. NEVER encode it in the role as “<role>@mobile” — that is the design the column replaced, and it would make a viewport look like a role. Omit viewport to read EVERY lane, which is what this tool did before the column existed. Pass it to answer “what is open on this screen AT THIS VIEWPORT” — a note written on a desktop-only sidebar is not feedback about the mobile image. Requires the engineering.ux_map_annotations migration (devfellowship/dfl-schema#793), which is at a human gate. Until it merges this call fails with a database error naming the missing relation — it does NOT silently succeed. |
resolve_ux_map_annotation | Move an annotation between “open” and “addressed”, and/or record the request pull request it produced. This is what makes the notes a QUEUE rather than a wall of sticky notes. ANY signed-in person may resolve ANY annotation — that is the queue functioning: the designer raises them, the engineers close them. Only the AUTHOR may edit the text, and the database enforces that with a trigger rather than with a convention. Deleting somebody’s feedback is NOT resolving it, and this tool cannot delete. THE ANCHOR IS IMMUTABLE. An annotation is anchored to (app, screen_id, source_file, component_name, element_tag, occurrence_index). Those columns cannot be updated: authenticated holds UPDATE on six columns only, and a BEFORE UPDATE trigger refuses an anchor change even for a privileged role. If the component is gone, the annotation is BROKEN and must be reported as broken — never moved to another element, and never re-created silently under the old id. Write a NEW annotation on whatever replaced it. “broken” is deliberately not a status, because brokenness is a property of the capture you are LOOKING AT, not of the row. Requires the engineering.ux_map_annotations migration (devfellowship/dfl-schema#793), which is at a human gate. Until it merges this call fails with a database error naming the missing relation — it does NOT silently succeed. |
index_ux_map_capture | Record a UX map capture — an app, a role, and the screens observed for that role — in engineering.ux_map_apps, engineering.ux_map_captures and engineering.ux_map_screens. This is the ONLY write path into those three tables for a human identity; every other writer is an automated collector. READS do not come through here: an app reads the ux_map tables directly from Supabase with the user session under RLS, because MCP is the AI-agent surface, not a data layer for a UI. The author is taken from your JWT by the database itself, so there is NO indexed_by, user_id or author argument and none may be added — a caller-supplied author would let this tool write a map as somebody else. Coverage is derived, not asserted: the database counts the screens payload, so there is no screens_total or screens_with_shot argument either. Idempotent on digest: re-indexing identical content writes nothing and answers already_indexed true with the existing row, which the result reports at the top level — read that flag before you believe a capture was refreshed. engineering.ux_map_validations is a DIFFERENT and separate marker, written by the person who validates a map against the running app; indexing a capture never validates it. A capture is identified by app, role, VIEWPORT and digest since 2026-09-02, so the same content can be indexed once per viewport. VIEWPORT is a lane BESIDE the role, never inside it. role says WHO was looking; viewport says ON WHAT. Known values: “desktop” (the default, and every capture taken before 2026-09-02) and “mobile”; anything else follows the “<W>x<H>” convention in CSS pixels, lower case, e.g. “390x844”. It is free text and lower-cased by the database, so “Mobile” and “mobile” are one lane. NEVER encode it in the role as “<role>@mobile” — that is the design the column replaced, and it would make a viewport look like a role. The caller must be a DFL member: the function refuses an IAM global level below 50. |
delete_ux_map_capture | PERMANENTLY delete ONE UX map capture — the engineering.ux_map_captures row named by capture_id — through engineering.ux_map_delete_capture. Its screens (engineering.ux_map_screens) and its human validations (engineering.ux_map_validations) go with it by ON DELETE CASCADE. It NEVER deletes an annotation and it never could: engineering.ux_map_annotations has ON DELETE SET NULL, so a design note SURVIVES with capture_id NULL, keeping its anchor and its capture_digest — deleting somebody’s feedback stays with the author-only DELETE policy on that table. It NEVER touches engineering.ux_map_apps: deleting the last capture of an app leaves the app row, on purpose. This is the tool for discarding a capture that should never have existed — a smoke test, a wrong role, a wrong viewport, a capture of a broken build. There is no undo and no soft-delete. To supersede a capture, INDEX A NEW ONE instead: the newest capture wins and the history stays. ⚠️ dry_run defaults to TRUE: the first call reports what WOULD go and deletes nothing. Every guard is evaluated on a dry run, so a refusal surfaces before you commit to anything. Committing REQUIRES confirm_digest to equal the capture’s digest, which the dry run prints — a uuid is not something a caller can sanity-check by looking at it. REFUSES, without deleting anything, when an annotation points at the capture (reason annotations_present) or when another capture measured its movement against it (reason baseline_referrers_present). That second refusal is NOT a preference: the baseline foreign key writes one column while ck_ux_map_captures_baseline_coherent constrains two, so the delete is IMPOSSIBLE until those captures are reset — clear_baseline_referrers does the reset and DISCARDS their movement numbers. Idempotent: an id that is already gone answers deleted: false, reason: "not_found" rather than failing. EVERY refusal also answers deleted: false with its own reason, and both sit at the top level of the result — read them before you believe a capture is gone. The deleter is taken from your JWT by the database itself, so there is NO deleted_by, user_id or actor argument and none may be added. There is no app_id, role or date filter either: one capture per call, addressed by id. The caller must be a DFL ADMIN: the function refuses an IAM global level below 80, which is deliberately NARROWER than the level 50 index_ux_map_capture needs — a wrong capture is self-correcting, a wrong delete is not. Requires the dfl-schema migration 20260902170000_engineering_ux_map_delete_capture_rpc.sql, which is at a human merge gate. Until it merges this tool answers migration_not_applied and names it; it never degrades to a silent success. |
update_ux_map_capture | Correct the PROSE and the PROVENANCE of ONE already-indexed UX map capture — the engineering.ux_map_captures row named by capture_id — through engineering.ux_map_update_capture. This is the ONLY update path that exists: authenticated holds SELECT on the ux_map tables and nothing else, and ux_map_captures has no UPDATE policy, so a coverage_note that says something FALSE cannot otherwise be corrected by anybody except the database owner. It edits EXACTLY 3 columns and no others: coverage_note, app_version, commit_sha. EVERYTHING ELSE IS EVIDENCE AND IS REFUSED — the identity and lane columns (role, viewport, app_uuid, run_id), everything read off the artifact (digest, artifact_url, artifact_media_id, captured_at, screens_total, screens_with_shot), and every derived movement number (baseline_kind, baseline_capture_id, largest_movement_ratio). A patch naming one of those is refused WHOLE, with reason field_not_editable; the good fields are NOT applied without the bad ones. The line is that a CLAIM ABOUT the evidence may be corrected and the evidence may not. To change the evidence, INDEX A NEW CAPTURE with index_ux_map_capture: the newest capture wins and the history stays. ⚠️ dry_run defaults to TRUE: the first call writes nothing and reports the exact diff plus the row identity — role, viewport, digest, captured_at — because a uuid is not something a caller can sanity-check by looking at it. Read it, then call again with dry_run false. Send a field to set it. OMIT a field to leave that column alone. Send it as null to CLEAR the column. Those three are different, and omitting is not clearing. A call that names no field at all is refused with reason empty_patch — this tool never writes an empty patch, because an update that changes nothing but stamps an audit trail is a lie about an edit. coverage_note is limited to 2000 characters and a longer one is REFUSED, never truncated: the caveats live at the end of a note, and the full account belongs in the capture manifest, which is published as the artifact. Returns before/after FOR THE PATCHED FIELDS ONLY, on every branch including the dry run and the refusals. before is the only undo path — these columns keep no version history — so record it if you may need to reverse the edit. An id that does not exist answers updated: false, reason: "not_found" rather than failing, and a patch that matches what is already stored answers no_change without stamping the audit columns. EVERY refusal also answers updated: false with its own reason, and both sit at the top level — read them before you believe the prose changed. The editor is taken from your JWT by the database itself, which stamps edited_by and edited_at, so there is NO edited_by, user_id or actor argument and none may be added. There is no app_id, role or date filter either: one capture per call, addressed by id. The caller must be a DFL MEMBER: the function refuses an IAM global level below 50. That is the SAME gate index_ux_map_capture uses, and deliberately not the level 80 delete_ux_map_capture uses: a member who may WRITE a coverage_note must be able to correct one, and the edit is reversible where a delete is not. Requires the dfl-schema migration 20260903090000_engineering_ux_map_update_capture_rpc.sql, which is at a human merge gate. Until it merges this tool answers migration_not_applied and names it; it never degrades to a silent success. |
ux_paths_app_images | Every screenshot of the MOST RECENT build of one application, from the UX-map index (dfl-ux-paths / “XPaths”). Use it when you need to LOOK at an app you cannot run — a design review, a migration gap, a “what does this screen look like today” question. READ-ONLY apart from the optional archive it uploads for you. A build is (app_version, commit_sha), NOT app_version alone, because several captures weeks apart can carry the same version string. A capture is per ROLE, so by default this unions every capture of that build — a screen only an admin can reach is still a screen of the build — and each image says which role saw it. Within ONE role and viewport only the newest capture is used: a second one is a re-index that supersedes the older row, not extra screens, and any row dropped that way is named in superseded_captures rather than hidden. Pass role to narrow, or capture_id to pin one capture exactly. viewport defaults to desktop; pass mobile or both. A mobile capture is addressed as the role “<role>@mobile” for a row written before the viewport column, and both spellings are read: an explicitly non-default column wins, then the suffix, because the column is NOT NULL DEFAULT desktop and a defaulted value is not evidence. Any row where the two disagree is reported in viewport.disagreements rather than resolved silently. Each image carries its own viewport, and viewport.missing_sibling says so when this app has no mobile capture at all — you never get desktop rows labelled as both. distinct_images counts DIGESTS and is the honest count of pictures: every screen gets its own media object, so distinct_urls always equals count even when two screens photographed the same pixels. identical_images names those pairs — some are deliberate, and one that is not means a screen nobody has actually looked at. EVERY image carries anonymous_access, MEASURED by an anonymous GET with no token: “open” means an external designer can open that url with no DFL session, “members_only” means they cannot. designer_handoff summarises it. The tiers are mixed within one app in practice, so read the per-image field rather than assuming. format “urls” (the default) returns the list and probes access, but downloads no image. format “zip” downloads them here, packs them with a manifest.json, and returns ONE media url whose visibility FOLLOWS THE CONTENT — private when every screenshot inside already opened anonymously, members otherwise, because an archive is only as shareable as its least shareable member. The zip is refused above 25 MB or 300 images: the result then carries zip_skipped_reason and the full url list, so fetch them yourself. A screenshot url is NOT uniformly public. Each one resolves through media-redirect, which reads that object’s own visibility: a members object answers 401 to an anonymous GET, while a private object answers 302 and then the image. So read anonymous_access on every image before you hand a url to anybody. Where it is “members_only”, fetch with Authorization: Bearer <your DFL Supabase user JWT> — the same token you used to call this tool; media-redirect accepts a bearer token precisely so an agent that cannot hold a browser cookie can read these. Where it is “open”, the url needs no session at all and can go straight to an external designer. A capture’s own screens_with_shot counts screens that carry a screenshot_digest, which is NOT the same as screens whose image can be fetched: a screen can be photographed and hashed with its PNG never uploaded. count below is the number of images that actually have a url. Where the two disagree, the difference is listed in screens_without_image. Reads are granted to authenticated and refused to anon, so an empty result from a signed-out caller means you were refused, not that the app has no captures. |
create_sheet | THE way to make a spreadsheet anywhere in DFL. When you are asked for a budget, a rubric table, a cost breakdown, a matrix or any grid of numbers in a plan, a proposal, an ADR or a handoff document, call this INSTEAD OF writing a markdown table into the body. It persists a row in work.sheets plus one row per tab in work.sheet_tabs — the store behind the dfl-sheets app at sheets.devfellowship.com/sheets/<id> — and returns the sheet with its tabs, whose id (a uuid) is what every other DFL surface references. A markdown table is dead text: nobody can edit it in a grid, sum a column of it, version it or fill it in as a template. TO PUT A SHEET IN A PLAN, do not hand-write the token: call the plans MCP (plans.mcp.devfellowship.com) attach_entity with the plan slug, type “sheet” and this uuid as the locator. It inserts {{dfl-entity:sheet:<uuid>}}, which the plans-app resolves at render time, so later edits to the sheet reach the plan with no new plan version. A sheet is a HEADER plus TABS, and one call writes both. Each tab carries columns (ordered, each with a stable key) and rows (ordered, each a bag of cells keyed by COLUMN KEY). A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. A formula you write here is an A1 formula, so its addresses follow the same convention the editor shows. A1 addressing follows the grid a human sees: ROW 1 IS THE HEADER (the column labels), so A1 row 2 is rows[0], the first data row. Column letters are positional — B is the second column of the tab, whatever its key. Ranges accept a cell (B2), a block (B2:D10), whole columns (B:D) or whole rows (2:10); lowercase and $ anchors are fine, and a reversed range reads the same as a forward one. A mixed range such as B2:D is REFUSED rather than guessed at. Rows are semi-structured: a cell key that no column declares is STORED, not rejected. It is also invisible in the editor, which renders by column. Any such key is named back in unmapped_cell_keys on the result — treat it as a typo in the column key until proven otherwise, and add the column if the data is real. Send no tabs and the sheet gets one empty tab named Sheet1, because a sheet with no tab opens blank in the editor. Use kind “budget” for money, “template” for a blank form somebody else fills in, and “table” for everything else. template_of points an instance back at the template it came from. entity_id / entity_name file the sheet under a work entity (an epic, a project, a plan slug) and are what list_sheets filters on and what the plans-app sidebar groups by. Unlike a diagram, a sheet with no entity is NOT hidden from the group — sheets are read by membership (ADR-3 of plan 20260909-dfl-sheets-primitive). This tool writes as YOU: the row carries your user id and passes RLS under your own JWT. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed. |
get_sheet | Read one sheet by id: the header plus every tab, each with its full columns and rows. Use it to see what a {{dfl-entity:sheet:<uuid>}} token in a plan actually points at, and ALWAYS to inspect the current content before you write — write_sheet_cells addresses rows by index or by id, and both come from here. A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. Pass tab to read one tab by name (case-insensitive) instead of all of them. Pass range to slice that tab down to an A1 block, which is how you read a corner of a large budget without pulling the whole thing through the context window. A1 addressing follows the grid a human sees: ROW 1 IS THE HEADER (the column labels), so A1 row 2 is rows[0], the first data row. Column letters are positional — B is the second column of the tab, whatever its key. Ranges accept a cell (B2), a block (B2:D10), whole columns (B:D) or whole rows (2:10); lowercase and $ anchors are fine, and a reversed range reads the same as a forward one. A mixed range such as B2:D is REFUSED rather than guessed at. A range is scoped to a single tab, so range goes together with tab. The result echoes range_applied and each row keeps its original data-row index in row_indexes, so an index you read from a slice still addresses the right row in a write. Set include_versions to list the snapshot history — version numbers, ids, commit messages and dates, newest first, WITHOUT the snapshot payloads, so the response stays small. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed. |
list_sheets | Find an EXISTING sheet and, above all, its uuid — the id you need to reference it from a plan (plans MCP attach_entity, or the {{dfl-entity:sheet:<uuid>}} token), to read it with get_sheet, or to write it. Filter by entity_id for every sheet of one work entity, by kind, or by search over the name. Run this BEFORE create_sheet when a sheet of the same thing may already exist: a duplicate splits the references and the two copies then drift apart. Filter by kind “template” to find the blank forms — a template is what you copy to answer a call for proposals, rather than something to fill in directly. The listing is a header listing: it carries no columns and no rows, so it stays small for a sheet with thousands of cells. Call get_sheet for the content. Results come back under your own RLS, ordered by work.sheets.updated_at, newest first. The two writers here bump that column when they change a tab, so the order reflects the last CONTENT change and not only the last rename. An edit made in the app writes the tab directly and does not bump it. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed. |
replace_sheet_tab | Replace the ENTIRE contents of one tab — its columns and all of its rows — in a single atomic write. Use it when you generate or regenerate a whole table: a budget laid out from a rubric, a matrix rebuilt from a source document, a template being drafted. THIS IS DESTRUCTIVE BY DESIGN. Whatever the tab held and you did not send is gone, including rows a human edited in the app since you last read it. To change a few cells and keep the rest, use write_sheet_cells, which merges. To be sure of what you are about to overwrite, call get_sheet first. Because of that, a whole-sheet snapshot is written to work.sheet_versions BEFORE the replace, unless you turn snapshot off. The snapshot covers EVERY tab, not only this one, so the state it records is a state somebody can actually go back to. Give a commit_message saying what changed and why; it is what makes the history readable later. Row ids are preserved when you send them and generated when you do not. Sending the ids you read from get_sheet is what keeps per-row references (and a reader’s scroll position) stable across a regeneration. A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. Rows are semi-structured: a cell key that no column declares is STORED, not rejected. It is also invisible in the editor, which renders by column. Any such key is named back in unmapped_cell_keys on the result — treat it as a typo in the column key until proven otherwise, and add the column if the data is real. Column keys must be unique within the tab, and the tab must already exist — this tool never creates one, because a typo in a tab name would otherwise silently make a second tab and leave the real one untouched. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed. |
write_sheet_cells | Write individual cells into one tab, MERGING with what is already there. This is the tool for filling in a template, correcting a few numbers, or adding a row to a budget. Every cell you do not name is left exactly as it was, and so is every field of a cell you do name but only partly write. Use replace_sheet_tab instead when you are regenerating a whole table. ADDRESSING. Each entry is {row, col, value?, formula?}. row is either a 0-BASED DATA ROW INDEX (a number: 0 is the first data row) or a ROW ID (a string, as returned by get_sheet). col is either a column KEY or an A1 column letter; a key is matched first, so a column genuinely keyed “A” is still reachable by its key. A1 addressing follows the grid a human sees: ROW 1 IS THE HEADER (the column labels), so A1 row 2 is rows[0], the first data row. Column letters are positional — B is the second column of the tab, whatever its key. Ranges accept a cell (B2), a block (B2:D10), whole columns (B:D) or whole rows (2:10); lowercase and $ anchors are fine, and a reversed range reads the same as a forward one. A mixed range such as B2:D is REFUSED rather than guessed at. A MISSING ROW IS CREATED. An unknown row id creates a row under that id, which makes a retry of the same call idempotent instead of appending a second copy. A numeric index past the end appends empty rows up to it, so the index means what it says. A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. Send value to set the value; send formula to set the formula; send both to store a formula together with its computed result. A value on its own CLEARS any formula that cell held, the way typing a number over a formula does in a spreadsheet, so the stored value and the stored formula never disagree. Pass value: null to empty a cell. THE BATCH IS ALL-OR-NOTHING: one bad address refuses the whole call and the database is not touched, so a partly-applied batch can never be left behind. The result names, for each write, the row id, the row index and the A1 address it landed on — read it back to confirm you addressed what you meant. Give a commit_message to also record a whole-sheet version before the write. Without one no version row is written, because a snapshot per cell edit would bury the history. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed. |
snapshot_sheet_version | Save the CURRENT state of a sheet — the header plus EVERY tab, with all their columns and rows — as a new row in work.sheet_versions, so this state stays recoverable through get_sheet_version. Use it to checkpoint a state nobody is about to change: the budget an ADR was decided against, a template before a person starts editing it. replace_sheet_tab and write_sheet_cells already snapshot on the way past, so call this one when you want the checkpoint WITHOUT also writing to the sheet right now. The snapshot covers every tab, not one tab, because a budget is read across its tabs and a one-tab checkpoint is not a state anybody can go back to. version_number is MAX(version_number) + 1 for the sheet. Two writers can compute the same next number at the same moment, so the UNIQUE (sheet_id, version_number) collision is retried against a recomputed maximum instead of failing opaquely. Give a commit_message saying what this state IS; it is the only thing that makes the history readable later. The history is APPEND-ONLY: work.sheet_versions grants SELECT and INSERT under RLS and nothing else, so there is no tool to edit or delete a version, on purpose. Writes run as you, under your own RLS. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed. |
list_sheet_versions | List the snapshot history of one sheet from work.sheet_versions, ordered by version_number DESC — NEWEST FIRST. Each row carries its version_number, its commit_message, who wrote it and when. It deliberately omits the snapshot payload: a sheet version holds EVERY tab of the sheet, so a page of them would be a whole spreadsheet many times over in your context. Read the one you want with get_sheet_version, which takes the version_number you read here. Paginate with limit (default 50, max 100) and offset; the result carries total and has_more. get_sheet with include_versions gives the same list beside the sheet content — use this tool when you want the history alone, or when you need to page past the newest 50. Reads run under your own RLS, so a sheet you cannot see returns an empty history rather than an error. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed. |
get_sheet_version | Read ONE stored snapshot of a sheet back, by sheet_id + version_number. It returns the payload as it was stored — the header plus EVERY tab, with all their columns and rows — which is what makes a prior state recoverable, as opposed to list_sheet_versions, which only indexes the history. Get the version_number from list_sheet_versions, or from get_sheet with include_versions. Pass tab to read one tab of the snapshot by name, case-insensitive. A whole-sheet payload is large, so slice it when you know which tab you want. A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. TO RESTORE this state, read it here and write it back with replace_sheet_tab, one tab at a time. Nothing here restores anything on its own: the history is APPEND-ONLY — work.sheet_versions grants SELECT and INSERT under RLS and nothing else — and a restore is a normal write that leaves its own snapshot behind, so the state you are replacing does not disappear. There is no tool to edit or delete a version, on purpose. Reads run under your own RLS, so “missing” and “not visible to you” are the same answer. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed. |
copy_sheet | Duplicate an existing sheet under a new name — the way a TEMPLATE becomes an INSTANCE. This is the tool for answering a call for proposals whose edital carries a budget template: copy the template, then fill the COPY in with write_sheet_cells. The source is never written, so the next submission starts from the same blank form. Use list_sheets with kind “template” to find one. Everything is copied: every tab, in order, with its columns, its meta and all of its rows — and the ROW IDS are preserved, so a reference to a row of the template still resolves in the copy and a later diff against the template lines up. Three things differ on purpose. kind becomes “table” unless you pass one, because a copy of a template is an instance rather than another template. template_of is set to the SOURCE id, which is how the instance is traced back to the form it came from. And entity_id / entity_name are inherited from the source only when you pass neither — pass the submission, the epic or the plan slug this copy belongs to, or it stays filed under the template. CLEAR_VALUES makes a BLANK FORM out of a filled one. It removes v from every cell whose column declares no formula, and keeps everything else: the columns, the row ids, every cell formula, and the t / fmt of every cell. A column that HAS a formula keeps its values, because those cells are computed rather than typed and the editor recomputes them. Be careful with it: in a template whose first column holds the rubric NAMES, those names are part of the form and clear_values removes them too. Copy without the flag and overwrite the numbers instead. A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. TO PUT A SHEET IN A PLAN, do not hand-write the token: call the plans MCP (plans.mcp.devfellowship.com) attach_entity with the plan slug, type “sheet” and this uuid as the locator. It inserts {{dfl-entity:sheet:<uuid>}}, which the plans-app resolves at render time, so later edits to the sheet reach the plan with no new plan version. The copy is written as YOU: its rows carry your user id and pass RLS under your own JWT. The source is read under your RLS too, so a source you cannot see reads as missing. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed. |
import_sheet | Turn a csv, a tsv, a markdown table or an .xlsx workbook you already have into a real DFL sheet. Use it when a person sends you a spreadsheet, when an edital carries a budget table, or when you have generated a table as text and want it to become something a human can edit at sheets.devfellowship.com and a plan can embed. Do NOT paste the table into a plan body as markdown: that is dead text nobody can sum, version or fill in. TO PUT A SHEET IN A PLAN, do not hand-write the token: call the plans MCP (plans.mcp.devfellowship.com) attach_entity with the plan slug, type “sheet” and this uuid as the locator. It inserts {{dfl-entity:sheet:<uuid>}}, which the plans-app resolves at render time, so later edits to the sheet reach the plan with no new plan version. THE FIRST ROW IS THE HEADER. Every column KEY is minted from it — accents folded, lower case, a single ”_” between words, so “Valor unitário” becomes valor_unitario. The result returns key_map, header text to key, because those keys are what write_sheet_cells addresses afterwards. A file whose first row is data imports that data as the column names, and the sheet still looks plausible — check key_map before you write. FORMATS. “csv” and “tsv” follow RFC 4180: a quoted field keeps its delimiters, its newlines and its "" escapes. “markdown” is a GFM pipe table — the ”| --- |” separator line is ignored, an escaped ”\ |
export_sheet | Read a sheet out in a portable format: “csv”, “markdown”, “json” or “xlsx”. Use it to hand a budget to somebody outside DFL, to paste a small table into a message or a PR body, to feed the rows to code as objects, or to attach a real .xlsx to a submission. This is the inverse of import_sheet, and an xlsx export re-imports into the same columns and the same values. To read a sheet in order to WRITE it, use get_sheet instead — it returns the row ids and the cell objects that write_sheet_cells addresses, which none of these formats carries. FORMULAS RENDER AS THEIR VALUES in csv, markdown and json, because a cell holds the formula AND its last computed value and the value is the number a reader wants. A cell that has a formula and has never been computed falls back to the formula text (“=B2*C2”). “xlsx” is the exception: an xlsx cell holds both, so the file carries a real formula plus its cached result and opens as a working spreadsheet. Nothing here evaluates anything. SCOPE. “csv” and “markdown” render ONE tab — pass tab, or omit it when the sheet has only one. “json” and “xlsx” cover EVERY tab when you omit tab: json returns one array of objects per tab, xlsx one worksheet per tab. “markdown” is a GFM pipe table with every ”|” inside a cell escaped, so the table survives being pasted into a plan or a PR body. “json” gives one object per row keyed by COLUMN KEY, plus _row_id — which is the id write_sheet_cells addresses rows by. “xlsx” comes back as BASE64 in content_base64; nothing here writes a file, so decode it where you need it. The sheet is read under your own RLS, so “missing” and “not visible to you” are the same answer. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed. |
create_diagram
Section titled “create_diagram”Create Diagram
THE way to make a diagram anywhere in DFL. When you are asked for a diagram in a plan, an ADR, a handoff document, a spec or a PR description, call this INSTEAD OF writing a Mermaid code block into the body. It persists a row in public.diagrams — the store behind the dfl-diagrams app at diagrams.devfellowship.com/diagrams/<id> — and returns that row, whose id (a uuid) is what every other DFL app references. A pasted code fence is dead text: it cannot be opened in the editor, edited, versioned, exported or searched, and the plan that holds it never updates when the design changes. TO PUT IT IN A PLAN, do not hand-write the token: call the plans MCP (plans.mcp.devfellowship.com) attach_entity with the plan slug, type “diagram” and this uuid as the locator. It inserts the reference token — {{dfl-diagram:<uuid>}}, whose long form {{dfl-entity:diagram:<uuid>}} is equivalent — which the plans-app resolves at render time, so later edits to the diagram reach the plan with no new plan version. The same token works in a document (see write_document diagram_entity_id). Use export_diagram only for places that cannot resolve a token, such as a GitHub PR body. epic_id is OPTIONAL. Pass it to file the diagram under a work.epics entity (stored as entity_id) and a non-epic id is still rejected, so resolve or create the epic (work MCP) first; check list_diagrams for that epic before creating a second diagram of the same thing. Omit it for a stand-alone diagram — the case for a diagram whose subject is a PLAN, which has no epic. Never invent an epic to fill the field: a wrong-but-valid id files the diagram under someone else’s epic. VISIBILITY, and it is not a formality: on public.diagrams the SELECT policy for authenticated users is entity_id IS NOT NULL, so entity_id doubles as the “shared” flag. A diagram created WITHOUT epic_id is therefore readable by ITS CREATOR ALONE, and the plans-app resolves a {{dfl-diagram:<uuid>}} token under the VIEWER’S own RLS with no service-role fallback — so an epic-less diagram embedded in a plan renders for you and for nobody else. Omit epic_id when the diagram is genuinely yours or is a draft; pass an epic when other people must read it. Either pass mermaid_text (a Mermaid flowchart/erDiagram/sequenceDiagram, optionally fenced in ```mermaid blocks) to have type/nodes/edges derived and auto-laid-out automatically, or pass nodes/edges directly in the native @xyflow/react shape plus an explicit type. PlantUML import is not supported yet. NODE LABELS ARE CAPPED AT 60 CHARACTERS and the cap is enforced on both paths (native nodes AND mermaid_text, where the node text becomes data.label): a diagram with a longer data.label or data.name is rejected outright, naming the offending nodes — nothing is written and nothing is truncated. Long prose (requirements, open questions, rationale) belongs in that node’s data.description, which has no length limit and renders as node detail rather than as the box label. A label that reads like a sentence is a label in the wrong field.
| Parameter | Type | Required | Description |
|---|---|---|---|
epic_id | string | no | OPTIONAL work.epics.id to file this diagram under. Stored as entity_id; entity_name is looked up from work.epics and the id is rejected if no such epic exists. OMIT it for a stand-alone diagram — notably one whose subject is a plan, which has no epic; reference it from the plan with the {{dfl-diagram:<uuid>}} token instead. Never guess a UUID to fill this in: a wrong-but-valid one files the diagram under someone else’s epic. Omitting it makes the diagram readable by you alone — see the visibility note on the tool. |
name | string | yes | Diagram name |
type | enum | no | Diagram type. Required unless mermaid_text is provided (in which case it is auto-detected and this is ignored). One of: flowchart, erd, sequence. |
nodes | object[] | no | Nodes in native @xyflow/react shape (default: empty). Ignored if mermaid_text is provided. |
edges | object[] | no | Edges in native @xyflow/react shape (default: empty). Ignored if mermaid_text is provided. |
mermaid_text | string | no | Raw Mermaid source (flowchart/erDiagram/sequenceDiagram), optionally fenced in ```mermaid blocks. When provided, type/nodes/edges are derived automatically and any explicit nodes/edges are ignored. |
update_diagram
Section titled “update_diagram”Update Diagram
Update an existing diagram in public.diagrams by id — the right way to change a diagram that a plan or a document already references. The uuid does not change, and plans/documents hold only the {{dfl-diagram:<uuid>}} reference token, so every one of them shows the new version on the next render and NO plan version is created. Never re-create a diagram to change it, and never paste an updated Mermaid block into the plan body instead. Pass any subset of name, description, type, nodes, edges — omitted fields are left untouched. Alternatively pass mermaid_text to replace type/nodes/edges wholesale from Mermaid source (auto-detected and auto-laid-out; explicit type/nodes/edges are then ignored). Set auto_layout: true to re-run the dagre layout over explicitly supplied nodes instead of keeping their positions. NODE LABELS ARE CAPPED AT 60 CHARACTERS — exactly the same enforced validation as create_diagram, on both the nodes path and the mermaid_text path: an update whose data.label or data.name is longer is rejected, naming the offending nodes; nothing is written and nothing is truncated. Long prose belongs in that node’s data.description, which has no limit. Writes are RLS-scoped to the caller: you can only update diagrams you created. There is no delete tool by design. By default this snapshots the diagram’s pre-update state into public.diagram_versions before writing (see snapshot / commit_message) — a prior state is only recoverable via get_diagram_version if it was snapshotted first.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | yes | The UUID of the diagram to update (public.diagrams.id) |
name | string | no | New diagram name. Omit to leave unchanged. |
description | string | no | New diagram-level description. Omit to leave unchanged. |
type | enum | no | New diagram type. Omit to leave unchanged. Ignored if mermaid_text is provided. One of: flowchart, erd, sequence. |
nodes | object[] | no | Replacement nodes in native @xyflow/react shape (full replacement, not a merge). Ignored if mermaid_text is provided. |
edges | object[] | no | Replacement edges in native @xyflow/react shape (full replacement, not a merge). Ignored if mermaid_text is provided. |
mermaid_text | string | no | Raw Mermaid source (flowchart/erDiagram/sequenceDiagram), optionally fenced in ```mermaid blocks. When provided, type/nodes/edges are derived and auto-laid-out, and any explicit type/nodes/edges are ignored. |
auto_layout | boolean | no | Re-run the dagre auto-layout over the supplied nodes, overwriting their positions (default: false). Ignored — and always applied — when mermaid_text is used. |
snapshot | boolean | no | Snapshot the diagram’s pre-update nodes/edges into public.diagram_versions before applying this update (default: true). If the snapshot fails, the update is aborted and nothing is written. Pass false to skip recording a revision. |
commit_message | string | no | Optional human-readable note attached to the pre-update snapshot (ignored when snapshot: false). |
list_diagrams
Section titled “list_diagrams”List Diagrams
Find an EXISTING diagram and, above all, its uuid — the id you need to reference it from a plan or a document (plans MCP attach_entity, or the {{dfl-diagram:<uuid>}} token). Filter by epic_id to get every diagram of a work.epics entity, or by name/type. Run this BEFORE create_diagram when a diagram of the same thing may already exist: a duplicate diagram splits the references and the two copies then drift apart. With no epic_id the list is NOT epic-scoped: it also includes STAND-ALONE diagrams, created without an epic (entity_id null) — the shape a diagram takes when it belongs to a plan. Those are returned under the caller’s RLS, so a stand-alone diagram is listed only for the user who created it, while an epic-bound one is listed for any authenticated user.
| Parameter | Type | Required | Description |
|---|---|---|---|
epic_id | string | no | Filter by work.epics.id (stored as entity_id on the diagram). Omit to list across every epic AND the stand-alone, epic-less diagrams. |
limit | number | no | Maximum number of diagrams to return (default: 50, max: 100) |
offset | number | no | Number of diagrams to skip (for pagination) |
search | string | no | Search by diagram name |
get_diagram
Section titled “get_diagram”Get Diagram
Get one diagram by id, including its full nodes/edges in the normalised @xyflow/react shape (the same shape whether it was created from mermaid_text or from explicit nodes). Use it to read what a {{dfl-diagram:<uuid>}} token in a plan or a document actually points at, and to inspect the current content before calling update_diagram. For a rendered picture or Mermaid source instead of the raw graph, use export_diagram.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | yes | The UUID of the diagram |
export_diagram
Section titled “export_diagram”Export Diagram
Export any stored diagram to a shareable artifact by id. Formats: svg (a standalone vector image rendered from the persisted node positions — use this when you need a picture; rasterise to PNG on the client at whatever DPI you want, since SVG has no fixed resolution), mermaid (Markdown-fenced Mermaid source) and plantuml. Works for erd, flowchart and sequence diagrams. Returns the artifact text plus a suggested filename and mime type. This is for surfaces that CANNOT resolve a DFL reference token — a GitHub PR body, a README, a slide, an e-mail. Do NOT export to Mermaid just to paste the fence into a plan or a document: those resolve {{dfl-diagram:<uuid>}} themselves (plans MCP attach_entity), and a pasted copy stops tracking the diagram the moment it is edited.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | yes | The UUID of the diagram (see list_diagrams / get_diagram) |
format | enum | no | Output format — ‘svg’ (default), ‘mermaid’ or ‘plantuml’ One of: svg, mermaid, plantuml. |
theme | enum | no | SVG colour scheme: ‘dark’ (default, matches the app canvas) or ‘light’ for print/docs One of: dark, light. |
scale | number | no | SVG only. Multiplier baked into the root width/height attributes so a naive svg->png conversion comes out hi-dpi. The viewBox is unchanged, so this never crops or reflows. Default 2. |
snapshot_diagram_version
Section titled “snapshot_diagram_version”Snapshot Diagram Version
Save the CURRENT nodes/edges of a diagram as a new row in public.diagram_versions, so this state is recoverable later via get_diagram_version. Generic and reusable — not tied to any one diagram or incident. version_number is COALESCE(MAX(version_number), 0) + 1 for that diagram_id (concurrent snapshots are retried on the UNIQUE (diagram_id, version_number) race, not left to fail opaquely). Reads are RLS-scoped: only the diagram’s owner can snapshot it. update_diagram already calls this automatically before applying changes (default snapshot: true) — call this tool directly when you want a checkpoint WITHOUT also changing the diagram right now. The revision this creates is also what lets a plan PIN a diagram: the plans MCP attach_entity honours mode “pinned” only when it is given a rev, and that rev is a public.diagram_versions id (read it back from list_diagram_versions). Snapshot first when an ADR or a decision block must keep showing the state the decision was taken against; leave the embed live everywhere else.
| Parameter | Type | Required | Description |
|---|---|---|---|
diagram_id | string | yes | The UUID of the diagram to snapshot (public.diagrams.id) |
commit_message | string | no | Optional human-readable note describing this checkpoint |
list_diagram_versions
Section titled “list_diagram_versions”List Diagram Versions
List the revision history of a diagram from public.diagram_versions, ordered by version_number DESC (newest first). Deliberately omits the full nodes/edges payload — each row carries node_count / edge_count instead, so the response stays small even for a diagram with many revisions. Use get_diagram_version to fetch the full nodes/edges of one specific revision. Each row’s id is also the rev the plans MCP attach_entity needs to PIN a plan embed to a fixed state — a pin without a rev is not honoured and renders live content instead.
| Parameter | Type | Required | Description |
|---|---|---|---|
diagram_id | string | yes | The UUID of the diagram whose versions to list (public.diagrams.id) |
limit | number | no | Maximum number of versions to return (default: 50, max: 100) |
offset | number | no | Number of versions to skip (for pagination) |
get_diagram_version
Section titled “get_diagram_version”Get Diagram Version
Get one specific revision of a diagram from public.diagram_versions, including its full nodes/edges — this is what makes a prior state actually recoverable, as opposed to list_diagram_versions which only returns counts. Identify the revision either by its own id, or by diagram_id + version_number.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | no | The UUID of the diagram_versions row. Provide this OR (diagram_id + version_number). |
diagram_id | string | no | The UUID of the parent diagram. Requires version_number. |
version_number | number | no | The version number to fetch. Requires diagram_id. |
generate_tasks
Section titled “generate_tasks”Generate Tasks (DEPRECATED)
⚠️ DEPRECATED — prefer create_spec_run followed by promote_spec_run_items. THIS IS THE ONLY TOOL IN THE DFL MCP FLEET THAT WRITES UNREVIEWED AI OUTPUT STRAIGHT INTO A REAL BACKLOG: it generates tasks from a spec and inserts them into work.tasks immediately, with NO review gate, no versioning, no durable candidate items, and no way to adjust points before they become real — the dfl-spec-builder frontend has always staged the same output for approval, and this tool does not. Its output is also invisible in the Spec Builder history. create_spec_run writes CANDIDATES (work.ai_spec_inputs + work.ai_spec_tasks) as a versioned, commentable, linkable spec run; promote_spec_run_items turns the approved ones into work.tasks behind an explicit dry_run: false. It also needs no epic, no project and no business unit, so it works for plan-only client projects that this tool cannot run at all. Kept only so existing callers do not break. Pass an existing epic_id, OR epic_name + project_id to create a new epic. Restricted to projects under the devfellowship/Revera business units (MVP). Uses the calling user’s JWT (RLS applies normally).
| Parameter | Type | Required | Description |
|---|---|---|---|
spec | string | yes | Free-text specification to generate tasks from |
epic_id | string | no | Existing work.epics.id to target |
epic_name | string | no | Name for a new epic (requires project_id) |
project_id | string | no | work.projects.id the new epic belongs to (requires epic_name) |
create_spec_run
Section titled “create_spec_run”Create Spec Run
Generate a pack of CANDIDATE tasks from spec prose and store it as a spec run — a first-class, versioned, linkable entity with its own URL. A spec run holds CANDIDATES, not tasks. This writes work.ai_spec_inputs + work.ai_spec_tasks and NEVER work.tasks. Nothing reaches the real backlog until promote_spec_run_items is called with dry_run: false. Creates version 1 of the run (a work.spec_run_versions snapshot with a points_total), and gives every item a stable short_ref (SR-01, SR-02, …) that survives every later re-generation and anchors every comment. Bind it to a plan with plan_slug — unlike the deprecated generate_tasks, this needs NO epic, no project and no business unit, which is exactly what lets a client project that lives only as a plan be run at all. A run does NOT store its plan binding and has no plan_slug column. The binding IS the {{dfl-entity:spec_run:<run_id>}} token in the plan body; work.entity_connections is only the index the plans-app derives from it. So creating a run does NOT make it appear in the plan’s Entidades rail — call attach_entity on the PLANS MCP (plans.mcp.devfellowship.com) with { slug, type: "spec_run", locator: <run_id> } to do that. One link per RUN, never one per item. Writing an entity_connections row directly would be reconciled away on the next publish, because the body is the source of truth. NOT idempotent: every call generates a new pack and a new run id, so a retry after a timeout can leave two runs — check list_spec_runs before retrying. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error.
| Parameter | Type | Required | Description |
|---|---|---|---|
spec | string | yes | The spec prose to generate from: a requirements write-up, a meeting transcript, a client conversation, or the body of a plan. Longer and more concrete prose produces a better decomposition — this text is stored verbatim on the run and is what a re-generation reasons about. |
plan_slug | string | no | The plans-app slug this run belongs to, e.g. “20260803-rmt-crm-us-client-requirement-model”. Sets the run type to “plan”, is echoed into the run’s ?plan= back-link, and produces the exact attach_entity call you must run next to make the run appear in that plan’s Entidades rail. It is NOT stored on the run and does NOT create the connection by itself. Must be a slug (lowercase kebab-case), never a UUID. |
title | string | no | Short human name for this run, shown in the Spec Builder history list and in the copy-all clipboard header, e.g. “RMT CRM — quebra de escopo”. Defaults to the plan slug, then to a truncated first line of spec. |
input_type | enum | no | What the prose IS, stored on the run for provenance: plan = the body of a plans-app plan (the default when plan_slug is given); text = a written spec (the default otherwise); conversation = a chat/client thread; meeting = a meeting transcript. It does not change how generation works — it changes what a later reader knows about where the numbers came from. One of: text, conversation, meeting, plan. |
epic_id | string | no | OPTIONAL work.epics.id to associate the run with a pipeline entity, purely so the Spec Builder /history filter can find it. It is NOT required, it does NOT gate generation, and it is NOT where promotion gets its epic — promote_spec_run_items takes its own epic_id. Omit it unless you already know the epic; never guess a UUID, because a wrong-but-valid one files this run under someone else’s epic. |
get_spec_run
Section titled “get_spec_run”Get Spec Run
Read a spec run: its items with points, short_ref anchors, review status and point provenance, its points_total, both version counters, and the OPEN comment batch. Reads LIVE by default. Pass version_number or version_id to read an exact historical version, which returns that version’s stored JSONB snapshot — so it keeps resolving even after items were removed or re-pointed. ⚠️ AN UNRESOLVABLE VERSION IS AN ERROR (version_not_found), NEVER a silent fallback to live: a quote pinned to v3 that quietly renders v7 is a wrong number that looks right. Read-only — bumps nothing, writes nothing, and is the side-effect-free way to inspect the open comment batch (handoff_spec_run_comments would bump it). Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run (the UUID in its spec-builder.devfellowship.com/history/<run_id> URL and in its {{dfl-entity:spec_run:…}} token). Get it from list_spec_runs. |
version_number | number | no | Read this exact run version (1 = the initial generation) instead of live. Mutually exclusive with version_id. A version this run does not have is an ERROR (version_not_found) — call without either argument to see the current current_version first. |
version_id | string | no | Read the exact work.spec_run_versions.id — the same value used as rev when pinning the run into a plan with attach_entity({ mode: "pinned", rev }). Mutually exclusive with version_number. A version id belonging to a different run is an ERROR (version_not_found), never a cross-run read. |
include_removed | boolean | no | Include soft-removed items (removed_at IS NOT NULL) in a LIVE read. Default false — removed items are kept forever so older versions stay faithful, but they are not part of the current pack and do NOT count toward points_total. Ignored for a version read, where the snapshot is returned exactly as it was stored. |
list_spec_runs
Section titled “list_spec_runs”List Spec Runs
Find spec runs and get the run_id every other spec-run tool needs. Filter by plan_slug (resolved through the work.entity_connections index the plans-app derives from plan bodies — a run does not store its own plan binding), by epic_id (the optional pipeline entity), or by status; omit all three to list the most recent runs. Default 20 results, max 50. Read-only. Next steps by exact name: get_spec_run to read one, attach_entity (PLANS MCP) to link one into a plan, comment_spec_run to review it. A run does NOT store its plan binding and has no plan_slug column. The binding IS the {{dfl-entity:spec_run:<run_id>}} token in the plan body; work.entity_connections is only the index the plans-app derives from it. So creating a run does NOT make it appear in the plan’s Entidades rail — call attach_entity on the PLANS MCP (plans.mcp.devfellowship.com) with { slug, type: "spec_run", locator: <run_id> } to do that. One link per RUN, never one per item. Writing an entity_connections row directly would be reconciled away on the next publish, because the body is the source of truth. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to.
| Parameter | Type | Required | Description |
|---|---|---|---|
plan_slug | string | no | Only runs attached to this plans-app slug, e.g. “20260803-rmt-crm-us-client-requirement-model”. Resolved by looking up work.entity_connections rows with target_type = "spec_run" for that slug, which exist only once the plan body carries the {{dfl-entity:spec_run:<run_id>}} token. An empty result therefore means “no run is linked from that plan’s body”, which is NOT the same as “no run exists for that work”. Must be a slug, never a UUID. |
epic_id | string | no | Only runs associated with this work.epics.id (work.ai_spec_inputs.entity_id). This is the optional pipeline hint set at creation, NOT the plan binding — use plan_slug for that. Most runs have no epic and will not match. |
status | enum | no | Only runs in this lifecycle state: draft = generated, not yet signed off; approved = the human accepted the breakdown but no real tasks exist yet; promoted = at least one item became a work.tasks row; discarded = abandoned, kept only so older references keep resolving. One of: draft, approved, promoted, discarded. |
limit | number | no | Max runs to return (default 20, max 50). |
offset | number | no | Rows to skip, for paging through more than one page of runs. Default 0. |
update_spec_run_items
Section titled “update_spec_run_items”Update Spec Run Items
Apply HUMAN edits to the items of a spec run — rename, re-describe, re-point, re-stage, re-tag, approve or reject. THIS BUMPS THE RUN VERSION (work.ai_spec_inputs.current_version) and writes a new work.spec_run_versions snapshot with a recomputed points_total. That is correct and intended: a quote is derived from the points, so every content change has to be pinnable to an exact version or the number stops being reproducible. It does NOT touch the comment version. Points carry provenance. points_set_by is ai until a human sets the value, then human forever, and the field name lands in human_edited_fields. A human-set point is never overwritten by a generator: regenerate_spec_run REJECTS an update touching it unless the item id is listed in repoint. Points use the Fibonacci scale 1/2/3/5/8/13/21. SEPARATELY from points_set_by, every item records HOW its number was produced: points_source (one of engine, human, generator_legacy, imported), plus engine_version and rule_id for the audit trail. The database enforces that an engine row NAMES its engine_version — a writer cannot claim engine provenance and leave the trail unfalsifiable. generator_legacy is the column DEFAULT and means “produced before provenance was recorded”, NOT “produced by the current generator”. Every field you set here is recorded in that item’s human_edited_fields, and any estimated_points you set flips points_set_by to human — which is what later makes regenerate_spec_run refuse to overwrite it — AND stamps points_source human in the same write. To correct provenance WITHOUT changing a number, use set_spec_run_points_provenance. Items keep their id and short_ref — this never replaces a row. All-or-nothing: if ANY edit names an item that is not in this run (unknown_item_id) or was soft-removed (item_already_removed), NOTHING is written and no version is cut. Approving an item does NOT create a task — promote_spec_run_items does that. Get item ids from get_spec_run. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run to edit. |
commit_message | string | yes | Why this edit happened, stored on the new version snapshot and shown in the history, e.g. “cliente pediu 8 pts no gate de Quoting”. Required: a version with no message is a number nobody can interpret six weeks later, which defeats the point of being able to pin a quote to it. |
edits | object[] | yes | The edits to apply, at most 100 per call. One version is cut for the whole batch, not one per edit — so group related edits into a single call and the history stays readable. |
set_spec_run_points_provenance
Section titled “set_spec_run_points_provenance”Set Spec Run Points Provenance
Record HOW the estimated_points of one or more spec-run items were produced — write points_source, engine_version and rule_id on work.ai_spec_tasks. SEPARATELY from points_set_by, every item records HOW its number was produced: points_source (one of engine, human, generator_legacy, imported), plus engine_version and rule_id for the audit trail. The database enforces that an engine row NAMES its engine_version — a writer cannot claim engine provenance and leave the trail unfalsifiable. generator_legacy is the column DEFAULT and means “produced before provenance was recorded”, NOT “produced by the current generator”. This is the ONLY sanctioned write path for those three columns: they are DATA, so they are never corrected by a dfl-schema migration. It does NOT change estimated_points, points_set_by or points_needs_review — use update_spec_run_items to change a number, this to record where the number came from. It therefore does NOT bump the run version and cuts no work.spec_run_versions snapshot: a provenance edit cannot move points_total, and a snapshot identical to its predecessor makes the history a quote is pinned to harder to read, not easier. Idempotent — re-sending the same values rewrites the same values. All-or-nothing: if ANY entry names an item that is not in this run (unknown_item_id), was soft-removed (item_already_removed), or claims points_source: "engine" without an engine_version (invalid_arguments), NOTHING is written. Get item ids from get_spec_run. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run whose items are being stamped. |
items | object[] | yes | The items to stamp, at most 100 per call. Provenance is per item, not per run — one run can legitimately hold engine-scored, human-set and legacy items at once. |
score_spec_run_items
Section titled “score_spec_run_items”Score Spec Run Items with the Points Engine
Price one or more spec-run items with the points_v2 decision table, and record the provenance of every number it produces. YOU classify each item — pass work_type and complexity; the TABLE decides the number. The table is 14 rules of reviewable data living in devfellowship/dfl-flows-definitions -> policies/work/points_v2.dmn.json, not logic in this repo: three hard rules from the founder (documentation → 0, migration → 0.5, duplicate → 0) evaluated FIRST, then eleven trusted cells whose values are corpus MEDIANS of historical tasks. Outside a trusted cell it emits NO NUMBER and escalates to a human — there is no catch-all rule and no default. On the historical corpus it declines ~61% of rows and ~71% of points, which is the designed behaviour, not a failure: a loud gap beats a confidently wrong price. The score is CLIENT-BLIND. No client, project or business-unit input exists anywhere in this path. The client premium belongs on the rate card, charged once — a fellow’s pay must not depend on who the invoice goes to. ⚠️ Accuracy caveat you must not restate as more than it is: the table reproduces Tainan’s own historical scores (MedAE 1.00, 69.4% within ±1pt on covered rows, versus 1.50 / 49.6% for always-guess-the-median). There is NO independent oracle — work.tasks has no effort column — so this is fidelity to past judgement, never a claim of correctness. Sell it as guard-rail plus audit trail. Writes estimated_points, and stamps points_source: "engine", engine_version and rule_id in the SAME write so a number and its provenance can never disagree. SEPARATELY from points_set_by, every item records HOW its number was produced: points_source (one of engine, human, generator_legacy, imported), plus engine_version and rule_id for the audit trail. The database enforces that an engine row NAMES its engine_version — a writer cannot claim engine provenance and leave the trail unfalsifiable. generator_legacy is the column DEFAULT and means “produced before provenance was recorded”, NOT “produced by the current generator”. DEFAULTS TO A DRY RUN: the plain call reports every verdict and writes nothing. Pass dry_run: false to apply. When applied and at least one point VALUE changed, THIS BUMPS THE RUN VERSION (work.ai_spec_inputs.current_version) and writes a new work.spec_run_versions snapshot with a recomputed points_total. That is correct and intended: a quote is derived from the points, so every content change has to be pinnable to an exact version or the number stops being reproducible. It does NOT touch the comment version. If every number the engine produced already matched what was stored, only provenance moved and NO version is cut — a snapshot identical to its predecessor makes the history a quote is pinned to harder to read. Human-set points are protected exactly as in regenerate_spec_run: an item with points_set_by: "human" is rejected (human_points_not_repointed) unless its id is listed in repoint. All-or-nothing on validation: if ANY entry names an item outside this run (unknown_item_id) or one that was soft-removed (item_already_removed), NOTHING is written. Get item ids from get_spec_run. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run whose items are being scored. |
items | object[] | yes | The items to score, at most 100 per call. Items you do not list are untouched — this is not a whole-run re-price unless you list the whole run. |
repoint | string[] | no | Item ids whose HUMAN-SET points this call is explicitly authorised to overwrite. Without it, scoring a points_set_by: "human" item is REJECTED and the whole call is refused, so you cannot mistake a dropped field for an applied one. Pass an id here only when a human asked for that specific point to be re-estimated. It does NOT clear the human mark: the item stays human-owned for the next round. |
dry_run | boolean | no | TRUE by default. A dry run evaluates every item, reports the number, the rule that produced it and the delta against what is stored, and writes NOTHING — no points, no provenance, no version. Pass false to apply. The two paths compute the identical verdicts, so a dry run is an exact preview. Default: true. |
commit_message | string | no | The version commit message, used only when a point value actually changes. Defaults to a message naming the engine version, so the snapshot history says which table produced the totals. |
regenerate_spec_run
Section titled “regenerate_spec_run”Regenerate Spec Run
TWO PATHS, chosen by whether you pass operations. ① OMIT operations → THE GENERATOR RUNS: the spec generator is re-invoked over spec (or, if you omit it, the prose already stored on the run), a spec you pass is PERSISTED to the run so the stored input stops describing a spec nobody works from any more, and you get back the freshly generated pack plus a ready-to-send proposed_operations list already keyed to the live item ids — outcome generated_proposal. It writes NO item and cuts NO version: the generator emits tasks with no ids, so applying them against existing rows could only guess at identity or destroy it. Edit the proposal and send it back through path ②. ② PASS operations → they are applied exactly as they always were (and a spec passed alongside is persisted in the same all-or-nothing call). Path ② re-works the item set AS OPERATIONS ON THE EXISTING ITEMS — never as a fresh list of tasks. Items are DURABLE: they keep their id and their short_ref across every re-generation, which is what lets a comment written three rounds ago still point at the right line. Operations: keep(item_id) · update(item_id, fields) · add(item) · remove(item_id, reason) · split(item_id, new_items) · merge(from_ids, into_id). A SPLIT IS NOT TWO NEW ITEMS. split(item_id, new_items) CONTINUES item_id — same row, same id, same short_ref, same points, removed_at still NULL — and only births new_items, each stamped lineage_ref = item_id, lineage_kind = "split_from" and points_needs_review = true. A human point is NEVER divided, copied or re-estimated across a split. A MERGE is symmetric: merge(from_ids, into_id) KEEPS into_id (modified) and SOFT-REMOVES from_ids, each stamped lineage_ref = into_id, lineage_kind = "merged_into". Removal is always soft — a hard delete would make an older pinned version stop resolving and dangle the short_ref in an already-sent clipboard batch. ⚠️ FULL COVERAGE IS REQUIRED: every live item must be named by exactly one operation. Use keep for the ones that do not change — an operation list that omits live items is rejected with unreferenced_item, because it is a fresh list in disguise. Call get_spec_run first to get the ids. ⚠️ HUMAN POINTS ARE GATED: an update (or a merge rewrite) that changes estimated_points on an item whose points_set_by is human is REJECTED with human_points_not_repointed unless that item id is listed in repoint. Points carry provenance. points_set_by is ai until a human sets the value, then human forever, and the field name lands in human_edited_fields. A human-set point is never overwritten by a generator: regenerate_spec_run REJECTS an update touching it unless the item id is listed in repoint. Points use the Fibonacci scale 1/2/3/5/8/13/21. ALL-OR-NOTHING: any rejection means NOTHING is written and no version is cut; the response names every rejection so you can fix the operation list and resend. THIS BUMPS THE RUN VERSION (work.ai_spec_inputs.current_version) and writes a new work.spec_run_versions snapshot with a recomputed points_total. That is correct and intended: a quote is derived from the points, so every content change has to be pinnable to an exact version or the number stops being reproducible. It does NOT touch the comment version. Never writes work.tasks. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run to re-work. |
commit_message | string | no | Why the set changed, stored on the new version snapshot, e.g. “quebrei o gate de Quoting em validação + persistência”. REQUIRED whenever operations is passed — a version nobody can interpret later cannot be meaningfully pinned to, so the call is refused with invalid_arguments rather than versioned anonymously. Ignored on the generator path, which cuts no version. |
spec | string | no | NEW spec prose for the run, PERSISTED to work.ai_spec_inputs.text, replacing what create_spec_run stored. Pass it when the spec itself moved on — a requirements rewrite, a new client conversation, a plan body that changed. It is the ONLY way to update a run’s stored input, and it is what the generator path generates FROM. Omit it to re-generate from the prose already on the run, which is the right call when the spec is unchanged but the GENERATOR has moved on and you want to see what it produces today. Passing prose identical to what is stored writes nothing. |
repoint | string[] | no | Item ids whose HUMAN-SET points this call is explicitly authorised to change. This is the opt-in for the one guard that cannot be argued with at runtime: without it, an update touching estimated_points on a points_set_by = "human" item is rejected with human_points_not_repointed. Pass an id here ONLY when a human asked for that specific point to be re-estimated. It does NOT clear the human mark — the item stays human-owned for the next round too, and it does not authorise anything beyond the ids you list. |
operations | object[] | no | The operation list, at most 100 operations. Must account for every live item exactly once (see keep). One version is cut for the whole list, and commit_message is then required. OMIT IT ENTIRELY to take the GENERATOR path instead: the generator is re-invoked and returns a proposed_operations list you can edit and send back here. Omitting it is the only way to make this tool actually generate anything. |
promote_spec_run_items
Section titled “promote_spec_run_items”Promote Spec Run Items
Turn APPROVED spec-run items into real work.tasks rows — the only tool here that writes the real backlog, and the only irreversible one (there is no task-delete tool in the DFL MCP fleet, by policy). ⚠️ dry_run DEFAULTS TO TRUE: the default call reports exactly what WOULD be promoted and writes nothing. Pass dry_run: false to actually create the tasks. Only items with status = "approved", no removed_at and no existing work_task_id are eligible; everything else is reported skipped with a reason (skipped_not_approved, skipped_removed, skipped_already_promoted). IDEMPOTENT — re-running promotes only the remainder, which is what makes a retry after a partial failure safe. Each item carries its own outcome, so a batch where some succeeded and one failed (failed) reads as exactly that. The DFL-XXXXX identifier is minted by the database trigger, never by this tool. On a real run it writes work_task_id/work_task_identifier back onto each item and THIS BUMPS THE RUN VERSION (work.ai_spec_inputs.current_version) and writes a new work.spec_run_versions snapshot with a recomputed points_total. That is correct and intended: a quote is derived from the points, so every content change has to be pinnable to an exact version or the number stops being reproducible. It does NOT touch the comment version. Approve items first with update_spec_run_items({ status: "approved" }). Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run to promote from. |
dry_run | boolean | no | DEFAULT TRUE. When true, nothing is written: you get the eligible list, the total points that would enter the backlog, and every skip reason. When false, the tasks are created for real. What a dry run does NOT guarantee: it does not lock anything, so an item approved or removed between the preview and the real call changes the outcome — re-read the preview if time has passed. |
epic_id | string | no | The work.epics.id to file the created tasks under. Optional because work.tasks.epic_id is nullable, but a task with no epic is invisible on the epic board — pass it whenever you know it. Get it from the dfl-work MCP (list_epics), never by guessing: a wrong-but-valid epic id files a client’s work under someone else’s epic. |
item_ids | string[] | no | Promote only these items instead of every eligible one. Ids not belonging to this run are reported unknown_item_id and the call is refused. Omit to promote every approved, non-removed, not-yet-promoted item. |
comment_spec_run
Section titled “comment_spec_run”Comment on Spec Run
Add a review comment to a whole spec run, or to ONE item of it, into the currently OPEN hand-off batch. ⚠️ THIS NEVER BUMPS THE RUN VERSION and never bumps the comment version either — commenting changes no content and sends nothing. The COMMENT version (work.ai_spec_inputs.comment_version) is a hand-off cursor, not an archive: it counts “batches I have copied and sent”, nothing else. Posting a comment bumps NEITHER axis — a comment is a request, and the edit that satisfies it is the change. Only handoff_spec_run_comments advances it. The comment is stamped with its batch_number and with context_version (the run version it was written against) SERVER-SIDE, so this tool and the Spec Builder UI cannot disagree about which batch a comment belongs to. Comment on an ITEM whenever the remark is about one line — the item comment carries the short_ref anchor that makes handoff_spec_run_comments produce an unambiguous agent prompt, whereas “quebra em duas” on the whole run is useless the moment it leaves the UI. Read the open batch with get_spec_run (side-effect free); send and close it with handoff_spec_run_comments (which DOES bump). Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run being reviewed. Always required, even when commenting on an item — the batch and the comment counter belong to the RUN. |
content | string | yes | The comment text, written as an instruction the receiving agent can act on — “vale mais pontos, isso é 8”, “quebra em duas: validação de campos e persistência”. This text is copied verbatim into the hand-off prompt, so vague remarks arrive vague. |
item_id | string | no | The work.ai_spec_tasks.id this comment is about. Omit to comment on the run as a whole. An item comment is keyed entity_name = "spec_run_item" and is rendered under that item’s short_ref in the hand-off prompt; a run comment is keyed "spec_run" and rendered at the top. Get item ids from get_spec_run — an id from another run is rejected. |
parent_id | string | no | The work.comments.id this is a reply to, for threading. Optional and rarely needed: the hand-off prompt is flat, so a threaded reply is preserved in the database but presented alongside its siblings. |
handoff_spec_run_comments
Section titled “handoff_spec_run_comments”Hand Off Spec Run Comments
Take the OPEN batch of review comments on a spec run, format it as a ready-to-paste agent prompt with a short_ref anchor per item, mark the batch as sent (handed_off_at) and BUMP THE COMMENT VERSION. The programmatic twin of the UI’s “copy all + bump”. ⚠️ RETURNS ONLY THE OPEN BATCH, never the comment history — a hand-off that re-sends already-delegated comments is the exact failure the bump exists to prevent. A second call with no new comments returns an EMPTY batch and bumps NOTHING. ⚠️ THE DEFAULT CALL BUMPS. Pass dry_run: true to preview the exact prompt without sending or bumping. To merely READ the open batch with no side effect at all, use get_spec_run instead — that is what it is for. This NEVER touches the run version: handing comments off changes no content. The COMMENT version (work.ai_spec_inputs.comment_version) is a hand-off cursor, not an archive: it counts “batches I have copied and sent”, nothing else. Posting a comment bumps NEITHER axis — a comment is a request, and the edit that satisfies it is the change. Only handoff_spec_run_comments advances it. The stamp and the counter move together inside one SECURITY DEFINER function, so a batch can never end up half-sent. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id whose open comment batch should be handed off. |
dry_run | boolean | no | DEFAULT FALSE — the plain call sends and bumps. Set true to see the exact prompt that WOULD be handed off, leaving handed_off_at and the comment counter untouched. What a dry run does NOT guarantee: it does not reserve the batch, so a comment added between the preview and the real call is included in the real one. |
delete_spec_run
Section titled “delete_spec_run”Delete Spec Run
PERMANENTLY delete one spec run and everything scoped to it: the run row (work.ai_spec_inputs), its items (work.ai_spec_tasks, via CASCADE), its version snapshots (work.spec_run_versions, via CASCADE) and its comments (work.comments keyed spec_run / spec_run_item, which have NO foreign key and would otherwise be orphaned). There is no undo and no soft-delete: this is the tool for discarding a run that should never have existed (a smoke test, a mis-generated pack), not for retiring one — a real run that is over gets status: "discarded" via update_spec_run_items, which keeps the history. ⚠️ dry_run defaults to TRUE: the first call always reports what WOULD go and deletes nothing. REFUSES, without deleting anything, when the run is still referenced by a plan (work.entity_connections.target_type = "spec_run") or when any item was already promoted into work.tasks — see the outcome strings run_attached_to_plan and run_has_promoted_items. The plan-reference check counts through work.entity_target_reference_count(), which is NOT row-filtered, so it also refuses for a plan you cannot read: reference_count can exceed the plan_slugs it names, and that gap means “ask the plan owner to detach it”, not “retry”. It NEVER deletes a work.tasks row: promoted tasks are real backlog and are not this tool’s to remove. Not idempotent in the useful sense — a second call on a deleted run returns run_not_found_or_not_entitled. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id to delete. Get it from list_spec_runs or from the create_spec_run that produced it. |
dry_run | boolean | no | DEFAULT TRUE. When true, counts everything that would be deleted and writes NOTHING — run this first, read the counts, then repeat with dry_run: false. A dry run still evaluates every guard, so a refusal surfaces before you commit to anything. |
confirm_title | string | no | REQUIRED when dry_run is false. Must equal the run’s title exactly (the title a dry run prints; for a legacy run with no title, the first 60 characters of its text). This exists because a UUID is not something a caller can sanity-check by looking at it — naming what you are destroying is the only guard that catches a right-shaped, wrong-run id. |
list_spec_run_packages
Section titled “list_spec_run_packages”List Spec Run Scope Packages
List the scope layers (the “onion”) of a spec run, each with its item count, point total, and — the number that actually sells — what that layer ADDS over the previous one. layer is the depth from the CORE: 1 is the innermost package (the MVP), and the package of layer N contains every item whose package has layer <= N. So assigning an item to layer 1 puts it in EVERY package. It is NOT the number the client reads — that is client_label. An item with no package is NOT in layer 1: it is in no package at all. Assign package_id null to take an item out of every package. A run with no layers is not broken: it is one package nobody has split yet. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run. |
upsert_spec_run_package
Section titled “upsert_spec_run_package”Create or Rename a Spec Run Scope Layer
Create or rename ONE scope layer of a spec run, keyed by (run_id, layer). layer is the depth from the CORE: 1 is the innermost package (the MVP), and the package of layer N contains every item whose package has layer <= N. So assigning an item to layer 1 puts it in EVERY package. It is NOT the number the client reads — that is client_label. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run. |
layer | number | yes | Depth from the core. 1 is the MVP layer; higher numbers wrap around it. |
name | string | yes | Internal name, e.g. “MVP”, “Essencial”, “Completo”. |
client_label | string | no | What the client reads, e.g. “Package 1”. Package 1 = MVP by convention. |
notes | string | no | Anything the curator wants recorded on this layer. |
assign_spec_run_layers
Section titled “assign_spec_run_layers”Assign Spec Run Items to Scope Layers
Assign items to scope layers in ONE batch. Each item points at the INNERMOST package that contains it. layer is the depth from the CORE: 1 is the innermost package (the MVP), and the package of layer N contains every item whose package has layer <= N. So assigning an item to layer 1 puts it in EVERY package. It is NOT the number the client reads — that is client_label. An item with no package is NOT in layer 1: it is in no package at all. Assign package_id null to take an item out of every package. Items you do not list are left alone. The write is REFUSED if another writer changed the run after this call read it — nothing is written, and you re-read and retry. Get item ids from get_spec_run and package ids from list_spec_run_packages. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run. |
assignments | object[] | yes | One entry per item to change. |
delete_spec_run_package
Section titled “delete_spec_run_package”Delete an Empty Spec Run Scope Layer
Delete a scope layer. REFUSED while any item still points at it — reassign those items first with assign_spec_run_layers. The refusal is deliberate: the foreign key is ON DELETE SET NULL, so a direct delete would silently un-package every item of that layer, and the run would quietly lose scope nobody removed.
| Parameter | Type | Required | Description |
|---|---|---|---|
package_id | string | yes | The layer id, from list_spec_run_packages. |
list_spec_run_feature_groups
Section titled “list_spec_run_feature_groups”List Spec Run Feature Groups
List the feature groups of a spec run with their item count and point total, PLUS the items that are outside every group. A feature group is the unit the BUYER has an opinion about (“I want the AI copilot”), not a unit of work. It does NOT price anything — the scope layer sells, and a package total is a plain sum. feature_group_id: null means the item is OUTSIDE every group — the base of the project that exists in any version of it (CI/CD, deploy, database, handover). That is a classification, not a missing value, and on b17c6fd2 it is 32% of the points. The two numbers always add up to the run total — if they do not, an item points at a group that no longer exists. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Acts as YOU (the caller’s JWT, RLS applies — never a service role). work.ai_spec_inputs, work.ai_spec_tasks and work.comments are gated by iam.is_member(), so a non-member sees nothing and a member sees every run. A run you cannot see is reported as run_not_found_or_not_entitled — deliberately the SAME outcome as a run that does not exist, so the tool cannot be used to probe for the existence of runs you are not entitled to.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run. |
upsert_spec_run_feature_group
Section titled “upsert_spec_run_feature_group”Create or Rename a Spec Run Feature Group
Create or rename one feature group of a run, keyed by (run_id, code). A feature group is the unit the BUYER has an opinion about (“I want the AI copilot”), not a unit of work. It does NOT price anything — the scope layer sells, and a package total is a plain sum. Groups are PER RUN — there is no shared catalog, because a group’s composition is run-specific. Without an explicit sort_order the group goes to the end, never tied with another: two rows with the same order make the list non-deterministic, and a proposal that reorders itself between two reads does not inspire confidence. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form. Requires the Phase-1 dfl-schema migration for spec runs (plan 20260804-spec-builder-run-first-class-plan-entity §7). Until it is merged the call fails with a database error naming the missing column or function — it does NOT silently degrade, because a run that looks saved but stored nothing durable is worse than an error.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run. |
code | string | yes | Short stable code within the run, e.g. “G10”. |
name | string | yes | Internal name, e.g. “Busca”. |
client_label | string | no | What the client reads, e.g. “Search”. |
sort_order | number | no | Display order. Omit to append. |
notes | string | no | Free text. This is where dependencies between groups live (“G11 needs G10”, “G14 conflicts with G10/G11 in the cloud”) — deliberately prose, not a graph, because while the group only organises there is no selection to validate. |
assign_spec_run_feature_groups
Section titled “assign_spec_run_feature_groups”Assign Spec Run Items to Feature Groups
Assign items to feature groups in ONE batch. feature_group_id: null means the item is OUTSIDE every group — the base of the project that exists in any version of it (CI/CD, deploy, database, handover). That is a classification, not a missing value, and on b17c6fd2 it is 32% of the points. Items you do not list are left alone. The write is REFUSED if another writer changed the run after this call read it — nothing is written, and you re-read and retry. Get item ids from get_spec_run and group ids from list_spec_run_feature_groups. Get run_id from list_spec_runs or from the create_spec_run that produced it. Never paste, guess or recall a run UUID: a wrong-but-valid UUID silently targets somebody else’s priced task breakdown, which is a commercial proposal in table form.
| Parameter | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | The work.ai_spec_inputs.id of the run. |
assignments | object[] | yes | One entry per item to change. |
delete_spec_run_feature_group
Section titled “delete_spec_run_feature_group”Delete an Empty Spec Run Feature Group
Delete a feature group. REFUSED while any item still points at it. The refusal matters more than it looks: the foreign key is ON DELETE SET NULL, and in this model “outside every group” is a CLASSIFICATION — it says “this is the base of the project”. A direct delete would make the run assert that about a dozen items without anyone deciding it. Move the items explicitly first, with assign_spec_run_feature_groups.
| Parameter | Type | Required | Description |
|---|---|---|---|
group_id | string | yes | The group id, from list_spec_run_feature_groups. |
assign_developer
Section titled “assign_developer”Assign Developer
Suggests and assigns the best-matched developer for a work.tasks row via the dfl-ai-task-assigner-matching-task Edge Function (deterministic scoring + optional AI re-rank). Always writes the top-scored candidate to work.tasks.owner_id and returns the full ranked suggestion list for transparency.
| Parameter | Type | Required | Description |
|---|---|---|---|
task_id | string | yes | work.tasks.id to assign a developer to |
seed_template
Section titled “seed_template”Seed Template
Create or update a documents.template + its documents.template_variable rows, without a dfl-schema migration. Idempotent by template name: re-running with the same name updates the existing template in place and replaces its full variable set with the one passed in this call. Reusable for any template (comercial, handoff, future ones) — not handoff-specific.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | yes | Template name — also the idempotency key |
document_type | enum | no | documents.document_type enum value (default: “other”) One of: contract, proposal, nda, sow, other, amendment. |
category | string | no | Free-form tag (e.g. “handoff”, “comercial”) used by the frontend to group templates |
description | string | no | — |
content | string | yes | Template body — Mustache {{variable_key}} placeholders; {{dfl-diagram:UUID}} for diagram slots |
variables | object[] | yes | Full variable set for this template — replaces any existing variables on each call |
get_handoff_document
Section titled “get_handoff_document”Get Handoff Document
Read documents.document rows. Pass “id” to fetch one document, or “epic_id” to list every document linked to a work.epics id (entity_id), most recently updated first.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | no | documents.document.id |
epic_id | string | no | work.epics.id (stored as entity_id on the document) — used when id is not given |
write_document
Section titled “write_document”Write Document
Create or update a documents.document row from a template category, an explicit template id, or a literal body. Generic: any category seeded via seed_template works — nothing here is specific to one document kind. TO PUT A DIAGRAM IN THE DOCUMENT, do not paste a Mermaid code block: create the diagram with create_diagram, put the diagram sentinel in the body and pass diagram_entity_id, and every diagram of that entity is injected as a live {{dfl-diagram:UUID}} reference token that keeps following the diagram as it is edited. Writes run on your user JWT, so documents.document RLS (iam.is_member()) decides whether they land; a write that matches no row is reported as a failure, never as an empty success. Does not compute rendered_content (a frontend concern, same as the dfl-documents wizard) and cannot set signature-lifecycle statuses.
| Parameter | Type | Required | Description |
|---|---|---|---|
title | string | yes | Document title (required — documents.document.title is NOT NULL) |
category | string | no | documents.template.category — instantiates the most recently updated ACTIVE template in it. One of category / template_id / content is required. |
template_id | string | no | Explicit documents.template.id to instantiate. One of category / template_id / content is required. |
content | string | no | Literal document body, no template. One of category / template_id / content is required. |
variables | object | no | variable_key -> value map stored in variables_data (Mustache substitution happens in the frontend) |
document_type | enum | no | documents.document_type enum. Defaults to the template’s type, else “other”. Note: there is no “handoff” value — that is a template category, not a type. One of: contract, proposal, nda, sow, other, amendment. |
status | enum | no | Authoring status, default “draft”. The signature-lifecycle statuses (sent/partially_signed/completed/finished/cancelled) are owned by the signing flow and are rejected here. One of: draft, pending. |
entity_id | string | no | Id of the row this document is about (e.g. a work.epics id), stored as entity_id |
entity_name | string | no | Human-readable name of that entity, stored as entity_name |
diagram_entity_id | string | no | Inject a live {{dfl-diagram:UUID}} reference token for every public.diagrams row of this entity (usually a work.epics id) in place of the body’s diagram sentinel — this is how a document gets a diagram, instead of a pasted Mermaid code block. The sentinel is the literal string “{{dfl-diagram:00000000-0000-0000-0000-000000000000}}”; put it in the template or the literal body first. Errors if the body has no sentinel. The tokens are resolved when the document renders, so the diagram stays live. |
document_id | string | no | Existing documents.document.id to update in place instead of creating a new row |
on_duplicate | enum | no | What to do when a document with the same title already exists for the same entity_id. “error” (default) refuses and returns the existing ids; “update” rewrites the most recent match; “create” inserts another row. One of: error, create, update. |
write_handoff_document
Section titled “write_handoff_document”Write Handoff Document
Convenience wrapper over write_document for the “handoff” template category: resolves a work.epics id to its name, instantiates the active handoff template, and injects {{dfl-diagram:UUID}} tokens for every diagram already created for that epic. Everything else (RLS, duplicate handling, audit log) is the same shared writer — use write_document directly for any other category.
| Parameter | Type | Required | Description |
|---|---|---|---|
epic_id | string | yes | work.epics.id this document belongs to (stored as entity_id) |
title | string | yes | Document title |
variables | object | yes | variable_key -> value map for the template’s variables (excluding diagram slots, which are auto-filled) |
document_id | string | no | Existing documents.document.id to update instead of creating a new one |
on_duplicate | enum | no | Same title already on this epic: “error” (default) refuses and returns the existing ids; “update” rewrites the most recent; “create” inserts another row. One of: error, create, update. |
link_epic_to_plan
Section titled “link_epic_to_plan”Link Epic to Plan
Bind a work.epics epic to a plan, so the plan shows which epic carries its work and the dfl-learn epics panel can find the plan from the epic. Writes ONE row in work.entity_connections with target_type “epic”. Idempotent: re-linking the same pair reports the existing row instead of creating a second one. It is epic-scoped ON PURPOSE and there is no target_type argument. The plans-app owns and rewrites the diagram / document / image / ux_path / spec_run rows of that table from the plan body on every publish, and the task rows on every set_plan_tasks, so a row of those types written here would report success and then vanish at the next publish. Attach a diagram, a document, an image, a ux_path or a spec_run with the plans MCP attach_entity; bind tasks with the plans MCP set_plan_tasks. Authorisation follows the plans-app: you must be able to READ the plan (an unreadable or absent plan both answer plan_not_found — the tool never confirms that a stranger’s personal plan exists), and you must be the plan OWNER or an admin (IAM level 80+).
| Parameter | Type | Required | Description |
|---|---|---|---|
plan_slug | string | yes | The plan slug, e.g. “20260812-ux-map-observed-reality-lens”. This is stored as entity_connections.entity_id, which is how every plan row in that table is addressed. |
epic_id | string | yes | The work.epics.id of the epic to bind. Find it with the work MCP list_epics. |
list_entity_connections
Section titled “list_entity_connections”List Entity Connections
Read the plan-to-entity links in work.entity_connections — the join that binds a plan to its tasks, epics, diagrams, documents, images, ux_paths and spec runs. READ-ONLY across every target type. Use it to inspect what a plan is bound to, or, with target_id, to find which plans reference one entity. It exists so nobody needs psql to answer that question. Results are filtered by the caller’s own RLS: you see the links of a plan you can read, and you see no link of a plan you cannot. An empty list can therefore mean either “no links” or “not your plan” — when you filter by plan_slug the response says which one it was, in plan_readable. To WRITE a link: epic links use link_epic_to_plan on this server; task links use set_plan_tasks on the plans MCP; diagram, document, image, ux_path and spec_run links are derived from the plan body by the plans-app, so use attach_entity on the plans MCP.
| Parameter | Type | Required | Description |
|---|---|---|---|
plan_slug | string | no | Filter to one plan, by the slug stored in entity_connections.entity_id. Omit to list every connection your RLS lets you see. |
target_type | enum | no | Filter to one kind of target. Omit for all kinds. One of: task, epic, diagram, document, image, ux_path, spec_run. |
target_id | string | no | Filter to one target entity by uuid — the reverse lookup, “which plans point at this epic / diagram / task”. Rows addressed by URL instead of uuid carry target_ref and are never matched by this filter. |
limit | number | no | Maximum rows to return (default 50, max 200). |
offset | number | no | Rows to skip, for pagination. |
annotate_ux_map_region
Section titled “annotate_ux_map_region”Annotate a UX-map region
Pin a design note to a REGION of a captured screen in engineering.ux_map_annotations. The anchor comes straight from a region of the screen’s regions.json — the file, the component, the tag and the occurrence — so nothing here is typed by hand or guessed. Written with YOUR user-JWT: the row records you as the author and the database refuses an author_id that is not yours, because a design opinion attributed to the wrong person is worse than no opinion. THE ANCHOR IS IMMUTABLE. An annotation is anchored to (app, screen_id, source_file, component_name, element_tag, occurrence_index). Those columns cannot be updated: authenticated holds UPDATE on six columns only, and a BEFORE UPDATE trigger refuses an anchor change even for a privileged role. If the component is gone, the annotation is BROKEN and must be reported as broken — never moved to another element, and never re-created silently under the old id. Write a NEW annotation on whatever replaced it. “broken” is deliberately not a status, because brokenness is a property of the capture you are LOOKING AT, not of the row. Every coordinate is a 0..1 FRACTION, never a pixel — a stored pixel goes silently wrong the moment the viewport changes. pin_rel_* and region_rel_* are fractions of the SCREEN; pin_in_region_* is the fraction of the REGION BOX, and it is the pair a surviving pin is redrawn from against the region box in the CURRENT capture. CHECK constraints reject anything outside 0..1. There is NO viewport argument, and none may be added. The database copies the viewport from the capture you named, for the same reason it takes the author from your JWT: what you were LOOKING AT is not a claim the writer gets to make. VIEWPORT is a lane BESIDE the role, never inside it. role says WHO was looking; viewport says ON WHAT. Known values: “desktop” (the default, and every capture taken before 2026-09-02) and “mobile”; anything else follows the “<W>x<H>” convention in CSS pixels, lower case, e.g. “390x844”. It is free text and lower-cased by the database, so “Mobile” and “mobile” are one lane. NEVER encode it in the role as “<role>@mobile” — that is the design the column replaced, and it would make a viewport look like a role. Requires the engineering.ux_map_annotations migration (devfellowship/dfl-schema#793), which is at a human gate. Until it merges this call fails with a database error naming the missing relation — it does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
app_uuid | string | yes | engineering.ux_map_apps.id — the application being annotated. |
capture_id | string | yes | engineering.ux_map_captures.id the note was written against. REQUIRED: an annotation is born from a real capture. It may outlive one — retention clears this column rather than deleting the note. |
capture_digest | string | yes | The capture’s own digest. It must MATCH the capture row: it is stored so the note keeps its provenance after retention deletes the capture. |
screen_id | string | yes | The spec’s sticky 1:1 join key. Must be a screen the capture actually recorded — a screen id nobody observed would look like a broken anchor forever. |
source_file | string | yes | ANCHOR. Repository-relative path from the region, e.g. src/pages/NotFound.tsx. |
component_name | string | yes | ANCHOR. The enclosing component from the region, e.g. NotFound. |
element_tag | string | yes | ANCHOR. The lowercased DOM tag from the region, e.g. h1. |
occurrence_index | number | no | ANCHOR. 0-based index among regions matching (source_file, component_name, element_tag) in DOCUMENT ORDER. Required because a component that renders a list emits N identical regions, so the other three name a SET rather than an element. Default: 0. |
pin_rel_x | number | yes | Click point, x, as a fraction of the screen width. Fraction of 0..1 — a PIXEL value is rejected by the database. |
pin_rel_y | number | yes | Click point, y, as a fraction of the screen height. Fraction of 0..1 — a PIXEL value is rejected by the database. |
region_rel_x | number | yes | Region box left edge. Fraction of 0..1 — a PIXEL value is rejected by the database. |
region_rel_y | number | yes | Region box top edge. Fraction of 0..1 — a PIXEL value is rejected by the database. |
region_rel_w | number | yes | Region box width. Fraction of 0..1 — a PIXEL value is rejected by the database. |
region_rel_h | number | yes | Region box height. Fraction of 0..1 — a PIXEL value is rejected by the database. |
pin_in_region_x | number | yes | Click point x WITHIN the region box. Fraction of 0..1 — a PIXEL value is rejected by the database. |
pin_in_region_y | number | yes | Click point y WITHIN the region box. Fraction of 0..1 — a PIXEL value is rejected by the database. |
body | string | yes | The note. What should change, and why. |
selector_hint | string | no | DIAGNOSTIC ONLY. The CSS path at write time. Never used to resolve an anchor — one inserted sibling invalidates an nth-child path. |
source_line | number | no | DIAGNOSTIC ONLY. The line at write time. Lines move on every edit above them. |
list_ux_map_annotations
Section titled “list_ux_map_annotations”List UX-map annotations
Read design notes pinned to regions of captured screens. Every filter is optional, so this answers “what is open on this screen”, “what is open on this FILE” — the reverse lookup an engineer opens a pull request from — and “what did this person raise”. READ-ONLY. This tool does NOT tell you whether an annotation still points at anything: that depends on the capture you are looking at, and is decided by resolving the anchor against that capture’s regions.json (resolveAnchor in @devfellowship/ux-paths-spec). A row here with status “open” may well be BROKEN against the newest capture. THE ANCHOR IS IMMUTABLE. An annotation is anchored to (app, screen_id, source_file, component_name, element_tag, occurrence_index). Those columns cannot be updated: authenticated holds UPDATE on six columns only, and a BEFORE UPDATE trigger refuses an anchor change even for a privileged role. If the component is gone, the annotation is BROKEN and must be reported as broken — never moved to another element, and never re-created silently under the old id. Write a NEW annotation on whatever replaced it. “broken” is deliberately not a status, because brokenness is a property of the capture you are LOOKING AT, not of the row. VIEWPORT is a lane BESIDE the role, never inside it. role says WHO was looking; viewport says ON WHAT. Known values: “desktop” (the default, and every capture taken before 2026-09-02) and “mobile”; anything else follows the “<W>x<H>” convention in CSS pixels, lower case, e.g. “390x844”. It is free text and lower-cased by the database, so “Mobile” and “mobile” are one lane. NEVER encode it in the role as “<role>@mobile” — that is the design the column replaced, and it would make a viewport look like a role. Omit viewport to read EVERY lane, which is what this tool did before the column existed. Pass it to answer “what is open on this screen AT THIS VIEWPORT” — a note written on a desktop-only sidebar is not feedback about the mobile image. Requires the engineering.ux_map_annotations migration (devfellowship/dfl-schema#793), which is at a human gate. Until it merges this call fails with a database error naming the missing relation — it does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
app_uuid | string | no | Filter to one application. |
screen_id | string | no | Filter to one screen. |
source_file | string | no | Filter to one source file — the “what is open on this file” lookup. |
component_name | string | no | Filter to one component. |
status | enum | no | open or addressed. Omit for both. One of: open, addressed. |
viewport | string | no | Filter to one viewport, e.g. “desktop” or “mobile”. Omit to read every lane. Matched lower-case, because that is how the database stores it. |
author_id | string | no | Filter to one author. |
limit | number | no | Maximum rows (default 50, max 200). |
offset | number | no | Rows to skip, for pagination. |
resolve_ux_map_annotation
Section titled “resolve_ux_map_annotation”Resolve or re-open a UX-map annotation
Move an annotation between “open” and “addressed”, and/or record the request pull request it produced. This is what makes the notes a QUEUE rather than a wall of sticky notes. ANY signed-in person may resolve ANY annotation — that is the queue functioning: the designer raises them, the engineers close them. Only the AUTHOR may edit the text, and the database enforces that with a trigger rather than with a convention. Deleting somebody’s feedback is NOT resolving it, and this tool cannot delete. THE ANCHOR IS IMMUTABLE. An annotation is anchored to (app, screen_id, source_file, component_name, element_tag, occurrence_index). Those columns cannot be updated: authenticated holds UPDATE on six columns only, and a BEFORE UPDATE trigger refuses an anchor change even for a privileged role. If the component is gone, the annotation is BROKEN and must be reported as broken — never moved to another element, and never re-created silently under the old id. Write a NEW annotation on whatever replaced it. “broken” is deliberately not a status, because brokenness is a property of the capture you are LOOKING AT, not of the row. Requires the engineering.ux_map_annotations migration (devfellowship/dfl-schema#793), which is at a human gate. Until it merges this call fails with a database error naming the missing relation — it does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
annotation_id | string | yes | engineering.ux_map_annotations.id. |
status | enum | no | Set to “addressed” to close it, “open” to re-open. addressed_at and addressed_by are set (and cleared) by the database, so do not send them. One of: open, addressed. |
request_pr_url | string | no | The request pull request this note produced, so the queue item and the PR point at each other. Must be https — the field is rendered as a link. |
body | string | no | Edit the note text. Allowed ONLY if you are its author; the database refuses it otherwise with “only the author may edit the body of an annotation”. |
index_ux_map_capture
Section titled “index_ux_map_capture”Index UX Map Capture
Record a UX map capture — an app, a role, and the screens observed for that role — in engineering.ux_map_apps, engineering.ux_map_captures and engineering.ux_map_screens. This is the ONLY write path into those three tables for a human identity; every other writer is an automated collector. READS do not come through here: an app reads the ux_map tables directly from Supabase with the user session under RLS, because MCP is the AI-agent surface, not a data layer for a UI. The author is taken from your JWT by the database itself, so there is NO indexed_by, user_id or author argument and none may be added — a caller-supplied author would let this tool write a map as somebody else. Coverage is derived, not asserted: the database counts the screens payload, so there is no screens_total or screens_with_shot argument either. Idempotent on digest: re-indexing identical content writes nothing and answers already_indexed true with the existing row, which the result reports at the top level — read that flag before you believe a capture was refreshed. engineering.ux_map_validations is a DIFFERENT and separate marker, written by the person who validates a map against the running app; indexing a capture never validates it. A capture is identified by app, role, VIEWPORT and digest since 2026-09-02, so the same content can be indexed once per viewport. VIEWPORT is a lane BESIDE the role, never inside it. role says WHO was looking; viewport says ON WHAT. Known values: “desktop” (the default, and every capture taken before 2026-09-02) and “mobile”; anything else follows the “<W>x<H>” convention in CSS pixels, lower case, e.g. “390x844”. It is free text and lower-cased by the database, so “Mobile” and “mobile” are one lane. NEVER encode it in the role as “<role>@mobile” — that is the design the column replaced, and it would make a viewport look like a role. The caller must be a DFL member: the function refuses an IAM global level below 50.
| Parameter | Type | Required | Description |
|---|---|---|---|
app_id | string | yes | Stable slug of the application this map describes, e.g. “dfl-learn”. Up to 200 characters. |
role | string | yes | The role the capture was taken as, e.g. “student”, “admin”, “anonymous”. Free text, up to 100 characters. A map is always a map FOR somebody: the same app shows different screens to different roles, so the role is part of what the capture claims. It says WHO was looking and nothing else — the viewport is its own argument. |
viewport | string | no | Which viewport this capture photographed. Defaults to “desktop”. VIEWPORT is a lane BESIDE the role, never inside it. role says WHO was looking; viewport says ON WHAT. Known values: “desktop” (the default, and every capture taken before 2026-09-02) and “mobile”; anything else follows the “<W>x<H>” convention in CSS pixels, lower case, e.g. “390x844”. It is free text and lower-cased by the database, so “Mobile” and “mobile” are one lane. NEVER encode it in the role as “<role>@mobile” — that is the design the column replaced, and it would make a viewport look like a role. |
digest | string | yes | Content digest of the capture, “sha256:<64 lowercase hex>”. It is the idempotency key: the same digest resolves to the same capture row. |
artifact_url | string | yes | https:// URL of the capture artifact itself. Only https is accepted. |
screens | object[] | no | The screens observed, each with a screen_id. Unknown keys inside a screen are REFUSED, here and in the database — check the spelling rather than expecting an extra field to be ignored. Omit for a capture with no screens; coverage is then zero. |
display_name | string | no | Human-readable name of the app. |
repo_full_name | string | no | The GitHub repository as “<owner>/<repo>”, e.g. “devfellowship/dfl-learn”. |
business_unit_id | string | no | Business unit that owns the app. |
artifact_media_id | string | no | media id of the artifact, when it is stored as a DFL media row. |
app_version | string | no | Version string of the app at capture time. |
commit_sha | string | no | Commit the app was running at, 7 to 40 lowercase hex characters. |
captured_at | string | no | ISO-8601 timestamp of when the capture was taken. Defaults to the time of the write, which is wrong for a capture you are indexing after the fact — pass it. |
coverage_note | string | no | What this map does NOT cover, in your own words. A partial map that says so is usable; a partial map that looks complete is not. |
run_id | string | no | Identifier of the run that produced the capture, when one exists. |
delete_ux_map_capture
Section titled “delete_ux_map_capture”Delete UX Map Capture
PERMANENTLY delete ONE UX map capture — the engineering.ux_map_captures row named by capture_id — through engineering.ux_map_delete_capture. Its screens (engineering.ux_map_screens) and its human validations (engineering.ux_map_validations) go with it by ON DELETE CASCADE. It NEVER deletes an annotation and it never could: engineering.ux_map_annotations has ON DELETE SET NULL, so a design note SURVIVES with capture_id NULL, keeping its anchor and its capture_digest — deleting somebody’s feedback stays with the author-only DELETE policy on that table. It NEVER touches engineering.ux_map_apps: deleting the last capture of an app leaves the app row, on purpose. This is the tool for discarding a capture that should never have existed — a smoke test, a wrong role, a wrong viewport, a capture of a broken build. There is no undo and no soft-delete. To supersede a capture, INDEX A NEW ONE instead: the newest capture wins and the history stays. ⚠️ dry_run defaults to TRUE: the first call reports what WOULD go and deletes nothing. Every guard is evaluated on a dry run, so a refusal surfaces before you commit to anything. Committing REQUIRES confirm_digest to equal the capture’s digest, which the dry run prints — a uuid is not something a caller can sanity-check by looking at it. REFUSES, without deleting anything, when an annotation points at the capture (reason annotations_present) or when another capture measured its movement against it (reason baseline_referrers_present). That second refusal is NOT a preference: the baseline foreign key writes one column while ck_ux_map_captures_baseline_coherent constrains two, so the delete is IMPOSSIBLE until those captures are reset — clear_baseline_referrers does the reset and DISCARDS their movement numbers. Idempotent: an id that is already gone answers deleted: false, reason: "not_found" rather than failing. EVERY refusal also answers deleted: false with its own reason, and both sit at the top level of the result — read them before you believe a capture is gone. The deleter is taken from your JWT by the database itself, so there is NO deleted_by, user_id or actor argument and none may be added. There is no app_id, role or date filter either: one capture per call, addressed by id. The caller must be a DFL ADMIN: the function refuses an IAM global level below 80, which is deliberately NARROWER than the level 50 index_ux_map_capture needs — a wrong capture is self-correcting, a wrong delete is not. Requires the dfl-schema migration 20260902170000_engineering_ux_map_delete_capture_rpc.sql, which is at a human merge gate. Until it merges this tool answers migration_not_applied and names it; it never degrades to a silent success.
| Parameter | Type | Required | Description |
|---|---|---|---|
capture_id | string | yes | The engineering.ux_map_captures.id to delete. Read it from the ux-paths viewer, from a list_ux_map_annotations result, or from the index_ux_map_capture call that created it. NEVER guess one: a uuid you did not read is a uuid that names somebody else’s capture. |
dry_run | boolean | no | DEFAULT TRUE. When true, counts everything that would change and deletes NOTHING — run this first, read would_change, then repeat with dry_run false. A dry run reports its numbers under would_change, NOT under counts, which stays null on this one branch. A dry run still evaluates every guard, and it prints the digest that confirm_digest needs. |
confirm_digest | string | no | REQUIRED when dry_run is false. Must equal the capture’s own digest exactly — the dry run prints it. This exists because a uuid is not something a caller can sanity-check by looking at it; naming what you are destroying is the only guard that catches a right-shaped, wrong-capture id. |
allow_detached_annotations | boolean | no | DEFAULT FALSE. Set true to accept that any annotation on this capture keeps its anchor and its capture_digest but loses capture_id. The note is NOT deleted — the foreign key does not allow that — it only stops recording which capture it was written against. |
clear_baseline_referrers | boolean | no | DEFAULT FALSE. Set true to reset every capture that measured its movement against this one back to not-measured, DISCARDING its movement numbers and its screens’ movement numbers. Without it the delete is refused, and that refusal is unavoidable rather than cautious: the baseline foreign key cannot detach those rows on its own without violating a CHECK. |
update_ux_map_capture
Section titled “update_ux_map_capture”Update UX Map Capture
Correct the PROSE and the PROVENANCE of ONE already-indexed UX map capture — the engineering.ux_map_captures row named by capture_id — through engineering.ux_map_update_capture. This is the ONLY update path that exists: authenticated holds SELECT on the ux_map tables and nothing else, and ux_map_captures has no UPDATE policy, so a coverage_note that says something FALSE cannot otherwise be corrected by anybody except the database owner. It edits EXACTLY 3 columns and no others: coverage_note, app_version, commit_sha. EVERYTHING ELSE IS EVIDENCE AND IS REFUSED — the identity and lane columns (role, viewport, app_uuid, run_id), everything read off the artifact (digest, artifact_url, artifact_media_id, captured_at, screens_total, screens_with_shot), and every derived movement number (baseline_kind, baseline_capture_id, largest_movement_ratio). A patch naming one of those is refused WHOLE, with reason field_not_editable; the good fields are NOT applied without the bad ones. The line is that a CLAIM ABOUT the evidence may be corrected and the evidence may not. To change the evidence, INDEX A NEW CAPTURE with index_ux_map_capture: the newest capture wins and the history stays. ⚠️ dry_run defaults to TRUE: the first call writes nothing and reports the exact diff plus the row identity — role, viewport, digest, captured_at — because a uuid is not something a caller can sanity-check by looking at it. Read it, then call again with dry_run false. Send a field to set it. OMIT a field to leave that column alone. Send it as null to CLEAR the column. Those three are different, and omitting is not clearing. A call that names no field at all is refused with reason empty_patch — this tool never writes an empty patch, because an update that changes nothing but stamps an audit trail is a lie about an edit. coverage_note is limited to 2000 characters and a longer one is REFUSED, never truncated: the caveats live at the end of a note, and the full account belongs in the capture manifest, which is published as the artifact. Returns before/after FOR THE PATCHED FIELDS ONLY, on every branch including the dry run and the refusals. before is the only undo path — these columns keep no version history — so record it if you may need to reverse the edit. An id that does not exist answers updated: false, reason: "not_found" rather than failing, and a patch that matches what is already stored answers no_change without stamping the audit columns. EVERY refusal also answers updated: false with its own reason, and both sit at the top level — read them before you believe the prose changed. The editor is taken from your JWT by the database itself, which stamps edited_by and edited_at, so there is NO edited_by, user_id or actor argument and none may be added. There is no app_id, role or date filter either: one capture per call, addressed by id. The caller must be a DFL MEMBER: the function refuses an IAM global level below 50. That is the SAME gate index_ux_map_capture uses, and deliberately not the level 80 delete_ux_map_capture uses: a member who may WRITE a coverage_note must be able to correct one, and the edit is reversible where a delete is not. Requires the dfl-schema migration 20260903090000_engineering_ux_map_update_capture_rpc.sql, which is at a human merge gate. Until it merges this tool answers migration_not_applied and names it; it never degrades to a silent success.
| Parameter | Type | Required | Description |
|---|---|---|---|
capture_id | string | yes | The engineering.ux_map_captures.id to correct. Read it from the ux-paths viewer, from a ux_paths_app_images result, or from the index_ux_map_capture call that created it. NEVER guess one: a uuid you did not read is a uuid that names somebody else’s capture. The dry run prints the role, viewport, digest and captured_at of whatever it found, so you can check. |
coverage_note | string | no | The corrected prose account of the capture: what was photographed, at what geometry, signed in as whom, what is missing and why. OMIT to leave the stored note alone; send null to clear it. Do NOT hand-write one when a generator produced the original — run that generator against the capture MANIFEST and send what it returns, so the note stays a function of the artifact instead of a remembered paraphrase. Limited to 2000 characters. |
app_version | string | no | The version string of the app at capture time. It is PROVENANCE the indexer copied from the flows document, not a value read off the artifact, which is why it can drift and why it is correctable. OMIT to leave it alone; send null to clear it. |
commit_sha | string | no | The git commit the capture was taken at, 7 to 40 lowercase hex characters. PROVENANCE the caller supplied, so a capture indexed without one — or with the wrong one — can be repaired here instead of by deleting the capture, which would destroy its screens and every human validation of it. OMIT to leave it alone; send null to clear it. |
dry_run | boolean | no | DEFAULT TRUE. When true, reports the exact diff and the identity of the row it found, and writes NOTHING — run this first, check that capture names the capture you meant, then repeat with dry_run false. A dry run evaluates every guard, so a refusal surfaces before you commit to anything. |
ux_paths_app_images
Section titled “ux_paths_app_images”UX-paths app images
Every screenshot of the MOST RECENT build of one application, from the UX-map index (dfl-ux-paths / “XPaths”). Use it when you need to LOOK at an app you cannot run — a design review, a migration gap, a “what does this screen look like today” question. READ-ONLY apart from the optional archive it uploads for you. A build is (app_version, commit_sha), NOT app_version alone, because several captures weeks apart can carry the same version string. A capture is per ROLE, so by default this unions every capture of that build — a screen only an admin can reach is still a screen of the build — and each image says which role saw it. Within ONE role and viewport only the newest capture is used: a second one is a re-index that supersedes the older row, not extra screens, and any row dropped that way is named in superseded_captures rather than hidden. Pass role to narrow, or capture_id to pin one capture exactly. viewport defaults to desktop; pass mobile or both. A mobile capture is addressed as the role “<role>@mobile” for a row written before the viewport column, and both spellings are read: an explicitly non-default column wins, then the suffix, because the column is NOT NULL DEFAULT desktop and a defaulted value is not evidence. Any row where the two disagree is reported in viewport.disagreements rather than resolved silently. Each image carries its own viewport, and viewport.missing_sibling says so when this app has no mobile capture at all — you never get desktop rows labelled as both. distinct_images counts DIGESTS and is the honest count of pictures: every screen gets its own media object, so distinct_urls always equals count even when two screens photographed the same pixels. identical_images names those pairs — some are deliberate, and one that is not means a screen nobody has actually looked at. EVERY image carries anonymous_access, MEASURED by an anonymous GET with no token: “open” means an external designer can open that url with no DFL session, “members_only” means they cannot. designer_handoff summarises it. The tiers are mixed within one app in practice, so read the per-image field rather than assuming. format “urls” (the default) returns the list and probes access, but downloads no image. format “zip” downloads them here, packs them with a manifest.json, and returns ONE media url whose visibility FOLLOWS THE CONTENT — private when every screenshot inside already opened anonymously, members otherwise, because an archive is only as shareable as its least shareable member. The zip is refused above 25 MB or 300 images: the result then carries zip_skipped_reason and the full url list, so fetch them yourself. A screenshot url is NOT uniformly public. Each one resolves through media-redirect, which reads that object’s own visibility: a members object answers 401 to an anonymous GET, while a private object answers 302 and then the image. So read anonymous_access on every image before you hand a url to anybody. Where it is “members_only”, fetch with Authorization: Bearer <your DFL Supabase user JWT> — the same token you used to call this tool; media-redirect accepts a bearer token precisely so an agent that cannot hold a browser cookie can read these. Where it is “open”, the url needs no session at all and can go straight to an external designer. A capture’s own screens_with_shot counts screens that carry a screenshot_digest, which is NOT the same as screens whose image can be fetched: a screen can be photographed and hashed with its PNG never uploaded. count below is the number of images that actually have a url. Where the two disagree, the difference is listed in screens_without_image. Reads are granted to authenticated and refused to anon, so an empty result from a signed-out caller means you were refused, not that the app has no captures.
| Parameter | Type | Required | Description |
|---|---|---|---|
app | string | yes | The application: its ux-map slug (“dfl-learn”, “itera-player”) or its repository as “<owner>/<repo>” (“devfellowship/dfl-learn”). The slug is tried first. |
role | string | no | Only the capture taken as this role, e.g. “superadmin”, “anonymous”. Omit to get every role of the latest build, which is what “all the images” usually means. |
capture_id | string | no | Pin one capture by id and skip the latest-build resolution entirely. Use it to read a HISTORICAL capture; without it this tool always answers about the newest build. |
viewport | enum | no | Which viewport of the build to return. Defaults to desktop. “both” returns desktop and mobile side by side, each image labelled. Every capture indexed before the mobile pass resolves to desktop, so the default changes nothing today. Tolerated before the viewport column exists: a mobile capture is addressed as the role “<role>@mobile” until then, and that spelling is read correctly. One of: desktop, mobile, both. |
format | enum | no | urls (default): the list, with anonymous_access measured per url and no image downloaded. zip: the images are fetched here with your token, archived with a manifest.json, and uploaded as ONE media object whose visibility follows the content — private when every screenshot inside already opened anonymously, members otherwise. Above the size or count cap, zip falls back to urls and says why. One of: urls, zip. |
create_sheet
Section titled “create_sheet”Create Sheet
THE way to make a spreadsheet anywhere in DFL. When you are asked for a budget, a rubric table, a cost breakdown, a matrix or any grid of numbers in a plan, a proposal, an ADR or a handoff document, call this INSTEAD OF writing a markdown table into the body. It persists a row in work.sheets plus one row per tab in work.sheet_tabs — the store behind the dfl-sheets app at sheets.devfellowship.com/sheets/<id> — and returns the sheet with its tabs, whose id (a uuid) is what every other DFL surface references. A markdown table is dead text: nobody can edit it in a grid, sum a column of it, version it or fill it in as a template. TO PUT A SHEET IN A PLAN, do not hand-write the token: call the plans MCP (plans.mcp.devfellowship.com) attach_entity with the plan slug, type “sheet” and this uuid as the locator. It inserts {{dfl-entity:sheet:<uuid>}}, which the plans-app resolves at render time, so later edits to the sheet reach the plan with no new plan version. A sheet is a HEADER plus TABS, and one call writes both. Each tab carries columns (ordered, each with a stable key) and rows (ordered, each a bag of cells keyed by COLUMN KEY). A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. A formula you write here is an A1 formula, so its addresses follow the same convention the editor shows. A1 addressing follows the grid a human sees: ROW 1 IS THE HEADER (the column labels), so A1 row 2 is rows[0], the first data row. Column letters are positional — B is the second column of the tab, whatever its key. Ranges accept a cell (B2), a block (B2:D10), whole columns (B:D) or whole rows (2:10); lowercase and $ anchors are fine, and a reversed range reads the same as a forward one. A mixed range such as B2:D is REFUSED rather than guessed at. Rows are semi-structured: a cell key that no column declares is STORED, not rejected. It is also invisible in the editor, which renders by column. Any such key is named back in unmapped_cell_keys on the result — treat it as a typo in the column key until proven otherwise, and add the column if the data is real. Send no tabs and the sheet gets one empty tab named Sheet1, because a sheet with no tab opens blank in the editor. Use kind “budget” for money, “template” for a blank form somebody else fills in, and “table” for everything else. template_of points an instance back at the template it came from. entity_id / entity_name file the sheet under a work entity (an epic, a project, a plan slug) and are what list_sheets filters on and what the plans-app sidebar groups by. Unlike a diagram, a sheet with no entity is NOT hidden from the group — sheets are read by membership (ADR-3 of plan 20260909-dfl-sheets-primitive). This tool writes as YOU: the row carries your user id and passes RLS under your own JWT. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | yes | Sheet name, as a human will look for it. |
description | string | no | What this sheet is for, and where its numbers came from. |
kind | enum | no | One of: table, budget, template. “budget” for money, “template” for a blank form to be filled in, “table” for anything else. Defaults to table. One of: table, budget, template. |
entity_id | string | no | Optional work entity this sheet belongs to — an epic id, a project id, a plan slug. Stored verbatim; nothing here validates it, so pass an id you actually resolved. |
entity_name | string | no | Human name of that entity, denormalised for display. |
template_of | string | no | uuid of the template sheet this one was made from. Set it on an instance, not on the template. |
tabs | object[] | no | Ordered tabs, each {name, sort_order?, columns?, rows?, meta?}. Defaults to one empty tab named Sheet1. Rows are positional here — you give the whole list, so nothing is addressed by A1. |
get_sheet
Section titled “get_sheet”Get Sheet
Read one sheet by id: the header plus every tab, each with its full columns and rows. Use it to see what a {{dfl-entity:sheet:<uuid>}} token in a plan actually points at, and ALWAYS to inspect the current content before you write — write_sheet_cells addresses rows by index or by id, and both come from here. A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. Pass tab to read one tab by name (case-insensitive) instead of all of them. Pass range to slice that tab down to an A1 block, which is how you read a corner of a large budget without pulling the whole thing through the context window. A1 addressing follows the grid a human sees: ROW 1 IS THE HEADER (the column labels), so A1 row 2 is rows[0], the first data row. Column letters are positional — B is the second column of the tab, whatever its key. Ranges accept a cell (B2), a block (B2:D10), whole columns (B:D) or whole rows (2:10); lowercase and $ anchors are fine, and a reversed range reads the same as a forward one. A mixed range such as B2:D is REFUSED rather than guessed at. A range is scoped to a single tab, so range goes together with tab. The result echoes range_applied and each row keeps its original data-row index in row_indexes, so an index you read from a slice still addresses the right row in a write. Set include_versions to list the snapshot history — version numbers, ids, commit messages and dates, newest first, WITHOUT the snapshot payloads, so the response stays small. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | yes | The uuid of the sheet (work.sheets.id). |
tab | string | no | Read one tab by name, case-insensitive. Omit to get every tab of the sheet. |
range | string | no | A1 slice of that one tab: “B2:D10”, “B2”, whole columns “B:D” or whole rows “2:10”. Row 1 is the header, so data starts at row 2. Give tab as well; a range spans one tab. |
include_versions | boolean | no | List the snapshot history of this sheet (newest 50 first, no payloads). Defaults to false. |
list_sheets
Section titled “list_sheets”List Sheets
Find an EXISTING sheet and, above all, its uuid — the id you need to reference it from a plan (plans MCP attach_entity, or the {{dfl-entity:sheet:<uuid>}} token), to read it with get_sheet, or to write it. Filter by entity_id for every sheet of one work entity, by kind, or by search over the name. Run this BEFORE create_sheet when a sheet of the same thing may already exist: a duplicate splits the references and the two copies then drift apart. Filter by kind “template” to find the blank forms — a template is what you copy to answer a call for proposals, rather than something to fill in directly. The listing is a header listing: it carries no columns and no rows, so it stays small for a sheet with thousands of cells. Call get_sheet for the content. Results come back under your own RLS, ordered by work.sheets.updated_at, newest first. The two writers here bump that column when they change a tab, so the order reflects the last CONTENT change and not only the last rename. An edit made in the app writes the tab directly and does not bump it. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
entity_id | string | no | Filter to one work entity — the epic id, project id or plan slug a sheet was filed under. |
kind | enum | no | Filter by kind: table, budget, template. One of: table, budget, template. |
search | string | no | Case-insensitive substring match on the sheet name. |
limit | number | no | How many to return. Defaults to 50, capped at 100. |
offset | number | no | How many to skip, for pagination. |
replace_sheet_tab
Section titled “replace_sheet_tab”Replace Sheet Tab
Replace the ENTIRE contents of one tab — its columns and all of its rows — in a single atomic write. Use it when you generate or regenerate a whole table: a budget laid out from a rubric, a matrix rebuilt from a source document, a template being drafted. THIS IS DESTRUCTIVE BY DESIGN. Whatever the tab held and you did not send is gone, including rows a human edited in the app since you last read it. To change a few cells and keep the rest, use write_sheet_cells, which merges. To be sure of what you are about to overwrite, call get_sheet first. Because of that, a whole-sheet snapshot is written to work.sheet_versions BEFORE the replace, unless you turn snapshot off. The snapshot covers EVERY tab, not only this one, so the state it records is a state somebody can actually go back to. Give a commit_message saying what changed and why; it is what makes the history readable later. Row ids are preserved when you send them and generated when you do not. Sending the ids you read from get_sheet is what keeps per-row references (and a reader’s scroll position) stable across a regeneration. A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. Rows are semi-structured: a cell key that no column declares is STORED, not rejected. It is also invisible in the editor, which renders by column. Any such key is named back in unmapped_cell_keys on the result — treat it as a typo in the column key until proven otherwise, and add the column if the data is real. Column keys must be unique within the tab, and the tab must already exist — this tool never creates one, because a typo in a tab name would otherwise silently make a second tab and leave the real one untouched. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
sheet_id | string | yes | The uuid of the sheet (work.sheets.id). |
tab | string | yes | Name of the tab to replace, case-insensitive. It must already exist. |
columns | object[] | yes | The complete ordered column list for the tab. Keys must be unique; a cell is stored under its key. |
rows | object[] | yes | The complete ordered row list. Keep the ids you read from get_sheet to keep row identity stable. |
meta | object | no | Tab-level hints (freeform, frozen, notes). Left as it is when omitted. |
snapshot | boolean | no | Write a whole-sheet version row before replacing. Defaults to true. Set it false only for a tab nobody has seen yet. |
commit_message | string | no | What this replacement changed, stored on the version row. |
write_sheet_cells
Section titled “write_sheet_cells”Write Sheet Cells
Write individual cells into one tab, MERGING with what is already there. This is the tool for filling in a template, correcting a few numbers, or adding a row to a budget. Every cell you do not name is left exactly as it was, and so is every field of a cell you do name but only partly write. Use replace_sheet_tab instead when you are regenerating a whole table. ADDRESSING. Each entry is {row, col, value?, formula?}. row is either a 0-BASED DATA ROW INDEX (a number: 0 is the first data row) or a ROW ID (a string, as returned by get_sheet). col is either a column KEY or an A1 column letter; a key is matched first, so a column genuinely keyed “A” is still reachable by its key. A1 addressing follows the grid a human sees: ROW 1 IS THE HEADER (the column labels), so A1 row 2 is rows[0], the first data row. Column letters are positional — B is the second column of the tab, whatever its key. Ranges accept a cell (B2), a block (B2:D10), whole columns (B:D) or whole rows (2:10); lowercase and $ anchors are fine, and a reversed range reads the same as a forward one. A mixed range such as B2:D is REFUSED rather than guessed at. A MISSING ROW IS CREATED. An unknown row id creates a row under that id, which makes a retry of the same call idempotent instead of appending a second copy. A numeric index past the end appends empty rows up to it, so the index means what it says. A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. Send value to set the value; send formula to set the formula; send both to store a formula together with its computed result. A value on its own CLEARS any formula that cell held, the way typing a number over a formula does in a spreadsheet, so the stored value and the stored formula never disagree. Pass value: null to empty a cell. THE BATCH IS ALL-OR-NOTHING: one bad address refuses the whole call and the database is not touched, so a partly-applied batch can never be left behind. The result names, for each write, the row id, the row index and the A1 address it landed on — read it back to confirm you addressed what you meant. Give a commit_message to also record a whole-sheet version before the write. Without one no version row is written, because a snapshot per cell edit would bury the history. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
sheet_id | string | yes | The uuid of the sheet (work.sheets.id). |
tab | string | yes | Name of the tab to write into, case-insensitive. It must already exist. |
cells | object[] | yes | The cells to write. Each needs at least one of value or formula. |
commit_message | string | no | Record a whole-sheet version before this write, with this message. No message, no version row. |
snapshot_sheet_version
Section titled “snapshot_sheet_version”Snapshot Sheet Version
Save the CURRENT state of a sheet — the header plus EVERY tab, with all their columns and rows — as a new row in work.sheet_versions, so this state stays recoverable through get_sheet_version. Use it to checkpoint a state nobody is about to change: the budget an ADR was decided against, a template before a person starts editing it. replace_sheet_tab and write_sheet_cells already snapshot on the way past, so call this one when you want the checkpoint WITHOUT also writing to the sheet right now. The snapshot covers every tab, not one tab, because a budget is read across its tabs and a one-tab checkpoint is not a state anybody can go back to. version_number is MAX(version_number) + 1 for the sheet. Two writers can compute the same next number at the same moment, so the UNIQUE (sheet_id, version_number) collision is retried against a recomputed maximum instead of failing opaquely. Give a commit_message saying what this state IS; it is the only thing that makes the history readable later. The history is APPEND-ONLY: work.sheet_versions grants SELECT and INSERT under RLS and nothing else, so there is no tool to edit or delete a version, on purpose. Writes run as you, under your own RLS. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
sheet_id | string | yes | The uuid of the sheet to snapshot (work.sheets.id). |
commit_message | string | no | What this checkpoint is, stored on the version row. Say what the state MEANS, not that it is a snapshot. |
list_sheet_versions
Section titled “list_sheet_versions”List Sheet Versions
List the snapshot history of one sheet from work.sheet_versions, ordered by version_number DESC — NEWEST FIRST. Each row carries its version_number, its commit_message, who wrote it and when. It deliberately omits the snapshot payload: a sheet version holds EVERY tab of the sheet, so a page of them would be a whole spreadsheet many times over in your context. Read the one you want with get_sheet_version, which takes the version_number you read here. Paginate with limit (default 50, max 100) and offset; the result carries total and has_more. get_sheet with include_versions gives the same list beside the sheet content — use this tool when you want the history alone, or when you need to page past the newest 50. Reads run under your own RLS, so a sheet you cannot see returns an empty history rather than an error. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
sheet_id | string | yes | The uuid of the sheet whose history to list (work.sheets.id). |
limit | number | no | How many versions to return, newest first. Defaults to 50, capped at 100. |
offset | number | no | How many versions to skip, for paging into older history. Defaults to 0. |
get_sheet_version
Section titled “get_sheet_version”Get Sheet Version
Read ONE stored snapshot of a sheet back, by sheet_id + version_number. It returns the payload as it was stored — the header plus EVERY tab, with all their columns and rows — which is what makes a prior state recoverable, as opposed to list_sheet_versions, which only indexes the history. Get the version_number from list_sheet_versions, or from get_sheet with include_versions. Pass tab to read one tab of the snapshot by name, case-insensitive. A whole-sheet payload is large, so slice it when you know which tab you want. A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. TO RESTORE this state, read it here and write it back with replace_sheet_tab, one tab at a time. Nothing here restores anything on its own: the history is APPEND-ONLY — work.sheet_versions grants SELECT and INSERT under RLS and nothing else — and a restore is a normal write that leaves its own snapshot behind, so the state you are replacing does not disappear. There is no tool to edit or delete a version, on purpose. Reads run under your own RLS, so “missing” and “not visible to you” are the same answer. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
sheet_id | string | yes | The uuid of the sheet (work.sheets.id). |
version_number | number | yes | Which version to read. Read it from list_sheet_versions; version 1 is the oldest. |
tab | string | no | Return only this tab of the snapshot, by name, case-insensitive. Omit to get every tab. |
copy_sheet
Section titled “copy_sheet”Copy Sheet
Duplicate an existing sheet under a new name — the way a TEMPLATE becomes an INSTANCE. This is the tool for answering a call for proposals whose edital carries a budget template: copy the template, then fill the COPY in with write_sheet_cells. The source is never written, so the next submission starts from the same blank form. Use list_sheets with kind “template” to find one. Everything is copied: every tab, in order, with its columns, its meta and all of its rows — and the ROW IDS are preserved, so a reference to a row of the template still resolves in the copy and a later diff against the template lines up. Three things differ on purpose. kind becomes “table” unless you pass one, because a copy of a template is an instance rather than another template. template_of is set to the SOURCE id, which is how the instance is traced back to the form it came from. And entity_id / entity_name are inherited from the source only when you pass neither — pass the submission, the epic or the plan slug this copy belongs to, or it stays filed under the template. CLEAR_VALUES makes a BLANK FORM out of a filled one. It removes v from every cell whose column declares no formula, and keeps everything else: the columns, the row ids, every cell formula, and the t / fmt of every cell. A column that HAS a formula keeps its values, because those cells are computed rather than typed and the editor recomputes them. Be careful with it: in a template whose first column holds the rubric NAMES, those names are part of the form and clear_values removes them too. Copy without the flag and overwrite the numbers instead. A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. TO PUT A SHEET IN A PLAN, do not hand-write the token: call the plans MCP (plans.mcp.devfellowship.com) attach_entity with the plan slug, type “sheet” and this uuid as the locator. It inserts {{dfl-entity:sheet:<uuid>}}, which the plans-app resolves at render time, so later edits to the sheet reach the plan with no new plan version. The copy is written as YOU: its rows carry your user id and pass RLS under your own JWT. The source is read under your RLS too, so a source you cannot see reads as missing. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
source_id | string | yes | The uuid of the sheet to copy (work.sheets.id). Usually a kind “template” sheet. |
name | string | yes | Name of the COPY. Say what it is an instance of — “Orçamento FINEP — submissão 2026”. |
description | string | no | What this copy is for. Defaults to the source’s description. |
kind | enum | no | One of: table, budget, template. Defaults to “table” — a copy of a template is an instance. Pass “template” only when you are deliberately making a second template. One of: table, budget, template. |
entity_id | string | no | The work entity the COPY belongs to — the submission, epic or plan slug. Inherited from the source when you pass neither this nor entity_name. |
entity_name | string | no | Human name of that entity, denormalised for display. |
clear_values | boolean | no | Blank the inputs, making a blank FORM out of a filled sheet. Removes v from every cell whose column has no formula; keeps the columns, the row ids, every cell formula and every t / fmt. Defaults to false, which copies the values as they are. |
import_sheet
Section titled “import_sheet”Import Sheet
Turn a csv, a tsv, a markdown table or an .xlsx workbook you already have into a real DFL sheet. Use it when a person sends you a spreadsheet, when an edital carries a budget table, or when you have generated a table as text and want it to become something a human can edit at sheets.devfellowship.com and a plan can embed. Do NOT paste the table into a plan body as markdown: that is dead text nobody can sum, version or fill in. TO PUT A SHEET IN A PLAN, do not hand-write the token: call the plans MCP (plans.mcp.devfellowship.com) attach_entity with the plan slug, type “sheet” and this uuid as the locator. It inserts {{dfl-entity:sheet:<uuid>}}, which the plans-app resolves at render time, so later edits to the sheet reach the plan with no new plan version. THE FIRST ROW IS THE HEADER. Every column KEY is minted from it — accents folded, lower case, a single ”_” between words, so “Valor unitário” becomes valor_unitario. The result returns key_map, header text to key, because those keys are what write_sheet_cells addresses afterwards. A file whose first row is data imports that data as the column names, and the sheet still looks plausible — check key_map before you write. FORMATS. “csv” and “tsv” follow RFC 4180: a quoted field keeps its delimiters, its newlines and its "" escapes. “markdown” is a GFM pipe table — the ”| --- |” separator line is ignored, an escaped ”\|” stays a pipe inside the cell, and prose around the table is skipped. “xlsx_b64” is the base64 of an .xlsx file, and gives ONE TAB PER WORKSHEET. VALUES ARE INFERRED. A field that reads exactly as a number becomes a number; “true” / “false” become booleans; a field starting with ”=” becomes a FORMULA. Everything else stays text, on purpose: “007” and “1,234” keep their leading zero and their separator rather than being silently reshaped. An .xlsx formula cell arrives as its formula plus its cached value. A cell is {v, f?, t?, fmt?}: v is the VALUE, f the FORMULA that produced it, t an optional per-cell type override and fmt an optional display format. A cell may hold both f and v — the formula plus its last computed value — so a reader sees a number without evaluating anything. Nothing here evaluates a formula; the editor does that. WHAT A FILE CANNOT CARRY is the DFL column type. Types are inferred per column (number, boolean, formula, else text), so “currency”, “percent” and “date” do NOT survive an import. Set them afterwards with replace_sheet_tab when the display matters. export_sheet is the inverse, and an xlsx written by it re-imports here into the same columns and the same values. The sheet is written as YOU: its rows carry your user id and pass RLS under your own JWT. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | yes | Name of the sheet to create, as a human will look for it. |
format | enum | yes | How to read content: “csv” (comma, RFC 4180), “tsv” (tab), “markdown” (a GFM pipe table), or “xlsx_b64” (the base64 of an .xlsx file — one tab per worksheet). One of: csv, tsv, markdown, xlsx_b64. |
content | string | yes | The table itself. Text for csv / tsv / markdown; base64 for xlsx_b64. The FIRST row is the header. |
description | string | no | What this sheet is for, and where the file came from. Worth writing — an import loses its source otherwise. |
kind | enum | no | One of: table, budget, template. Defaults to table. One of: table, budget, template. |
entity_id | string | no | Optional work entity to file this sheet under — an epic id, a project id, a plan slug. |
entity_name | string | no | Human name of that entity, denormalised for display. |
tab_name | string | no | Name of the single tab created from csv / tsv / markdown. Defaults to Sheet1. Ignored for xlsx_b64, where each worksheet keeps its own name. |
export_sheet
Section titled “export_sheet”Export Sheet
Read a sheet out in a portable format: “csv”, “markdown”, “json” or “xlsx”. Use it to hand a budget to somebody outside DFL, to paste a small table into a message or a PR body, to feed the rows to code as objects, or to attach a real .xlsx to a submission. This is the inverse of import_sheet, and an xlsx export re-imports into the same columns and the same values. To read a sheet in order to WRITE it, use get_sheet instead — it returns the row ids and the cell objects that write_sheet_cells addresses, which none of these formats carries. FORMULAS RENDER AS THEIR VALUES in csv, markdown and json, because a cell holds the formula AND its last computed value and the value is the number a reader wants. A cell that has a formula and has never been computed falls back to the formula text (“=B2*C2”). “xlsx” is the exception: an xlsx cell holds both, so the file carries a real formula plus its cached result and opens as a working spreadsheet. Nothing here evaluates anything. SCOPE. “csv” and “markdown” render ONE tab — pass tab, or omit it when the sheet has only one. “json” and “xlsx” cover EVERY tab when you omit tab: json returns one array of objects per tab, xlsx one worksheet per tab. “markdown” is a GFM pipe table with every ”|” inside a cell escaped, so the table survives being pasted into a plan or a PR body. “json” gives one object per row keyed by COLUMN KEY, plus _row_id — which is the id write_sheet_cells addresses rows by. “xlsx” comes back as BASE64 in content_base64; nothing here writes a file, so decode it where you need it. The sheet is read under your own RLS, so “missing” and “not visible to you” are the same answer. The tables work.sheets / work.sheet_tabs / work.sheet_versions are applied in production (devfellowship/dfl-schema #955, 2026-09-09). There is no fallback path here: a database error comes back verbatim, so a call that cannot write says so and does NOT silently succeed.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | yes | The uuid of the sheet (work.sheets.id). |
format | enum | yes | What to produce: “csv” (comma, RFC 4180 quoting), “markdown” (a GFM pipe table), “json” (one object per row, keyed by column key) or “xlsx” (a real workbook, returned base64). One of: csv, markdown, json, xlsx. |
tab | string | no | Which tab, case-insensitive. Required for csv and markdown when the sheet has more than one tab. Omit for json or xlsx to cover every tab. |