diff --git a/godot-addon/addons/godotiq/godotiq_server.gd b/godot-addon/addons/godotiq/godotiq_server.gd
index 97864a5..8c41145 100644
--- a/godot-addon/addons/godotiq/godotiq_server.gd
+++ b/godot-addon/addons/godotiq/godotiq_server.gd
@@ -124,6 +124,8 @@ func _dispatch(peer_id: int, id: String, method: String, params: Dictionary) ->
 			_handle_editor_context(peer_id, id)
 		"scene_tree":
 			_handle_scene_tree(peer_id, id, params)
+		"node_ops":
+			_handle_node_ops(peer_id, id, params)
 		"run":
 			_handle_run(peer_id, id, params)
 		"stop":
@@ -291,6 +293,320 @@ func _find_by_name_recursive(node: Node, target_name: String) -> Node:
 	return null
 
 
+func _handle_node_ops(peer_id: int, id: String, params: Dictionary) -> void:
+	var operations: Array = params.get("operations", [])
+	if not (operations is Array) or operations.size() == 0:
+		_send_error(peer_id, id, "INVALID_PARAMS", "operations must be a non-empty array")
+		return
+
+	var scene_root := EditorInterface.get_edited_scene_root()
+	if scene_root == null:
+		_send_error(peer_id, id, "NO_SCENE", "No scene is currently open in the editor")
+		return
+
+	if undo_redo == null:
+		_send_error(peer_id, id, "NO_UNDO_REDO", "UndoRedo manager not available")
+		return
+
+	var action_name := "GodotIQ: %d node operation(s)" % operations.size()
+	undo_redo.create_action(action_name)
+
+	var results: Array = []
+	var any_succeeded := false
+
+	for op_data in operations:
+		if not (op_data is Dictionary):
+			results.append({"status": "error", "error": "Invalid operation format"})
+			continue
+		var result := _execute_node_op(op_data, scene_root, undo_redo)
+		results.append(result)
+		if result["status"] == "ok":
+			any_succeeded = true
+
+	if any_succeeded:
+		undo_redo.commit_action()
+	else:
+		undo_redo.commit_action()
+		undo_redo.get_history_undo_redo(undo_redo.get_object_history_id(scene_root)).clear_history()
+
+	send_response(peer_id, id, {
+		"results": results,
+		"scene_modified": any_succeeded,
+		"undo_available": any_succeeded,
+		"action_name": action_name,
+	})
+
+
+func _execute_node_op(op_data: Dictionary, scene_root: Node, ur) -> Dictionary:
+	var op: String = str(op_data.get("op", ""))
+	match op:
+		"move":
+			return _op_move(op_data, scene_root, ur)
+		"rotate":
+			return _op_rotate(op_data, scene_root, ur)
+		"scale":
+			return _op_scale(op_data, scene_root, ur)
+		"set_property":
+			return _op_set_property(op_data, scene_root, ur)
+		"add_child":
+			return _op_add_child(op_data, scene_root, ur)
+		"delete":
+			return _op_delete(op_data, scene_root, ur)
+		"duplicate":
+			return _op_duplicate(op_data, scene_root, ur)
+		"reparent":
+			return _op_reparent(op_data, scene_root, ur)
+		_:
+			return {"op": op, "status": "error", "error": "Unknown operation: %s" % op}
+
+
+func _find_node_by_name_or_path(name_or_path: String, scene_root: Node) -> Node:
+	var node := scene_root.get_node_or_null(NodePath(name_or_path))
+	if node != null:
+		return node
+	return _find_by_name_recursive(scene_root, name_or_path)
+
+
+func _op_move(op_data: Dictionary, scene_root: Node, ur) -> Dictionary:
+	var node_name: String = str(op_data.get("node", ""))
+	var node := _find_node_by_name_or_path(node_name, scene_root)
+	if node == null:
+		return {"op": "move", "node": node_name, "status": "error", "error": "Node not found: %s" % node_name}
+
+	var pos: Array = op_data.get("position", [0, 0, 0])
+	if node is Node3D:
+		var old_pos := (node as Node3D).position
+		var new_pos := Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
+		ur.add_do_property(node, "position", new_pos)
+		ur.add_undo_property(node, "position", old_pos)
+	elif node is Node2D:
+		var old_pos := (node as Node2D).position
+		var new_pos := Vector2(float(pos[0]), float(pos[1]))
+		ur.add_do_property(node, "position", new_pos)
+		ur.add_undo_property(node, "position", old_pos)
+	elif node is Control:
+		var old_pos := (node as Control).position
+		var new_pos := Vector2(float(pos[0]), float(pos[1]))
+		ur.add_do_property(node, "position", new_pos)
+		ur.add_undo_property(node, "position", old_pos)
+	else:
+		return {"op": "move", "node": node_name, "status": "error", "error": "Node does not support position"}
+
+	return {"op": "move", "node": node_name, "status": "ok"}
+
+
+func _op_rotate(op_data: Dictionary, scene_root: Node, ur) -> Dictionary:
+	var node_name: String = str(op_data.get("node", ""))
+	var node := _find_node_by_name_or_path(node_name, scene_root)
+	if node == null:
+		return {"op": "rotate", "node": node_name, "status": "error", "error": "Node not found: %s" % node_name}
+
+	if not (node is Node3D):
+		return {"op": "rotate", "node": node_name, "status": "error", "error": "Rotate only supports Node3D"}
+
+	var rot: Array = op_data.get("rotation", [0, 0, 0])
+	var n3d: Node3D = node
+	var old_rot := n3d.rotation_degrees
+	var new_rot := Vector3(float(rot[0]), float(rot[1]), float(rot[2]))
+	ur.add_do_property(node, "rotation_degrees", new_rot)
+	ur.add_undo_property(node, "rotation_degrees", old_rot)
+	return {"op": "rotate", "node": node_name, "status": "ok"}
+
+
+func _op_scale(op_data: Dictionary, scene_root: Node, ur) -> Dictionary:
+	var node_name: String = str(op_data.get("node", ""))
+	var node := _find_node_by_name_or_path(node_name, scene_root)
+	if node == null:
+		return {"op": "scale", "node": node_name, "status": "error", "error": "Node not found: %s" % node_name}
+
+	if not (node is Node3D):
+		return {"op": "scale", "node": node_name, "status": "error", "error": "Scale only supports Node3D"}
+
+	var sc: Array = op_data.get("scale", [1, 1, 1])
+	var n3d: Node3D = node
+	var old_scale := n3d.scale
+	var new_scale := Vector3(float(sc[0]), float(sc[1]), float(sc[2]))
+	ur.add_do_property(node, "scale", new_scale)
+	ur.add_undo_property(node, "scale", old_scale)
+	return {"op": "scale", "node": node_name, "status": "ok"}
+
+
+func _op_set_property(op_data: Dictionary, scene_root: Node, ur) -> Dictionary:
+	var node_name: String = str(op_data.get("node", ""))
+	var node := _find_node_by_name_or_path(node_name, scene_root)
+	if node == null:
+		return {"op": "set_property", "node": node_name, "status": "error", "error": "Node not found: %s" % node_name}
+
+	var property: String = str(op_data.get("property", ""))
+	var value = op_data.get("value")
+
+	# Handle sub-path syntax (e.g., "position:x")
+	var parts := property.split(":")
+	var base_property: String = parts[0]
+
+	var old_value = node.get(base_property)
+	if old_value == null:
+		return {"op": "set_property", "node": node_name, "status": "error", "error": "Property not found: %s" % property}
+
+	if parts.size() > 1:
+		# Sub-path: modify only the component
+		var sub_path: String = parts[1]
+		var ref_value = old_value
+		if ref_value is Vector3:
+			var v := Vector3(ref_value.x, ref_value.y, ref_value.z)
+			match sub_path:
+				"x": v.x = float(value)
+				"y": v.y = float(value)
+				"z": v.z = float(value)
+			value = v
+		elif ref_value is Vector2:
+			var v := Vector2(ref_value.x, ref_value.y)
+			match sub_path:
+				"x": v.x = float(value)
+				"y": v.y = float(value)
+			value = v
+		elif ref_value is Color:
+			var c := Color(ref_value.r, ref_value.g, ref_value.b, ref_value.a)
+			match sub_path:
+				"r": c.r = float(value)
+				"g": c.g = float(value)
+				"b": c.b = float(value)
+				"a": c.a = float(value)
+			value = c
+		else:
+			return {"op": "set_property", "node": node_name, "status": "error", "error": "Sub-path not supported for type: %s" % typeof(ref_value)}
+	else:
+		value = _convert_value(value, old_value)
+
+	ur.add_do_property(node, base_property, value)
+	ur.add_undo_property(node, base_property, old_value)
+	return {"op": "set_property", "node": node_name, "status": "ok", "property": property}
+
+
+func _op_add_child(op_data: Dictionary, scene_root: Node, ur) -> Dictionary:
+	var parent_name: String = str(op_data.get("parent", ""))
+	var parent := _find_node_by_name_or_path(parent_name, scene_root)
+	if parent == null:
+		return {"op": "add_child", "status": "error", "error": "Parent not found: %s" % parent_name}
+
+	var new_node: Node = null
+	var scene_path: String = str(op_data.get("scene", ""))
+	var type_name: String = str(op_data.get("type", ""))
+
+	if scene_path != "":
+		var packed = ResourceLoader.load(scene_path)
+		if packed == null or not (packed is PackedScene):
+			return {"op": "add_child", "status": "error", "error": "Failed to load scene: %s" % scene_path}
+		new_node = packed.instantiate()
+	elif type_name != "":
+		if not ClassDB.class_exists(type_name) or not ClassDB.can_instantiate(type_name):
+			return {"op": "add_child", "status": "error", "error": "Cannot instantiate type: %s" % type_name}
+		new_node = ClassDB.instantiate(type_name)
+	else:
+		return {"op": "add_child", "status": "error", "error": "Must provide 'scene' or 'type'"}
+
+	var child_name: String = str(op_data.get("name", "NewNode"))
+	new_node.name = child_name
+
+	ur.add_do_method(parent, "add_child", new_node)
+	ur.add_do_method(new_node, "set_owner", scene_root)
+	ur.add_do_reference(new_node)
+	ur.add_undo_method(parent, "remove_child", new_node)
+
+	# Set position after adding if provided
+	var pos = op_data.get("position", null)
+	if pos is Array and pos.size() >= 2:
+		if new_node is Node3D and pos.size() >= 3:
+			ur.add_do_property(new_node, "position", Vector3(float(pos[0]), float(pos[1]), float(pos[2])))
+		elif new_node is Node2D or new_node is Control:
+			ur.add_do_property(new_node, "position", Vector2(float(pos[0]), float(pos[1])))
+
+	return {"op": "add_child", "node": child_name, "parent": parent_name, "status": "ok"}
+
+
+func _op_delete(op_data: Dictionary, scene_root: Node, ur) -> Dictionary:
+	var node_name: String = str(op_data.get("node", ""))
+	var node := _find_node_by_name_or_path(node_name, scene_root)
+	if node == null:
+		return {"op": "delete", "node": node_name, "status": "error", "error": "Node not found: %s" % node_name}
+
+	if node == scene_root:
+		return {"op": "delete", "node": node_name, "status": "error", "error": "Cannot delete scene root"}
+
+	var parent := node.get_parent()
+	ur.add_do_method(parent, "remove_child", node)
+	ur.add_undo_method(parent, "add_child", node)
+	ur.add_undo_method(node, "set_owner", scene_root)
+	ur.add_undo_reference(node)
+	return {"op": "delete", "node": node_name, "status": "ok"}
+
+
+func _op_duplicate(op_data: Dictionary, scene_root: Node, ur) -> Dictionary:
+	var node_name: String = str(op_data.get("node", ""))
+	var node := _find_node_by_name_or_path(node_name, scene_root)
+	if node == null:
+		return {"op": "duplicate", "node": node_name, "status": "error", "error": "Node not found: %s" % node_name}
+
+	var dup := node.duplicate()
+	var dup_name: String = str(op_data.get("name", str(node.name) + "_copy"))
+	dup.name = dup_name
+
+	var parent := node.get_parent()
+	ur.add_do_method(parent, "add_child", dup)
+	ur.add_do_reference(dup)
+	ur.add_undo_method(parent, "remove_child", dup)
+
+	# Owner setting happens after commit via _set_owner_recursive
+	return {"op": "duplicate", "node": node_name, "new_name": dup_name, "status": "ok"}
+
+
+func _op_reparent(op_data: Dictionary, scene_root: Node, ur) -> Dictionary:
+	var node_name: String = str(op_data.get("node", ""))
+	var node := _find_node_by_name_or_path(node_name, scene_root)
+	if node == null:
+		return {"op": "reparent", "node": node_name, "status": "error", "error": "Node not found: %s" % node_name}
+
+	var new_parent_name: String = str(op_data.get("new_parent", ""))
+	var new_parent := _find_node_by_name_or_path(new_parent_name, scene_root)
+	if new_parent == null:
+		return {"op": "reparent", "node": node_name, "status": "error", "error": "New parent not found: %s" % new_parent_name}
+
+	var old_parent := node.get_parent()
+	ur.add_do_method(old_parent, "remove_child", node)
+	ur.add_do_method(new_parent, "add_child", node)
+	ur.add_do_method(node, "set_owner", scene_root)
+	ur.add_undo_method(new_parent, "remove_child", node)
+	ur.add_undo_method(old_parent, "add_child", node)
+	ur.add_undo_method(node, "set_owner", scene_root)
+	return {"op": "reparent", "node": node_name, "new_parent": new_parent_name, "status": "ok"}
+
+
+func _convert_value(value, reference_value):
+	if reference_value is Vector3 and value is Array and value.size() >= 3:
+		return Vector3(float(value[0]), float(value[1]), float(value[2]))
+	elif reference_value is Vector2 and value is Array and value.size() >= 2:
+		return Vector2(float(value[0]), float(value[1]))
+	elif reference_value is Color:
+		if value is Array and value.size() >= 3:
+			var a: float = float(value[3]) if value.size() >= 4 else 1.0
+			return Color(float(value[0]), float(value[1]), float(value[2]), a)
+		elif value is String:
+			return Color(value)
+	elif reference_value is int and (value is int or value is float):
+		return int(value)
+	elif reference_value is float and (value is int or value is float):
+		return float(value)
+	elif reference_value is bool:
+		return bool(value)
+	return value
+
+
+func _set_owner_recursive(node: Node, owner: Node) -> void:
+	node.owner = owner
+	for child in node.get_children():
+		_set_owner_recursive(child, owner)
+
+
 # --- Game-side forwarding ---
 
 func _handle_game_forward(peer_id: int, id: String, msg_type: String, params: Dictionary, timeout_ms: int) -> void:
