workflowy_create_single_node
Create a single node in WorkFlowy with a name, optional parent ID, and optional note for hierarchical task management.
Instructions
DEPRECATED: Use workflowy_etch (ETCH) instead
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| name | Yes | ||
| parent_id | No | ||
| note | No |
Implementation Reference
- src/workflowy_mcp/server.py:929-956 (registration)Registration of deprecated 'workflowy_create_single_node' tool. Raises ValueError directing to ETCH or warning version.# Tool: Create Single Node (Base - Deprecated) @mcp.tool(name="workflowy_create_single_node", description="DEPRECATED: Use workflowy_etch (ETCH) instead") async def create_single_node_base( name: str, parent_id: str | None = None, note: str | None = None, ) -> dict: """Deprecated - use ETCH instead.""" raise ValueError("""⚠️ FUNCTION RENAMED The function 'workflowy_create_single_node' has been renamed to 'workflowy_create_single_node__WARNING__prefer_ETCH'. BUT MORE IMPORTANTLY: Use workflowy_etch (ETCH command) instead! ✅ RECOMMENDED: workflowy_etch( parent_id="...", nodes=[{"name": "Your node", "note": "...", "children": []}] ) ETCH is better: - Works for 1 node or 100 nodes - Validation and auto-escaping built-in - Same performance, more capability 📚 Build the ETCH habit! """)
- src/workflowy_mcp/server.py:957-1023 (handler)MCP tool handler for single node creation (warning version). Validates secret_code, prepares NodeCreateRequest, calls core client.create_node with retry/rate-limit handling.# Tool: Create Single Node (With Warning) @mcp.tool(name="workflowy_create_single_node__WARNING__prefer_ETCH", description="⚠️ WARNING: Prefer workflowy_etch (ETCH) instead. This creates ONE node only.") async def create_node( name: str, parent_id: str | None = None, note: str | None = None, layout_mode: Literal["bullets", "todo", "h1", "h2", "h3"] | None = None, position: Literal["top", "bottom"] = "bottom", _completed: bool = False, secret_code: str | None = None, ) -> dict: """Create a SINGLE node in WorkFlowy. ⚠️ WARNING: Prefer workflowy_etch (ETCH) for creating 2+ nodes. This tool is ONLY for: - Adding one VYRTHEX to existing log (real-time work) - One quick update to a known node - Live work in progress Args: name: The text content of the node parent_id: ID of the parent node (optional) note: Additional note/description for the node layout_mode: Layout mode for the node (bullets, todo, h1, h2, h3) (optional) position: Where to place the new node - "bottom" (default) or "top" _completed: Whether the node should be marked as completed (not used) secret_code: Authorization code from Dan (required for WARNING functions) Returns: Dictionary with node data and warning message """ # 🔐 SECRET CODE VALIDATION is_valid, error = validate_secret_code(secret_code, "workflowy_create_single_node__WARNING__prefer_ETCH") if not is_valid: raise ValueError(error) client = get_client() request = NodeCreateRequest( # type: ignore[call-arg] name=name, parent_id=parent_id, note=note, layoutMode=layout_mode, position=position, ) if _rate_limiter: await _rate_limiter.acquire() try: node = await client.create_node(request) if _rate_limiter: _rate_limiter.on_success() # Return node data with warning message return { **node.model_dump(), "_warning": "⚠️ WARNING: You just created a SINGLE node. For 2+ nodes, use workflowy_etch instead (same performance, more capability)." } except Exception as e: if _rate_limiter and hasattr(e, "__class__") and e.__class__.__name__ == "RateLimitError": _rate_limiter.on_rate_limit(getattr(e, "retry_after", None)) raise # Tool: Update Node
- Core implementation of node creation: validates/escapes fields, handles retries/rate-limits, posts to /nodes/, fetches created node.async def create_node( self, request: NodeCreateRequest, _internal_call: bool = False, max_retries: int = 10 ) -> WorkFlowyNode: """Create a new node in WorkFlowy with exponential backoff retry. Args: request: Node creation request _internal_call: Internal flag - bypasses single-node forcing function (not exposed to MCP) max_retries: Maximum retry attempts (default 10) """ import asyncio logger = _ClientLogger() # Check for single-node override token (skip if internal call) if not _internal_call: SINGLE_NODE_TOKEN = "<<<I_REALLY_NEED_SINGLE_NODE>>>" if request.name and request.name.startswith(SINGLE_NODE_TOKEN): # Strip token and proceed request.name = request.name.replace(SINGLE_NODE_TOKEN, "", 1) else: # Suggest ETCH instead raise NetworkError("""⚠️ PREFER ETCH - Use workflowy_etch for consistency and capability You called workflowy_create_single_node, but workflowy_etch has identical performance. ✅ RECOMMENDED (same speed, more capability): workflowy_etch( parent_id="...", nodes=[{"name": "Your node", "note": "...", "children": []}] ) 📚 Benefits of ETCH: - Same 1 tool call (no performance difference) - Validation and auto-escaping built-in - Works for 1 node or 100 nodes (consistent pattern) - Trains you to think in tree structures ⚙️ OVERRIDE (if you truly need single-node operation): workflowy_create_single_node( name="<<<I_REALLY_NEED_SINGLE_NODE>>>Your node", ... ) 🎯 Build the ETCH habit - it's your go-to tool! """) # Validate and escape name field processed_name, name_warning = self._validate_name_field(request.name) if processed_name is not None: request.name = processed_name if name_warning: logger.info(name_warning) # Validate and escape note field # Skip newline check if internal call (for bulk operations testing) processed_note, note_warning = self._validate_note_field(request.note, skip_newline_check=_internal_call) if processed_note is None and note_warning: # Blocking error raise NetworkError(note_warning) # Strip override token if present if processed_note and processed_note.startswith("<<<LITERAL_BACKSLASH_N_INTENTIONAL>>>"): processed_note = processed_note.replace("<<<LITERAL_BACKSLASH_N_INTENTIONAL>>>", "", 1) # Use processed (escaped) note request.note = processed_note # Log warning if escaping occurred if note_warning and "AUTO-ESCAPED" in note_warning: logger.info(note_warning) retry_count = 0 base_delay = 1.0 while retry_count < max_retries: # Force delay at START of each iteration (rate limit protection) await asyncio.sleep(API_RATE_LIMIT_DELAY) try: response = await self.client.post("/nodes/", json=request.model_dump(exclude_none=True)) data = await self._handle_response(response) # Create endpoint returns just {"item_id": "..."} item_id = data.get("item_id") if not item_id: raise NetworkError(f"Invalid response from create endpoint: {data}") # Fetch the created node to get actual saved state (including note field) get_response = await self.client.get(f"/nodes/{item_id}") node_data = await self._handle_response(get_response) node = WorkFlowyNode(**node_data["node"]) # Best-effort: mark this node as dirty in the /nodes-export cache so that # any subtree exports including it can trigger a refresh when needed. try: self._mark_nodes_export_dirty([node.id]) except Exception: # Cache dirty marking must never affect API behavior pass return node except RateLimitError as e: retry_count += 1 retry_after = getattr(e, 'retry_after', None) or (base_delay * (2 ** retry_count)) logger.warning( f"Rate limited on create_node. Retry after {retry_after}s. " f"Attempt {retry_count}/{max_retries}" ) if retry_count < max_retries: await asyncio.sleep(retry_after) else: raise except NetworkError as e: retry_count += 1 _log( f"Network error on create_node: {e}. Retry {retry_count}/{max_retries}" ) if retry_count < max_retries: await asyncio.sleep(base_delay * (2 ** retry_count)) else: raise except httpx.TimeoutException as err: retry_count += 1 logger.warning( f"Timeout error: {err}. Retry {retry_count}/{max_retries}" ) if retry_count < max_retries: await asyncio.sleep(base_delay * (2 ** retry_count)) else: raise TimeoutError("create_node") from err raise NetworkError("create_node failed after maximum retries")
- Pydantic schema NodeCreateRequest defining input validation for create_node requests.class NodeCreateRequest(BaseModel): """Request payload for creating a new node.""" parent_id: str | None = Field(None, description="Parent node ID ('None' for root level)") name: str = Field(..., description="Text content (required)") note: str | None = Field(None, description="Note content (optional)") layoutMode: Literal["bullets", "todo", "h1", "h2", "h3"] | None = Field( None, description="Display mode (bullets, todo, h1, h2, h3)" ) position: Literal["top", "bottom"] | None = Field( "top", description="Position: 'top' or 'bottom' (default: 'top')" ) @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: """Ensure name is non-empty.""" if not v or not v.strip(): raise ValueError("Node name must be non-empty") return v @field_validator("parent_id") @classmethod def validate_parent_id(cls, v: str | None) -> str | None: """Keep parent_id as-is - None means root level.""" return v
- Helper for validating and escaping name field (double-encode for GUI, preserve whitelisted markup). Used in create_node.def _validate_name_field(name: str | None) -> tuple[str | None, str | None]: """Validate and smart-escape name field for Workflowy compatibility. Workflowy NAME field behavior (Dec 2025): - API decodes entities ONCE on input before storage - GUI decodes entities AGAIN when rendering - Result: Must DOUBLE-ENCODE for proper display New behavior with parser: - Properly matched whitelisted tags (<b>/<i>/<s>/<code> and allowed <span class="colored c-...">) are preserved as markup ranges - EVERYTHING ELSE is treated as plain text and has < and > escaped - Then we double-encode '&' across the entire string so that a round-trip API+GUI decode yields the intended characters """ if name is None: return (None, None) # Per-node whitener override via inline token in the NAME text mode, clean_name = WorkFlowyClientCore._extract_whitener_mode(name) if mode == "raw": return (clean_name, None) if clean_name is not None: name = clean_name text = name segments = WorkFlowyClientCore._segment_whitelisted_markup(text) import re wrapped_segments = [] for seg in segments: if seg["kind"] == "markup": v = seg["value"] if re.fullmatch(r'<span\s+class="colored\s+(?:c|bc)-[^"]+">.*?</span>', v): seg = {"kind": "markup", "value": f"<b>{v}</b>"} wrapped_segments.append(seg) segments = wrapped_segments # SINGLE-STAGE: escape &, <, > in text segments only; leave markup untouched result_chars: list[str] = [] for seg in segments: if seg["kind"] == "markup": result_chars.append(seg["value"]) else: for ch in seg["value"]: if ch == '&': result_chars.append('&') elif ch == '<': result_chars.append('<') elif ch == '>': result_chars.append('>') else: result_chars.append(ch) escaped_name = ''.join(result_chars) return (escaped_name, None)