diff --git a/packages/core/src/behaviors/mod.rs b/packages/core/src/behaviors/mod.rs
index 89a6fb6a9..61ae8837b 100644
--- a/packages/core/src/behaviors/mod.rs
+++ b/packages/core/src/behaviors/mod.rs
@@ -1051,6 +1051,35 @@ impl SchemaNodeBehavior {
             validate_schema_field(field)?;
         }
 
+        // Validate title_template syntax: every '{' must have a matching '}' and a non-empty field name
+        if let Some(template) = &schema.title_template {
+            let bytes = template.as_bytes();
+            let mut i = 0;
+            while i < bytes.len() {
+                if bytes[i] == b'{' {
+                    // Find matching '}'
+                    match bytes[i + 1..].iter().position(|&c| c == b'}') {
+                        None => {
+                            return Err(NodeValidationError::InvalidProperties(
+                                "title_template contains an unclosed '{' placeholder".to_string(),
+                            ));
+                        }
+                        Some(end) => {
+                            let field_name = &template[i + 1..i + 1 + end];
+                            if field_name.is_empty() {
+                                return Err(NodeValidationError::InvalidProperties(
+                                    "title_template contains an empty '{}' placeholder".to_string(),
+                                ));
+                            }
+                            i += 1 + end + 1; // skip past '}'
+                            continue;
+                        }
+                    }
+                }
+                i += 1;
+            }
+        }
+
         Ok(())
     }
 }
@@ -2856,6 +2885,112 @@ mod tests {
         ));
     }
 
+    // =========================================================================
+    // title_template Validation Tests (Issue #824)
+    // =========================================================================
+
+    #[test]
+    fn test_title_template_valid() {
+        use crate::models::SchemaNode;
+        let behavior = SchemaNodeBehavior;
+
+        let node = Node::new(
+            "schema".to_string(),
+            "Customer".to_string(),
+            json!({
+                "isCore": false,
+                "schemaVersion": 1,
+                "description": "",
+                "titleTemplate": "{first_name} {last_name}",
+                "fields": [],
+                "relationships": []
+            }),
+        );
+        let schema = SchemaNode::from_node(node).unwrap();
+        assert!(
+            behavior.validate_schema_node(&schema).is_ok(),
+            "Valid title_template should pass validation"
+        );
+    }
+
+    #[test]
+    fn test_title_template_unclosed_brace_rejected() {
+        use crate::models::SchemaNode;
+        let behavior = SchemaNodeBehavior;
+
+        let node = Node::new(
+            "schema".to_string(),
+            "Customer".to_string(),
+            json!({
+                "isCore": false,
+                "schemaVersion": 1,
+                "description": "",
+                "titleTemplate": "{first_name",
+                "fields": [],
+                "relationships": []
+            }),
+        );
+        let schema = SchemaNode::from_node(node).unwrap();
+        let result = behavior.validate_schema_node(&schema);
+        assert!(result.is_err(), "Unclosed brace should fail validation");
+        assert!(matches!(
+            result,
+            Err(NodeValidationError::InvalidProperties(ref msg))
+                if msg.contains("unclosed")
+        ));
+    }
+
+    #[test]
+    fn test_title_template_empty_placeholder_rejected() {
+        use crate::models::SchemaNode;
+        let behavior = SchemaNodeBehavior;
+
+        let node = Node::new(
+            "schema".to_string(),
+            "Customer".to_string(),
+            json!({
+                "isCore": false,
+                "schemaVersion": 1,
+                "description": "",
+                "titleTemplate": "{} {last_name}",
+                "fields": [],
+                "relationships": []
+            }),
+        );
+        let schema = SchemaNode::from_node(node).unwrap();
+        let result = behavior.validate_schema_node(&schema);
+        assert!(result.is_err(), "Empty placeholder should fail validation");
+        assert!(matches!(
+            result,
+            Err(NodeValidationError::InvalidProperties(ref msg))
+                if msg.contains("empty")
+        ));
+    }
+
+    #[test]
+    fn test_title_template_none_is_valid() {
+        use crate::models::SchemaNode;
+        let behavior = SchemaNodeBehavior;
+
+        // No title_template field at all — should pass
+        let node = Node::new(
+            "schema".to_string(),
+            "Widget".to_string(),
+            json!({
+                "isCore": false,
+                "schemaVersion": 1,
+                "description": "",
+                "fields": [],
+                "relationships": []
+            }),
+        );
+        let schema = SchemaNode::from_node(node).unwrap();
+        assert!(
+            behavior.validate_schema_node(&schema).is_ok(),
+            "Schema without title_template should pass validation"
+        );
+    }
+
     // =========================================================================
     // TaskNode from_node Format Tests
     // =========================================================================
