Graph My Task
Turn the user's task description into a vanilla workflow graph: an honest flowchart of how Claude would accomplish the task using ONLY built-in abilities (reasoning, reading/writing files, running commands, browsing if available). Pretend no skills, plugins, connectors, or MCP servers exist.
Rules of decomposition
- Be honest, not flattering. Include the tedious parts: manual data gathering, format wrangling, retry loops after failures, human review gates, copy-paste steps. The pain is the point — the knowledge-base stage below shows how helpers erase it.
- 6–16 nodes. Fewer means you're summarizing; more means you're micro-stepping.
- Exactly one
inputnode (gathering requirements/materials from the user) and at least oneoutputnode (the delivered result). - Node kinds:
input,process,decision(branching judgment),loop(bounded iteration over items),review(human-in-the-loop gate),output. - painLevel rubric (1–5): 1 = trivial/instant · 2 = easy but attention-consuming · 3 = moderate effort or fiddly formatting · 4 = slow, error-prone, or many manual sub-steps · 5 = heavy manual work across multiple tools/sessions.
- Edges:
sequencefor normal flow,branchout of decisions (label each branch),retryfor backward loops (label the failure reason). The graph must be connected; every non-input node is reachable from the input node. - ids are kebab-case (
^[a-z0-9][a-z0-9-]*$), short and descriptive.
ROOT
This skill has two homes: a checkout of its own repository (invoked /graph-my-task), and the installed untangle plugin (invoked /untangle:graph-my-task), where these files live in the plugin's install directory while the working directory is the user's own project. One derivation covers both: ROOT is the directory three levels up from this SKILL.md — SKILL.md → graph-my-task/ → skills/ → .claude/ → ROOT. The harness names this SKILL.md's directory when it loads the skill; derive ROOT from that path, never from where the session happens to be. In a checkout that lands on the repo root; installed as the plugin it lands on a versioned install directory (e.g. …/cache/untangle/untangle/1.0.0/). Wherever <ROOT> appears below, write out that absolute path. schema/, scripts/, and kb/ always resolve from ROOT and never from the working directory — the working directory gets the output file and tier 2's deleted-after scratch, nothing more. And one honest caveat: some harnesses install this SKILL.md alone — no schema/, scripts/, or kb/ beside it — which is survivable: when the ROOT files are missing, the validation ladder, KB tier 2.7, and the hand-over's drop-page fallback below cover every dependency, and nothing else in this skill needs ROOT.
Output
Write to out/<slug>.workflow.json — under the current working directory, the project the user is running in — where <slug> is a kebab-case slug of the title. The alternative gallery/<slug>.workflow.json (when the user says it's a gallery/showcase piece) only makes sense in a checkout of this repo: gallery/ lives at ROOT.
Document shape
This reference mirrors <ROOT>/schema/workflow.schema.json, which stays authoritative whenever it exists — read it before authoring. When it does not (some harnesses install this SKILL.md alone), author from this reference: every cap, enum, and pattern below is transcribed from that schema, not summarized. Two facts hold everywhere: the schema sets additionalProperties: false at every level, so an unknown key anywhere is a rejection; and "non-empty" below means minLength: 1, so an empty string is one too.
The document is an object with exactly four keys, all required:
meta— required:task,title,generatedAt,model,kbSource.task— the user's words; non-empty stringtitle— your concise name; non-empty stringgeneratedAt— ISO 8601 UTC date-time string (the schema'sformat: date-time)model— your model id; non-empty stringkbSource— exactly"airtable"or"none"(the knowledge-base stage below decides which)promptIntro— optional; non-empty when present (see "The optimized prompt" below)
nodes— array of node objects, at least 3. A node — required:id,label,kind,description,painLevel.id— kebab-case:^[a-z0-9][a-z0-9-]*$label— string, 1–60 characterskind—input|process|decision|loop|review|outputdescription— non-empty stringpainLevel— integer, 1 to 5 (the rubric above)lane— optional string
edges— array of edge objects, at least 2. An edge — required:from,to,kind.from,to— node ids, same kebab pattern (the schema checks only the pattern; the validator's integrity pass — rung 1's bundle, or you on rungs 2–3 — checks they name real nodes)kind—sequence|branch|retrylabel— optional string, at most 60 characters
suggestions— array, filled by the knowledge-base stage below;[]when no knowledge base is linked or nothing matched. A suggestion — required:nodeId,airtableRecordId,name,url,category,claim,effect.nodeId— a node id, same kebab pattern (and it must exist innodes)airtableRecordId—^rec[A-Za-z0-9]{14}$name— non-empty stringurl— string matching^https?://category—Claude Skill|Claude Plugin|MCP Server|Connector|Otherclaim— non-empty stringinstall— optional string (the one string the schema gives no length floor; step 5 still says omit the key when blank)promptFragment— optional; non-empty when presenteffect— required object; required inside it:removeNodes,mergeNodes,newEdges,metrics.removeNodes,mergeNodes— arrays of node ids; each may be emptyreplaceWith— optional; one full node, all five required node fields, same caps and enumsnewEdges— array of edges, may be empty; same shape asedgesitemsmetrics— all four fields required, each an integer ≥ 0:stepsSaved,estTimeSavedMin,estTokensSaved,manualInterventionsRemoved
Knowledge base (suggestions)
This stage attaches real, existing helpers — Claude skills, plugins, and MCP servers from a curated Airtable knowledge base — to the nodes they would collapse. Run it once the nodes and edges are settled and before the validation loop; the suggestions belong in the same file (if you already wrote the file, update it in place).
HARD RULES — no exceptions, no judgment calls:
Suggestions may ONLY reference rows that exist in the Airtable response. Never invent, remember, or import resources from anywhere else.
At most ONE suggestion per Airtable row per graph — the same row must never be attached to two nodes.
airtableRecordIdis the viewer's identity key, so a repeat is not a cosmetic slip: the viewer disables the ENTIRE suggestion layer for the file (a SUGGESTIONS DISABLED notice), not just the duplicate cards.
A resource you know about from training, from another repo, from your own memory of this session, or from a web search is not eligible. If it is not in the response you fetched, it does not exist for this graph. (The reverse is fine: one node may carry several suggestions, as long as each comes from a different row.)
1. Which knowledge base? Five tiers, in order
There are five ways this stage can end up with rows. Try them strictly in order and stop at the first one that hands you rows — you never climb back up a tier.
| Tier | Condition | Source | meta.kbSource |
|---|---|---|---|
| 1 | AIRTABLE_API_KEY is set |
Airtable REST, straight from the base (step 2 · tier 1) | "airtable" |
| 2 | tier 1 handed you no rows (no key set, or the key path failed) | the public feed — no token, no setup (step 2 · tier 2) | "airtable" |
| 2.5 | tier 2 handed you no rows (feed unreachable, non-200, or empty) | the bundled snapshot — <ROOT>/kb/kb.json (step 2 · tier 2.5) |
"airtable" |
| 2.7 | tier 2.5 handed you no rows (no snapshot on disk, unparseable, or empty) | that same snapshot, fetched from the repo — GitHub raw (step 2 · tier 2.7) | "airtable" |
| 3 | no source returned rows | nothing — the vanilla graph | "none" |
Tiers 1, 2, 2.5, and 2.7 are the same table read four ways — live, mirrored, mirrored to disk, and that disk copy fetched from its repo — so all four are "airtable". And in a session behind an egress proxy, a 403 / host_not_allowed on any of these hosts is a normal tier exit, not an error to fight: move down a tier and say so in one line.
Start by checking the AIRTABLE_API_KEY environment variable. Probe it, don't assume — and print only whether it is there, never the key itself:
if ($env:AIRTABLE_API_KEY) { 'set' } else { 'missing' }
echo ${AIRTABLE_API_KEY:+set}
(the bash form prints an empty line when the variable is unset or empty)
- Set → tier 1, fetch from Airtable (step 2 · tier 1). If that fetch fails (401, 404, network error) or returns zero rows, do not retry more than once and do not fabricate anything: drop to tier 2 and say so in the report. Tier 2 serves the public feed, not the base the key pointed at, so the mandated one-line failure report must name the substitution —
Airtable fetch failed (401); used the public feed instead. Someone running their own base has to know the suggestions came from the default knowledge base rather than from their rows. - Unset or empty → tier 2, fetch the public feed (step 2 · tier 2). A missing key does not end this stage and does not mean a vanilla graph — the feed needs no key at all.
- Tier 2 unusable too → tier 2.5, read the bundled snapshot (step 2 · tier 2.5) — a daily CI mirror of that same feed, committed to this repo, so it is on disk even when the network is not. Rows from it carry one extra duty: the report must state the snapshot's age (step 2 · tier 2.5 says how).
- No snapshot on disk — missing, unparseable, or empty → tier 2.7, fetch that same snapshot from the repo it is committed to (step 2 · tier 2.7). Some harnesses install this SKILL.md alone, with no
kb/beside it — the raw GitHub copy is the same file from a different host, which may also be the one host a proxy still allows. Its rows carry tier 2.5's age duty unchanged. - Tier 2.7 empty-handed too — non-200, unparseable, or empty → tier 3. Skip the rest of this stage: set
meta.kbSource: "none", leavesuggestions: [], and tell the user "KB not linked" in the report. This is normal, not a failure: the vanilla graph is the deliverable.
2. Fetch every row
Four sources, one job: end up holding every row of the knowledge-base table. Read only the tier step 1 sent you to.
Tier 1 — straight from Airtable (AIRTABLE_API_KEY is set)
Resolve the base and table from the environment, with Tahir's base as the default so a fork can point elsewhere:
| Variable | Default |
|---|---|
UNTANGLE_AIRTABLE_BASE |
appRSePRgk4jlaRUc |
UNTANGLE_AIRTABLE_TABLE |
tblOJzSLHAW7lbBWv |
Endpoint: https://api.airtable.com/v0/<base>/<table> · Header: Authorization: Bearer $AIRTABLE_API_KEY
Pagination is mandatory. Airtable returns at most 100 records per request as { "records": [...], "offset": "..." }. An offset in the response means more rows exist; request again with that exact offset value. Repeat until a response comes back with no offset key. Never stop after the first page.
Use whichever of these fits the session. All three do the same thing.
Node (any platform, walks all pages by itself, prints id + fields per row):
node -e "(async()=>{const B=process.env.UNTANGLE_AIRTABLE_BASE||'appRSePRgk4jlaRUc',T=process.env.UNTANGLE_AIRTABLE_TABLE||'tblOJzSLHAW7lbBWv';let out=[],offset;do{const u=new URL('https://api.airtable.com/v0/'+B+'/'+T);u.searchParams.set('pageSize','100');if(offset)u.searchParams.set('offset',offset);const r=await fetch(u,{headers:{Authorization:'Bearer '+process.env.AIRTABLE_API_KEY}});if(!r.ok){console.error('airtable',r.status,await r.text());process.exitCode=1;return}const j=await r.json();out=out.concat(j.records);offset=j.offset}while(offset);console.log(JSON.stringify(out.map(r=>Object.assign({id:r.id},r.fields)),null,1))})()"
curl (bash / Git Bash) — page 1:
curl -sS -H "Authorization: Bearer $AIRTABLE_API_KEY" \
"https://api.airtable.com/v0/${UNTANGLE_AIRTABLE_BASE:-appRSePRgk4jlaRUc}/${UNTANGLE_AIRTABLE_TABLE:-tblOJzSLHAW7lbBWv}?pageSize=100"
curl — every page after the first (paste the previous response's offset value verbatim; -G + --data-urlencode escapes it safely):
curl -sS -G -H "Authorization: Bearer $AIRTABLE_API_KEY" \
--data-urlencode "pageSize=100" \
--data-urlencode "offset=PASTE_OFFSET_FROM_PREVIOUS_RESPONSE" \
"https://api.airtable.com/v0/${UNTANGLE_AIRTABLE_BASE:-appRSePRgk4jlaRUc}/${UNTANGLE_AIRTABLE_TABLE:-tblOJzSLHAW7lbBWv}"
PowerShell (in PowerShell 5.1 curl is an alias for Invoke-WebRequest, so the bash flags above fail — use this instead; it walks all pages):
$base = if ($env:UNTANGLE_AIRTABLE_BASE) { $env:UNTANGLE_AIRTABLE_BASE } else { 'appRSePRgk4jlaRUc' }
$table = if ($env:UNTANGLE_AIRTABLE_TABLE) { $env:UNTANGLE_AIRTABLE_TABLE } else { 'tblOJzSLHAW7lbBWv' }
$headers = @{ Authorization = "Bearer $env:AIRTABLE_API_KEY" }
$rows = @(); $offset = $null
do {
$uri = "https://api.airtable.com/v0/$base/$table" + '?pageSize=100'
if ($offset) { $uri += '&offset=' + [uri]::EscapeDataString($offset) }
$page = Invoke-RestMethod -Uri $uri -Headers $headers
$rows += $page.records
$offset = $page.offset
} while ($offset)
$rows | ConvertTo-Json -Depth 6
A bad or missing key surfaces as an AUTHENTICATION_REQUIRED JSON body (curl, node) or a thrown (401) Unauthorized (PowerShell). Either way, step 1's fallback applies — do not paper over it.
Two things about the response shape, both of which matter later:
- Each record is
{ "id": "recXXXXXXXXXXXXXX", "createdTime": "...", "fields": { ... } }. Theidis the only legal source ofairtableRecordId. - Airtable omits empty fields entirely. An absent key in
fieldsmeans blank — not an error, and not something to guess at.
Tier 2 — the public feed (tier 1 handed you no rows: no key set, or the key path failed)
A cached public mirror of that same Airtable table, served by tahirlone.com. Plain GET, no authentication header of any kind, and no pagination — one request returns the entire knowledge base.
This is the tier for both keyless runs and runs whose Airtable fetch broke. If you arrived here from a failed tier 1, the rows below come from the public base, not the one the key pointed at — the report must say so (step 1).
| Variable | Default |
|---|---|
UNTANGLE_KB_URL |
https://tahirlone.com/api/untangle/kb |
curl (bash / Git Bash) — body to a file, status to the terminal. Keep it that way: a failing feed answers with a full HTML error page, and dumping that into the session costs thousands of tokens for nothing.
curl -sS -o untangle-kb-scratch.json -w 'HTTP %{http_code}\n' "${UNTANGLE_KB_URL:-https://tahirlone.com/api/untangle/kb}"
Read untangle-kb-scratch.json only when that line printed HTTP 200; on any other status leave the file unopened (it holds an error body or a site error page) and go to tier 2.5. Delete untangle-kb-scratch.json once the suggestions are authored — it is scratch, not a project artifact. (The awkward name is deliberate: this file lands in the working directory — in plugin mode, the user's own project — and a scratch write must never overwrite a file of theirs.)
PowerShell (Invoke-RestMethod parses the JSON for you and throws on any non-200 — that throw is your signal to go to tier 2.5):
$kbUrl = if ($env:UNTANGLE_KB_URL) { $env:UNTANGLE_KB_URL } else { 'https://tahirlone.com/api/untangle/kb' }
$feed = Invoke-RestMethod -Uri $kbUrl
$feed.records | ConvertTo-Json -Depth 6
A 200 response is this envelope and nothing else (the recXXXX… ids below are placeholders for shape only — never copy one into a graph):
{
"updatedAt": "2026-07-30T14:05:00.000Z",
"recordCount": 2,
"records": [
{
"id": "recXXXXXXXXXXXXXX",
"name": "owner/example-mcp",
"url": "https://github.com/owner/example-mcp",
"category": "MCP Server",
"description": "Runs SQL against a warehouse and returns typed results.",
"language": "TypeScript",
"stars": 1840,
"dateFirstSeen": "2026-06-02",
"capabilityTags": ["data-etl", "api-integration"],
"stepArchetypes": ["data-etl", "research"],
"improvementClaim": "Replaces hand-written export scripts with one query call.",
"install": "claude mcp add example-mcp"
},
{
"id": "recYYYYYYYYYYYYYY",
"name": "owner/plain-repo",
"url": "https://github.com/owner/plain-repo",
"category": "GitHub Trending"
}
]
}
updatedAt— ISO 8601 timestamp of the mirror's last refresh from Airtable. Informational; never write it into the graph.recordCount— how many objects are inrecords.records— the rows themselves. This array is the whole knowledge base: there is nooffset, nonextlink, and no second page to request.
Anything else means tier 2 is unusable. Do not retry more than once, do not fabricate rows — report the failure in one line and go to tier 2.5:
| Response | What it means |
|---|---|
503 { "error": "kb_unavailable" } |
the feed is not configured on the server |
502 { "error": "upstream_failed" } |
the feed could not reach Airtable |
any other non-200 status, a network/DNS error, a timeout, or records: [] |
nothing usable came back |
Record shape. Each element of records is a flat object: the fields sit at the top level, not nested under a fields key, and their names are camelCase. Absent fields are omitted entirely, exactly as Airtable does it — an absent key means blank, not an error, and not something to guess at (see owner/plain-repo above, which carries no enrichment fields and is therefore not a candidate under step 3).
The omission spares nothing: name, url, and category can be missing too. id is the only key guaranteed on every record. So — a candidate row with no name or no url cannot become a suggestion at all: the schema requires both, and url must match ^https?://. Skip such a row silently and never invent a value to fill the hole. (A missing category is harmless — step 5 already writes Other for anything outside the schema's enum.)
Steps 3–5 are written against the Airtable field names, and they apply here unchanged: this feed is that Airtable table, one feed record per Airtable row. Translate the names with this table; nothing else about those steps changes.
| Feed key | Airtable field | Type | Read by |
|---|---|---|---|
id |
the record id itself | string, ^rec[A-Za-z0-9]{14}$ |
the only legal source of airtableRecordId — copy it verbatim, character for character |
name |
Name |
string | step 5 → name |
url |
URL |
string | step 5 → url |
category |
Category |
string | step 3 candidate filter · step 5 → category |
description |
Description |
string | step 4 matching · step 5 claim fallback |
capabilityTags |
Capability Tags |
array of strings | step 3 candidate filter · step 4 matching |
stepArchetypes |
Step Archetypes |
array of strings | step 4 matching (strongest signal) |
improvementClaim |
Improvement Claim |
string | step 5 → claim |
install |
Install |
string | step 5 → install |
language |
Language |
string | nothing |
stars |
Stars |
number | nothing |
dateFirstSeen |
Date First Seen |
string | nothing |
One gap to hold on to: the feed does not carry Why Noteworthy. Step 5's claim fallback names Description / Why Noteworthy; on this tier only description exists, so a row with no improvementClaim gets its one line written from that row's own description alone — never from anywhere else.
The feed is a cached snapshot: an edit made in the source base reaches it typically within ~30 minutes (server cache + background refresh); during upstream outages the feed serves the last good copy and updatedAt shows its age. Neither case is a failure and neither needs working around: use exactly the rows the feed returned. Rows from tier 2 count fully as reading the knowledge base — meta.kbSource is "airtable", same as tier 1 (step 7).
The knowledge-base table's fields and select choices are documented in <ROOT>/kb/airtable-template.md. Read it if a row's shape surprises you, or if the user is setting up their own base.
Tier 2.5 — the bundled snapshot (tier 2 unusable)
<ROOT>/kb/kb.json, resolved from the root this skill ships in — the checkout or the plugin install directory, whichever home this SKILL.md was read from. It is a daily CI mirror of the very feed tier 2 just failed to reach, committed by the KB snapshot workflow, so it is a tracked file that is always there: not tier 2's scratch untangle-kb-scratch.json, and never deleted. No network, no request — just read it from disk.
The file is the tier-2 envelope on disk with one addition: fetchedAt, the ISO 8601 timestamp of the run that took the snapshot. That field is the snapshot's age; hold on to it for the report.
PowerShell (set $root to the absolute ROOT first; an error from the read — no file, or a file that is not JSON — is your signal to go to tier 3):
$root = '<ROOT>'
$snap = Get-Content (Join-Path $root 'kb/kb.json') -Raw | ConvertFrom-Json
"mirrored $($snap.fetchedAt) — $($snap.records.Count) records"
$snap.records | ConvertTo-Json -Depth 6
Node (any platform) — same signal, a thrown error means tier 3; the trailing argument is the snapshot's ROOT-resolved path:
node -e "const s=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));console.log('mirrored '+s.fetchedAt+' — '+s.records.length+' records');console.log(JSON.stringify(s.records,null,1))" "<ROOT>/kb/kb.json"
Present, parseable, and records non-empty → those records are the rows: the same flat camelCase shape as tier 2, so the key-translation table above and steps 3–5 apply unchanged, and meta.kbSource is "airtable" (step 7). One extra duty comes with them: these rows are a mirror, not the live feed, so the report's knowledge-base line (## Report, item 4) must carry the staleness note — KB snapshot — last mirrored <date>, the date read from fetchedAt. (A hand-rolled snapshot might lack that field; then the file's last commit date stands in: git -C "<ROOT>" log -1 --format=%cs -- kb/kb.json.)
Missing, unparseable, or records: [] → tier 2.7: the snapshot is a committed file, so the repo it lives in can serve it when the disk cannot — a different host than tier 2's feed, and its own turn at the network.
Tier 2.7 — the snapshot from its repo (no snapshot on disk)
The same kb/kb.json tier 2.5 just looked for, fetched from the repository it is committed to — for the harnesses that ship this SKILL.md with no kb/ beside it. Plain GET, no auth, one URL: https://raw.githubusercontent.com/tahirzlone/untangle/main/kb/kb.json
curl (bash / Git Bash) — tier 2's scratch discipline, unchanged: body to the same scratch file, status to the terminal, read only on HTTP 200, delete once the suggestions are authored:
curl -sS -o untangle-kb-scratch.json -w 'HTTP %{http_code}\n' https://raw.githubusercontent.com/tahirzlone/untangle/main/kb/kb.json
PowerShell (a throw means non-200 — that throw is your signal to go to tier 3):
$snap = Invoke-RestMethod -Uri 'https://raw.githubusercontent.com/tahirzlone/untangle/main/kb/kb.json'
"mirrored $($snap.fetchedAt) — $($snap.records.Count) records"
$snap.records | ConvertTo-Json -Depth 6
What comes back is tier 2.5's file, byte for byte: the tier-2 envelope plus fetchedAt. Everything tier 2.5 says applies unchanged — the flat camelCase records, the key-translation table and steps 3–5, meta.kbSource: "airtable" (step 7), and the report's staleness duty: KB snapshot — last mirrored <date>, the date read from fetchedAt. Non-200, a network error, an unparseable body, or records: [] → tier 3.
3. Candidate filter
A row is a candidate if either condition holds:
- its
CategoryisClaude Skill,Claude Plugin, orMCP Server; or - its
Capability Tagsis non-empty (present with at least one value) — whatever theCategory, includingGitHub TrendingandOther.
Every other row (typically a GitHub Trending row nobody has enriched yet) is not a candidate. Ignore it silently; unenriched rows are not defects.
4. Match candidates to nodes
For each node, compare the node's kind + label + description against each candidate's Step Archetypes + Capability Tags + Description.
Step Archetypes is the strongest signal — it names the kind of step the resource upgrades (research, scaffold, code, test, browser-verify, deploy, document, data-etl, review, orchestrate). Node kind alone never decides a match: a process node might be research, ETL, or deployment work. The label and description say which.
A match is worth keeping only when the resource would actually erase or collapse the work that node describes — not merely sit in the same topic area. Prefer the highest-painLevel nodes; that is where a helper is visibly worth installing.
- 0–3 suggestions per graph is NORMAL. A forced match is worse than none.
- Fetched the knowledge base and nothing matched? That is a legitimate result:
meta.kbSource: "airtable",suggestions: [], and say so plainly in the report.
5. Author each suggestion
One object per match, in suggestions:
| Field | Value |
|---|---|
nodeId |
the id of the node this upgrades — must be an id that exists in nodes. This is where the suggestion badge appears, so it is normally the painful node the resource takes over (usually one of the nodes the effect removes or merges) |
airtableRecordId |
the row's real id from the response, copied exactly (^rec[A-Za-z0-9]{14}$). Never type one from memory, never edit one, never make one up — this field is what proves the resource is real |
name |
the row's Name, verbatim |
url |
the row's URL, verbatim (must start with http:// or https://) |
category |
the row's Category, verbatim — except that the schema's enum is Claude Skill, Claude Plugin, MCP Server, Connector, Other. Airtable's GitHub Trending choice is not in that enum: write Other for those rows. Any other value outside the enum also becomes Other |
claim |
the row's Improvement Claim, verbatim. Blank or absent → write one line yourself in the same plain style, grounded ONLY in that row's own Description / Why Noteworthy. Never invent a repo, a feature, or a capability the row does not support |
install |
the row's Install, verbatim. Blank or absent → omit the key entirely rather than writing an empty string |
promptFragment |
optional — the instructions for using this resource at this step, written by you. See "The optimized prompt" below; omit the key when you have nothing grounded to say |
effect |
the patch — see step 6 |
6. The effect patch
effect is not decoration; the viewer executes it. Applying suggestion S to workflow W does exactly this, in order:
- Delete every node in
S.effect.removeNodesandS.effect.mergeNodes. - If
S.effect.replaceWithexists, add it as a new node. - Drop every edge touching a deleted node; add
S.effect.newEdgesverbatim. - Remove S from
suggestions, along with any OTHER suggestion whosenodeIdor effect references a deleted node (its target is gone). - Add
S.effect.metricsto the session totals.
The result is re-validated. A patch that breaks the graph is refused and the card renders as un-appliable — a wasted suggestion.
Fields:
removeNodes(required array, may be empty) — nodes the resource makes unnecessary outright.mergeNodes(required array, may be empty) — nodes that collapse intoreplaceWith. Deleted exactly likeremoveNodes; the distinction is only how the UI narrates it. A node id must never appear in both arrays.replaceWith(optional, a single full node:id,label,kind,description,painLevel) — the one step that stands in for what was removed. Itsidmust be new kebab-case, must not collide with any surviving node id, and must not equal any OTHER suggestion'sreplaceWith.id— each replacement node id must be unique across the wholesuggestionsarray. (Two suggestions introducing the same id both validate, but once one is applied the other collides with the node it just created and is permanently un-appliable.) ItspainLevelis the eased work, so it belongs at the bottom of the rubric (1–2) — a replacement as painful as what it replaced is not an improvement.newEdges(required array, may be empty) — edges to add after the deletions. Every endpoint must be a surviving node id orreplaceWith.id. An endpoint this same effect deletes is a hard error.metrics(required) —stepsSaved,estTimeSavedMin,estTokensSaved,manualInterventionsRemoved, all integers ≥ 0.
Every effect must eliminate or replace at least one node. An effect that deletes nothing passes the schema and still fails on screen: with empty removeNodes and empty mergeNodes the user clicks APPLY and watches an identical graph, and adding a replaceWith on its own only makes it worse — the graph grows a node and stepsSaved computes negative. If a resource eliminates nothing, it is not load-bearing; drop the suggestion instead. A real effect takes one of two shapes:
- Collapse:
removeNodes(and/ormergeNodes) withnewEdgesclosing the gap — steps disappear entirely. - Substitute:
mergeNodeslisting the painful steps plus a low-painreplaceWithandnewEdgeswiring it in — several manual steps become one helper-driven step.
Two traps to author around:
- Rewire what you cut. Because step 3 drops every edge touching a deleted node, removing a node from the middle of the flow leaves its upstream and downstream disconnected. Supply
newEdgesreconnecting them (upstream →replaceWith→ downstream, or upstream → downstream directly). Nothing downstream checks connectivity — no validator and no reducer will catch a missed rewire; the patch applies happily and strands an orphaned node on screen. YOU are the only gate: after authoring the effect, re-walk every surviving node and confirm it still has a path from the input node. - Respect the size floor. The schema requires at least 3 nodes and at least 2 edges, and the graph is re-validated AFTER the patch applies. Count it before you write it:
nodes − (removeNodes + mergeNodes) + (replaceWith ? 1 : 0) ≥ 3, and surviving edges +newEdges≥ 2. On a small graph, keep effects modest — one or two nodes. Never author an effect that would shrink the graph below the floor.
Metrics, estimated conservatively — this number is on screen next to a real resource, so it has to survive scrutiny:
stepsSaved— the count of nodes this effect actually eliminates:removeNodes + mergeNodes − (replaceWith ? 1 : 0). Never more than that.estTimeSavedMin— minutes the eliminated steps genuinely cost, read off theirpainLevel(a pain-2 node is a few minutes, not an hour).estTokensSaved—0unless you have a real basis for a number.0is honest; a rounded guess is not.manualInterventionsRemoved— count only eliminatedreviewnodes and explicit human hand-offs. Usually0or1.
If two suggestions target overlapping nodes, that is allowed but understand the consequence: applying the first deletes the second's target, so the second disappears (step 4). Prefer suggestions with disjoint targets so the user can apply them all.
7. Set meta.kbSource
"airtable"— you fetched the knowledge base, whatever the match count (including zero). Tiers 1, 2, 2.5, and 2.7 all count: the public feed is Airtable data too, and the snapshot is that feed on disk — or fetched back off its repo."none"— tier 3: no source returned rows. Thensuggestionsmust be[].
8. Self-check before validating
Walk the list; the validator catches most of it, but a caught error costs a round trip:
- every
nodeIdexists innodes - every
airtableRecordIdmatches^rec[A-Za-z0-9]{14}$and appears in a response you actually fetched - no
airtableRecordIdappears twice insuggestions - every
categoryis one of the schema's five values -
installpresent only when it has real content - all
removeNodes/mergeNodesids exist; no id in both - no effect is a no-op — each one removes, merges, or substitutes at least one node
- every
newEdgesendpoint is a surviving node id orreplaceWith.id - every
replaceWith.idis unique across the wholesuggestionsarray (no two suggestions introduce the same replacement id) - every surviving node still has a path from the input node after each patch — you are the only connectivity check
- post-patch counts still ≥ 3 nodes and ≥ 2 edges
-
metricsare four non-negative integers -
meta.kbSourcematches what you actually did - every
promptFragmentis 2–4 imperative sentences that name the resource, say when in the flow to use it, and say what it replaces - no
promptFragmentasserts a capability its row does not claim, and none leans on another suggestion being applied - no
promptFragmentmentions installing at all — the viewer's setup block states every install -
meta.promptIntrokeeps every requirement the user stated, adds none, and names no resources - neither prompt field is an empty string — the key is omitted instead
The optimized prompt
The viewer assembles an optimized prompt the user can paste straight into Claude: meta.promptIntro first, then the promptFragment of every suggestion they applied, in flow order, then a setup block — one line reading "Before you start, install what the steps above rely on:", and under it the install of every applied resource that has one, one backticked command per line. A resource with nothing to install contributes no line, and neither does an install string still carrying a line break after trimming — the line is the unit the reader ticks out, so the install kit takes that string instead, into its commented read-it-yourself section. The installs are the viewer's to state and yours to leave alone: the user can tick a resource out of that block, and a command written into your prose could not be taken back out with it. Both fields are optional prose that you write — without them the viewer templates a serviceable line per suggestion out of name / category / claim, so a file that omits them still works. Author them last: after the suggestions and effects are settled, before the validation loop.
These fields are prose about rows you already fetched, so the HARD RULES above cover them unchanged — a fragment may not name a resource that is not a suggestion in this graph, and it may not describe a capability its row does not claim. Never write an empty string: the schema rejects "". Nothing grounded to say → omit the key.
meta.promptIntro — the opening
One paragraph, 2–4 sentences, rewriting meta.task as the opening of a prompt addressed to Claude instead of a description of what the user wants.
- Imperative, second person: "Build the weekly issue…", never "The user would like…".
- Carry over every requirement the user actually stated, and add none. No stack, deadline, tone, or acceptance criterion they did not give you.
- One sentence may frame the shape of the work the graph found — the phases, the review gate, the delivered artifact. No more than one.
- Name no resources. The fragments introduce those one at a time, each at the step where it belongs.
- Write it whatever the knowledge base did: a vanilla graph with
kbSource: "none"still deserves a clean opening. - Can't beat
meta.taskverbatim? Omit the key — the viewer falls back to the user's own words, which is never wrong.
suggestions[].promptFragment — one instruction per resource
Per suggestion, 2–4 sentences telling Claude to use THAT resource at THAT point in the work. Four things every fragment does:
- Names the resource exactly as the suggestion's
name. - Says when in the flow to reach for it — anchored to the work the target node describes, in the task's own vocabulary ("before you rank anything", "once the tests exist"), so the fragments read as a sequence when the viewer concatenates them.
- Says what it replaces — the manual work this effect removes or merges, named as work rather than as node ids.
- Stays inside the row's claim. Every capability it asserts must be supported by that row's
Improvement Claim,Description, orCapability Tags. No flags, subcommands, config keys, or API shapes you have not seen in the row — inventing one is the same offence as inventing a resource.
And three things a fragment never does:
- Mention installing. Not the row's
Install, not a paraphrase of it, not "add it first" — nothing. The viewer's setup block already states every applied install under a line of its own, where the user can tick one back out again; a command written into your sentence is one they cannot. Nothing is lost by leaving it out, and a fragment that says it anyway says it twice. - Talk about the graph. This is instruction for doing the work, not a tour of the diagram: no "this node", "the suggestion above", "as the graph shows".
- Lean on its neighbours. The user may apply this suggestion and no other, so the fragment has to read correctly as the only one in the prompt. Never refer to another fragment or another resource.
Grounded in a row named example/rss-mcp, claim "Fetches and filters feeds in one tool call instead of manual parsing.", install claude mcp add rss -- npx rss-mcp:
Write this — "Use the example/rss-mcp server to gather the articles instead of fetching each feed and parsing the XML by hand. Call it once with the feed list and a 7-day window before you rank anything, then work from what it returns."
Not this — "example/rss-mcp is a fast, powerful RSS tool that handles all your feed needs." It names no moment in the flow, replaces nothing the user can point at, and "handles all your feed needs" is a capability the row never claimed.
Nor this — the same good fragment with "Add it first with
claude mcp add rss -- npx rss-mcp." on the end. The instruction is right; the install is not yours to state. The viewer lists that command in the setup block, where the user can take it out again.
Validation loop (mandatory)
Validation is a ladder: three rungs, in order, and the first rung the environment can offer is the one that runs. Every run validates on some rung and says which, in one status line — never skip validation silently, and never claim the official validator passed when a lower rung did the checking.
- Rung 1 — the bundled validator.
<ROOT>/scripts/validate.bundle.mjsexists → run:node "<ROOT>/scripts/validate.bundle.mjs" <path-you-wrote>(e.g.out/<slug>.workflow.jsonorgallery/<slug>.workflow.json) — from where
…(truncated)