Engineering
Spec runs, task assignment, diagrams, documents, sheets, UX maps and plan links.
One MCP server, dfl-mcp-engineering, serves seven tool groups. Add the host once and your client gets all seven.
| Endpoint | https://engineering.mcp.devfellowship.com/mcp |
|---|---|
| Tools | 53 in 7 groups |
| Package | packages/dfl-mcp-engineering |
| Auth |
Your dfl-auth login token as Authorization: Bearer <token>. Every call runs as
you, under RLS. See Auth & security.
|
{ "mcpServers": { "dfl-engineering": { "type": "http", "url": "https://engineering.mcp.devfellowship.com/mcp", "headers": { "Authorization": "Bearer YOUR_ACCESS_TOKEN" } } }}Other clients (Cursor, VS Code, codex, the Anthropic SDK): see Getting started, step 3.
Spec builder
Section titled “Spec builder”Turn spec prose into a spec run: a first-class, versioned, linkable entity holding candidate tasks with points, review status and a stable per-item short_ref. A run has its own URL (spec-builder.devfellowship.com/history/<run_id>), can be referenced from a plan body as {{dfl-entity:spec_run:<run_id>}}, and reaches the real backlog only through an explicit promotion.
| Backing data | work.ai_spec_inputs (the run), work.ai_spec_tasks (its items), work.spec_run_versions (its version snapshots), work.comments (entity_name spec_run / spec_run_item), work.entity_connections (the plan binding), work.tasks (only on promotion). Calls the dfl-ai-spec-builder-n8n-proxy Edge Function for generation — despite the name, no n8n is involved. |
The two version axes
Section titled “The two version axes”A run carries two independent counters, and they must never be merged into one:
Run version (current_version) | Comment version (comment_version) | |
|---|---|---|
| Models | the pack’s content | the conversation about it |
| Bumped by | update_spec_run_items, regenerate_spec_run, promote_spec_run_items | only handoff_spec_run_comments |
| Not bumped by | any comment | any content edit |
| Pinnable | yes — attach_entity({ mode: "pinned", rev: <spec_run_versions.id> }) | no; a cursor, not an artifact |
A quote is derived from a run version’s stored points_total, so pin the run whenever a plan cites its points — an unpinned reference means the number moves the moment anyone edits an item. If a comment bumped the run version, a quote would move because somebody asked a question.
Item durability
Section titled “Item durability”Items keep their id and their short_ref across every re-generation. regenerate_spec_run therefore takes an operation list, never a fresh task list:
split(item_id, new_items)CONTINUESitem_id— same row, same id, sameshort_ref, points untouched — and only birthsnew_items, each withlineage_ref,lineage_kind: "split_from"andpoints_needs_review: true. A human point is never divided, copied or re-estimated across a split.merge(from_ids, into_id)KEEPSinto_idand soft-removesfrom_idswithlineage_kind: "merged_into".- Removal is always soft, so an older pinned version keeps resolving and an already-sent
short_refnever dangles. - An
updatetouching a human-setestimated_pointsis rejected unless the item is named inrepoint. The whole call is refused rather than the field silently dropped.
Re-running the generator
Section titled “Re-running the generator”regenerate_spec_run has two paths, chosen by whether you pass operations:
operations omitted | operations passed | |
|---|---|---|
| What happens | the generator runs over spec (or the prose already stored on the run) | your operation list is applied |
| Items | untouched | reconciled |
| Run version | not cut | one version cut, commit_message required |
| Returns | outcome: "generated_proposal" — the fresh pack plus proposed_operations, a complete valid operation list keyed to the live ids | the reconciliation report |
spec is the only way to update a run’s stored prose (work.ai_spec_inputs.text), and it works on both paths. On the apply path it rides inside the same all-or-nothing envelope as the items: a rejected operation list leaves the prose exactly as it was.
The generator path deliberately proposes instead of applying. The generator emits tasks with no ids, so writing them against existing rows could only guess which live item each one is — or replace the set and destroy the short_refs that already-sent comment batches point at. Edit proposed_operations into the update / split / merge / remove operations you actually mean and send it back through the apply path.
Binding a run to a plan
Section titled “Binding a run to a plan”A run stores no 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 the index the plans-app derives from it. So after create_spec_run, call attach_entity on the Plans MCP with { slug, type: "spec_run", locator: <run_id> } — one link per run, never one per task.
Spec builder tools
Section titled “Spec builder tools”Turn a spec into a pack of candidate tasks with points, review and approve them, and promote the approved ones to the real backlog.
generate_tasks
⚠️ DEPRECATED — prefer create_spec_run followed by promote_spec_run_items.
generate_tasksGenerate 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
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.
create_spec_runCreate 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
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.
get_spec_runGet 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
Find spec runs and get the run_id every other spec-run tool needs.
list_spec_runsList 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
Apply HUMAN edits to the items of a spec run — rename, re-describe, re-point, re-stage, re-tag, approve or reject.
update_spec_run_itemsUpdate 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
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.
set_spec_run_points_provenanceSet 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
Price one or more spec-run items with the points_v2 decision table, and record the provenance of every number it produces.
score_spec_run_itemsScore 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
TWO PATHS, chosen by whether you pass operations.
regenerate_spec_runRegenerate 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
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).
promote_spec_run_itemsPromote 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
Add a review comment to a whole spec run, or to ONE item of it, into the currently OPEN hand-off batch.
comment_spec_runComment 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
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.
handoff_spec_run_commentsHand 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
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…
delete_spec_runDelete 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
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.
list_spec_run_packagesList 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
Create or rename ONE scope layer of a spec run, keyed by (run_id, layer).
upsert_spec_run_packageCreate 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
Assign items to scope layers in ONE batch.
assign_spec_run_layersAssign 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
Delete a scope layer. REFUSED while any item still points at it — reassign those items first with assign_spec_run_layers.
delete_spec_run_packageDelete 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
List the feature groups of a spec run with their item count and point total, PLUS the items that are outside every group.
list_spec_run_feature_groupsList 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
Create or rename one feature group of a run, keyed by (run_id, code).
upsert_spec_run_feature_groupCreate 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
Assign items to feature groups in ONE batch.
assign_spec_run_feature_groupsAssign 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
Delete a feature group. REFUSED while any item still points at it.
delete_spec_run_feature_groupDelete 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. |
Task assigner
Section titled “Task assigner”Suggest and assign the best-matched developer for a work.tasks row, via the deterministic-scoring +
optional AI re-rank Edge Function already used by the dfl-task-assigner frontend.
| Backing data | work.tasks (read + owner_id write), read of work.epics/work.projects/business_units for matching context. |
How matching works
Section titled “How matching works”assign_developer resolves the task’s context server-side — work.tasks.epic_id → work.epics.project_id →
work.projects (name + business_unit_id) → business_units.name — then POSTs { task, context } to the
dfl-ai-task-assigner-matching-task Edge Function using the calling user’s own JWT (same “golden rule” as every
other tool in this package: never service_role). The Edge Function scores candidates deterministically
(technology overlap, completed task count, average rating) and optionally re-ranks with an LLM, returning
{ developerId, deterministicScore, completedTasks }[] sorted by score.
The tool always assigns the top-scored candidate — there is no “suggest only” mode. If you need a different
developer, call assign_developer to see the ranked candidates, then correct work.tasks.owner_id manually
if the auto-pick isn’t right for this task.
Task assigner tools
Section titled “Task assigner tools”Find the best-matched developer for a task and assign it.
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).
assign_developerAssign 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. |
Diagrams
Section titled “Diagrams”Create, update and read diagrams (flowchart/ERD/sequence) in public.diagrams, optionally linked to a work.epics entity via entity_id — a diagram can also stand alone, at a visibility cost.
| Backing data | public.diagrams, public.diagram_versions, read of work.epics for entity resolution. |
Putting a diagram in a plan or a document
Section titled “Putting a diagram in a plan or a document”The diagram is stored once and embedded by reference everywhere else. The token is:
{{dfl-diagram:<uuid>}} ← short form{{dfl-entity:diagram:<uuid>}} ← long form, identical meaningThe consuming app resolves the token when it renders, which is the whole point: editing the diagram reaches every plan and document that points at it, and creates no new plan version.
In a plan — do not hand-write the token. Use the plans MCP
(https://plans.mcp.devfellowship.com/mcp):
create_diagramhere → take theidfrom the response.attach_entitythere, with the planslug,type: "diagram"and that uuid aslocator.
To pin the embed to a fixed state (inside an ADR or a decision block), pass rev as well —
a public.diagram_versions id from list_diagram_versions. mode: "pinned" without a rev cannot be honoured and renders live content under a badge.
In a document — put the diagram sentinel
({{dfl-diagram:00000000-0000-0000-0000-000000000000}}) in the template or the literal body
and pass diagram_entity_id to write_document; every diagram of that entity is injected as
a real token. write_handoff_document does the same automatically for the handoff category.
Anywhere else (a GitHub PR body, a README, a slide) nothing resolves DFL tokens, so that
is what export_diagram is for.
Exporting a diagram
Section titled “Exporting a diagram”export_diagram is the way to get a diagram out of the system — as a picture for a client
deck, or as text for a PR body. It renders from the diagram’s persisted position/measured
values, so the output matches the layout a user sees in the app rather than re-running layout.
Supported format values:
- svg (default) — standalone vector image,
image/svg+xml - mermaid — Markdown-fenced Mermaid source,
text/vnd.mermaid - plantuml —
@startuml … @enduml,text/vnd.plantuml
Options: theme (dark, the default, matches the app canvas — or light for print/docs) and
scale (SVG only, default 2).
The response carries the artifact plus filename, mime_type, and node_count/edge_count
so you can sanity-check that nothing was dropped:
{ "id": "39b0e55f-…", "name": "RMT CRM — Domain Data Model", "diagram_type": "erd", "format": "svg", "mime_type": "image/svg+xml", "filename": "rmt-crm-domain-data-model.svg", "node_count": 25, "edge_count": 28, "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" …"}Why the image format is SVG, not PNG
Section titled “Why the image format is SVG, not PNG”Rasterising needs a DOM or a native rasteriser, and this package runs headless in Node with neither. That constraint happens to point at the better artifact anyway: an export tool cannot know what resolution its caller needs, and a PNG that came out too small to read is the exact failure this tool exists to avoid. SVG is vector, so there is no resolution to get wrong, and it is text, so it travels through MCP without base64 bloat.
scale only feeds the root width/height attributes, so a naive svg → png conversion is
already hi-dpi. The viewBox never changes — scale cannot crop or reflow the diagram.
# rasterise at whatever DPI you actually needrsvg-convert -d 192 -p 192 diagram.svg -o diagram.pngexport_diagram dispatches on node.type rather than on diagram type, so erd, flowchart
and sequence all render through one path, and an unrecognised node type degrades to a
labelled box instead of disappearing.
Node labels are capped at 60 characters
Section titled “Node labels are capped at 60 characters”Both write tools (create_diagram and update_diagram) reject — never truncate — any node whose
data.label (or data.name, for ERD entities and sequence lifelines) is longer than 60 characters.
The rejection names every offending node id and its length, writes nothing to the database, and tells you
where the text belongs instead:
{ "error": "1 node label(s) exceed the 60-character limit: q6 (data.label, 214 chars: \"OPEN QUESTION 6 - customer ID rules are u…\"). A node label is a box on a canvas, not a sentence — keep it a short noun phrase (<= 60 chars) and move the prose to that node's \"data.description\", which has no length limit and is surfaced as node detail. Nothing was written and nothing was truncated: resend the diagram with shortened labels."}Where prose goes: data.description. It has no length limit and is rendered as node detail rather than
as the box label, so a requirement paragraph, an open question or a design rationale is preserved verbatim
and stays attached to its node:
{ "id": "q6", "type": "data", "position": { "x": 0, "y": 0 }, "data": { "label": "Open question 6: customer ID", "description": "Customer ID rules are undefined. Sequential per tenant or global, which email when the contact has several, what padding, and what happens when the email local part is shorter than five characters." }}The cap applies to the mermaid_text path too, because Mermaid node text becomes data.label on import
(Q6[some very long sentence]) — that is precisely how the unreadable diagrams of 2026-08 were produced.
Shorten the label inside the Mermaid source and add the prose via data.description on a follow-up
update_diagram call with explicit nodes.
Why 60. The auto-layout bounds a node box at 280px wide; at the canvas font size that fits roughly 36
characters per line, so 60 characters wraps to at most two lines and stays inside the box the layout engine
actually reserved for it. Longer labels render a box several times wider than the space dagre allocated,
and the stored positions collide on open. estimateNodeSize() now derives width (bounded) and height
(wrapped-line count) from the real label instead of returning a constant 180×60, so generated positions
are honest even for pre-existing rows being re-laid-out.
Rejection is deliberate over truncation: truncating a label silently destroys a client requirement that the author meant to record.
Importing from Mermaid text
Section titled “Importing from Mermaid text”Pass mermaid_text instead of type/nodes/edges to create a diagram directly from Mermaid source
(flowchart, erDiagram, or sequenceDiagram — optionally fenced in ```mermaid blocks). The type is
auto-detected and nodes are auto-laid-out (via @dagrejs/dagre, ported from dfl-diagrams/src/lib/mermaid/);
any explicit nodes/edges passed alongside mermaid_text are ignored. PlantUML import is not supported
yet — only Mermaid.
{ "epic_id": "...", "name": "Checkout flow", "mermaid_text": "flowchart TD\nA[Start] --> B{Payment OK?}\nB -->|Yes| C[Confirm]\nB -->|No| D[Retry]"}Malformed mermaid_text returns a structured {"error": "Failed to parse mermaid_text: ..."} response
(not a thrown error) and never touches the database.
Updating a diagram
Section titled “Updating a diagram”update_diagram edits an existing row by id. Every field except id is optional and omitted fields are
left untouched; nodes/edges are a full replacement, not a merge.
id—string, required.public.diagrams.id(UUID).name—string, optional. New diagram name.description—string, optional. New diagram-level description (thepublic.diagrams.descriptioncolumn — distinct from a node’sdata.description).type—"flowchart" | "erd" | "sequence", optional. Ignored whenmermaid_textis given.nodes—object[], optional. Replacement nodes, native@xyflow/reactshape. Ignored whenmermaid_textis given.edges—object[], optional. Replacement edges, native@xyflow/reactshape. Ignored whenmermaid_textis given.mermaid_text—string, optional. Replacestype+nodes+edgeswholesale from Mermaid source; type auto-detected and nodes auto-laid-out.auto_layout—boolean, optional (defaultfalse). Re-run thedagrelayout over the suppliednodes, overwriting their positions. Always applied on themermaid_textpath.
{ "id": "d8f56c71-…", "name": "Deal Pipeline", "auto_layout": true, "nodes": [ { "id": "q6", "type": "data", "position": { "x": 0, "y": 0 }, "data": { "label": "Open question 6: customer ID", "description": "…full paragraph…" } } ], "edges": []}Responses: {"success": true, "diagram": {…}} on success;
{"error": "Nothing to update: provide at least one of name, description, type, nodes, edges, mermaid_text"}
when no mutable field is passed; {"error": "Diagram not found or not owned by you: <id>"} when the row does
not exist or the owner-scoped RLS policy hides it from the caller (the two are indistinguishable by
design); and the node-label rejection above when a label is over the cap.
There is still no delete tool — a deliberate absence across DFL MCP packages, not a gap to fill.
Diagram revisions
Section titled “Diagram revisions”Before snapshot_diagram_version existed, update_diagram did a full replacement of nodes/edges with
no history — editing a diagram silently destroyed the previous state, with no recovery path. public.diagram_versions
closes that gap: each row is a point-in-time copy of a diagram’s nodes/edges, numbered per-diagram from 1.
snapshot_diagram_version—diagram_id(required) +commit_message(optional). Reads the diagram’s CURRENTnodes/edgesand inserts a newdiagram_versionsrow withversion_number = COALESCE(MAX(version_number), 0) + 1for that diagram. Retries a handful of times if two callers race to compute the same nextversion_number(theUNIQUE (diagram_id, version_number)constraint fires and the retry recomputesMAXrather than failing opaquely). Returns the created row’sid,version_number,commit_message,created_at, andnode_count/edge_count— not the full payload. Owner-scoped: the same{"error": "Diagram not found or not owned by you: <id>"}shape asupdate_diagramwhen the caller doesn’t own the parent diagram.list_diagram_versions—diagram_id(required) +limit/offset(optional, default 50/max 100). Returns version rows orderedversion_number DESC(newest first) withnode_count/edge_countinstead of the fullnodes/edges, so the response stays small even for a diagram with a long revision history.get_diagram_version— identify the revision by its ownid, or bydiagram_id+version_number. Returns the full storednodes/edgesfor that revision — this is the tool that makes a prior state actually recoverable, as opposed tolist_diagram_versions’s counts-only rows.
update_diagram snapshots by default
Section titled “update_diagram snapshots by default”update_diagram takes two new optional arguments:
snapshot—boolean, defaulttrue. When true, the diagram’s PRE-updatenodes/edgesare snapshotted intodiagram_versions(same insert path assnapshot_diagram_version) before the update is applied. If that snapshot fails for any reason, the update is aborted — nothing is written topublic.diagramseither. An update that cannot be rolled back is not an update, it is an overwrite; making the snapshot the default is what makes the revision mechanism real rather than optional.commit_message—string, optional. Attached to the pre-update snapshot (ignored whensnapshot: false). Defaults to"auto: pre-update snapshot (mcp)"when omitted — deliberately distinct from thedfl-diagramseditor’s own"auto: baseline"/"auto: checkpoint"conventions, so the history can tell an MCP-tool-driven revision apart from one written by a human in the editor.
Callers that genuinely don’t want a revision recorded (e.g. a high-frequency autosave path) pass
snapshot: false to skip it entirely — no snapshot is inserted and the update behaves exactly as before this
feature existed.
version_number is computed via the existing public.get_next_version_number(p_diagram_id) RPC — the same
one dfl-diagrams’ own editor checkpoint path uses (src/hooks/useDiagramVersions.ts) — rather than a
second, independent “what’s the next version” query. Both snapshot_diagram_version and update_diagram’s
auto-snapshot retry up to 3 times on the diagram_versions_diagram_id_version_number_key unique-violation
race, matching the editor’s own retry semantics for the same RPC.
// snapshot the current state before applying a change{ "id": "d8f56c71-…", "name": "Deal Pipeline v2", "commit_message": "Added retry step after payment decline" }
// skip the revision entirely{ "id": "d8f56c71-…", "name": "Deal Pipeline v2", "snapshot": false }To recover a prior state, read it back with get_diagram_version and re-apply it via update_diagram
(passing its nodes/edges — update_diagram’s own default snapshot: true means even that recovery
write is itself recorded as a new revision).
epic_id is optional — and what omitting it costs
Section titled “epic_id is optional — and what omitting it costs”create_diagram used to require epic_id. It no longer does, and the requirement was never a database
rule: public.diagrams has no epic_id column and never had one. It has a generic, nullable,
foreign-key-less entity_id (plus a denormalised entity_name). The requirement lived only in this tool’s
Zod schema, so a diagram whose subject was a plan — which has no epic — had no correct home and got parked
on whatever epic was nearest. This mirrors create_spec_run, which dropped the same requirement for the same
reason: the epic is a pipeline hint, not the identity of the artifact.
- Pass
epic_idto file the diagram under awork.epicsentity. A given id is still validated, so a non-epic id is still rejected. - Omit it for a stand-alone diagram. Reference it from a plan with the
{{dfl-diagram:<uuid>}}token — that token is the binding, and it resolves with no epic involved. - Never guess a UUID to fill the field. A wrong-but-valid one files the diagram under someone else’s epic.
The epic id is a work.epics.id — not a work.projects.id
Section titled “The epic id is a work.epics.id — not a work.projects.id”When you do pass it, create_diagram’s epic_id argument must be the id of a row in work.epics, resolved server-side via
.schema('work').from('epics').select('id, name').eq('id', epic_id). It is not the project id, even
though list_diagrams/get_diagram return the diagram’s entity_id alongside an entity_name that reads
like a project name (e.g. "devfellowship") — that is the epic’s project name, not the epic id itself.
Passing a work.projects.id here returns {"error": "Epic not found: <id>"}.
To find a real epic_id to test with, query the work domain instead (https://work.mcp.devfellowship.com/mcp,
tool list_epics or get_epic) — dfl-mcp-engineering has no epic-listing tool of its own.
Connecting manually (raw HTTP, no MCP client)
Section titled “Connecting manually (raw HTTP, no MCP client)”Useful for one-off testing (curl) without wiring up a full MCP client. The protocol is Streamable HTTP:
every call after initialize must repeat the Mcp-Session-Id header returned on the first response.
TOKEN="<seu Bearer token, de ~/.dfl-mcp/credentials.json>"
# 1. initialize — captura o Mcp-Session-Id do header de respostacurl -sS -D headers.txt -X POST "https://engineering.mcp.devfellowship.com/mcp" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer $TOKEN" \ --data-binary '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-test","version":"1.0"}}}'SESSION=$(grep -i "mcp-session-id" headers.txt | cut -d' ' -f2 | tr -d '\r')
# 2. tools/list — confirma quais tools estao deployadas nesse momentocurl -sS -X POST "https://engineering.mcp.devfellowship.com/mcp" \ -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SESSION" \ --data-binary '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# 3. tools/call — chamada real da toolcurl -sS -X POST "https://engineering.mcp.devfellowship.com/mcp" \ -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SESSION" \ --data-binary '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_diagrams","arguments":{"limit":5}}}'Every response is an SSE-framed line (event: message\ndata: {...}) even though the request was plain JSON —
parse the data: line to get the JSON-RPC payload.
RLS: created_by must be set on insert
Section titled “RLS: created_by must be set on insert”create_diagram must set created_by: userId (the caller’s own auth.uid(), extracted server-side from the
validated JWT) on every insert — omitting it makes every call fail with
"Database error: new row violates row-level security policy for table \"diagrams\"" (confirmed against
production on 2026-07-14/15, see PR #223).
public.diagrams has two policies, and the second one is the one that decides who can read what
(measured against production 2026-08-13):
| Policy | Command | Predicate |
|---|---|---|
Owners can manage their diagrams | FOR ALL | auth.uid() = created_by |
Authenticated can view entity-bound diagrams | FOR SELECT | entity_id IS NOT NULL |
Two consequences worth stating plainly, because they are not obvious from the column names:
entity_idis the sharing flag. A diagram with any non-nullentity_idis readable by every authenticated user — there is no epic-membership check. A diagram with a NULLentity_idis readable by its owner alone. That is why omittingepic_idoncreate_diagramcosts visibility.- The old note here is superseded. This page previously said
public.diagramshad “a single owner-scoped RLS policy”, and thatlist_diagrams/get_diagram“only ever return diagrams owned by the calling user, never teammates’ diagrams under the same epic … flagged with Tainan, not yet resolved”. The entity-bound SELECT policy resolved that: a teammate’s epic-bound diagram is returned. Verified by reading four diagrams owned by another user as thesmokeidentity under enforced RLS.
Diagrams tools
Section titled “Diagrams tools”Draw a diagram for a plan or a document, change it, keep its revisions, and export it as an SVG image or as Mermaid or PlantUML text.
create_diagram
THE way to make a diagram anywhere in DFL.
create_diagramCreate 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
Update an existing diagram in public.diagrams by id — the right way to change a diagram that a plan or a document already references.
update_diagramUpdate 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
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).
list_diagramsList 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
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).
get_diagramGet 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
Export any stored diagram to a shareable artifact by id.
export_diagramExport 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
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.
snapshot_diagram_versionSnapshot 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
List the revision history of a diagram from public.diagram_versions, ordered by version_number DESC (newest first).
list_diagram_versionsList 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
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.
get_diagram_versionGet 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. |
Documents
Section titled “Documents”Seed documents.template/documents.template_variable rows without a dfl-schema migration, and author
documents.document rows from any template category (or from a literal body) for any entity.
| Backing data | documents.template, documents.template_variable, documents.document; read of public.diagrams and work.epics for diagram injection and entity resolution. |
One writer, not two
Section titled “One writer, not two”write_handoff_document does not have its own write path — it resolves the epic and then calls the same
writeDocument core as write_document (src/tools/documents/write-document-core.ts). Everything below —
required fields, duplicate handling, RLS behaviour, the audit entry — is therefore identical for both tools.
write_document contract
Section titled “write_document contract”Required — title. documents.document.title is NOT NULL with no default.
Required — exactly one body source, out of category, template_id, or content. category picks the most
recently updated active template in that category; template_id names one explicitly (an inactive one is
refused); content writes a literal body with no template. Zero sources, or more than one, is an error — the
tool does not guess.
Optional:
variables—variable_key→ value map stored invariables_data. Substitution happens in the frontend.document_type— defaults to the template’s type, else"other".status—"draft"(default) or"pending"only. The signature-lifecycle values (sent,partially_signed,completed,finished,cancelled) belong to the signing flow and are rejected by the input schema: an agent must not be able to declare a document signed.entity_id/entity_name— free linkage to whatever row the document is about.diagram_entity_id— injects diagram tokens for that entity (see below).document_id— update this row instead of creating one.on_duplicate—"error"(default),"update", or"create".
Update is in scope. Pass document_id to rewrite an existing row in place; the result reports
action: "created" or action: "updated". There is no delete tool.
Duplicates are refused, not silently multiplied. documents.document has no uniqueness constraint, so
nothing at the database level stops two rows with the same title. Before inserting, write_document looks for
an existing document with the same title and the same entity_id (or the same title with a null
entity_id). If it finds one, the default on_duplicate: "error" refuses and returns
existing_document_ids so the caller can decide; "update" rewrites the most recently updated match; "create"
inserts another row anyway.
RLS, attribution, and why a failed write is never a success
Section titled “RLS, attribution, and why a failed write is never a success”Every call runs on the caller’s user JWT (createSupabaseClient(jwt)), never service_role — the fleet rule
from Tainan, 2026-06-17. documents.document carries a single policy, document_write (ALL for
authenticated, USING/WITH CHECK = iam.is_member()), so a caller who is not a DFL member cannot read or
write these rows at all.
That has a sharp edge worth stating: under RLS, an UPDATE on a row you may not see affects zero rows and
returns no error — indistinguishable from “the row does not exist”, and easy to mistake for success. This tool
treats a zero-row update as a failure, with an explicit message saying nothing was written. Likewise, passing
diagram_entity_id when the body has no diagram sentinel is an error rather than a quiet no-op.
documents.document has no created_by column, so authorship is recorded separately: each successful write
inserts a documents.document_log row with event_type: "mcp_document_created" / "mcp_document_updated" and
event_data.actor_user_id set to the calling user. The document is committed before that log is attempted, so a
log failure is reported honestly as audit_logged: false rather than failing or hiding the write.
Why seed via a tool, not a migration
Section titled “Why seed via a tool, not a migration”documents.template/documents.template_variable are reference/seed data, not schema — per the pipeline plan
(Tainan, 2026-07-09), seeding them goes through this tool, not a PR touching dfl-schema/supabase/migrations/
(that repo’s own AGENTS.md forbids INSERT in migrations; see PR #143 there, rejected for exactly this).
seed_template is idempotent by name, so re-seeding the same template (e.g. after editing its content) is
safe to run repeatedly.
No document_type: "handoff" — use category: "handoff" instead
Section titled “No document_type: "handoff" — use category: "handoff" instead”documents.document_type is a fixed Postgres enum (contract | proposal | nda | sow | other | amendment) — it
has no "handoff" value. The convention used here is document_type: "other" (or "sow") plus the free-form
category: "handoff" column, which the dfl-documents frontend already uses to group templates. Passing
document_type: "handoff" to seed_template fails at the database level with an enum error.
Diagram injection does not render images — it inserts tokens
Section titled “Diagram injection does not render images — it inserts tokens”Neither tool renders Mermaid/PlantUML to an image server-side. Given diagram_entity_id (which
write_handoff_document sets to the epic id), the writer queries public.diagrams for that entity (same filter
as list_diagrams) and replaces the body’s diagram sentinel with real {{dfl-diagram:UUID}} tokens — one per diagram found. The dfl-documents frontend
(src/utils/diagram-token.ts) already resolves that token into a rendered diagram when the document is viewed
or edited there. This was a deliberate scope decision (confirmed by Tainan): porting the Mermaid/PlantUML parser
into this Node package was judged unnecessary complexity once the client-side token mechanism was found to
already cover the need.
Seeding a handoff template
Section titled “Seeding a handoff template”{ "name": "Handoff Técnico v2", "category": "handoff", "document_type": "other", "content": "...markdown with {{variable_key}} placeholders and one {{dfl-diagram:00000000-0000-0000-0000-000000000000}} sentinel...", "variables": [ { "variable_key": "projeto_nome", "label": "Nome do projeto", "is_required": true }, { "variable_key": "estimativa_pontos", "label": "Estimativa (pontos de história)", "variable_type": "number" } ]}Estimates in a handoff template must use variable_type: "number" with the unit spelled out in the label
(pontos or horas) — never variable_type: "currency". documents.variable_type has no dedicated points/hours
value, and the plan’s business rule is that a handoff document never exposes a monetary value.
Authoring a document in an arbitrary category
Section titled “Authoring a document in an arbitrary category”write_document is category-parameterised, so a new kind of document needs no new tool — seed a template with
seed_template, then instantiate it:
{ "title": "Runbook — deploy do dfl-mcp-engineering", "category": "runbook", "entity_id": "3f1c…", "entity_name": "Deploy pipeline", "variables": { "responsavel": "William", "ambiente": "prod" }}Or with no template at all, for a one-off body:
{ "title": "Notas da reunião 2026-08-05", "content": "# Notas\n\n- ..." }Documents tools
Section titled “Documents tools”Write documents from templates, for example the hand-off document of an epic.
seed_template
Create or update a documents.template + its documents.template_variable rows, without a dfl-schema migration.
seed_templateSeed 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
Read documents.document rows.
get_handoff_documentGet 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
Create or update a documents.document row from a template category, an explicit template id, or a literal body.
write_documentWrite 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
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…
write_handoff_documentWrite 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. |
Sheets
Section titled “Sheets”A spreadsheet as a first-class DFL artifact, the way a diagram is. An agent
writes it here, a person edits the same rows at
sheets.devfellowship.com, a plan embeds it, and a proposal can point an answer
at one.
| Backing data | work.sheets (the header), work.sheet_tabs (one row per tab), work.sheet_versions (whole-sheet snapshots). Applied by dfl-schema #955 on 2026-09-09. |
| Plan | 20260909-dfl-sheets-primitive |
There is no delete_sheet, the same as for diagrams. There is also no tool that
edits or deletes a version; see History.
The shape
Section titled “The shape”A sheet is a header plus tabs, and a tab is one document: an ordered
columns list and an ordered rows list.
{ "columns": [ { "key": "rubrica", "label": "Rubrica", "type": "text" }, { "key": "quantidade", "label": "Quantidade", "type": "number" }, { "key": "valor_unitario", "label": "Valor unitário", "type": "currency" }, { "key": "total", "label": "Total", "type": "currency" } ], "rows": [ { "id": "9f2c…", "cells": { "rubrica": { "v": "Bolsas" }, "quantidade": { "v": 3 }, "valor_unitario": { "v": 4000, "fmt": "BRL" }, "total": { "v": 12000, "f": "=B2*C2" } } } ]}A cell is {v, f, t, fmt} — value, formula, per-cell type override, 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 in this
MCP evaluates a formula; the editor does.
type is one of text, number, currency, percent, date, boolean,
formula, select. The list is closed on purpose — the grid has to render
every one of them, so a new type is a code change rather than a string an agent
invents.
One row per tab, not one row per cell
Section titled “One row per tab, not one row per cell”A ten-tab budget is not one blob that two writers lose updates on: each tab is
its own row and its own atomic write. The cost of that choice is that changing
one cell is a read-modify-write, which is exactly why write_sheet_cells exists
instead of leaving callers to rebuild rows themselves.
Rows are semi-structured
Section titled “Rows are semi-structured”A cell key that no column declares is stored, not rejected — and it is also
invisible in the editor, which renders by column. Every write names such a key
back in unmapped_cell_keys. Treat it as a typo in the column key until proven
otherwise, and add the column when the data is real.
A1 addressing
Section titled “A1 addressing”Row 1 is the header — the line of column labels the grid shows. So A1 row 2
is rows[0], the first data row. Getting that backwards writes every value one
row up, and the sheet still looks plausible.
Column letters are positional: B is the second column of the tab, whatever
its key.
B2— one cell.B2:D10— a block.B:D— whole columns, every row.2:10— whole rows, every column.
Lowercase and $ anchors are accepted, and a reversed range (D10:B2) reads the
same as a forward one — as a drag that starts bottom-right does. A mixed
range such as B2:D is refused rather than guessed at: a spreadsheet would
silently promote it to whole columns, and an agent that meant B2:D10 would read
the wrong slice and never know.
A range slices one tab, so pass tab with it. The result echoes
range_applied, and each returned row keeps its original data-row index in
row_indexes — so an index read from a slice still addresses the right row in a
write.
The two writers are not interchangeable
Section titled “The two writers are not interchangeable”This is the one confusion here that loses data.
replace_sheet_tab is the bulk path. It replaces a tab’s whole contents, so
whatever the tab held and you did not send is gone — including rows a person
edited in the app since your last read. Use it when you generate or regenerate a
whole table. Because it destroys, it writes a whole-sheet snapshot to
work.sheet_versions first; snapshot defaults to true, and a snapshot that
fails refuses the replace rather than proceeding without its safety net. The
snapshot covers every tab, not only the one being written, because a budget is
read across its tabs and a one-tab checkpoint is not a state anyone can return
to.
write_sheet_cells is the surgical path. It merges: every cell you do not
name survives, and so does every field (t, fmt) of a cell you name but only
partly write. A value written without a formula clears that cell’s
formula, the way typing a number over one does in a spreadsheet, so the stored
value and the stored formula never disagree.
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.
Its batch is all-or-nothing: one bad address refuses the whole call and the database is not touched. The result names, per write, the row id, the row index and the A1 address it landed on — read it back to confirm you addressed what you meant.
Snapshots differ between the two on purpose. write_sheet_cells writes a version
row only when you give it a commit_message, because a snapshot per cell
edit would bury the history it exists to keep.
History
Section titled “History”work.sheet_versions holds whole-sheet snapshots: the header plus every tab,
with all their columns and rows. A one-tab checkpoint is not a state anybody can
return to, because a budget is read across its tabs.
Three ways a version row gets written, and they do not overlap:
replace_sheet_tabwrites one before it destroys, unless you turnsnapshotoff. A snapshot that fails refuses the replace.write_sheet_cellswrites one only when you give it acommit_message— a snapshot per cell edit would bury the history it exists to keep.snapshot_sheet_versionwrites one and changes nothing else. That is the tool for a state worth keeping when no write is coming: the budget an ADR was decided against, a template before somebody starts editing it.
version_number is MAX(version_number) + 1 for the sheet — not a count.
Two writers can compute the same next number at the same moment (a
replace_sheet_tab on tab A racing one on tab B is a normal thing to do), so
the UNIQUE (sheet_id, version_number) collision is retried against a
recomputed maximum instead of failing opaquely.
Reading it back
Section titled “Reading it back”list_sheet_versions is the index: number, commit message, author, date,
newest first, and deliberately no snapshot payload — fifty versions of a
ten-tab sheet is a whole spreadsheet fifty times over. get_sheet with
include_versions gives the same list beside the content; use the dedicated
tool when you want the history alone, or when you need to page past the newest
50.
get_sheet_version is what makes a prior state actually recoverable: it
returns the payload as stored. Pass tab to slice it to one tab by name.
Append-only, on purpose
Section titled “Append-only, on purpose”The history is SELECT + INSERT under RLS (lane L1 of the plan grants nothing else). There is no tool that edits or deletes a version row, and there is not going to be one.
So a restore is a normal write: read the state with get_sheet_version and
put it back with replace_sheet_tab, one tab at a time. That write leaves its
own version row behind, which means the state you replaced does not disappear
either — undo has an undo.
Template → instance
Section titled “Template → instance”copy_sheet is the path an edital’s budget template takes to become one
submission’s answer. It duplicates everything — every tab in order, its columns,
its meta and all of its rows, with the row ids preserved, so a reference to
a row of the template still resolves in the copy and a later diff against the
template lines up. The source is never written, so the next submission starts
from the same blank form.
Three things differ on purpose:
kindbecomestableunless you pass one, because a copy of a template is an instance and not another template;template_ofis set to the source id, which is how the instance is traced back to the form it came from;entity_id/entity_nameare inherited from the source only when you pass neither. Pass the submission, the epic or the plan slug the copy belongs to, or it stays filed under the template.
What clear_values takes, and what it keeps
Section titled “What clear_values takes, and what it keeps”It removes v from every cell whose column declares no formula. It keeps the
columns, the row ids, every cell formula (f), 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 — blanking them would
write over a cell nobody types in.
Import and export
Section titled “Import and export”import_sheet and export_sheet are inverses. An xlsx written by the second
re-imports through the first into the same columns and the same values —
formulas included, because an xlsx cell holds a formula and its cached
result, exactly as a DFL cell does.
| Direction | Formats |
|---|---|
import_sheet | csv, tsv, markdown, xlsx_b64 |
export_sheet | csv, markdown, json, xlsx |
The first row is the header
Section titled “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 — and the same
function runs on the way out, which is what makes the round trip close. A
repeated header becomes total, total_2; a blank one becomes column_3.
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 —
read key_map back before you write.
Values are inferred; types are not carried
Section titled “Values are inferred; types are not carried”A field that reads exactly as a number becomes a number, true / false become
booleans, and a field starting with = becomes a formula. Everything else stays
text on purpose: 007 keeps its leading zero and 1,234 keeps its separator,
because a leading zero is how an id is written and a thousands separator is a
locale the file does not declare.
No file format carries a DFL column type, so a column comes back as number,
boolean, formula or text. currency, percent and date do not survive
an import — set them afterwards with replace_sheet_tab when the display
matters.
On the way out
Section titled “On the way out”Formulas render as their values in csv, markdown and json, because the
value is the number a reader wants; a cell that has a formula and was never
computed falls back to the formula text. xlsx is the exception and writes both.
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
gives one object per row keyed by column key, plus _row_id (prefixed so it
cannot collide with a column genuinely keyed id). xlsx comes back as base64
in content_base64; nothing here writes a file.
To read a sheet in order to write it, use get_sheet rather than
export_sheet — none of these formats carries the cell objects or the addressing
that write_sheet_cells needs.
The library
Section titled “The library”xlsx is exceljs 4.4.0, MIT
(ADR-6 of the plan; the licence was verified from the package at the time it was
added). It is imported lazily, inside the two functions that need it, so neither
a server boot nor the docs generator loads a spreadsheet engine to read tool
metadata.
Putting a sheet in a plan
Section titled “Putting a sheet in a plan”Do not hand-write the token. Call the plans MCP attach_entity with the plan
slug, type sheet and the 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.
Visibility
Section titled “Visibility”Reads run under the caller’s own RLS, so missing and not visible to you are the same answer and the tools say so rather than claiming the sheet is gone.
Unlike a diagram, a sheet with no entity is not hidden from the group.
public.diagrams uses entity_id IS NOT NULL as a de-facto “shared” flag, which
makes an unparented diagram readable by its creator alone; sheets are read by
membership instead (ADR-3 of the plan). entity_id here is a grouping key —
what list_sheets filters on and what the plans-app sidebar groups by — not a
visibility switch.
Ordering
Section titled “Ordering”list_sheets orders by work.sheets.updated_at. Nothing in the database bumps
that column when only a tab changes, so both writers here touch the parent after
a successful write and the order reflects the last content change. An edit
made in the app writes the tab directly and does not bump it; a trigger in
dfl-schema would be the proper fix for that path.
Sheets tools
Section titled “Sheets tools”Make a spreadsheet, write cells, keep versions, and import or export CSV, Markdown and Excel.
create_sheet
THE way to make a spreadsheet anywhere in DFL.
create_sheetCreate 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
Read one sheet by id: the header plus every tab, each with its full columns and rows.
get_sheetGet 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
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.
list_sheetsList 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
Replace the ENTIRE contents of one tab — its columns and all of its rows — in a single atomic write.
replace_sheet_tabReplace 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
Write individual cells into one tab, MERGING with what is already there.
write_sheet_cellsWrite 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
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.
snapshot_sheet_versionSnapshot 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
List the snapshot history of one sheet from work.sheet_versions, ordered by version_number DESC — NEWEST FIRST.
list_sheet_versionsList 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
Read ONE stored snapshot of a sheet back, by sheet_id + version_number.
get_sheet_versionGet 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
Duplicate an existing sheet under a new name — the way a TEMPLATE becomes an INSTANCE.
copy_sheetCopy 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
Turn a csv, a tsv, a markdown table or an .xlsx workbook you already have into a real DFL sheet.
import_sheetImport 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
Read a sheet out in a portable format: "csv", "markdown", "json" or "xlsx".
export_sheetExport 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. |
UX map
Section titled “UX map”Index a capture of an app, read back every screenshot of its most recent build, then pin a design note to a region of one of those screens, list the queue, and resolve it.
| Backing data | engineering.ux_map_apps, ux_map_captures, ux_map_screens (written through one RPC) and engineering.ux_map_annotations. |
Reading every image of the latest build
Section titled “Reading every image of the latest build”list_ux_paths_app_images answers the question an agent actually has: show me what
this app looks like today. It takes app — the ux-map slug (dfl-learn) or the
repository as <owner>/<repo> — plus the optional role, capture_id,
viewport and format.
A build is (app_version, commit_sha), not app_version
Section titled “A build is (app_version, commit_sha), not app_version”itera-player has eight captures spanning nineteen days that all read
2026-08-12-63cdc99, with eight different commits. Grouping on the version
string alone would union three weeks of history and call it “the latest
version”, so the pair is the key.
A capture is per role, so the default unions the roles
Section titled “A capture is per role, so the default unions the roles”dfl-learn at 2026-08-18-77be345 has three captures — superadmin,
community_member, anonymous. A screen only a superadmin reaches is still a
screen of that build, so every image comes back labelled with the role that saw
it. role narrows to one. capture_id pins one capture and the answer says
resolved_from: "capture_id (pinned — this is NOT necessarily the latest build)",
so a historical read cannot be mistaken for a current one.
viewport — defaults to desktop, never to a silent mix
Section titled “viewport — defaults to desktop, never to a silent mix”desktop | mobile | both. A mobile capture is addressed as the role
<role>@mobile until the viewport column lands, and both spellings are
read — the column wins when it exists, because it is the fact; the suffix is
the pre-migration spelling of the same fact; a row with neither is a desktop
capture, because every capture indexed before the mobile pass was taken at 1280.
Each image carries its own viewport and its base role, so superadmin
rather than superadmin@mobile — that string is an address, not a role.
The default is desktop and not both, because a both default would silently
double every answer the day the mobile pass lands, and a caller who asked for
“the images” would get two pictures per screen with no idea why. When the app has
no mobile capture at all, viewport.missing_sibling says so.
One capture per role and viewport — a re-index supersedes, it does not add
Section titled “One capture per role and viewport — a re-index supersedes, it does not add”Unioning the captures of a build is right across roles and wrong within one. A role at a viewport at a build has exactly one current capture; a second is a re-index, and the older row is superseded.
This is not hypothetical. dfl-website-brand carries two anonymous capture
rows at app_version 2026-09-02-97f1be5 with the same 39 screens, so the naive
union returned 78 images for a 39-screen site — every page twice, with
screen_id repeated.
The dropped row comes back in superseded_captures, with its digest and the
reason, rather than being hidden. The double row is a defect in the index, not
in the read: index_ux_map_capture is idempotent on p_digest, so two rows mean
it ran twice with two different digests for the same screens — worth somebody’s
attention. What this tool refuses to do is pass that through as duplicated
output.
distinct_images — count pictures by DIGEST, not by url
Section titled “distinct_images — count pictures by DIGEST, not by url”Every screen gets its own media object, so distinct_urls always equals
count and says nothing about whether two screens photographed the same pixels.
distinct_images counts digests, and identical_images names the pairs.
The dfl-lesson-studio capture of 2026-09-02 is the case in point: 31
screens, 31 urls, 29 distinct images.
| pair | why |
|---|---|
editor_export_modal + editor_publish_opens_export_modal | deliberate — with no completed export, Publicar opens the export modal (that repo’s PR #315) |
preview_mobile + mobile_preview_screens | unexplained, per the capture’s own coverage note: one of the two is not independently photographed |
Surfacing the pairs is what lets a reviewer tell those two cases apart. A deliberate duplicate is fine; an accidental one means a screen nobody has actually looked at — exactly the gap a UX map exists to close. Rows with no digest are skipped rather than grouped, because a missing digest is not evidence of sameness.
anonymous_access — who can open each url
Section titled “anonymous_access — who can open each url”Every image carries anonymous_access: open when somebody with no DFL
session can open that url, members_only when they cannot, unknown when
the probe got no answer.
It is measured, not read off a column. public.media.visibility sits behind
an owner-scoped RLS policy, so a caller who did not upload the screenshot gets no
row at all and cannot tell private from not visible to me. The tool issues
one anonymous GET per distinct url — no token, no cookie,
redirect: "manual" — and reads the answer. Measured against production on
2026-09-02:
| object tier | anonymous GET | followed |
|---|---|---|
a members object | 401 | 401 |
a private object | 302 | 200 image/png |
redirect: "manual" is what makes it cheap: a private object answers 302 to a
presigned url and the bytes never move.
format: "zip"
Section titled “format: "zip"”The images are fetched here with the caller’s own token, packed with a
manifest.json, uploaded as one media object, and its url is returned. Above
25 MB or 300 images it refuses, sets zip_skipped_reason, and returns
the url list instead — so a caller is never left with nothing. The archive is
stored uncompressed: PNG is already deflate-compressed, so its size is the sum of
its images.
The archive’s visibility follows its content. private when every screenshot
inside already opened anonymously — that is the link-capability tier an external
designer can open with no DFL login. members the moment one members-only
image is inside, because an archive is only as shareable as its least shareable
member: a private zip of members-only screenshots would make every one of them
readable by anyone holding a single url, which is the door dfl-ux-paths#81
closed. Never public.
Indexing a capture
Section titled “Indexing a capture”index_ux_map_capture calls one SECURITY DEFINER function with the caller’s
own JWT. One transaction writes the app row, the capture row and every screen
row, so a capture never claims screens it does not have.
Required: app_id, role, digest (sha256: plus 64 lowercase hex) and an
https:// artifact_url. Optional: viewport (default desktop), screens,
display_name, repo_full_name, business_unit_id, artifact_media_id,
app_version, commit_sha, captured_at, coverage_note, run_id.
Each screen object accepts screen_id (required) plus name, route,
screenshot_url, screenshot_media_id, screenshot_digest, regions_url,
regions_media_id and source_ref_file — and nothing else. An unknown key
is refused by the tool and by the database; a typo is never quietly dropped.
Two arguments that do not exist, on purpose
Section titled “Two arguments that do not exist, on purpose”| Not an argument | Because |
|---|---|
indexed_by / user_id / author | The database stamps the author from auth.uid(). A caller-supplied author makes the tool a way to write a map as somebody else, and provenance is the whole value of indexing one. |
screens_total / screens_with_shot | The database counts the screens payload. Coverage is the number a reviewer judges a map by, so the author is the last party who should assert it. |
The viewport is a lane BESIDE the role
Section titled “The viewport is a lane BESIDE the role”role says who was looking. viewport says on what. They are two
columns, and a viewport must never be written into a role: the tool refuses an
@ in role, because tenant_admin@mobile would be listed as a role by
every menu that reads the distinct roles of an application. That spelling was the
earlier design; a column replaced it on 2026-09-02.
Known values are desktop — the default, and every capture taken before that
date — and mobile. Anything else follows the <W>x<H> convention in CSS
pixels, lower case, e.g. 390x844. It is free text on purpose, exactly as role
is: the set of viewports a product is captured at is not the database’s to close.
The value is lower-cased, so Mobile and mobile are one lane.
The tool sends p_viewport only when it is not desktop, so a desktop call
still resolves against a database where dfl-schema
20260902160000_engineering_ux_map_captures_viewport.sql has not applied. A call
that genuinely asks for another viewport is answered migration_not_applied,
naming that migration — it is never filed as a desktop row instead, because a
capture under the wrong viewport is worse than a missing one.
Idempotency
Section titled “Idempotency”(app, role, viewport, digest) identifies the capture, and digest identifies
the content within that lane. Re-indexing identical content writes nothing
and returns the existing row with already_indexed: true, which the tool reports
at the top level of its result. Read it: a no-op reported as a write is the
failure mode here. To record new content, change the content and its digest.
index_ux_map_capture needs the engineering.index_ux_map_capture function from
dfl-schema migration 20260813235500_engineering_ux_map_index_rpc.sql, which is
at a human merge gate. Until it merges the tool answers migration_not_applied
and names that migration — it never degrades to a silent success. A caller whose
IAM global level is below 50 (member) is answered forbidden.
Deleting a capture
Section titled “Deleting a capture”delete_ux_map_capture is the other half of the pair, and it exists because there
was no delete path at all. Measured read-only against production on
2026-09-02: authenticated holds SELECT on the ux_map tables and nothing else,
and the only DELETE policies in the schema are on ux_map_annotations and
ux_map_validations. A capture indexed by mistake — a smoke test, a wrong role, a
wrong viewport, a capture of a broken build — could not be removed by anybody
except the database owner.
dry_run defaults to true. The first call counts everything that would
change, deletes nothing, evaluates every guard, and prints the digest that
confirm_digest needs. Committing without that digest is refused, because a uuid
is not something a caller can sanity-check by looking at it.
The gate is IAM global level 80 (admin) — deliberately narrower than the
level 50 index_ux_map_capture needs. A wrong capture is additive and
self-correcting; a wrong delete takes the screens and every human validation
of that capture, with no record that the row ever existed.
What goes, what survives
Section titled “What goes, what survives”Every one of these was read off pg_constraint, not inferred:
- Screens (
engineering.ux_map_screens) —ON DELETE CASCADE. Deleted, and counted. - Validations (
engineering.ux_map_validations) —ON DELETE CASCADE. Deleted, and counted. - Annotations (
engineering.ux_map_annotations) —ON DELETE SET NULL. They survive, keeping the anchor and thecapture_digest. - A capture that measured against this one (
baseline_capture_id) —ON DELETE SET NULL, which blocks the delete. See the caution below. - The app row (
engineering.ux_map_apps) — never touched. Deleting the last capture of an app leaves the app row, on purpose.
This tool never deletes an annotation and could not if it tried. What a note
loses is the row saying which capture it was written against, so
allow_detached_annotations makes that an explicit choice rather than a
discovery in the counts afterwards. Deleting feedback stays where it already is:
the author-only DELETE policy on that table.
Reading the result
Section titled “Reading the result”deleted and reason sit at the top level. Every refusal answers
deleted: false with its own reason, and so does an id that is already gone
(not_found — the state you asked for, but not a confirmation that this call
removed anything).
The reason values, in full:
- deleted — the row is gone;
countsrecords what went with it. - dry_run — nothing happened;
would_changeis the projection. - not_found — no capture has that id.
- confirm_digest_required and confirm_digest_mismatch — nothing happened; re-read the dry run.
- annotations_present — nothing happened; acknowledge with
allow_detached_annotations. - baseline_referrers_present — nothing happened, and it cannot happen until the referrers are reset.
There is no deleted_by, user_id or actor argument — the database reads the
deleter from auth.uid(), and a delete leaves no other trace. There is no
app_id, role or date filter either: one capture per call, addressed by id,
because a destructive tool scoped by a predicate eventually deletes what the
predicate also matched.
delete_ux_map_capture needs the engineering.ux_map_delete_capture function
from dfl-schema migration
20260902170000_engineering_ux_map_delete_capture_rpc.sql, which is at a human
merge gate. Until it merges the tool answers migration_not_applied and names
that migration. There is no fallback and there must not be: no other delete route
exists, and psql against production is read-only.
Correcting a capture
Section titled “Correcting a capture”update_ux_map_capture is the third member of the capture family, and it exists
for the same reason the other two do: the table grants authenticated nothing
but SELECT. Measured read-only against production on 2026-09-03, the relacl on
engineering.ux_map_captures is authenticated=r, and the single policy on it
is a SELECT policy with USING true. So a coverage_note that says something
false could not be corrected by anybody except the database owner.
That is not hypothetical. Two rows carried a false sentence on 2026-09-03: one
claimed a capture “is not geometrically uniform”, which is false by
construction — capture.mjs reads the IHDR of every PNG and throws on a
mismatch — and one counted “the 36 images” of a capture that photographed 33.
The generator was repaired; the rows could not be.
It edits three columns, and refuses every other one
Section titled “It edits three columns, and refuses every other one”coverage_note— prose. An account of the capture, not a reading off it.app_version— provenance the indexer copied from the flows document. It drifts.commit_sha— provenance the caller supplied.
Everything else is evidence, in three kinds, and a patch that names one of
them is refused whole with reason: "field_not_editable" — the good fields
are never applied without the bad ones.
| Kind | Columns | How an edit breaks it |
|---|---|---|
| identity / lane | id, app_uuid, run_id, role, viewport | Three of them build uq_ux_map_captures_app_role_viewport_digest, so an edit silently re-keys the capture. |
| read off the artifact | digest, artifact_url, artifact_media_id, captured_at, screens_total, screens_with_shot | A rewritten digest makes the row claim content it does not have — and every later re-index of the real content then answers already_indexed for a row that is not it. |
| derived | baseline_*, largest_movement_*, movement_measured_at | Editing a conclusion without re-running the comparison turns a measurement into an opinion. |
The line is: a claim about the evidence may be corrected; the evidence may not. To change the evidence, index a new capture — the newest capture wins and the history stays.
edited_at and edited_by are closed for the reason they exist. An audit stamp
a caller can write is not an audit stamp.
Omitting is not clearing
Section titled “Omitting is not clearing”Three states, and they are different:
- send a field → set that column;
- omit a field → leave that column alone;
- send it as
null→ clear that column.
The tool never coalesces the last two. A caller correcting a note would otherwise echo back every field it read, nulls included, and blank the provenance of the row it was fixing.
A call that names no field at all is refused with reason: "empty_patch",
before any database call. An UPDATE that changes no column but stamps
edited_at and edited_by records an edit that did not happen.
Reading the result
Section titled “Reading the result”updated and reason sit at the top level, on every branch. The failure
mode here is a caller reading “success” and believing the prose changed.
- updated — the row holds the new values.
- dry_run — nothing was written; read
would_change, and checkcapturenames the row you meant. - not_found — no capture has that id. A state, not an error, and not a confirmation.
- no_change — the row already held everything you sent, so
edited_atwas not stamped. - empty_patch and coverage_note_too_long — nothing was written; the message says what to fix.
- field_not_editable — nothing was written;
rejected_fieldsnames what you asked for andeditable_fieldsnames what is allowed.
before and after come back for the patched fields only, on every branch.
before is the only undo path: these columns keep no version history. That
is also why this tool needs no confirm_digest where the deleter does — a wrong
edit is reversed by calling again with the old text, and a wrong delete is not
reversed at all.
Do not hand-write a note that a generator produced. 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.
The gate is level 50, not 80
Section titled “The gate is level 50, not 80”update_ux_map_capture needs the same IAM global level index_ux_map_capture
needs, and deliberately not the level 80 delete_ux_map_capture needs.
Whoever may write a coverage_note must be able to correct one, and an edit is
reversible where a delete is not.
There is no edited_by, user_id or actor argument — the database reads the
editor from auth.uid() and stamps it. There is no app_id, role or date
filter either: one capture per call, addressed by id.
The tool needs the engineering.ux_map_update_capture function from dfl-schema
migration 20260903090000_engineering_ux_map_update_capture_rpc.sql, which is at
a human merge gate. Until it merges the tool answers migration_not_applied and
names that migration. There is no fallback and there must not be:
authenticated holds no UPDATE privilege on the table, and psql against
production is read-only.
What an annotation is anchored to
Section titled “What an annotation is anchored to”Tainan decided the shape on 2026-08-13, answering “screen or region”: region, anchored to the source file plus the component identity, and not to the pixel bounding box.
The box locates the click. What gets stored is the component identity that
regions.jsonalready carries.
A stored box is a fact about one rendering; (file, component, tag, occurrence)
is a fact about the code. Only the second still means something after a
re-layout — and a re-layout is the ordinary case, not the exotic one.
An annotation also carries a viewport
Section titled “An annotation also carries a viewport”annotate_ux_map_region has 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. It joins the frozen anchor set, so nobody can move a note
onto an image its author never saw.
It is a stored column rather than a join through capture_id, and that is
deliberate. capture_id is nullable with ON DELETE SET NULL — retention
deletes unvalidated captures and the feedback has to outlive them. A viewport
filter built on that join would return NULL for exactly the oldest
annotations, the ones nobody has addressed yet, and a read filter that silently
drops the rows the queue exists to surface is worse than no filter.
list_ux_map_annotations takes viewport as an optional filter. Omit it to read
every lane, which is what the tool did before the column existed. Pass it to ask
“what is open on this screen at this viewport” — a note raised on a
desktop-only sidebar is not feedback about the mobile image. Before the migration
applies, a list without a viewport still answers, and a list with one is
refused rather than served unfiltered.
🚨 The anchor is immutable
Section titled “🚨 The anchor is immutable”If the component is gone, the annotation is broken and must be reported as broken. It is never moved onto a plausible neighbour.
The temptation is real: a renamed component usually leaves an element of the same tag at the same box, so a resolver that matched “the element that looks right” would answer confidently and wrongly — and a pixel diff of the two captures would report zero change. Losing a note is recoverable. A note attributed to the wrong element is a wrong statement that gets acted on.
The database enforces this three ways, so no tool has to be trusted with it:
- every anchor column is
NOT NULL, so a component-less “screen note” cannot be stored at all; authenticatedholdsUPDATEon six columns and no others, so the anchor is unwritable one layer below RLS;- a
BEFORE UPDATEtrigger raises on any anchor change even for a role that does hold the privilege.
Coordinates are fractions, never pixels
Section titled “Coordinates are fractions, never pixels”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’s box in the current capture.
A component that grew from 200 px to 400 px keeps the pin on the same part of
itself. CHECK constraints reject anything outside 0..1.
Who may do what
Section titled “Who may do what”| Action | Who |
|---|---|
| Read | any signed-in user. anon is refused a layer below RLS. |
| Write a note | you, as yourself — author_id = auth.uid() is enforced. |
| Resolve / re-open | anyone signed in. The designer raises them, the engineers close them. |
| Edit the text | the author only, enforced by a trigger. |
| Delete a note | the author only — and no tool offers it. Deleting somebody’s feedback is not resolving it, and delete_ux_map_capture cannot reach a note either. |
| Correct a capture’s prose | any member (level 50), through update_ux_map_capture. The database stamps edited_by from auth.uid(). |
These tools carry the caller’s user-JWT. There is no service_role path: an
opinion a service identity writes on somebody’s behalf is an assertion, not
evidence. list_ux_paths_app_images follows the same rule on both of its gates: it
reads the index with the caller’s client, and its zip mode fetches every
screenshot with the caller’s own bearer token.
The ux-paths viewer writes the same table directly with the reader’s own session, which is the correct data path for a UI. These tools are the agent’s path to it, not a second one for the browser.
capture_idis required on INSERT — an annotation is born from a real capture. It is nullable so retention can clear it: the note outlives the capture, keeping its content-addressedcapture_digestas provenance.- A patch with no fields is refused rather than reported as success, and an
RLS-filtered
UPDATEthat touches zero rows raises. - Requires the
engineering.ux_map_annotationsmigration (devfellowship/dfl-schema#793), which is at a human merge gate. Until it merges these tools return the database’s own error and never degrade to a silent success.
UX map tools
Section titled “UX map tools”Record screenshots of an app's screens and pin design notes to parts of a screen.
annotate_ux_map_region
Pin a design note to a REGION of a captured screen in engineering.ux_map_annotations.
annotate_ux_map_regionAnnotate 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
Read design notes pinned to regions of captured screens.
list_ux_map_annotationsList 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
Move an annotation between "open" and "addressed", and/or record the request pull request it produced.
resolve_ux_map_annotationResolve 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
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.
index_ux_map_captureIndex 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
PERMANENTLY delete ONE UX map capture — the engineering.ux_map_captures row named by capture_id — through engineering.ux_map_delete_capture.
delete_ux_map_captureDelete 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
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.
update_ux_map_captureUpdate 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 list_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. |
list_ux_paths_app_images
Every screenshot of the MOST RECENT build of one application, from the UX-map index (dfl-ux-paths / "XPaths").
list_ux_paths_app_imagesUX-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. |
Entity connections
Section titled “Entity connections”Read the plan↔entity join, work.entity_connections, and bind an epic to a plan.
| Backing data | work.entity_connections (read all target types, insert epic only), read of work.epics, and a read of the plans-app GET /api/plans/<slug> as the caller. |
Why the writer is epic-scoped, and stays that way
Section titled “Why the writer is epic-scoped, and stays that way”work.entity_connections holds seven target types. The plans-app owns and
reconciles six of them. On every publish it runs
DELETE FROM work.entity_connections WHERE entity_id = $1 AND target_type = ANY($2::text[]);-- $2 = {diagram, document, image, ux_path, spec_run}and re-inserts exactly what the plan body’s {{dfl-entity:…}} tokens say
(dfl-plans → lib/entity-registry.js, REGISTRY_TARGET_TYPES).
PATCH /api/plans/:slug/tasks does the same for target_type = 'task'.
A row of any of those six written from this server would be deleted by the
next publish of that plan. The tool call returns success; the row is gone
later, with nothing to point at. That is why there is no general writer here and
why link_epic_to_plan has no target_type argument.
task and epic are excluded from the reconcile scope deliberately, by name,
with a unit test in dfl-plans that fails if anyone widens it. task already
has a writer — set_plan_tasks on the plans MCP. epic had a live reader
(the dfl-learn epics panel) and, until this tool, no writer anywhere in the
fleet. That is the gap this fills.
Where each target type is written
Section titled “Where each target type is written”| Target type | Write it with | Server |
|---|---|---|
| epic | link_epic_to_plan | engineering (this page) |
| task | set_plan_tasks | plans |
| diagram, document, image, ux_path, spec_run | attach_entity (inserts the token into the plan body; the app derives the row) | plans |
Authorisation — and its honest limit
Section titled “Authorisation — and its honest limit”The four RLS policies on the table are not symmetric:
| Command | Policy for authenticated |
|---|---|
| SELECT | plans.current_user_can_read(entity_id) AND work.entity_target_is_readable(target_type, target_id) |
| INSERT | auth.uid() = created_by |
| UPDATE | auth.uid() = created_by |
| DELETE | auth.uid() = created_by |
Reads are plan-scoped in the database. list_entity_connections therefore
needs no gate of its own: it runs with the caller’s JWT and the policy does the
filtering. It never uses service_role.
Writes are not. The INSERT policy checks only that you stamp your own uid;
it says nothing about the plan. The real permission gate for plan links lives in
the plans-app HTTP layer (canEdit() — owner, or admin at IAM level 80+).
link_epic_to_plan closes that by delegating rather than re-implementing:
- It calls
GET https://plans.devfellowship.com/api/plans/<slug>carrying the caller’s own JWT. The plans-app resolves that token itself and answers 404 for a plan the caller may not read. The tool reportsplan_not_found— neverforbidden— so it cannot confirm that a stranger’s personal plan exists. - It then applies the edit half locally: owner, or IAM level ≥ 80, using
public.get_my_iam_role(), which the database evaluates fromauth.uid(). - A plans-app that does not answer produces
plans_app_unavailableand writes nothing. An unanswered gate is never rounded down to permission.
Reading is permission-filtered, and says so
Section titled “Reading is permission-filtered, and says so”Since dfl-schema #791 the SELECT policy is plan-scoped. An RLS-filtered
SELECT does not fail — it succeeds and returns nothing. So “this plan has no
links” and “you may not read this plan” arrive as the same empty array.
list_entity_connections always returns an rls_note saying the list is scoped
to you, and when a plan_slug filter produced zero rows it additionally
reports plan_readable:
Value of plan_readable | Meaning |
|---|---|
| true | You can read the plan. It genuinely has no links of that kind. |
| false | The plan is absent or not yours. The empty list proves nothing about its links. |
| ”unknown” | The plans-app did not answer. Treat the empty list as undetermined. |
entity_idis the plan slug, not a uuid — that is how every plan row in this table is addressed.entity_namenames the source entity (the plan title), matching how the plans-app populates it.created_byis stamped with the caller’s uid. Since the DELETE policy is the same predicate, whoever created a link can remove it.- Re-linking the same plan and epic returns
already_linked: trueand inserts nothing; a lost unique-violation race is reported as success, because the end state is the intended one. - There is deliberately no unlink tool in this group yet.
Entity connections tools
Section titled “Entity connections tools”See and make the links between a plan and its tasks, epics, diagrams and documents.
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.
link_epic_to_planLink 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
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.
list_entity_connectionsList 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. |
Deprecated names
Section titled “Deprecated names”These old names still work until their removal date. Each one calls the same handler as its new name. Use the new name in new code.
These old names still answer until the date shown. Call the new name.
| Deprecated name | Use instead | Removed after |
|---|---|---|
ux_paths_app_images | list_ux_paths_app_images | 2026-12-04 |