diff --git a/packages/core/src/models/core_schemas.rs b/packages/core/src/models/core_schemas.rs
index e87395340..299f9c96a 100644
--- a/packages/core/src/models/core_schemas.rs
+++ b/packages/core/src/models/core_schemas.rs
@@ -167,6 +167,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
                 },
             ],
             relationships: vec![],
+            title_template: None,
         },
         // Text schema - plain text content (no extra fields)
         SchemaNode {
@@ -180,6 +181,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
             description: "Plain text content".to_string(),
             fields: vec![],
             relationships: vec![],
+            title_template: None,
         },
         // Date schema - daily note containers (no extra fields)
         SchemaNode {
@@ -193,6 +195,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
             description: "Date node schema".to_string(),
             fields: vec![],
             relationships: vec![],
+            title_template: None,
         },
         // Header schema - markdown headers (no extra fields)
         SchemaNode {
@@ -206,6 +209,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
             description: "Markdown header (h1-h6)".to_string(),
             fields: vec![],
             relationships: vec![],
+            title_template: None,
         },
         // Code block schema - code with syntax highlighting (no extra fields)
         SchemaNode {
@@ -219,6 +223,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
             description: "Code block with syntax highlighting".to_string(),
             fields: vec![],
             relationships: vec![],
+            title_template: None,
         },
         // Quote block schema - blockquotes (no extra fields)
         SchemaNode {
@@ -232,6 +237,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
             description: "Blockquote for citations".to_string(),
             fields: vec![],
             relationships: vec![],
+            title_template: None,
         },
         // Ordered list schema - numbered list items (no extra fields)
         SchemaNode {
@@ -245,6 +251,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
             description: "Numbered list item".to_string(),
             fields: vec![],
             relationships: vec![],
+            title_template: None,
         },
         // Horizontal line schema - thematic break (no extra fields)
         SchemaNode {
@@ -258,6 +265,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
             description: "Horizontal rule / thematic break".to_string(),
             fields: vec![],
             relationships: vec![],
+            title_template: None,
         },
         // Table schema - GFM markdown table (no extra fields)
         SchemaNode {
@@ -271,6 +279,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
             description: "GFM markdown table with alignment support".to_string(),
             fields: vec![],
             relationships: vec![],
+            title_template: None,
         },
         // Collection schema - hierarchical labels for organizing nodes
         SchemaNode {
@@ -284,6 +293,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
             description: "Hierarchical label for organizing nodes into groups".to_string(),
             fields: vec![],        // Uses content for name
             relationships: vec![], // member_of is a native edge, not schema-defined
+            title_template: None,
         },
         // Checkbox schema - pure content node with state encoded in content string
         SchemaNode {
@@ -297,6 +307,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
             description: "Checkbox item — markdown annotation, not a managed task".to_string(),
             fields: vec![],
             relationships: vec![],
+            title_template: None,
         },
         // Query schema - saved query definitions
         SchemaNode {
@@ -440,6 +451,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
                 },
             ],
             relationships: vec![],
+            title_template: None,
         },
     ]
 }
