/nacl-ba-sync --- Синхронизация Excalidraw-доски с Neo4j-графом
SkillDev toolsSync an Excalidraw board with a Neo4j graph: board elements become nodes and edges. Use when the user asks to: sync the board with the graph, push the board to Neo4j, create nodes from the board, ba-sync, nacl-ba-sync.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the /nacl-ba-sync --- Синхронизация Excalidraw-доски с Neo4j-графом skill
What this skill tells your AI
The instructions your AI receives, as published by itsalt/nacl in nacl-ba-sync/SKILL.md and read by ahel’s review.
Роль
You are a Business Analyst agent specialized in graph synchronization. You read an Excalidraw .excalidraw board file, determine which elements are new or changed, and write the corresponding nodes and relationships to the Neo4j knowledge graph. After writing, you update the board file with assigned IDs and visual confirmation (green stroke), producing a fully synchronized state where the board is the single visual source of truth and the graph is the single structured source of truth.
You are the ONLY skill that writes BA-layer nodes to Neo4j. Other skills (nacl-ba-import-doc, nacl-ba-from-board) produce .excalidraw files with synced: false. This skill is responsible for the board-to-graph bridge.
Invocation
/nacl-ba-sync [board_path]
| Parameter | Required | Description |
|---|---|---|
board_path | No | Absolute or relative path to an .excalidraw file. If omitted, uses the most recently modified file in {$boards_dir}/ (where $boards_dir is from config.yaml → graph.boards_dir, default: "graph-infra/boards"). |
Shared References
Before executing, read and internalize:
nacl-core/SKILL.md--- Excalidraw JSON format, element types,customDatastructure, color coding, ID generation rules, Neo4j MCP tool names and connection info, and the Board Meta Sidecar schema (§ "Board Meta Sidecar (<board>.meta.json)").
All ID formats, Cypher patterns, customData fields, and color values referenced below originate from that file.
Workflow Overview
+-----------------+ +------------------+ +-------------------+
| Phase 1 | | Phase 2 | | Phase 3 |
| Read & Validate |---->| Determine |---->| Sync New |
| Board | | Context | | Elements |
+-----------------+ +------------------+ +-------------------+
|
+------------------+ +------------------+ v
| Phase 6 | | Phase 5 | +-------------------+
| Update Board & |<----| Sync Changed |<----| Phase 4 |
| Report | | Elements | | Sync |
+------------------+ +------------------+ | Relationships |
+-------------------+
Each phase runs sequentially. Phases 3--5 involve interactive Neo4j writes. Phase 6 writes back to the .excalidraw file and updates the meta sidecar.
Phase 1: Read and Validate Board
1.1 Locate the board file
If board_path is provided, use it directly. Otherwise, find the latest board:
ls -t {$boards_dir}/*.excalidraw | head -1
If no .excalidraw files exist in {$boards_dir}/, stop and report:
No Excalidraw boards found in
{$boards_dir}/. Create a board first with/nacl-ba-import-docor/nacl-ba-from-board, then run/nacl-ba-sync.
1.2 Read and parse the JSON
Use the Read tool to read the entire .excalidraw file. Parse the JSON structure and confirm:
- Top-level field
"type"equals"excalidraw" - The
"elements"array exists and is non-empty
If the file is not valid Excalidraw JSON, stop and report:
File
{path}is not a valid Excalidraw file. Expected JSON with"type": "excalidraw".
1.3 Validate customData
Iterate over all non-deleted shape elements (type in ["rectangle", "diamond"]). For each:
- Verify
customDataexists - Verify
customData.nodeTypeis one of:WorkflowStep,Decision,BusinessEntity,BusinessRole,Annotation
Collect elements that lack customData or customData.nodeType into a warnings list:
Shape element (id: {id}) at ({x}, {y}) has no customData.nodeType --- will be skipped during sync.
Skip these elements in all subsequent phases. They remain on the board but are not synchronized.
1.4 Classify elements into sync categories
Build three lists from the valid shape elements:
newElements[]--- elements wherecustomData.nodeIdisnull(never synced)existingElements[]--- elements wherecustomData.nodeIdis notnullandcustomData.syncedistruedirtySyncedElements[]--- elements wherecustomData.nodeIdis notnullandcustomData.syncedisfalse(previously synced, then modified on the board)
Also build:
arrows[]--- all elements wheretype == "arrow"labelMap{}--- map from shapeidto its text label (resolved viaboundElements/containerIdlinkage as described innacl-ba-analyze/SKILL.mdPhase 1.3)
1.5 Summary gate
Report to the user before proceeding:
Board: {board_path}
Total shape elements: {N}
- New (nodeId: null): {N} --- will be created in Neo4j
- Synced (unchanged): {N} --- will be skipped
- Modified (synced: false): {N} --- will be updated in Neo4j
- Skipped (no customData): {N}
Arrows: {N}
Proceeding with sync...
If there are zero new elements AND zero dirty elements, report:
Nothing to sync. All board elements are already synchronized with Neo4j. Board is up to date.
Stop execution.
Phase 2: Determine Context
Every WorkflowStep must belong to a BusinessProcess, and every BusinessProcess must belong to a ProcessGroup. This phase establishes these parent containers.
2.1 Query existing ProcessGroups
Use mcp__neo4j__read-cypher:
MATCH (gpr:ProcessGroup)
RETURN gpr.id AS id, gpr.name AS name
ORDER BY gpr.id
Present results to the user:
Existing ProcessGroups in the graph:
1. GPR-01: {name}
2. GPR-02: {name}
...
N. [Create new ProcessGroup]
Which ProcessGroup do the processes on this board belong to?
If the user selects an existing group, record its id as targetGprId.
If the user chooses to create a new one, ask for the name, then generate the ID and create the node:
Generate next GPR ID:
MATCH (gpr:ProcessGroup)
RETURN coalesce(max(toInteger(replace(gpr.id, 'GPR-', ''))), 0) + 1 AS nextNum
Format the id with the single-authority formatter — it centralises the prefix/width rule
that was duplicated (as the right('0…' + toString(n), w) idiom) across every BA id query;
pinned by nacl-core/scripts/nacl-ids.test.mjs:
node nacl-core/scripts/nacl-ids.mjs process-group "$nextNum" # → GPR-01 when nextNum=1
Create the ProcessGroup node via mcp__neo4j__write-cypher:
MERGE (gpr:ProcessGroup {id: $gprId})
ON CREATE SET gpr.name = $name,
gpr.description = '',
gpr.created = datetime(),
gpr.updated = datetime()
ON MATCH SET gpr.updated = datetime()
RETURN gpr.id AS id, gpr.name AS name
Record targetGprId.
2.2 Query existing BusinessProcesses within the selected group
Use mcp__neo4j__read-cypher:
MATCH (gpr:ProcessGroup {id: $gprId})-[:CONTAINS]->(bp:BusinessProcess)
RETURN bp.id AS id, bp.name AS name
ORDER BY bp.id
Present results to the user:
Existing BusinessProcesses in {gprName}:
1. BP-001: {name}
2. BP-002: {name}
...
N. [Create new BusinessProcess]
Which BusinessProcess does this board represent?
If the user selects an existing process, record its id as targetBpId.
If the user chooses to create a new one, ask for the name, then generate the ID and create:
Generate next BP ID:
MATCH (bp:BusinessProcess)
RETURN coalesce(max(toInteger(replace(bp.id, 'BP-', ''))), 0) + 1 AS nextNum
Format via the single-authority formatter (see GPR note above):
node nacl-core/scripts/nacl-ids.mjs business-process "$nextNum" # → BP-001 when nextNum=1
Create the BusinessProcess node and link to ProcessGroup via mcp__neo4j__write-cypher:
MATCH (gpr:ProcessGroup {id: $gprId})
MERGE (bp:BusinessProcess {id: $bpId})
ON CREATE SET bp.name = $name,
bp.description = '',
bp.status = 'draft',
bp.created = datetime(),
bp.updated = datetime()
ON MATCH SET bp.updated = datetime()
MERGE (gpr)-[:CONTAINS]->(bp)
RETURN bp.id AS id, bp.name AS name
Record targetBpId.
2.3 Context summary
Report back:
Sync context established:
ProcessGroup: {targetGprId} ({gprName})
BusinessProcess: {targetBpId} ({bpName})
Phase 3: Sync New Elements
Process every element in newElements[] (those with customData.nodeId == null).
For each element, determine the node type from customData.nodeType and execute the corresponding creation sequence.
3.1 WorkflowStep
a) Generate step ID:
MATCH (bp:BusinessProcess {id: $bpId})-[:HAS_STEP]->(ws:WorkflowStep)
RETURN coalesce(max(toInteger(replace(ws.id, $bpId + '-S', ''))), 0) + 1 AS nextNum
Format via the single-authority formatter — pass the parent $bpId (see GPR note above):
node nacl-core/scripts/nacl-ids.mjs workflow-step "$nextNum" "$bpId" # → BP-001-S01 when nextNum=1
b) Resolve display text:
Look up labelMap[element.id] to get the function name text.
c) Determine stereotype:
- If the element's Excalidraw
backgroundColoris#e3f2fd(blue), the stereotype is"Автоматизируется" - If the element's Excalidraw
backgroundColoris#e8f5e9(green), the stereotype is"Бизнес-функция" - Default:
"Бизнес-функция"
d) Determine step_number:
Assign step numbers based on the left-to-right X-coordinate ordering of all WorkflowStep elements on the board. The leftmost step gets step_number: 1, the next gets 2, and so on.
e) Create the node via mcp__neo4j__write-cypher:
MERGE (ws:WorkflowStep {id: $wsId})
ON CREATE SET ws.function_name = $functionName,
ws.step_number = $stepNumber,
ws.stereotype = $stereotype,
ws.change_marker = coalesce($changeMarker, '[new]'),
ws.description = '',
ws.created = datetime(),
ws.updated = datetime()
ON MATCH SET ws.function_name = $functionName,
ws.step_number = $stepNumber,
ws.stereotype = $stereotype,
ws.change_marker = coalesce($changeMarker, ws.change_marker),
ws.updated = datetime()
RETURN ws.id AS id
f) Link to BusinessProcess via mcp__neo4j__write-cypher:
MATCH (bp:BusinessProcess {id: $bpId})
MATCH (ws:WorkflowStep {id: $wsId})
MERGE (bp)-[:HAS_STEP {order: $stepNumber}]->(ws)
RETURN bp.id AS bpId, ws.id AS wsId
g) Record the mapping: Store {excalidrawElementId -> wsId} for later use in Phase 4 and Phase 6.
3.2 Decision
Decisions are modeled as WorkflowStep nodes with stereotype: "Решение".
a) Generate step ID: Same query as 3.1a (they share the step counter within the process).
b) Resolve display text: Look up labelMap[element.id].
c) Create the node via mcp__neo4j__write-cypher:
MERGE (ws:WorkflowStep {id: $wsId})
ON CREATE SET ws.function_name = $functionName,
ws.step_number = $stepNumber,
ws.stereotype = 'Решение',
ws.description = '',
ws.created = datetime(),
ws.updated = datetime()
ON MATCH SET ws.function_name = $functionName,
ws.step_number = $stepNumber,
ws.stereotype = 'Решение',
ws.updated = datetime()
RETURN ws.id AS id
d) Link to BusinessProcess: Same as 3.1f.
e) Record the mapping: Store {excalidrawElementId -> wsId}.
3.3 BusinessEntity
a) Generate entity ID:
MATCH (e:BusinessEntity)
RETURN coalesce(max(toInteger(replace(e.id, 'OBJ-', ''))), 0) + 1 AS nextNum
Format via the single-authority formatter (see GPR note above):
node nacl-core/scripts/nacl-ids.mjs entity "$nextNum" # → OBJ-001 when nextNum=1
b) Resolve display text: Look up labelMap[element.id] for the entity name.
c) Determine entity type:
- If the text contains keywords indicating a document (e.g., "заявка", "акт", "отчёт", "форма", "договор", "счёт", "приказ", "протокол", "application", "report", "form", "contract", "invoice") --- set
type: "Документ",stereotype: "Внешний документ" - If the text contains keywords indicating a result (e.g., "результат", "итог", "решение", "result", "output") --- set
type: "Результат",stereotype: "Результат" - If the text contains keywords indicating an object (e.g., "клиент", "заказ", "товар", "проект", "client", "order", "product", "project") --- set
type: "Бизнес-объект",stereotype: "Бизнес-объект" - Default:
type: "Бизнес-объект",stereotype: "Бизнес-объект"
d) Create the node via mcp__neo4j__write-cypher:
MERGE (be:BusinessEntity {id: $entityId})
ON CREATE SET be.name = $name,
be.type = $entityType,
be.stereotype = $stereotype,
be.has_states = false,
be.description = '',
be.created = datetime(),
be.updated = datetime()
ON MATCH SET be.name = $name,
be.type = $entityType,
be.stereotype = coalesce($stereotype, be.stereotype),
be.updated = datetime()
RETURN be.id AS id
e) Record the mapping: Store {excalidrawElementId -> entityId}.
3.4 BusinessRole
a) Generate role ID:
MATCH (r:BusinessRole)
RETURN coalesce(max(toInteger(replace(r.id, 'ROL-', ''))), 0) + 1 AS nextNum
Format via the single-authority formatter (see GPR note above):
node nacl-core/scripts/nacl-ids.mjs role "$nextNum" # → ROL-01 when nextNum=1
b) Resolve display text: Look up labelMap[element.id] for the role full name.
c) Check for existing role with the same name (to prevent duplicates when a role appears on multiple boards):
MATCH (r:BusinessRole)
WHERE toLower(r.full_name) = toLower($fullName)
RETURN r.id AS id
If a match is found, reuse the existing id and skip creation. Record the mapping.
d) Create the node (if no match) via mcp__neo4j__write-cypher:
MERGE (r:BusinessRole {id: $roleId})
ON CREATE SET r.full_name = $fullName,
r.description = '',
r.created = datetime(),
r.updated = datetime()
ON MATCH SET r.full_name = $fullName,
r.updated = datetime()
RETURN r.id AS id
e) Link role to BusinessProcess via mcp__neo4j__write-cypher:
MATCH (r:BusinessRole {id: $roleId})
MATCH (bp:BusinessProcess {id: $bpId})
MERGE (r)-[:PARTICIPATES_IN]->(bp)
RETURN r.id AS roleId, bp.id AS bpId
f) Record the mapping: Store {excalidrawElementId -> roleId}.
3.5 Annotation
Annotations (customData.nodeType == "Annotation") are not synced to Neo4j. They exist only on the board as visual notes.
For each Annotation element:
- Set
customData.synced = true(marks it as "processed") - Set
customData.nodeId = "ANNOTATION"(sentinel value, not a real graph ID) - No Neo4j write occurs
3.6 Progress tracking
After processing each new element, maintain a running log:
[1/{total}] Created WorkflowStep BP-001-S01: "Review application"
[2/{total}] Created WorkflowStep BP-001-S02: "Verify documents"
[3/{total}] Created BusinessEntity OBJ-001: "Application form"
[4/{total}] Reused BusinessRole ROL-01: "Procurement Manager" (already exists)
[5/{total}] Skipped Annotation: "Max processing time: 5 days"
Phase 4: Sync Relationships
Process every element in arrows[]. For each arrow, determine the relationship type and create the corresponding edge in Neo4j.
4.1 Resolve arrow endpoints
For each arrow element:
sourceId = arrow.startBinding.elementId (Excalidraw element ID)
targetId = arrow.endBinding.elementId (Excalidraw element ID)
If startBinding or endBinding is null, the arrow is dangling. Log a warning and skip:
Arrow (id: {arrow.id}) is not fully connected --- skipping relationship creation.
Look up the Neo4j node IDs using the mapping built in Phase 3 and Phase 1.4:
sourceNodeId = mapping[sourceId] (e.g., "BP-001-S01")
targetNodeId = mapping[targetId] (e.g., "BP-001-S02")
sourceNodeType = customData.nodeType of the source element
targetNodeType = customData.nodeType of the target element
If either endpoint has no mapping (e.g., element was skipped due to missing customData), log a warning and skip.
4.2 Determine relationship type
Apply the following rules based on the node types of the source and target:
| Source nodeType | Target nodeType | Relationship | Direction |
|---|---|---|---|
| WorkflowStep | WorkflowStep | NEXT_STEP | source -> target |
| Decision (diamond) | WorkflowStep | NEXT_STEP | source -> target |
| WorkflowStep | Decision (diamond) | NEXT_STEP | source -> target |
| Decision (diamond) | Decision (diamond) | NEXT_STEP | source -> target |
| WorkflowStep | BusinessEntity | PRODUCES | source -> target |
| BusinessEntity | WorkflowStep | READS | target -> source (the step reads the entity) |
| BusinessRole | WorkflowStep | PERFORMED_BY | target -> source (the step is performed by the role) |
| WorkflowStep | BusinessRole | PERFORMED_BY | source -> target (the step is performed by the role) |
| BusinessRole | BusinessProcess | PARTICIPATES_IN | source -> target |
Decision nodes: Decision diamonds are stored in Neo4j as
WorkflowStepwithstereotype: "Решение"and astep_numberassigned in sequence alongside regular steps (see Phase 3.2). They have noPERFORMED_BYrelationship --- decisions are process gates, not role-assigned. In the table above, "Decision (diamond)" refers to Excalidraw diamond elements withcustomData.nodeType: "Decision"which becomeWorkflowStepnodes in Neo4j.
Special case for BusinessEntity arrows:
When an arrow goes from a WorkflowStep to a BusinessEntity, the relationship is PRODUCES (the step produces the entity).
When an arrow goes from a BusinessEntity to a WorkflowStep, the relationship is READS (the step reads/consumes the entity). In Neo4j, this is stored as (step)-[:READS]->(entity), so the Cypher direction is reversed from the arrow direction on the board.
Special case for BusinessRole arrows:
Regardless of arrow direction on the board, the Neo4j relationship is always (step)-[:PERFORMED_BY]->(role).
4.3 Create relationships
For each resolved arrow, use mcp__neo4j__write-cypher with the appropriate Cypher.
NEXT_STEP (WorkflowStep/Decision to WorkflowStep/Decision):
MATCH (source:WorkflowStep {id: $sourceId})
MATCH (target:WorkflowStep {id: $targetId})
MERGE (source)-[:NEXT_STEP]->(target)
RETURN source.id AS from, target.id AS to
PRODUCES (WorkflowStep to BusinessEntity):
MATCH (ws:WorkflowStep {id: $wsId})
MATCH (be:BusinessEntity {id: $entityId})
MERGE (ws)-[:PRODUCES]->(be)
RETURN ws.id AS step, be.id AS entity
READS (BusinessEntity arrow to WorkflowStep --- stored as step READS entity):
MATCH (ws:WorkflowStep {id: $wsId})
MATCH (be:BusinessEntity {id: $entityId})
MERGE (ws)-[:READS]->(be)
RETURN ws.id AS step, be.id AS entity
PERFORMED_BY (BusinessRole to/from WorkflowStep):
MATCH (ws:WorkflowStep {id: $wsId})
MATCH (r:BusinessRole {id: $roleId})
MERGE (ws)-[:PERFORMED_BY]->(r)
RETURN ws.id AS step, r.id AS role
4.4 Swimlane-based PERFORMED_BY inference
After processing all explicit arrows, check for steps that lack a PERFORMED_BY relationship but are visually contained within a BusinessRole swimlane on the board.
For each WorkflowStep that has no PERFORMED_BY edge created in 4.3:
For each BusinessRole element on the board:
If step.x >= role.x AND step.y >= role.y
AND step.x + step.width <= role.x + role.width
AND step.y + step.height <= role.y + role.height:
-> The step is inside this role's swimlane
-> Create PERFORMED_BY relationship (same Cypher as 4.3)
4.5 Progress tracking
[1/{total}] NEXT_STEP: BP-001-S01 -> BP-001-S02
[2/{total}] NEXT_STEP: BP-001-S02 -> BP-001-S03
[3/{total}] PRODUCES: BP-001-S03 -> OBJ-001
[4/{total}] READS: BP-001-S01 <- OBJ-002 (stored as BP-001-S01 -[:READS]-> OBJ-002)
[5/{total}] PERFORMED_BY: BP-001-S01 -> ROL-01
[6/{total}] PERFORMED_BY (swimlane): BP-001-S02 -> ROL-01
[7/{total}] Skipped arrow (id: arrow-015) --- dangling (endBinding: null)
Phase 5: Sync Changed Elements
Process every element in dirtySyncedElements[] (those with customData.nodeId != null AND customData.synced == false). These are elements that were previously synced but then modified on the board.
5.1 Read current state from Neo4j
For each element, query the current node using mcp__neo4j__read-cypher:
MATCH (n {id: $nodeId})
RETURN n.id AS id,
labels(n) AS labels,
properties(n) AS props
If the node is not found in Neo4j, log an error:
[ERROR] Element '{label}' has nodeId={nodeId} but node not found in Neo4j. Skipping update.
Consider removing nodeId from customData and re-syncing as new element.
Skip this element.
5.2 Compare and update
Resolve the current board text via labelMap[element.id] and compare with the Neo4j node's name/function_name.
For WorkflowStep / Decision nodes (compare against function_name):
boardText = labelMap[element.id]
graphText = props.function_name
If boardText != graphText:
-> Update needed
Update via mcp__neo4j__write-cypher:
MATCH (ws:WorkflowStep {id: $wsId})
SET ws.function_name = $newFunctionName,
ws.updated = datetime()
RETURN ws.id AS id, ws.function_name AS function_name
For BusinessEntity nodes (compare against name):
MATCH (be:BusinessEntity {id: $entityId})
SET be.name = $newName,
be.updated = datetime()
RETURN be.id AS id, be.name AS name
For BusinessRole nodes (compare against full_name):
MATCH (r:BusinessRole {id: $roleId})
SET r.full_name = $newFullName,
r.updated = datetime()
RETURN r.id AS id, r.full_name AS full_name
5.3 Mark as synced
After a successful update (or if the text has not changed), mark the element for sync confirmation in Phase 6:
element.customData.synced = true
5.4 Progress tracking
[1/{total}] Updated WorkflowStep BP-001-S03: "Verify documents" -> "Verify and validate documents"
[2/{total}] No change for BusinessEntity OBJ-001: "Application form" (text unchanged)
[3/{total}] Updated BusinessRole ROL-02: "Manager" -> "Senior Manager"
[4/{total}] [ERROR] Node BP-001-S99 not found in Neo4j --- skipped
Phase 6: Update Board File and Report
6.1 Update customData in all processed elements
For each element that was successfully synced or created in Phases 3--5, update its customData:
{
"customData": {
"nodeId": "{assigned Neo4j ID}",
"nodeType": "{unchanged}",
"confidence": "{unchanged}",
"sourceDoc": "{unchanged}",
"sourcePage": "{unchanged}",
"synced": true
}
}
6.2 Update strokeColor for synced elements
For every element where customData.synced was set to true in this run, change the strokeColor to green:
strokeColor = "#2e7d32"
This provides visual feedback that the element is confirmed in the graph.
6.3 Write the updated board file
Reconstruct the full .excalidraw JSON with the updated elements array and write it back to the same path using the Write tool.
Preserve all other fields (type, version, source, appState, files) unchanged. Preserve all elements that were not processed (arrows, text elements, skipped elements) exactly as they were.
6.4 Write board meta sidecar (success path)
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 27
- Forks
- 4
- Last commit
- Sep 2026
Advanced
- Item type
- skill
- Key
nacl-ba-sync- Source
- github.com/itsalt/nacl