diff --git a/packages/core/src/models/schema_node.rs b/packages/core/src/models/schema_node.rs
index 88e3a02ce..1c1001ee1 100644
--- a/packages/core/src/models/schema_node.rs
+++ b/packages/core/src/models/schema_node.rs
@@ -96,6 +96,14 @@ pub struct SchemaNode {
     /// created when the schema is saved. See [`SchemaRelationship`] for details.
     #[serde(default)]
     pub relationships: Vec<SchemaRelationship>,
+
+    /// Optional template for computing the node's indexed title from its properties.
+    ///
+    /// Uses `{field_name}` syntax, e.g. `"{first_name} {last_name} ({email})"`.
+    /// When set, title is interpolated from node properties instead of content.
+    /// Missing or null fields are replaced with empty strings.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub title_template: Option<String>,
 }
 
 fn default_version() -> i64 {
@@ -156,6 +164,12 @@ impl SchemaNode {
             .and_then(|v| serde_json::from_value(v.clone()).ok())
             .unwrap_or_default();
 
+        let title_template = node
+            .properties
+            .get("titleTemplate")
+            .and_then(|v| v.as_str())
+            .map(|s| s.to_string());
+
         Ok(Self {
             id: node.id,
             content: node.content,
@@ -167,6 +181,7 @@ impl SchemaNode {
             description,
             fields,
             relationships,
+            title_template,
         })
     }
 
@@ -174,7 +189,7 @@ impl SchemaNode {
     ///
     /// This creates a Node with properties populated from the strongly-typed fields.
     pub fn into_node(self) -> Node {
-        let properties = serde_json::json!({
+        let mut properties = serde_json::json!({
             "isCore": self.is_core,
             "schemaVersion": self.schema_version,
             "description": self.description,
@@ -182,6 +197,10 @@ impl SchemaNode {
             "relationships": self.relationships,
         });
 
+        if let Some(template) = self.title_template {
+            properties["titleTemplate"] = serde_json::Value::String(template);
+        }
+
         Node {
             id: self.id,
             node_type: "schema".to_string(),
diff --git a/packages/core/src/services/node_service.rs b/packages/core/src/services/node_service.rs
index b41cfeb89..2c64566af 100644
--- a/packages/core/src/services/node_service.rs
+++ b/packages/core/src/services/node_service.rs
@@ -1370,12 +1370,12 @@ impl NodeService {
         // NOTE: root_id filtering removed - hierarchy now managed via relationships
 
         // Issue #821: Populate title for @mention search
-        // Task nodes always get indexed (regardless of hierarchy level)
-        // Issue #844: Collection nodes always get titles (for indexed lookup)
-        // Other node types will have title set by create_node_with_parent if they're root nodes
+        // Issue #824: Schema-driven title_template support
         // Only set title if not already set (create_node_with_parent may have set it for root nodes)
-        if (node.node_type == "task" || node.node_type == "collection") && node.title.is_none() {
-            node.title = Some(crate::utils::strip_markdown(&node.content));
+        if node.title.is_none() {
+            // For task/collection we know they're always titled; for others we need to check
+            // is_root=None will only trigger a DB lookup for non-task/collection/date/schema types
+            node.title = self.compute_title(&node, None).await?;
         }
 
         // For schema nodes, use atomic creation with DDL generation (Issue #691, #703)
@@ -1564,23 +1564,35 @@ impl NodeService {
         let node_type = params.node_type.clone();
 
         // Issue #821: Determine title for @mention search
-        // Title is set for: task nodes (always), collection nodes (always), OR root nodes (no parent)
-        // Note: create_node will also set title for task nodes, but we set it here too
-        // for root non-task nodes that won't go through the task check in create_node
-        // TODO #824: Refactor to schema-driven title_template approach
-        // Issue #844: Collections now get titles (for indexed lookup) but are excluded from @mention
-        let title = if params.node_type == "task"
-            || params.node_type == "collection"
-            || params.parent_id.is_none()
-        {
-            // Exclude certain types from having titles
-            // TODO #824: Replace hardcoded exclusions with schema-driven title_template
-            match params.node_type.as_str() {
-                "date" | "schema" => None,
-                _ => Some(crate::utils::strip_markdown(&params.content)),
-            }
-        } else {
-            None
+        // Issue #824: Schema-driven title_template support
+        // Normalize properties to namespaced format so compute_title can find fields correctly.
+        // (create_node will normalize again, but the result is idempotent)
+        let title = {
+            let normalized_props = if params.node_type != "schema" {
+                Self::normalize_flat_properties_to_namespace(
+                    &params.node_type,
+                    &params.properties,
+                    None,
+                )
+            } else {
+                params.properties.clone()
+            };
+            let temp_node = Node {
+                id: node_id.clone(),
+                node_type: params.node_type.clone(),
+                content: params.content.clone(),
+                version: 1,
+                properties: normalized_props,
+                mentions: vec![],
+                mentioned_in: vec![],
+                created_at: chrono::Utc::now(),
+                modified_at: chrono::Utc::now(),
+                title: None,
+                lifecycle_status: "active".to_string(),
+            };
+            // is_root = parent_id.is_none() — avoids a DB lookup at create time
+            self.compute_title(&temp_node, Some(params.parent_id.is_none()))
+                .await?
         };
 
         let node = Node {
@@ -2096,6 +2108,77 @@ impl NodeService {
         })
     }
 
+    /// Compute the indexed title for a node (Issue #824).
+    ///
+    /// Priority:
+    /// 1. Schema has `title_template` → interpolate from properties
+    /// 2. task/collection type → `strip_markdown(content)`
+    /// 3. Root node (no parent), not date/schema → `strip_markdown(content)`
+    /// 4. Otherwise → `None`
+    ///
+    /// The `is_root` parameter avoids a redundant DB lookup when the caller already
+    /// knows the root status (e.g. `parent_id.is_none()` at creation time). Pass
+    /// `None` to have this method look it up only when needed.
+    async fn compute_title(
+        &self,
+        node: &Node,
+        is_root: Option<bool>,
+    ) -> Result<Option<String>, NodeServiceError> {
+        // date/schema nodes never get titles regardless of template
+        if node.node_type == "date" || node.node_type == "schema" {
+            return Ok(None);
+        }
+
+        // Check for title_template in the schema for this node type
+        match self.get_schema_node(&node.node_type).await {
+            Ok(Some(schema)) => {
+                if let Some(template) = &schema.title_template {
+                    // Properties are stored namespaced: { "node_type": { "field": value } }
+                    // Unwrap to the inner namespace object for template interpolation
+                    let flat_props = node
+                        .properties
+                        .get(&node.node_type)
+                        .unwrap_or(&node.properties);
+                    return Ok(Some(crate::utils::interpolate_title_template(
+                        template, flat_props,
+                    )));
+                }
+            }
+            Ok(None) => {} // No schema for this type — fall through to content-based logic
+            Err(e) => {
+                // Schema lookup failed; fall through to content-based title rather than
+                // blocking the create/update operation
+                tracing::warn!(
+                    node_type = %node.node_type,
+                    error = %e,
+                    "compute_title: schema lookup failed, falling back to content-based title"
+                );
+            }
+        }
+
+        // Fall back to content-based title
+        let title = match node.node_type.as_str() {
+            "task" | "collection" => Some(crate::utils::strip_markdown(&node.content)),
+            _ => {
+                let root = match is_root {
+                    Some(v) => v,
+                    None => self
+                        .store
+                        .get_parent_id(&node.id)
+                        .await
+                        .map_err(|e| NodeServiceError::query_failed(e.to_string()))?
+                        .is_none(),
+                };
+                if root {
+                    Some(crate::utils::strip_markdown(&node.content))
+                } else {
+                    None
+                }
+            }
+        };
+        Ok(title)
+    }
+
     /// Update a node without version checking (no OCC).
     ///
     /// **Prefer `update_node()`** which enforces optimistic concurrency control.
@@ -2156,6 +2239,7 @@ impl NodeService {
         let mut updated = existing.clone();
         let mut content_changed = false;
         let mut node_type_changed = false;
+        let mut properties_changed = false;
 
         if let Some(node_type) = update.node_type {
             node_type_changed = updated.node_type != node_type;
@@ -2173,6 +2257,7 @@ impl NodeService {
         // Use reorder_siblings() or move_node() for ordering changes.
 
         if let Some(properties) = update.properties {
+            properties_changed = true;
             // Issue #838: Normalize flat client properties to namespaced format before merging
             // Skip for schema nodes - they use a special non-namespaced format
             if updated.node_type == "schema" {
@@ -2221,33 +2306,11 @@ impl NodeService {
             self.validate_node_against_schema(&updated).await?;
         }
 
-        // Issue #821: Sync title when content or node_type changes
-        // Title is indexed for @mention search on task nodes and root nodes
-        // Issue #844: Collection nodes now get titles (for indexed lookup)
-        // TODO #824: Refactor to schema-driven title_template approach
-        let title_update = if content_changed || node_type_changed {
-            // Determine if this node should have a title
-            // 1. Task nodes always get titles (regardless of hierarchy)
-            // 2. Collection nodes always get titles (for indexed lookup, but excluded from @mention)
-            // 3. Root nodes (no parent) get titles
-            // 4. Date and schema nodes never get titles
-            // TODO #824: Replace hardcoded exclusions with schema-driven title_template
-            let should_have_title = match updated.node_type.as_str() {
-                "date" | "schema" => false,
-                "task" | "collection" => true,
-                _ => {
-                    // Check if root node (no parent)
-                    self.get_parent(id).await?.is_none()
-                }
-            };
-
-            if should_have_title {
-                Some(Some(crate::utils::strip_markdown(&updated.content)))
-            } else {
-                // Clear title for nodes that shouldn't have one
-                // (e.g., when changing from task to text child node)
-                Some(None)
-            }
+        // Issue #821: Sync title when content, node_type, or properties change
+        // Issue #824: Schema-driven title_template — also trigger on properties_changed
+        let title_update = if content_changed || node_type_changed || properties_changed {
+            let new_title = self.compute_title(&updated, None).await?;
+            Some(new_title)
         } else {
             None // No title update needed
         };
@@ -2356,6 +2419,7 @@ impl NodeService {
         let mut updated = existing.clone();
         let mut content_changed = false;
         let mut node_type_changed = false;
+        let mut properties_changed = false;
 
         if let Some(node_type) = update.node_type {
             node_type_changed = updated.node_type != node_type;
@@ -2373,6 +2437,7 @@ impl NodeService {
         // Use reorder_siblings() or move_node() for ordering changes.
 
         if let Some(properties) = update.properties {
+            properties_changed = true;
             // Issue #838: Normalize flat client properties to namespaced format before merging
             // Skip for schema nodes - they use a special non-namespaced format
             if updated.node_type == "schema" {
@@ -2403,29 +2468,11 @@ impl NodeService {
             self.validate_node_against_schema(&updated).await?;
         }
 
-        // Issue #821: Sync title when content or node_type changes
-        // Title is indexed for @mention search on task nodes and root nodes
-        // TODO #824: Refactor to schema-driven title_template approach
-        let title_update = if content_changed || node_type_changed {
-            // Determine if this node should have a title
-            // TODO #824: Replace hardcoded exclusions with schema-driven title_template
-            // Use optimized get_parent_id() instead of get_parent() for root check
-            let should_have_title = match updated.node_type.as_str() {
-                "date" | "schema" | "collection" => false,
-                "task" => true,
-                _ => self
-                    .store
-                    .get_parent_id(id)
-                    .await
-                    .map_err(|e| NodeServiceError::query_failed(e.to_string()))?
-                    .is_none(),
-            };
-
-            if should_have_title {
-                Some(Some(crate::utils::strip_markdown(&updated.content)))
-            } else {
-                Some(None)
-            }
+        // Issue #821: Sync title when content, node_type, or properties change
+        // Issue #824: Schema-driven title_template — also trigger on properties_changed
+        let title_update = if content_changed || node_type_changed || properties_changed {
+            let new_title = self.compute_title(&updated, None).await?;
+            Some(new_title)
         } else {
             None
         };
@@ -8851,7 +8898,11 @@ mod tests {
                     {
                         "name": "status",
                         "type": "enum",
-                        "required": true
+                        "required": true,
+                        "coreValues": [
+                            { "value": "open", "label": "Open" },
+                            { "value": "done", "label": "Done" }
+                        ]
                     }
                 ],
                 "relationships": [
@@ -10533,4 +10584,190 @@ mod tests {
             );
         }
     }
+
+    mod title_template_tests {
+        use super::*;
+
+        /// Helper: create a custom schema with the given title_template
+        async fn create_custom_schema(service: &NodeService, type_id: &str, title_template: &str) {
+            let schema_node = Node::new_with_id(
+                type_id.to_string(),
+                "schema".to_string(),
+                type_id.to_string(),
+                json!({
+                    "isCore": false,
+                    "schemaVersion": 1,
+                    "description": format!("Test schema for {}", type_id),
+                    "titleTemplate": title_template,
+                    "fields": [],
+                    "relationships": []
+                }),
+            );
+            service.create_node(schema_node).await.unwrap();
+        }
+
+        /// Test: creating a node of a custom type with title_template computes title from properties
+        #[tokio::test]
+        async fn test_title_template_on_create() {
+            let (service, _temp) = create_test_service().await;
+
+            create_custom_schema(&service, "customer", "{first_name} {last_name}").await;
+
+            let node = Node::new_with_id(
+                uuid::Uuid::new_v4().to_string(),
+                "customer".to_string(),
+                "".to_string(),
+                json!({"first_name": "John", "last_name": "Doe"}),
+            );
+            let node_id = node.id.clone();
+            service.create_node(node).await.unwrap();
+
+            let retrieved = service.get_node(&node_id).await.unwrap().unwrap();
+            assert_eq!(
+                retrieved.title,
+                Some("John Doe".to_string()),
+                "Title should be interpolated from template on create"
+            );
+        }
+
+        /// Test: updating node properties triggers title recomputation via template
+        #[tokio::test]
+        async fn test_title_template_recomputed_on_property_update() {
+            let (service, _temp) = create_test_service().await;
+
+            create_custom_schema(&service, "customer2", "{first_name} {last_name}").await;
+
+            let node = Node::new_with_id(
+                uuid::Uuid::new_v4().to_string(),
+                "customer2".to_string(),
+                "".to_string(),
+                json!({"first_name": "Jane", "last_name": "Smith"}),
+            );
+            let node_id = node.id.clone();
+            service.create_node(node).await.unwrap();
+
+            // Verify initial title
+            let initial = service.get_node(&node_id).await.unwrap().unwrap();
+            assert_eq!(initial.title, Some("Jane Smith".to_string()));
+
+            // Update properties → title should recompute
+            let update = crate::models::NodeUpdate {
+                properties: Some(json!({"first_name": "Janet"})),
+                ..Default::default()
+            };
+            service
+                .update_node_unchecked(&node_id, update)
+                .await
+                .unwrap();
+
+            let updated = service.get_node(&node_id).await.unwrap().unwrap();
+            assert_eq!(
+                updated.title,
+                Some("Janet Smith".to_string()),
+                "Title should recompute when properties change"
+            );
+        }
+
+        /// Test: updating content does NOT override template-based title
+        #[tokio::test]
+        async fn test_title_template_takes_priority_over_content() {
+            let (service, _temp) = create_test_service().await;
+
+            create_custom_schema(&service, "customer3", "{first_name} {last_name}").await;
+
+            let node = Node::new_with_id(
+                uuid::Uuid::new_v4().to_string(),
+                "customer3".to_string(),
+                "original content".to_string(),
+                json!({"first_name": "Alice", "last_name": "Wonder"}),
+            );
+            let node_id = node.id.clone();
+            service.create_node(node).await.unwrap();
+
+            // Verify template wins over content on create
+            let initial = service.get_node(&node_id).await.unwrap().unwrap();
+            assert_eq!(initial.title, Some("Alice Wonder".to_string()));
+
+            // Update content → template should still produce the title
+            let update = crate::models::NodeUpdate {
+                content: Some("updated content".to_string()),
+                ..Default::default()
+            };
+            service
+                .update_node_unchecked(&node_id, update)
+                .await
+                .unwrap();
+
+            let updated = service.get_node(&node_id).await.unwrap().unwrap();
+            assert_eq!(
+                updated.title,
+                Some("Alice Wonder".to_string()),
+                "Template-based title should take priority over content"
+            );
+        }
+
+        /// Test: node type without template falls back to existing content-based behavior
+        #[tokio::test]
+        async fn test_no_title_template_falls_back_to_content() {
+            let (service, _temp) = create_test_service().await;
+
+            // Create a schema without title_template
+            let schema_node = Node::new_with_id(
+                "widget".to_string(),
+                "schema".to_string(),
+                "Widget".to_string(),
+                json!({
+                    "isCore": false,
+                    "schemaVersion": 1,
+                    "description": "Widget schema with no title_template",
+                    "fields": [],
+                    "relationships": []
+                }),
+            );
+            service.create_node(schema_node).await.unwrap();
+
+            // Root widget node (no parent) should get content as title
+            let params = CreateNodeParams {
+                node_type: "widget".to_string(),
+                content: "My Widget".to_string(),
+                parent_id: None,
+                insert_after_node_id: None,
+                properties: json!({}),
+                id: None,
+            };
+            let widget_id = service.create_node_with_parent(params).await.unwrap();
+            let retrieved = service.get_node(&widget_id).await.unwrap().unwrap();
+            assert_eq!(
+                retrieved.title,
+                Some("My Widget".to_string()),
+                "Root node without template should use content as title"
+            );
+        }
+
+        /// Test: missing template fields produce empty strings (not panics)
+        #[tokio::test]
+        async fn test_title_template_missing_fields_graceful() {
+            let (service, _temp) = create_test_service().await;
+
+            create_custom_schema(&service, "contact", "{first_name} {last_name} ({email})").await;
+
+            // Create node with only first_name — last_name and email missing
+            let node = Node::new_with_id(
+                uuid::Uuid::new_v4().to_string(),
+                "contact".to_string(),
+                "".to_string(),
+                json!({"first_name": "Bob"}),
+            );
+            let node_id = node.id.clone();
+            service.create_node(node).await.unwrap();
+
+            let retrieved = service.get_node(&node_id).await.unwrap().unwrap();
+            // Missing fields become empty strings, whitespace is collapsed and trimmed
+            assert_eq!(
+                retrieved.title,
+                Some("Bob ()".to_string()),
+                "Missing fields should produce empty strings, not errors"
+            );
+        }
+    }
 }
diff --git a/packages/core/src/utils/markdown.rs b/packages/core/src/utils/markdown.rs
index 316637667..2860cc1d3 100644
--- a/packages/core/src/utils/markdown.rs
+++ b/packages/core/src/utils/markdown.rs
@@ -96,6 +96,62 @@ pub fn strip_markdown(content: &str) -> String {
     result.trim().to_string()
 }
 
+/// Interpolate a title template string using node properties.
+///
+/// Replaces `{field_name}` tokens with the corresponding property value.
+/// Non-string values (numbers, booleans) are converted to their string representation.
+/// Missing or null fields are replaced with empty strings.
+/// The result is trimmed of leading/trailing whitespace.
+///
+/// # Arguments
+///
+/// * `template` - The template string with `{field_name}` placeholders
+/// * `properties` - The node's properties JSON object
+///
+/// # Examples
+///
+/// ```
+/// use nodespace_core::utils::interpolate_title_template;
+/// use serde_json::json;
+///
+/// let props = json!({"first_name": "John", "last_name": "Doe"});
+/// assert_eq!(
+///     interpolate_title_template("{first_name} {last_name}", &props),
+///     "John Doe"
+/// );
+/// ```
+pub fn interpolate_title_template(template: &str, properties: &serde_json::Value) -> String {
+    let mut result = String::with_capacity(template.len());
+    let chars: Vec<char> = template.chars().collect();
+    let mut i = 0;
+
+    while i < chars.len() {
+        if chars[i] == '{' {
+            // Find the closing brace
+            if let Some(end) = chars[i + 1..].iter().position(|&c| c == '}') {
+                let field_name: String = chars[i + 1..i + 1 + end].iter().collect();
+                // Look up the field value in properties
+                let value = properties.get(&field_name);
+                match value {
+                    Some(serde_json::Value::String(s)) => result.push_str(s),
+                    Some(serde_json::Value::Number(n)) => result.push_str(&n.to_string()),
+                    Some(serde_json::Value::Bool(b)) => result.push_str(&b.to_string()),
+                    Some(serde_json::Value::Null) | None => {} // empty string for missing/null
+                    Some(other) => result.push_str(&other.to_string()),
+                }
+                i += 1 + end + 1; // skip past '}'
+                continue;
+            }
+        }
+        result.push(chars[i]);
+        i += 1;
+    }
+
+    // Normalize whitespace and trim
+    let normalized = WHITESPACE_RE.replace_all(&result, " ");
+    normalized.trim().to_string()
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -212,4 +268,93 @@ mod tests {
         let expected = "Header Some bold text List item";
         assert_eq!(strip_markdown(input), expected);
     }
+
+    // -------------------------------------------------------------------------
+    // interpolate_title_template tests
+    // -------------------------------------------------------------------------
+
+    #[test]
+    fn test_interpolate_basic_fields() {
+        let props = serde_json::json!({"first_name": "John", "last_name": "Doe"});
+        assert_eq!(
+            interpolate_title_template("{first_name} {last_name}", &props),
+            "John Doe"
+        );
+    }
+
+    #[test]
+    fn test_interpolate_missing_field_is_empty() {
+        let props = serde_json::json!({"first_name": "Jane"});
+        assert_eq!(
+            interpolate_title_template("{first_name} {last_name}", &props),
+            "Jane"
+        );
+    }
+
+    #[test]
+    fn test_interpolate_null_field_is_empty() {
+        let props = serde_json::json!({"first_name": "Alice", "email": null});
+        assert_eq!(
+            interpolate_title_template("{first_name} ({email})", &props),
+            "Alice ()"
+        );
+    }
+
+    #[test]
+    fn test_interpolate_number_value() {
+        let props = serde_json::json!({"invoice_number": 42});
+        assert_eq!(
+            interpolate_title_template("Invoice #{invoice_number}", &props),
+            "Invoice #42"
+        );
+    }
+
+    #[test]
+    fn test_interpolate_boolean_value() {
+        let props = serde_json::json!({"active": true});
+        assert_eq!(
+            interpolate_title_template("Active: {active}", &props),
+            "Active: true"
+        );
+    }
+
+    #[test]
+    fn test_interpolate_multiple_fields() {
+        let props = serde_json::json!({
+            "first_name": "John",
+            "last_name": "Doe",
+            "email": "john@example.com"
+        });
+        assert_eq!(
+            interpolate_title_template("{first_name} {last_name} ({email})", &props),
+            "John Doe (john@example.com)"
+        );
+    }
+
+    #[test]
+    fn test_interpolate_trims_whitespace() {
+        let props = serde_json::json!({"first_name": "Jane", "last_name": ""});
+        assert_eq!(
+            interpolate_title_template("{first_name} {last_name}", &props),
+            "Jane"
+        );
+    }
+
+    #[test]
+    fn test_interpolate_no_placeholders() {
+        let props = serde_json::json!({});
+        assert_eq!(
+            interpolate_title_template("Static Title", &props),
+            "Static Title"
+        );
+    }
+
+    #[test]
+    fn test_interpolate_all_fields_missing() {
+        let props = serde_json::json!({});
+        assert_eq!(
+            interpolate_title_template("{first_name} {last_name}", &props),
+            ""
+        );
+    }
 }
diff --git a/packages/core/src/utils/mod.rs b/packages/core/src/utils/mod.rs
index 779ad394b..3eea1edd6 100644
--- a/packages/core/src/utils/mod.rs
+++ b/packages/core/src/utils/mod.rs
@@ -4,4 +4,4 @@
 
 mod markdown;
 
-pub use markdown::strip_markdown;
+pub use markdown::{interpolate_title_template, strip_markdown};
