AkurAI Build
Menu

AkurAI-CRMCommit f21a8121

Commit f21a8121

f21a8121a42248001d0b1e88d62efe693179ea69 by Ólafur B. J.

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75459d8..5b91bfb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 ## [Unreleased]
 
+## [0.1.1] - 2026-06-26
+
 ### Added
 
 - Project scaffold based on AkurAI-Framework
@@ -12,3 +14,6 @@
 - CLI binary with serve/seed commands
 - Integration tests: 7 passing
 - Open-source release: MIT, public repo
+- AkurAI-Framework theme system: vendor themes.css (17 themes), theme.js ESM switcher, flash-guard, theme-picker across all pages
+- All CSS color tokens aliased to framework semantic tokens (--bg, --panel, --accent, --fg, --muted, --ok, --warn, --danger, --info, --border-2, etc.)
+- Deployed to https://akurai-crm.olibuijr.com (EC2, nginx, systemd, Let's Encrypt)
diff --git a/Cargo.lock b/Cargo.lock
index f293011..327f55a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -12,7 +12,7 @@ dependencies = [
 
 [[package]]
 name = "akurai-crm"
-version = "0.1.0"
+version = "0.1.1"
 dependencies = [
  "akurai-collections",
  "akurai-http",
@@ -48,7 +48,7 @@ version = "0.8.0"
 
 [[package]]
 name = "crm-api"
-version = "0.1.0"
+version = "0.1.1"
 dependencies = [
  "akurai-collections",
  "akurai-http",
@@ -60,7 +60,7 @@ dependencies = [
 
 [[package]]
 name = "crm-core"
-version = "0.1.0"
+version = "0.1.1"
 dependencies = [
  "akurai-json",
  "akurai-schema",
@@ -68,7 +68,7 @@ dependencies = [
 
 [[package]]
 name = "crm-mcp"
-version = "0.1.0"
+version = "0.1.1"
 dependencies = [
  "akurai-http",
  "akurai-json",
diff --git a/Cargo.toml b/Cargo.toml
index 9cf9f90..0201879 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ members = [
 ]
 
 [workspace.package]
-version = "0.1.0"
+version = "0.1.1"
 edition = "2021"
 license = "MIT"
 repository = "https://github.com/olibuijr/akurai-crm"
diff --git a/VERSION b/VERSION
index 6e8bf73..17e51c3 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.1.0
+0.1.1
diff --git a/crates/crm-api/src/handlers/crud.rs b/crates/crm-api/src/handlers/crud.rs
index 6eb02d7..508da7f 100644
--- a/crates/crm-api/src/handlers/crud.rs
+++ b/crates/crm-api/src/handlers/crud.rs
@@ -1,5 +1,7 @@
 use crate::handlers::timeline::record_timeline;
-use crate::router_setup::{bad_request, internal_error, json_response, not_found, upper_bound, CrmState};
+use crate::router_setup::{
+    bad_request, internal_error, json_response, not_found, upper_bound, CrmState,
+};
 use akurai_http::{Request, Response};
 use akurai_json::Value;
 use std::sync::{Arc, Mutex};
@@ -34,7 +36,10 @@ fn build_key(coll_name: &str, id: u64) -> Vec<u8> {
     key
 }
 
-pub fn list_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn list_route(
+    state: Arc<Mutex<CrmState>>,
+    entity: &'static str,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
     Box::new(move |_req: &Request| {
         let state = match state.lock() {
             Ok(s) => s,
@@ -52,7 +57,8 @@ pub fn list_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn
 
         if let Ok(entries) = db.range(&prefix, &end) {
             for (_key_bytes, val_bytes) in entries {
-                if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or("")) {
+                if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or(""))
+                {
                     records.push(json);
                 }
             }
@@ -62,7 +68,10 @@ pub fn list_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn
     })
 }
 
-pub fn get_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn get_route(
+    state: Arc<Mutex<CrmState>>,
+    entity: &'static str,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
     Box::new(move |req: &Request| {
         let id = match extract_id(&req.path) {
             Some(id) => id,
@@ -80,19 +89,20 @@ pub fn get_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn F
 
         let key = build_key(coll_name, id);
         match db.get(&key) {
-            Ok(Some(val)) => {
-                match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
-                    Ok(json) => json_response(json),
-                    Err(_) => internal_error("corrupted data"),
-                }
-            }
+            Ok(Some(val)) => match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
+                Ok(json) => json_response(json),
+                Err(_) => internal_error("corrupted data"),
+            },
             Ok(None) => not_found(&format!("{entity}/{id}")),
             Err(e) => internal_error(&format!("storage: {e}")),
         }
     })
 }
 
-pub fn create_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn create_route(
+    state: Arc<Mutex<CrmState>>,
+    entity: &'static str,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
     Box::new(move |req: &Request| {
         let body = match parse_body(req) {
             Ok(v) => v,
@@ -156,7 +166,10 @@ pub fn create_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dy
     })
 }
 
-pub fn update_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn update_route(
+    state: Arc<Mutex<CrmState>>,
+    entity: &'static str,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
     Box::new(move |req: &Request| {
         let id = match extract_id(&req.path) {
             Some(id) => id,
@@ -179,12 +192,10 @@ pub fn update_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dy
         let key = build_key(coll_name, id);
 
         let existing = match db.get(&key) {
-            Ok(Some(val)) => {
-                match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
-                    Ok(v) => v,
-                    Err(_) => return internal_error("corrupted data"),
-                }
-            }
+            Ok(Some(val)) => match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
+                Ok(v) => v,
+                Err(_) => return internal_error("corrupted data"),
+            },
             Ok(None) => return not_found(&format!("{entity}/{id}")),
             Err(e) => return internal_error(&format!("storage: {e}")),
         };
@@ -241,7 +252,10 @@ pub fn update_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dy
     })
 }
 
-pub fn delete_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn delete_route(
+    state: Arc<Mutex<CrmState>>,
+    entity: &'static str,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
     Box::new(move |req: &Request| {
         let id = match extract_id(&req.path) {
             Some(id) => id,
diff --git a/crates/crm-api/src/handlers/meta.rs b/crates/crm-api/src/handlers/meta.rs
index 69b4551..7010eef 100644
--- a/crates/crm-api/src/handlers/meta.rs
+++ b/crates/crm-api/src/handlers/meta.rs
@@ -9,32 +9,55 @@ pub fn meta_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Respon
             Ok(s) => s,
             Err(_) => return internal_error("lock poisoned"),
         };
-        let objects: Vec<Value> = state.objects.iter().map(|obj| {
-            let fields: Vec<Value> = obj.fields.iter().map(|f| {
-                Value::Object(vec![
-                    ("id".into(), Value::Int(f.id as i64)),
-                    ("name".into(), Value::Str(f.name.clone())),
-                    ("label".into(), Value::Str(f.label.clone())),
-                    ("type".into(), Value::Str(format!("{:?}", f.field_type).to_lowercase())),
-                    ("required".into(), Value::Bool(f.is_required)),
-                    ("unique".into(), Value::Bool(f.is_unique)),
-                    ("description".into(), Value::Str(f.description.clone().unwrap_or_default())),
-                ])
-            }).collect();
+        let objects: Vec<Value> = state
+            .objects
+            .iter()
+            .map(|obj| {
+                let fields: Vec<Value> = obj
+                    .fields
+                    .iter()
+                    .map(|f| {
+                        Value::Object(vec![
+                            ("id".into(), Value::Int(f.id as i64)),
+                            ("name".into(), Value::Str(f.name.clone())),
+                            ("label".into(), Value::Str(f.label.clone())),
+                            (
+                                "type".into(),
+                                Value::Str(format!("{:?}", f.field_type).to_lowercase()),
+                            ),
+                            ("required".into(), Value::Bool(f.is_required)),
+                            ("unique".into(), Value::Bool(f.is_unique)),
+                            (
+                                "description".into(),
+                                Value::Str(f.description.clone().unwrap_or_default()),
+                            ),
+                        ])
+                    })
+                    .collect();
 
-            Value::Object(vec![
-                ("id".into(), Value::Int(obj.id as i64)),
-                ("nameSingular".into(), Value::Str(obj.name_singular.clone())),
-                ("namePlural".into(), Value::Str(obj.name_plural.clone())),
-                ("labelSingular".into(), Value::Str(obj.label_singular.clone())),
-                ("labelPlural".into(), Value::Str(obj.label_plural.clone())),
-                ("description".into(), Value::Str(obj.description.clone().unwrap_or_default())),
-                ("icon".into(), Value::Str(obj.icon.clone().unwrap_or_default())),
-                ("isCustom".into(), Value::Bool(obj.is_custom)),
-                ("isSearchable".into(), Value::Bool(obj.is_searchable)),
-                ("fields".into(), Value::Array(fields)),
-            ])
-        }).collect();
+                Value::Object(vec![
+                    ("id".into(), Value::Int(obj.id as i64)),
+                    ("nameSingular".into(), Value::Str(obj.name_singular.clone())),
+                    ("namePlural".into(), Value::Str(obj.name_plural.clone())),
+                    (
+                        "labelSingular".into(),
+                        Value::Str(obj.label_singular.clone()),
+                    ),
+                    ("labelPlural".into(), Value::Str(obj.label_plural.clone())),
+                    (
+                        "description".into(),
+                        Value::Str(obj.description.clone().unwrap_or_default()),
+                    ),
+                    (
+                        "icon".into(),
+                        Value::Str(obj.icon.clone().unwrap_or_default()),
+                    ),
+                    ("isCustom".into(), Value::Bool(obj.is_custom)),
+                    ("isSearchable".into(), Value::Bool(obj.is_searchable)),
+                    ("fields".into(), Value::Array(fields)),
+                ])
+            })
+            .collect();
 
         json_response(Value::Object(vec![
             ("version".into(), Value::Str("0.1.0".into())),
diff --git a/crates/crm-api/src/handlers/search.rs b/crates/crm-api/src/handlers/search.rs
index 7e68fe5..3eb958c 100644
--- a/crates/crm-api/src/handlers/search.rs
+++ b/crates/crm-api/src/handlers/search.rs
@@ -23,16 +23,21 @@ fn url_decode(s: &str) -> String {
 fn json_values_contain(val: &akurai_json::Value, term: &str) -> bool {
     match val {
         akurai_json::Value::Str(s) => s.to_lowercase().contains(term),
-        akurai_json::Value::Object(pairs) => pairs.iter().any(|(_, v)| json_values_contain(v, term)),
+        akurai_json::Value::Object(pairs) => {
+            pairs.iter().any(|(_, v)| json_values_contain(v, term))
+        }
         akurai_json::Value::Array(items) => items.iter().any(|v| json_values_contain(v, term)),
         _ => false,
     }
 }
 
-pub fn search_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn search_route(
+    state: Arc<Mutex<CrmState>>,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
     Box::new(move |req: &Request| {
         let query = req.query.as_deref().unwrap_or("");
-        let search_term = query.split('&')
+        let search_term = query
+            .split('&')
             .find_map(|pair| {
                 let mut parts = pair.splitn(2, '=');
                 if parts.next()? == "q" {
@@ -60,16 +65,27 @@ pub fn search_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Resp
         };
         let mut results = Vec::new();
 
-        let entity_prefixes = ["people:", "companies:", "opportunities:", "tasks:", "notes:"];
+        let entity_prefixes = [
+            "people:",
+            "companies:",
+            "opportunities:",
+            "tasks:",
+            "notes:",
+        ];
         for prefix_str in &entity_prefixes {
             let prefix = prefix_str.as_bytes();
             let end = upper_bound(prefix);
             if let Ok(entries) = db.range(prefix, &end) {
                 for (_key_bytes, val_bytes) in entries {
-                    if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or("")) {
+                    if let Ok(json) =
+                        akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or(""))
+                    {
                         if json_values_contain(&json, &search_term) {
                             let entry = vec![
-                                ("entity".into(), Value::Str(prefix_str.trim_end_matches(':').into())),
+                                (
+                                    "entity".into(),
+                                    Value::Str(prefix_str.trim_end_matches(':').into()),
+                                ),
                                 ("record".into(), json),
                             ];
                             results.push(Value::Object(entry));
diff --git a/crates/crm-api/src/handlers/static_files.rs b/crates/crm-api/src/handlers/static_files.rs
index 7ebc5db..c29615b 100644
--- a/crates/crm-api/src/handlers/static_files.rs
+++ b/crates/crm-api/src/handlers/static_files.rs
@@ -2,7 +2,9 @@ use crate::router_setup::{internal_error, not_found, CrmState};
 use akurai_http::{Request, Response};
 use std::sync::{Arc, Mutex};
 
-pub fn static_file_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn static_file_route(
+    state: Arc<Mutex<CrmState>>,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
     Box::new(move |req: &Request| {
         let path = &req.path;
         let state = match state.lock() {
@@ -19,8 +21,11 @@ pub fn static_file_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) ->
         };
 
         let canonical = std::fs::canonicalize(&file_path).ok();
-        let frontend_canonical = std::fs::canonicalize(frontend).unwrap_or_else(|_| std::path::PathBuf::from("/nonexistent"));
-        let is_safe = canonical.as_ref().is_some_and(|c| c.starts_with(&frontend_canonical));
+        let frontend_canonical = std::fs::canonicalize(frontend)
+            .unwrap_or_else(|_| std::path::PathBuf::from("/nonexistent"));
+        let is_safe = canonical
+            .as_ref()
+            .is_some_and(|c| c.starts_with(&frontend_canonical));
 
         if !is_safe {
             return not_found("invalid path");
diff --git a/crates/crm-api/src/handlers/timeline.rs b/crates/crm-api/src/handlers/timeline.rs
index 7bd58e1..ea5bdd0 100644
--- a/crates/crm-api/src/handlers/timeline.rs
+++ b/crates/crm-api/src/handlers/timeline.rs
@@ -3,7 +3,9 @@ use akurai_http::{Request, Response};
 use akurai_json::Value;
 use std::sync::{Arc, Mutex};
 
-pub fn timeline_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn timeline_route(
+    state: Arc<Mutex<CrmState>>,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
     Box::new(move |req: &Request| {
         let query = req.query.as_deref().unwrap_or("");
         let mut entity_type = String::new();
@@ -30,15 +32,18 @@ pub fn timeline_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Re
             let mut parts = pair.splitn(2, '=');
             match parts.next() {
                 Some("entityType") => entity_type = url_decode(parts.next().unwrap_or("")),
-                Some("entityId") => entity_id = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0),
+                Some("entityId") => {
+                    entity_id = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0)
+                }
                 _ => {}
             }
         }
 
         if entity_type.is_empty() || entity_id == 0 {
-            return json_response(Value::Object(vec![
-                ("timeline".into(), Value::Array(vec![])),
-            ]));
+            return json_response(Value::Object(vec![(
+                "timeline".into(),
+                Value::Array(vec![]),
+            )]));
         }
 
         let state = match state.lock() {
@@ -55,7 +60,8 @@ pub fn timeline_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Re
         let end = upper_bound(&prefix);
         if let Ok(entries) = db.range(&prefix, &end) {
             for (_key_bytes, val_bytes) in entries {
-                if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or("")) {
+                if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or(""))
+                {
                     activities.push(json);
                 }
             }
@@ -79,12 +85,19 @@ pub fn record_timeline(
     actor_id: Option<u64>,
 ) -> Result<(), String> {
     use std::time::{SystemTime, UNIX_EPOCH};
-    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
+    let now = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .unwrap_or_default()
+        .as_secs() as i64;
 
     use std::collections::hash_map::RandomState;
     use std::hash::{BuildHasher, Hasher};
     let rand_suffix = RandomState::new().build_hasher().finish() % 10000;
-    let key = format!("timeline:{}:{}:{}_{}", entity_type, entity_id, now, rand_suffix).into_bytes();
+    let key = format!(
+        "timeline:{}:{}:{}_{}",
+        entity_type, entity_id, now, rand_suffix
+    )
+    .into_bytes();
 
     let record = Value::Object(vec![
         ("action".into(), Value::Str(action.into())),
diff --git a/crates/crm-api/src/router_setup.rs b/crates/crm-api/src/router_setup.rs
index 4d75f67..3bfdf93 100644
--- a/crates/crm-api/src/router_setup.rs
+++ b/crates/crm-api/src/router_setup.rs
@@ -32,12 +32,23 @@ impl CrmState {
 
     pub fn now() -> i64 {
         use std::time::{SystemTime, UNIX_EPOCH};
-        SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64
+        SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_secs() as i64
     }
 }
 
-pub fn r(state: &'static str, p: &'static str, h: Box<dyn Fn(&Request) -> Response + Send + Sync>) -> Route {
-    Route { method: state, pattern: p, handler: h }
+pub fn r(
+    state: &'static str,
+    p: &'static str,
+    h: Box<dyn Fn(&Request) -> Response + Send + Sync>,
+) -> Route {
+    Route {
+        method: state,
+        pattern: p,
+        handler: h,
+    }
 }
 
 /// Build the route table for the CRM API
@@ -46,34 +57,146 @@ pub fn build_router(state: Arc<Mutex<CrmState>>) -> Vec<Route> {
         r("OPTIONS", "/*", options_handler()),
         r("GET", "/", handlers::static_file_route(Arc::clone(&state))),
         r("GET", "/*", handlers::static_file_route(Arc::clone(&state))),
-        r("GET", "/api/_meta", handlers::meta_route(Arc::clone(&state))),
-        r("GET", "/api/people", handlers::list_route(Arc::clone(&state), "people")),
-        r("GET", "/api/people/:id", handlers::get_route(Arc::clone(&state), "people")),
-        r("POST", "/api/people", handlers::create_route(Arc::clone(&state), "people")),
-        r("PUT", "/api/people/:id", handlers::update_route(Arc::clone(&state), "people")),
-        r("DELETE", "/api/people/:id", handlers::delete_route(Arc::clone(&state), "people")),
-        r("GET", "/api/companies", handlers::list_route(Arc::clone(&state), "companies")),
-        r("GET", "/api/companies/:id", handlers::get_route(Arc::clone(&state), "companies")),
-        r("POST", "/api/companies", handlers::create_route(Arc::clone(&state), "companies")),
-        r("PUT", "/api/companies/:id", handlers::update_route(Arc::clone(&state), "companies")),
-        r("DELETE", "/api/companies/:id", handlers::delete_route(Arc::clone(&state), "companies")),
-        r("GET", "/api/opportunities", handlers::list_route(Arc::clone(&state), "opportunities")),
-        r("GET", "/api/opportunities/:id", handlers::get_route(Arc::clone(&state), "opportunities")),
-        r("POST", "/api/opportunities", handlers::create_route(Arc::clone(&state), "opportunities")),
-        r("PUT", "/api/opportunities/:id", handlers::update_route(Arc::clone(&state), "opportunities")),
-        r("DELETE", "/api/opportunities/:id", handlers::delete_route(Arc::clone(&state), "opportunities")),
-        r("GET", "/api/tasks", handlers::list_route(Arc::clone(&state), "tasks")),
-        r("GET", "/api/tasks/:id", handlers::get_route(Arc::clone(&state), "tasks")),
-        r("POST", "/api/tasks", handlers::create_route(Arc::clone(&state), "tasks")),
-        r("PUT", "/api/tasks/:id", handlers::update_route(Arc::clone(&state), "tasks")),
-        r("DELETE", "/api/tasks/:id", handlers::delete_route(Arc::clone(&state), "tasks")),
-        r("GET", "/api/notes", handlers::list_route(Arc::clone(&state), "notes")),
-        r("GET", "/api/notes/:id", handlers::get_route(Arc::clone(&state), "notes")),
-        r("POST", "/api/notes", handlers::create_route(Arc::clone(&state), "notes")),
-        r("PUT", "/api/notes/:id", handlers::update_route(Arc::clone(&state), "notes")),
-        r("DELETE", "/api/notes/:id", handlers::delete_route(Arc::clone(&state), "notes")),
-        r("GET", "/api/search", handlers::search_route(Arc::clone(&state))),
-        r("GET", "/api/timeline", handlers::timeline_route(Arc::clone(&state))),
+        r(
+            "GET",
+            "/api/_meta",
+            handlers::meta_route(Arc::clone(&state)),
+        ),
+        r(
+            "GET",
+            "/api/people",
+            handlers::list_route(Arc::clone(&state), "people"),
+        ),
+        r(
+            "GET",
+            "/api/people/:id",
+            handlers::get_route(Arc::clone(&state), "people"),
+        ),
+        r(
+            "POST",
+            "/api/people",
+            handlers::create_route(Arc::clone(&state), "people"),
+        ),
+        r(
+            "PUT",
+            "/api/people/:id",
+            handlers::update_route(Arc::clone(&state), "people"),
+        ),
+        r(
+            "DELETE",
+            "/api/people/:id",
+            handlers::delete_route(Arc::clone(&state), "people"),
+        ),
+        r(
+            "GET",
+            "/api/companies",
+            handlers::list_route(Arc::clone(&state), "companies"),
+        ),
+        r(
+            "GET",
+            "/api/companies/:id",
+            handlers::get_route(Arc::clone(&state), "companies"),
+        ),
+        r(
+            "POST",
+            "/api/companies",
+            handlers::create_route(Arc::clone(&state), "companies"),
+        ),
+        r(
+            "PUT",
+            "/api/companies/:id",
+            handlers::update_route(Arc::clone(&state), "companies"),
+        ),
+        r(
+            "DELETE",
+            "/api/companies/:id",
+            handlers::delete_route(Arc::clone(&state), "companies"),
+        ),
+        r(
+            "GET",
+            "/api/opportunities",
+            handlers::list_route(Arc::clone(&state), "opportunities"),
+        ),
+        r(
+            "GET",
+            "/api/opportunities/:id",
+            handlers::get_route(Arc::clone(&state), "opportunities"),
+        ),
+        r(
+            "POST",
+            "/api/opportunities",
+            handlers::create_route(Arc::clone(&state), "opportunities"),
+        ),
+        r(
+            "PUT",
+            "/api/opportunities/:id",
+            handlers::update_route(Arc::clone(&state), "opportunities"),
+        ),
+        r(
+            "DELETE",
+            "/api/opportunities/:id",
+            handlers::delete_route(Arc::clone(&state), "opportunities"),
+        ),
+        r(
+            "GET",
+            "/api/tasks",
+            handlers::list_route(Arc::clone(&state), "tasks"),
+        ),
+        r(
+            "GET",
+            "/api/tasks/:id",
+            handlers::get_route(Arc::clone(&state), "tasks"),
+        ),
+        r(
+            "POST",
+            "/api/tasks",
+            handlers::create_route(Arc::clone(&state), "tasks"),
+        ),
+        r(
+            "PUT",
+            "/api/tasks/:id",
+            handlers::update_route(Arc::clone(&state), "tasks"),
+        ),
+        r(
+            "DELETE",
+            "/api/tasks/:id",
+            handlers::delete_route(Arc::clone(&state), "tasks"),
+        ),
+        r(
+            "GET",
+            "/api/notes",
+            handlers::list_route(Arc::clone(&state), "notes"),
+        ),
+        r(
+            "GET",
+            "/api/notes/:id",
+            handlers::get_route(Arc::clone(&state), "notes"),
+        ),
+        r(
+            "POST",
+            "/api/notes",
+            handlers::create_route(Arc::clone(&state), "notes"),
+        ),
+        r(
+            "PUT",
+            "/api/notes/:id",
+            handlers::update_route(Arc::clone(&state), "notes"),
+        ),
+        r(
+            "DELETE",
+            "/api/notes/:id",
+            handlers::delete_route(Arc::clone(&state), "notes"),
+        ),
+        r(
+            "GET",
+            "/api/search",
+            handlers::search_route(Arc::clone(&state)),
+        ),
+        r(
+            "GET",
+            "/api/timeline",
+            handlers::timeline_route(Arc::clone(&state)),
+        ),
     ]
 }
 
@@ -94,9 +217,8 @@ pub fn json_response(data: akurai_json::Value) -> Response {
 }
 
 pub fn error_response(status: u16, msg: &str) -> Response {
-    let data = akurai_json::Value::Object(vec![
-        ("error".into(), akurai_json::Value::Str(msg.into())),
-    ]);
+    let data =
+        akurai_json::Value::Object(vec![("error".into(), akurai_json::Value::Str(msg.into()))]);
     let mut resp = json_response(data);
     resp.status = status;
     resp.reason = match status {
@@ -104,7 +226,8 @@ pub fn error_response(status: u16, msg: &str) -> Response {
         404 => "Not Found",
         500 => "Internal Server Error",
         _ => "Error",
-    }.into();
+    }
+    .into();
     resp
 }
 
@@ -112,8 +235,14 @@ pub fn options_handler() -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
     Box::new(|_req: &Request| {
         Response::new(204)
             .with_header("Access-Control-Allow-Origin", "*")
-            .with_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
-            .with_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
+            .with_header(
+                "Access-Control-Allow-Methods",
+                "GET, POST, PUT, DELETE, OPTIONS",
+            )
+            .with_header(
+                "Access-Control-Allow-Headers",
+                "Content-Type, Authorization",
+            )
             .with_header("Access-Control-Max-Age", "86400")
     })
 }
diff --git a/crates/crm-api/tests/api_test.rs b/crates/crm-api/tests/api_test.rs
index 6aa89fd..b68bdf6 100644
--- a/crates/crm-api/tests/api_test.rs
+++ b/crates/crm-api/tests/api_test.rs
@@ -1,8 +1,7 @@
-use crm_api::CrmState;
 use akurai_json::Value;
+use crm_api::CrmState;
 use std::path::PathBuf;
 
-
 #[test]
 fn test_create_and_list_people() {
     let db_path = "_test_people.db";
@@ -12,7 +11,9 @@ fn test_create_and_list_people() {
     let mut db = state.db.lock().unwrap();
 
     let now = std::time::SystemTime::now()
-        .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
+        .duration_since(std::time::UNIX_EPOCH)
+        .unwrap_or_default()
+        .as_secs() as i64;
 
     let record = Value::Object(vec![
         ("id".into(), Value::Int(1)),
@@ -33,7 +34,9 @@ fn test_create_and_list_people() {
     let mut found = false;
     if let Ok(entries) = db.range(b"people:", b"people:\xff") {
         for (key, _) in entries {
-            if key == b"people:1" { found = true; }
+            if key == b"people:1" {
+                found = true;
+            }
         }
     }
     assert!(found, "should find the person in range scan");
diff --git a/crates/crm-cli/src/cmd_seed.rs b/crates/crm-cli/src/cmd_seed.rs
index a3c0786..4af41f6 100644
--- a/crates/crm-cli/src/cmd_seed.rs
+++ b/crates/crm-cli/src/cmd_seed.rs
@@ -1,5 +1,5 @@
-use crm_api::CrmState;
 use akurai_json::Value;
+use crm_api::CrmState;
 
 pub fn run(db_path: &str) -> Result<(), String> {
     println!("🌱 Seeding CRM database: {db_path}");
@@ -10,7 +10,10 @@ pub fn run(db_path: &str) -> Result<(), String> {
 
     let mut insert = |entity: &str, id: u64, fields: Vec<(&str, Value)>| -> Result<(), String> {
         use std::time::{SystemTime, UNIX_EPOCH};
-        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
+        let now = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_secs() as i64;
         let mut obj = Vec::new();
         obj.push(("id".into(), Value::Int(id as i64)));
         obj.push(("createdAt".into(), Value::Int(now)));
@@ -26,140 +29,239 @@ pub fn run(db_path: &str) -> Result<(), String> {
     };
 
     // People
-    insert("people", 1, vec![
-        ("firstName", Value::Str("Anna".into())),
-        ("lastName", Value::Str("Jónsdóttir".into())),
-        ("email", Value::Str("anna@example.is".into())),
-        ("jobTitle", Value::Str("CEO".into())),
-        ("companyId", Value::Int(1)),
-    ])?;
-    insert("people", 2, vec![
-        ("firstName", Value::Str("Björn".into())),
-        ("lastName", Value::Str("Guðmundsson".into())),
-        ("email", Value::Str("bjorn@example.is".into())),
-        ("jobTitle", Value::Str("CTO".into())),
-        ("companyId", Value::Int(1)),
-    ])?;
-    insert("people", 3, vec![
-        ("firstName", Value::Str("Dóra".into())),
-        ("lastName", Value::Str("Sigurðardóttir".into())),
-        ("email", Value::Str("dora@acme.is".into())),
-        ("jobTitle", Value::Str("VP Engineering".into())),
-        ("companyId", Value::Int(2)),
-    ])?;
-    insert("people", 4, vec![
-        ("firstName", Value::Str("Einar".into())),
-        ("lastName", Value::Str("Pálsson".into())),
-        ("email", Value::Str("einar@startup.is".into())),
-        ("jobTitle", Value::Str("Founder".into())),
-    ])?;
-    insert("people", 5, vec![
-        ("firstName", Value::Str("Fríða".into())),
-        ("lastName", Value::Str("Jónsdóttir".into())),
-        ("email", Value::Str("frida@nordic.is".into())),
-        ("jobTitle", Value::Str("Sales Director".into())),
-        ("companyId", Value::Int(3)),
-    ])?;
+    insert(
+        "people",
+        1,
+        vec![
+            ("firstName", Value::Str("Anna".into())),
+            ("lastName", Value::Str("Jónsdóttir".into())),
+            ("email", Value::Str("anna@example.is".into())),
+            ("jobTitle", Value::Str("CEO".into())),
+            ("companyId", Value::Int(1)),
+        ],
+    )?;
+    insert(
+        "people",
+        2,
+        vec![
+            ("firstName", Value::Str("Björn".into())),
+            ("lastName", Value::Str("Guðmundsson".into())),
+            ("email", Value::Str("bjorn@example.is".into())),
+            ("jobTitle", Value::Str("CTO".into())),
+            ("companyId", Value::Int(1)),
+        ],
+    )?;
+    insert(
+        "people",
+        3,
+        vec![
+            ("firstName", Value::Str("Dóra".into())),
+            ("lastName", Value::Str("Sigurðardóttir".into())),
+            ("email", Value::Str("dora@acme.is".into())),
+            ("jobTitle", Value::Str("VP Engineering".into())),
+            ("companyId", Value::Int(2)),
+        ],
+    )?;
+    insert(
+        "people",
+        4,
+        vec![
+            ("firstName", Value::Str("Einar".into())),
+            ("lastName", Value::Str("Pálsson".into())),
+            ("email", Value::Str("einar@startup.is".into())),
+            ("jobTitle", Value::Str("Founder".into())),
+        ],
+    )?;
+    insert(
+        "people",
+        5,
+        vec![
+            ("firstName", Value::Str("Fríða".into())),
+            ("lastName", Value::Str("Jónsdóttir".into())),
+            ("email", Value::Str("frida@nordic.is".into())),
+            ("jobTitle", Value::Str("Sales Director".into())),
+            ("companyId", Value::Int(3)),
+        ],
+    )?;
 
     println!("  ✓ 5 people created");
 
     // Companies
-    insert("companies", 1, vec![
-        ("name", Value::Str("AkurAI".into())),
-        ("domainName", Value::Str("akurai.is".into())),
-        ("annualRevenue", Value::Int(50_000_000)),
-        ("employeeCount", Value::Int(25)),
-        ("websiteUrl", Value::Str("https://akurai.is".into())),
-    ])?;
-    insert("companies", 2, vec![
-        ("name", Value::Str("Acme Corp".into())),
-        ("domainName", Value::Str("acme.is".into())),
-        ("annualRevenue", Value::Int(1_200_000_000)),
-        ("employeeCount", Value::Int(150)),
-    ])?;
-    insert("companies", 3, vec![
-        ("name", Value::Str("Nordic Technologies".into())),
-        ("domainName", Value::Str("nordic.is".into())),
-        ("annualRevenue", Value::Int(850_000_000)),
-        ("employeeCount", Value::Int(75)),
-    ])?;
-    insert("companies", 4, vec![
-        ("name", Value::Str("Startup Iceland".into())),
-        ("domainName", Value::Str("startup.is".into())),
-        ("annualRevenue", Value::Int(50_000_000)),
-        ("employeeCount", Value::Int(5)),
-    ])?;
+    insert(
+        "companies",
+        1,
+        vec![
+            ("name", Value::Str("AkurAI".into())),
+            ("domainName", Value::Str("akurai.is".into())),
+            ("annualRevenue", Value::Int(50_000_000)),
+            ("employeeCount", Value::Int(25)),
+            ("websiteUrl", Value::Str("https://akurai.is".into())),
+        ],
+    )?;
+    insert(
+        "companies",
+        2,
+        vec![
+            ("name", Value::Str("Acme Corp".into())),
+            ("domainName", Value::Str("acme.is".into())),
+            ("annualRevenue", Value::Int(1_200_000_000)),
+            ("employeeCount", Value::Int(150)),
+        ],
+    )?;
+    insert(
+        "companies",
+        3,
+        vec![
+            ("name", Value::Str("Nordic Technologies".into())),
+            ("domainName", Value::Str("nordic.is".into())),
+            ("annualRevenue", Value::Int(850_000_000)),
+            ("employeeCount", Value::Int(75)),
+        ],
+    )?;
+    insert(
+        "companies",
+        4,
+        vec![
+            ("name", Value::Str("Startup Iceland".into())),
+            ("domainName", Value::Str("startup.is".into())),
+            ("annualRevenue", Value::Int(50_000_000)),
+            ("employeeCount", Value::Int(5)),
+        ],
+    )?;
 
     println!("  ✓ 4 companies created");
 
     // Opportunities
-    insert("opportunities", 1, vec![
-        ("name", Value::Str("AkurAI Platform Deal".into())),
-        ("amount", Value::Int(25_000_000)),
-        ("stage", Value::Str("negotiation".into())),
-        ("probability", Value::Int(70)),
-        ("personId", Value::Int(1)),
-        ("companyId", Value::Int(1)),
-    ])?;
-    insert("opportunities", 2, vec![
-        ("name", Value::Str("Acme Enterprise License".into())),
-        ("amount", Value::Int(50_000_000)),
-        ("stage", Value::Str("proposal".into())),
-        ("probability", Value::Int(50)),
-        ("personId", Value::Int(2)),
-        ("companyId", Value::Int(2)),
-    ])?;
-    insert("opportunities", 3, vec![
-        ("name", Value::Str("Nordic Consulting".into())),
-        ("amount", Value::Int(8_500_000)),
-        ("stage", Value::Str("meeting".into())),
-        ("probability", Value::Int(30)),
-        ("personId", Value::Int(5)),
-        ("companyId", Value::Int(3)),
-    ])?;
-    insert("opportunities", 4, vec![
-        ("name", Value::Str("Startup Iceland Partnership".into())),
-        ("amount", Value::Int(2_500_000)),
-        ("stage", Value::Str("new".into())),
-        ("probability", Value::Int(10)),
-        ("personId", Value::Int(4)),
-        ("companyId", Value::Int(4)),
-    ])?;
-    insert("opportunities", 5, vec![
-        ("name", Value::Str("Lost Deal - Legacy Migrate".into())),
-        ("amount", Value::Int(10_000_000)),
-        ("stage", Value::Str("lost".into())),
-        ("probability", Value::Int(0)),
-    ])?;
+    insert(
+        "opportunities",
+        1,
+        vec![
+            ("name", Value::Str("AkurAI Platform Deal".into())),
+            ("amount", Value::Int(25_000_000)),
+            ("stage", Value::Str("negotiation".into())),
+            ("probability", Value::Int(70)),
+            ("personId", Value::Int(1)),
+            ("companyId", Value::Int(1)),
+        ],
+    )?;
+    insert(
+        "opportunities",
+        2,
+        vec![
+            ("name", Value::Str("Acme Enterprise License".into())),
+            ("amount", Value::Int(50_000_000)),
+            ("stage", Value::Str("proposal".into())),
+            ("probability", Value::Int(50)),
+            ("personId", Value::Int(2)),
+            ("companyId", Value::Int(2)),
+        ],
+    )?;
+    insert(
+        "opportunities",
+        3,
+        vec![
+            ("name", Value::Str("Nordic Consulting".into())),
+            ("amount", Value::Int(8_500_000)),
+            ("stage", Value::Str("meeting".into())),
+            ("probability", Value::Int(30)),
+            ("personId", Value::Int(5)),
+            ("companyId", Value::Int(3)),
+        ],
+    )?;
+    insert(
+        "opportunities",
+        4,
+        vec![
+            ("name", Value::Str("Startup Iceland Partnership".into())),
+            ("amount", Value::Int(2_500_000)),
+            ("stage", Value::Str("new".into())),
+            ("probability", Value::Int(10)),
+            ("personId", Value::Int(4)),
+            ("companyId", Value::Int(4)),
+        ],
+    )?;
+    insert(
+        "opportunities",
+        5,
+        vec![
+            ("name", Value::Str("Lost Deal - Legacy Migrate".into())),
+            ("amount", Value::Int(10_000_000)),
+            ("stage", Value::Str("lost".into())),
+            ("probability", Value::Int(0)),
+        ],
+    )?;
 
     println!("  ✓ 5 opportunities created");
 
     // Tasks
-    insert("tasks", 1, vec![
-        ("title", Value::Str("Follow up with Anna about AkurAI deal".into())),
-        ("status", Value::Str("in_progress".into())),
-        ("dueAt", Value::Int(
-            (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64) + 86400 * 3
-        )),
-    ])?;
-    insert("tasks", 2, vec![
-        ("title", Value::Str("Prepare proposal for Acme Corp".into())),
-        ("status", Value::Str("todo".into())),
-        ("dueAt", Value::Int(
-            (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64) + 86400 * 7
-        )),
-    ])?;
-    insert("tasks", 3, vec![
-        ("title", Value::Str("Send NDA to Nordic Technologies".into())),
-        ("status", Value::Str("done".into())),
-    ])?;
-    insert("tasks", 4, vec![
-        ("title", Value::Str("Review Q3 pipeline forecast".into())),
-        ("status", Value::Str("todo".into())),
-        ("dueAt", Value::Int(
-            (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64) + 86400 * 14
-        )),
-    ])?;
+    insert(
+        "tasks",
+        1,
+        vec![
+            (
+                "title",
+                Value::Str("Follow up with Anna about AkurAI deal".into()),
+            ),
+            ("status", Value::Str("in_progress".into())),
+            (
+                "dueAt",
+                Value::Int(
+                    (std::time::SystemTime::now()
+                        .duration_since(std::time::UNIX_EPOCH)
+                        .unwrap_or_default()
+                        .as_secs() as i64)
+                        + 86400 * 3,
+                ),
+            ),
+        ],
+    )?;
+    insert(
+        "tasks",
+        2,
+        vec![
+            ("title", Value::Str("Prepare proposal for Acme Corp".into())),
+            ("status", Value::Str("todo".into())),
+            (
+                "dueAt",
+                Value::Int(
+                    (std::time::SystemTime::now()
+                        .duration_since(std::time::UNIX_EPOCH)
+                        .unwrap_or_default()
+                        .as_secs() as i64)
+                        + 86400 * 7,
+                ),
+            ),
+        ],
+    )?;
+    insert(
+        "tasks",
+        3,
+        vec![
+            (
+                "title",
+                Value::Str("Send NDA to Nordic Technologies".into()),
+            ),
+            ("status", Value::Str("done".into())),
+        ],
+    )?;
+    insert(
+        "tasks",
+        4,
+        vec![
+            ("title", Value::Str("Review Q3 pipeline forecast".into())),
+            ("status", Value::Str("todo".into())),
+            (
+                "dueAt",
+                Value::Int(
+                    (std::time::SystemTime::now()
+                        .duration_since(std::time::UNIX_EPOCH)
+                        .unwrap_or_default()
+                        .as_secs() as i64)
+                        + 86400 * 14,
+                ),
+            ),
+        ],
+    )?;
 
     println!("  ✓ 4 tasks created");
 
diff --git a/crates/crm-cli/src/cmd_serve.rs b/crates/crm-cli/src/cmd_serve.rs
index 74c864e..772684f 100644
--- a/crates/crm-cli/src/cmd_serve.rs
+++ b/crates/crm-cli/src/cmd_serve.rs
@@ -12,8 +12,7 @@ pub struct Config {
 }
 
 pub fn run(cfg: Config) -> io::Result<()> {
-    let state = CrmState::new(&cfg.db, cfg.dir.clone())
-        .map_err(io::Error::other)?;
+    let state = CrmState::new(&cfg.db, cfg.dir.clone()).map_err(io::Error::other)?;
     let state = Arc::new(Mutex::new(state));
 
     let routes = build_router(state);
diff --git a/crates/crm-cli/src/main.rs b/crates/crm-cli/src/main.rs
index 8803252..02cb93d 100644
--- a/crates/crm-cli/src/main.rs
+++ b/crates/crm-cli/src/main.rs
@@ -50,7 +50,9 @@ fn parse_serve(args: &[String]) -> Result<cmd_serve::Config, String> {
     let mut i = 0;
     while i < args.len() {
         match args[i].as_str() {
-            "--host" => { host = next(args, &mut i, "--host")?; }
+            "--host" => {
+                host = next(args, &mut i, "--host")?;
+            }
             "--port" | "-p" => {
                 port = next(args, &mut i, "--port")?
                     .parse()
@@ -66,7 +68,12 @@ fn parse_serve(args: &[String]) -> Result<cmd_serve::Config, String> {
         }
         i += 1;
     }
-    Ok(cmd_serve::Config { host, port, dir, db })
+    Ok(cmd_serve::Config {
+        host,
+        port,
+        dir,
+        db,
+    })
 }
 
 fn next(args: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
diff --git a/crates/crm-core/src/entities/company.rs b/crates/crm-core/src/entities/company.rs
index 356cfc0..6a6407b 100644
--- a/crates/crm-core/src/entities/company.rs
+++ b/crates/crm-core/src/entities/company.rs
@@ -25,7 +25,10 @@ impl Company {
             fields.push(("domainName".into(), Value::Str(d.clone())));
         }
         if let Some(ref a) = self.address {
-            fields.push(("address".into(), Value::Str(format!("{}, {}, {} {}", a.street, a.city, a.state, a.zip))));
+            fields.push((
+                "address".into(),
+                Value::Str(format!("{}, {}, {} {}", a.street, a.city, a.state, a.zip)),
+            ));
         }
         if let Some(r) = self.annual_revenue {
             fields.push(("annualRevenue".into(), Value::Int(r)));
@@ -48,12 +51,24 @@ impl Company {
         use akurai_json::Value;
         let pairs = match val {
             Value::Object(p) => p,
-            _ => return Err(crate::CoreError::InvalidEntity("expected object for Company".into())),
+            _ => {
+                return Err(crate::CoreError::InvalidEntity(
+                    "expected object for Company".into(),
+                ))
+            }
         };
         let mut c = Company {
-            id, name: String::new(), domain_name: None, address: None,
-            annual_revenue: None, employee_count: None, linkedin_url: None,
-            website_url: None, created_at: 0, updated_at: 0, created_by: None,
+            id,
+            name: String::new(),
+            domain_name: None,
+            address: None,
+            annual_revenue: None,
+            employee_count: None,
+            linkedin_url: None,
+            website_url: None,
+            created_at: 0,
+            updated_at: 0,
+            created_by: None,
         };
         for (k, v) in pairs {
             match k.as_str() {
@@ -69,7 +84,9 @@ impl Company {
             }
         }
         if c.name.is_empty() {
-            return Err(crate::CoreError::Validation("company name is required".into()));
+            return Err(crate::CoreError::Validation(
+                "company name is required".into(),
+            ));
         }
         Ok(c)
     }
diff --git a/crates/crm-core/src/entities/mod.rs b/crates/crm-core/src/entities/mod.rs
index 6d4ae47..a011d71 100644
--- a/crates/crm-core/src/entities/mod.rs
+++ b/crates/crm-core/src/entities/mod.rs
@@ -1,13 +1,13 @@
-mod person;
 mod company;
-mod opportunity;
-mod task;
 mod note;
+mod opportunity;
+mod person;
+mod task;
 mod timeline;
 
-pub use person::Person;
 pub use company::Company;
-pub use opportunity::Opportunity;
-pub use task::Task;
 pub use note::Note;
+pub use opportunity::Opportunity;
+pub use person::Person;
+pub use task::Task;
 pub use timeline::TimelineActivity;
diff --git a/crates/crm-core/src/entities/note.rs b/crates/crm-core/src/entities/note.rs
index b5704be..5091dc3 100644
--- a/crates/crm-core/src/entities/note.rs
+++ b/crates/crm-core/src/entities/note.rs
@@ -34,11 +34,22 @@ impl Note {
         use akurai_json::Value;
         let pairs = match val {
             Value::Object(p) => p,
-            _ => return Err(crate::CoreError::InvalidEntity("expected object for Note".into())),
+            _ => {
+                return Err(crate::CoreError::InvalidEntity(
+                    "expected object for Note".into(),
+                ))
+            }
         };
         let mut n = Note {
-            id, title: None, body: String::new(), person_ids: vec![], company_ids: vec![],
-            opportunity_ids: vec![], created_at: 0, updated_at: 0, created_by: None,
+            id,
+            title: None,
+            body: String::new(),
+            person_ids: vec![],
+            company_ids: vec![],
+            opportunity_ids: vec![],
+            created_at: 0,
+            updated_at: 0,
+            created_by: None,
         };
         for (k, v) in pairs {
             match k.as_str() {
@@ -49,17 +60,26 @@ impl Note {
                 _ => {
                     if k == "personIds" {
                         if let Value::Array(arr) = v {
-                            n.person_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+                            n.person_ids = arr
+                                .iter()
+                                .filter_map(|x| x.as_i64().map(|n| n as u64))
+                                .collect();
                         }
                     }
                     if k == "companyIds" {
                         if let Value::Array(arr) = v {
-                            n.company_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+                            n.company_ids = arr
+                                .iter()
+                                .filter_map(|x| x.as_i64().map(|n| n as u64))
+                                .collect();
                         }
                     }
                     if k == "opportunityIds" {
                         if let Value::Array(arr) = v {
-                            n.opportunity_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+                            n.opportunity_ids = arr
+                                .iter()
+                                .filter_map(|x| x.as_i64().map(|n| n as u64))
+                                .collect();
                         }
                     }
                 }
diff --git a/crates/crm-core/src/entities/opportunity.rs b/crates/crm-core/src/entities/opportunity.rs
index c9b594c..e2f96b4 100644
--- a/crates/crm-core/src/entities/opportunity.rs
+++ b/crates/crm-core/src/entities/opportunity.rs
@@ -46,12 +46,24 @@ impl Opportunity {
         use akurai_json::Value;
         let pairs = match val {
             Value::Object(p) => p,
-            _ => return Err(crate::CoreError::InvalidEntity("expected object for Opportunity".into())),
+            _ => {
+                return Err(crate::CoreError::InvalidEntity(
+                    "expected object for Opportunity".into(),
+                ))
+            }
         };
         let mut o = Opportunity {
-            id, name: String::new(), amount: None, stage: PipelineStage::New,
-            close_date: None, probability: None, person_id: None, company_id: None,
-            created_at: 0, updated_at: 0, created_by: None,
+            id,
+            name: String::new(),
+            amount: None,
+            stage: PipelineStage::New,
+            close_date: None,
+            probability: None,
+            person_id: None,
+            company_id: None,
+            created_at: 0,
+            updated_at: 0,
+            created_by: None,
         };
         for (k, v) in pairs {
             match k.as_str() {
@@ -79,7 +91,9 @@ impl Opportunity {
             }
         }
         if o.name.is_empty() {
-            return Err(crate::CoreError::Validation("opportunity name is required".into()));
+            return Err(crate::CoreError::Validation(
+                "opportunity name is required".into(),
+            ));
         }
         Ok(o)
     }
diff --git a/crates/crm-core/src/entities/person.rs b/crates/crm-core/src/entities/person.rs
index 08e1559..66a6332 100644
--- a/crates/crm-core/src/entities/person.rs
+++ b/crates/crm-core/src/entities/person.rs
@@ -52,7 +52,11 @@ impl Person {
         use akurai_json::Value;
         let pairs = match val {
             Value::Object(p) => p,
-            _ => return Err(crate::CoreError::InvalidEntity("expected object for Person".into())),
+            _ => {
+                return Err(crate::CoreError::InvalidEntity(
+                    "expected object for Person".into(),
+                ))
+            }
         };
         let mut p = Person {
             id,
@@ -71,8 +75,18 @@ impl Person {
             match k.as_str() {
                 "firstName" => p.name.first = v.as_str().unwrap_or("").to_string(),
                 "lastName" => p.name.last = v.as_str().unwrap_or("").to_string(),
-                "email" => p.email = Some(Email { address: v.as_str().unwrap_or("").to_string(), label: "work".into() }),
-                "phone" => p.phone = Some(Phone { number: v.as_str().unwrap_or("").to_string(), label: "mobile".into() }),
+                "email" => {
+                    p.email = Some(Email {
+                        address: v.as_str().unwrap_or("").to_string(),
+                        label: "work".into(),
+                    })
+                }
+                "phone" => {
+                    p.phone = Some(Phone {
+                        number: v.as_str().unwrap_or("").to_string(),
+                        label: "mobile".into(),
+                    })
+                }
                 "jobTitle" => p.job_title = Some(v.as_str().unwrap_or("").to_string()),
                 "companyId" => p.company_id = v.as_i64().map(|n| n as u64),
                 "linkedinUrl" => p.linkedin_url = Some(v.as_str().unwrap_or("").to_string()),
diff --git a/crates/crm-core/src/entities/task.rs b/crates/crm-core/src/entities/task.rs
index 99baed7..674e4f9 100644
--- a/crates/crm-core/src/entities/task.rs
+++ b/crates/crm-core/src/entities/task.rs
@@ -7,9 +7,9 @@ pub struct Task {
     pub description: Option<String>, // rich text / markdown
     pub status: TaskStatus,
     pub due_at: Option<Timestamp>,
-    pub assignee_id: Option<RecordId>, // person or user
-    pub person_ids: Vec<RecordId>,   // linked people
-    pub company_ids: Vec<RecordId>,  // linked companies
+    pub assignee_id: Option<RecordId>,  // person or user
+    pub person_ids: Vec<RecordId>,      // linked people
+    pub company_ids: Vec<RecordId>,     // linked companies
     pub opportunity_ids: Vec<RecordId>, // linked opps
     pub created_at: Timestamp,
     pub updated_at: Timestamp,
@@ -44,12 +44,25 @@ impl Task {
         use akurai_json::Value;
         let pairs = match val {
             Value::Object(p) => p,
-            _ => return Err(crate::CoreError::InvalidEntity("expected object for Task".into())),
+            _ => {
+                return Err(crate::CoreError::InvalidEntity(
+                    "expected object for Task".into(),
+                ))
+            }
         };
         let mut t = Task {
-            id, title: String::new(), description: None, status: TaskStatus::Todo,
-            due_at: None, assignee_id: None, person_ids: vec![], company_ids: vec![],
-            opportunity_ids: vec![], created_at: 0, updated_at: 0, created_by: None,
+            id,
+            title: String::new(),
+            description: None,
+            status: TaskStatus::Todo,
+            due_at: None,
+            assignee_id: None,
+            person_ids: vec![],
+            company_ids: vec![],
+            opportunity_ids: vec![],
+            created_at: 0,
+            updated_at: 0,
+            created_by: None,
         };
         for (k, v) in pairs {
             match k.as_str() {
@@ -71,24 +84,35 @@ impl Task {
                 _ => {
                     if k == "personIds" {
                         if let Value::Array(arr) = v {
-                            t.person_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+                            t.person_ids = arr
+                                .iter()
+                                .filter_map(|x| x.as_i64().map(|n| n as u64))
+                                .collect();
                         }
                     }
                     if k == "companyIds" {
                         if let Value::Array(arr) = v {
-                            t.company_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+                            t.company_ids = arr
+                                .iter()
+                                .filter_map(|x| x.as_i64().map(|n| n as u64))
+                                .collect();
                         }
                     }
                     if k == "opportunityIds" {
                         if let Value::Array(arr) = v {
-                            t.opportunity_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+                            t.opportunity_ids = arr
+                                .iter()
+                                .filter_map(|x| x.as_i64().map(|n| n as u64))
+                                .collect();
                         }
                     }
                 }
             }
         }
         if t.title.is_empty() {
-            return Err(crate::CoreError::Validation("task title is required".into()));
+            return Err(crate::CoreError::Validation(
+                "task title is required".into(),
+            ));
         }
         Ok(t)
     }
diff --git a/crates/crm-core/src/entities/timeline.rs b/crates/crm-core/src/entities/timeline.rs
index 76784bc..3e786dd 100644
--- a/crates/crm-core/src/entities/timeline.rs
+++ b/crates/crm-core/src/entities/timeline.rs
@@ -17,7 +17,10 @@ impl TimelineActivity {
         use akurai_json::Value;
         let mut fields = vec![
             ("id".into(), Value::Int(self.id as i64)),
-            ("entityType".into(), Value::Str(self.entity_type.singular().into())),
+            (
+                "entityType".into(),
+                Value::Str(self.entity_type.singular().into()),
+            ),
             ("entityId".into(), Value::Int(self.entity_id as i64)),
             ("action".into(), Value::Str(self.action.clone())),
         ];
diff --git a/crates/crm-core/src/metadata/mod.rs b/crates/crm-core/src/metadata/mod.rs
index 127690b..afd41c2 100644
--- a/crates/crm-core/src/metadata/mod.rs
+++ b/crates/crm-core/src/metadata/mod.rs
@@ -83,12 +83,78 @@ impl ObjectMetadata {
             description: Some("A contact person".into()),
             icon: Some("user".into()),
             fields: vec![
-                FieldMetadata { id: 1, name: "name".into(), label: "Name".into(), field_type: FieldType::FullName, description: None, is_required: true, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 2, name: "email".into(), label: "Email".into(), field_type: FieldType::Email, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 3, name: "phone".into(), label: "Phone".into(), field_type: FieldType::Phone, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 4, name: "jobTitle".into(), label: "Job Title".into(), field_type: FieldType::Text, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 5, name: "companyId".into(), label: "Company".into(), field_type: FieldType::Relation, description: Some("Related company".into()), is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 6, name: "linkedinUrl".into(), label: "LinkedIn".into(), field_type: FieldType::Link, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
+                FieldMetadata {
+                    id: 1,
+                    name: "name".into(),
+                    label: "Name".into(),
+                    field_type: FieldType::FullName,
+                    description: None,
+                    is_required: true,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 2,
+                    name: "email".into(),
+                    label: "Email".into(),
+                    field_type: FieldType::Email,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 3,
+                    name: "phone".into(),
+                    label: "Phone".into(),
+                    field_type: FieldType::Phone,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 4,
+                    name: "jobTitle".into(),
+                    label: "Job Title".into(),
+                    field_type: FieldType::Text,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 5,
+                    name: "companyId".into(),
+                    label: "Company".into(),
+                    field_type: FieldType::Relation,
+                    description: Some("Related company".into()),
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 6,
+                    name: "linkedinUrl".into(),
+                    label: "LinkedIn".into(),
+                    field_type: FieldType::Link,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
             ],
             is_custom: false,
             is_searchable: true,
@@ -105,12 +171,78 @@ impl ObjectMetadata {
             description: Some("An organization or business".into()),
             icon: Some("building".into()),
             fields: vec![
-                FieldMetadata { id: 7, name: "name".into(), label: "Name".into(), field_type: FieldType::Text, description: None, is_required: true, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 8, name: "domainName".into(), label: "Domain".into(), field_type: FieldType::Text, description: None, is_required: false, is_unique: true, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 9, name: "annualRevenue".into(), label: "Annual Revenue".into(), field_type: FieldType::Currency, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 10, name: "employeeCount".into(), label: "Employees".into(), field_type: FieldType::Number, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 11, name: "websiteUrl".into(), label: "Website".into(), field_type: FieldType::Link, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 12, name: "linkedinUrl".into(), label: "LinkedIn".into(), field_type: FieldType::Link, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
+                FieldMetadata {
+                    id: 7,
+                    name: "name".into(),
+                    label: "Name".into(),
+                    field_type: FieldType::Text,
+                    description: None,
+                    is_required: true,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 8,
+                    name: "domainName".into(),
+                    label: "Domain".into(),
+                    field_type: FieldType::Text,
+                    description: None,
+                    is_required: false,
+                    is_unique: true,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 9,
+                    name: "annualRevenue".into(),
+                    label: "Annual Revenue".into(),
+                    field_type: FieldType::Currency,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 10,
+                    name: "employeeCount".into(),
+                    label: "Employees".into(),
+                    field_type: FieldType::Number,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 11,
+                    name: "websiteUrl".into(),
+                    label: "Website".into(),
+                    field_type: FieldType::Link,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 12,
+                    name: "linkedinUrl".into(),
+                    label: "LinkedIn".into(),
+                    field_type: FieldType::Link,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
             ],
             is_custom: false,
             is_searchable: true,
@@ -127,21 +259,126 @@ impl ObjectMetadata {
             description: Some("A sales deal or pipeline entry".into()),
             icon: Some("trending-up".into()),
             fields: vec![
-                FieldMetadata { id: 13, name: "name".into(), label: "Name".into(), field_type: FieldType::Text, description: None, is_required: true, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 14, name: "amount".into(), label: "Amount".into(), field_type: FieldType::Currency, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 15, name: "stage".into(), label: "Stage".into(), field_type: FieldType::Select, description: Some("Pipeline stage".into()), is_required: true, is_unique: false, is_custom: false, default_value: Some(Value::Str("new".into())), options: vec![
-                    FieldOption { label: "New".into(), value: "new".into(), color: Some("#94a3b8".into()) },
-                    FieldOption { label: "Screening".into(), value: "screening".into(), color: Some("#60a5fa".into()) },
-                    FieldOption { label: "Meeting".into(), value: "meeting".into(), color: Some("#818cf8".into()) },
-                    FieldOption { label: "Proposal".into(), value: "proposal".into(), color: Some("#a78bfa".into()) },
-                    FieldOption { label: "Negotiation".into(), value: "negotiation".into(), color: Some("#f59e0b".into()) },
-                    FieldOption { label: "Won".into(), value: "won".into(), color: Some("#22c55e".into()) },
-                    FieldOption { label: "Lost".into(), value: "lost".into(), color: Some("#ef4444".into()) },
-                ]},
-                FieldMetadata { id: 16, name: "closeDate".into(), label: "Close Date".into(), field_type: FieldType::Date, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 17, name: "probability".into(), label: "Probability".into(), field_type: FieldType::Number, description: Some("Win probability (%)".into()), is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 18, name: "personId".into(), label: "Contact".into(), field_type: FieldType::Relation, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 19, name: "companyId".into(), label: "Company".into(), field_type: FieldType::Relation, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
+                FieldMetadata {
+                    id: 13,
+                    name: "name".into(),
+                    label: "Name".into(),
+                    field_type: FieldType::Text,
+                    description: None,
+                    is_required: true,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 14,
+                    name: "amount".into(),
+                    label: "Amount".into(),
+                    field_type: FieldType::Currency,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 15,
+                    name: "stage".into(),
+                    label: "Stage".into(),
+                    field_type: FieldType::Select,
+                    description: Some("Pipeline stage".into()),
+                    is_required: true,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: Some(Value::Str("new".into())),
+                    options: vec![
+                        FieldOption {
+                            label: "New".into(),
+                            value: "new".into(),
+                            color: Some("#94a3b8".into()),
+                        },
+                        FieldOption {
+                            label: "Screening".into(),
+                            value: "screening".into(),
+                            color: Some("#60a5fa".into()),
+                        },
+                        FieldOption {
+                            label: "Meeting".into(),
+                            value: "meeting".into(),
+                            color: Some("#818cf8".into()),
+                        },
+                        FieldOption {
+                            label: "Proposal".into(),
+                            value: "proposal".into(),
+                            color: Some("#a78bfa".into()),
+                        },
+                        FieldOption {
+                            label: "Negotiation".into(),
+                            value: "negotiation".into(),
+                            color: Some("#f59e0b".into()),
+                        },
+                        FieldOption {
+                            label: "Won".into(),
+                            value: "won".into(),
+                            color: Some("#22c55e".into()),
+                        },
+                        FieldOption {
+                            label: "Lost".into(),
+                            value: "lost".into(),
+                            color: Some("#ef4444".into()),
+                        },
+                    ],
+                },
+                FieldMetadata {
+                    id: 16,
+                    name: "closeDate".into(),
+                    label: "Close Date".into(),
+                    field_type: FieldType::Date,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 17,
+                    name: "probability".into(),
+                    label: "Probability".into(),
+                    field_type: FieldType::Number,
+                    description: Some("Win probability (%)".into()),
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 18,
+                    name: "personId".into(),
+                    label: "Contact".into(),
+                    field_type: FieldType::Relation,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 19,
+                    name: "companyId".into(),
+                    label: "Company".into(),
+                    field_type: FieldType::Relation,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
             ],
             is_custom: false,
             is_searchable: true,
@@ -158,15 +395,75 @@ impl ObjectMetadata {
             description: Some("A to-do item".into()),
             icon: Some("check-square".into()),
             fields: vec![
-                FieldMetadata { id: 20, name: "title".into(), label: "Title".into(), field_type: FieldType::Text, description: None, is_required: true, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 21, name: "description".into(), label: "Description".into(), field_type: FieldType::RichText, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 22, name: "status".into(), label: "Status".into(), field_type: FieldType::Select, description: None, is_required: true, is_unique: false, is_custom: false, default_value: Some(Value::Str("todo".into())), options: vec![
-                    FieldOption { label: "Todo".into(), value: "todo".into(), color: Some("#94a3b8".into()) },
-                    FieldOption { label: "In Progress".into(), value: "in_progress".into(), color: Some("#60a5fa".into()) },
-                    FieldOption { label: "Done".into(), value: "done".into(), color: Some("#22c55e".into()) },
-                    FieldOption { label: "Cancelled".into(), value: "cancelled".into(), color: Some("#ef4444".into()) },
-                ]},
-                FieldMetadata { id: 23, name: "dueAt".into(), label: "Due Date".into(), field_type: FieldType::Date, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
+                FieldMetadata {
+                    id: 20,
+                    name: "title".into(),
+                    label: "Title".into(),
+                    field_type: FieldType::Text,
+                    description: None,
+                    is_required: true,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 21,
+                    name: "description".into(),
+                    label: "Description".into(),
+                    field_type: FieldType::RichText,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 22,
+                    name: "status".into(),
+                    label: "Status".into(),
+                    field_type: FieldType::Select,
+                    description: None,
+                    is_required: true,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: Some(Value::Str("todo".into())),
+                    options: vec![
+                        FieldOption {
+                            label: "Todo".into(),
+                            value: "todo".into(),
+                            color: Some("#94a3b8".into()),
+                        },
+                        FieldOption {
+                            label: "In Progress".into(),
+                            value: "in_progress".into(),
+                            color: Some("#60a5fa".into()),
+                        },
+                        FieldOption {
+                            label: "Done".into(),
+                            value: "done".into(),
+                            color: Some("#22c55e".into()),
+                        },
+                        FieldOption {
+                            label: "Cancelled".into(),
+                            value: "cancelled".into(),
+                            color: Some("#ef4444".into()),
+                        },
+                    ],
+                },
+                FieldMetadata {
+                    id: 23,
+                    name: "dueAt".into(),
+                    label: "Due Date".into(),
+                    field_type: FieldType::Date,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
             ],
             is_custom: false,
             is_searchable: true,
@@ -183,8 +480,30 @@ impl ObjectMetadata {
             description: Some("A rich-text note".into()),
             icon: Some("file-text".into()),
             fields: vec![
-                FieldMetadata { id: 24, name: "title".into(), label: "Title".into(), field_type: FieldType::Text, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
-                FieldMetadata { id: 25, name: "body".into(), label: "Body".into(), field_type: FieldType::RichText, description: None, is_required: true, is_unique: false, is_custom: false, default_value: None, options: vec![] },
+                FieldMetadata {
+                    id: 24,
+                    name: "title".into(),
+                    label: "Title".into(),
+                    field_type: FieldType::Text,
+                    description: None,
+                    is_required: false,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
+                FieldMetadata {
+                    id: 25,
+                    name: "body".into(),
+                    label: "Body".into(),
+                    field_type: FieldType::RichText,
+                    description: None,
+                    is_required: true,
+                    is_unique: false,
+                    is_custom: false,
+                    default_value: None,
+                    options: vec![],
+                },
             ],
             is_custom: false,
             is_searchable: true,
diff --git a/crates/crm-core/src/types.rs b/crates/crm-core/src/types.rs
index 32dd511..43b8d88 100644
--- a/crates/crm-core/src/types.rs
+++ b/crates/crm-core/src/types.rs
@@ -1,5 +1,3 @@
-
-
 /// Unique record identifier (auto-increment u64, stored as big-endian in B+tree)
 pub type RecordId = u64;
 
@@ -18,7 +16,10 @@ pub struct FullName {
 
 impl FullName {
     pub fn new(first: &str, last: &str) -> Self {
-        Self { first: first.into(), last: last.into() }
+        Self {
+            first: first.into(),
+            last: last.into(),
+        }
     }
     pub fn display(&self) -> String {
         format!("{} {}", self.first, self.last)
@@ -70,7 +71,15 @@ pub enum PipelineStage {
 
 impl PipelineStage {
     pub fn all() -> &'static [Self] {
-        &[Self::New, Self::Screening, Self::Meeting, Self::Proposal, Self::Negotiation, Self::Won, Self::Lost]
+        &[
+            Self::New,
+            Self::Screening,
+            Self::Meeting,
+            Self::Proposal,
+            Self::Negotiation,
+            Self::Won,
+            Self::Lost,
+        ]
     }
     pub fn label(&self) -> &str {
         match self {
diff --git a/crates/crm-core/tests/integration.rs b/crates/crm-core/tests/integration.rs
index 8959713..08274cf 100644
--- a/crates/crm-core/tests/integration.rs
+++ b/crates/crm-core/tests/integration.rs
@@ -5,8 +5,14 @@ fn test_person_roundtrip() {
     let person = Person {
         id: 1,
         name: FullName::new("Anna", "Jónsdóttir"),
-        email: Some(Email { address: "anna@test.is".into(), label: "work".into() }),
-        phone: Some(Phone { number: "+354 123 4567".into(), label: "mobile".into() }),
+        email: Some(Email {
+            address: "anna@test.is".into(),
+            label: "work".into(),
+        }),
+        phone: Some(Phone {
+            number: "+354 123 4567".into(),
+            label: "mobile".into(),
+        }),
         job_title: Some("CEO".into()),
         company_id: Some(1),
         linkedin_url: None,
@@ -26,9 +32,10 @@ fn test_person_roundtrip() {
 
 #[test]
 fn test_company_validation() {
-    let result = Company::from_json(1, &akurai_json::Value::Object(vec![
-        ("name".into(), akurai_json::Value::Str("".into())),
-    ]));
+    let result = Company::from_json(
+        1,
+        &akurai_json::Value::Object(vec![("name".into(), akurai_json::Value::Str("".into()))]),
+    );
     assert!(result.is_err());
 }
 
diff --git a/crates/crm-mcp/src/server.rs b/crates/crm-mcp/src/server.rs
index d9adbcd..e6dd125 100644
--- a/crates/crm-mcp/src/server.rs
+++ b/crates/crm-mcp/src/server.rs
@@ -16,7 +16,8 @@ pub struct McpServer {
 
 impl McpServer {
     pub fn new(config: McpConfig) -> Result<Self, String> {
-        let btree = BTree::open(&config.db_path).map_err(|e| format!("failed to open MCP database: {e}"))?;
+        let btree = BTree::open(&config.db_path)
+            .map_err(|e| format!("failed to open MCP database: {e}"))?;
         let tools = ToolRegistry::new(Arc::new(Mutex::new(btree)));
         Ok(Self {
             config,
@@ -53,6 +54,8 @@ impl McpServer {
 
         let server = akurai_http::Server::bind(&addr)
             .map_err(|e| format!("failed to bind MCP server: {e}"))?;
-        server.run(handler).map_err(|e| format!("server error: {e}"))
+        server
+            .run(handler)
+            .map_err(|e| format!("server error: {e}"))
     }
 }
diff --git a/crates/crm-mcp/src/tools.rs b/crates/crm-mcp/src/tools.rs
index 23c91d6..e4f4a23 100644
--- a/crates/crm-mcp/src/tools.rs
+++ b/crates/crm-mcp/src/tools.rs
@@ -14,44 +14,87 @@ impl ToolRegistry {
     /// List all available tools with their schemas
     pub fn list_tools(&self) -> Vec<Value> {
         vec![
-            self.tool_def("list_people", "List all CRM contacts", vec![
-                ("limit", "integer", "Maximum number of results", false),
-            ]),
-            self.tool_def("get_person", "Get a person by ID", vec![
-                ("id", "integer", "Person record ID", true),
-            ]),
-            self.tool_def("create_person", "Create a new person", vec![
-                ("firstName", "string", "First name", true),
-                ("lastName", "string", "Last name", true),
-                ("email", "string", "Email address", false),
-                ("jobTitle", "string", "Job title", false),
-                ("companyId", "integer", "Company ID", false),
-            ]),
-            self.tool_def("list_companies", "List all companies", vec![
-                ("limit", "integer", "Maximum number of results", false),
-            ]),
-            self.tool_def("get_company", "Get a company by ID", vec![
-                ("id", "integer", "Company record ID", true),
-            ]),
-            self.tool_def("list_opportunities", "List all sales opportunities", vec![
-                ("stage", "string", "Filter by stage (new/screening/meeting/proposal/negotiation/won/lost)", false),
-                ("limit", "integer", "Maximum number of results", false),
-            ]),
-            self.tool_def("get_opportunity", "Get an opportunity by ID", vec![
-                ("id", "integer", "Opportunity record ID", true),
-            ]),
-            self.tool_def("get_pipeline", "Get pipeline summary with counts per stage", vec![]),
-            self.tool_def("search_crm", "Search across all CRM entities", vec![
-                ("query", "string", "Search term", true),
-            ]),
-            self.tool_def("list_tasks", "List tasks", vec![
-                ("status", "string", "Filter by status (todo/in_progress/done/cancelled)", false),
-                ("limit", "integer", "Maximum number of results", false),
-            ]),
+            self.tool_def(
+                "list_people",
+                "List all CRM contacts",
+                vec![("limit", "integer", "Maximum number of results", false)],
+            ),
+            self.tool_def(
+                "get_person",
+                "Get a person by ID",
+                vec![("id", "integer", "Person record ID", true)],
+            ),
+            self.tool_def(
+                "create_person",
+                "Create a new person",
+                vec![
+                    ("firstName", "string", "First name", true),
+                    ("lastName", "string", "Last name", true),
+                    ("email", "string", "Email address", false),
+                    ("jobTitle", "string", "Job title", false),
+                    ("companyId", "integer", "Company ID", false),
+                ],
+            ),
+            self.tool_def(
+                "list_companies",
+                "List all companies",
+                vec![("limit", "integer", "Maximum number of results", false)],
+            ),
+            self.tool_def(
+                "get_company",
+                "Get a company by ID",
+                vec![("id", "integer", "Company record ID", true)],
+            ),
+            self.tool_def(
+                "list_opportunities",
+                "List all sales opportunities",
+                vec![
+                    (
+                        "stage",
+                        "string",
+                        "Filter by stage (new/screening/meeting/proposal/negotiation/won/lost)",
+                        false,
+                    ),
+                    ("limit", "integer", "Maximum number of results", false),
+                ],
+            ),
+            self.tool_def(
+                "get_opportunity",
+                "Get an opportunity by ID",
+                vec![("id", "integer", "Opportunity record ID", true)],
+            ),
+            self.tool_def(
+                "get_pipeline",
+                "Get pipeline summary with counts per stage",
+                vec![],
+            ),
+            self.tool_def(
+                "search_crm",
+                "Search across all CRM entities",
+                vec![("query", "string", "Search term", true)],
+            ),
+            self.tool_def(
+                "list_tasks",
+                "List tasks",
+                vec![
+                    (
+                        "status",
+                        "string",
+                        "Filter by status (todo/in_progress/done/cancelled)",
+                        false,
+                    ),
+                    ("limit", "integer", "Maximum number of results", false),
+                ],
+            ),
         ]
     }
 
-    fn tool_def(&self, name: &str, description: &str, params: Vec<(&str, &str, &str, bool)>) -> Value {
+    fn tool_def(
+        &self,
+        name: &str,
+        description: &str,
+        params: Vec<(&str, &str, &str, bool)>,
+    ) -> Value {
         let mut properties = Vec::new();
         let mut required = Vec::new();
         for (pname, ptype, pdesc, is_required) in params {
@@ -69,11 +112,14 @@ impl ToolRegistry {
         Value::Object(vec![
             ("name".into(), Value::Str(name.into())),
             ("description".into(), Value::Str(description.into())),
-            ("inputSchema".into(), Value::Object(vec![
-                ("type".into(), Value::Str("object".into())),
-                ("properties".into(), Value::Object(properties)),
-                ("required".into(), Value::Array(required)),
-            ])),
+            (
+                "inputSchema".into(),
+                Value::Object(vec![
+                    ("type".into(), Value::Str("object".into())),
+                    ("properties".into(), Value::Object(properties)),
+                    ("required".into(), Value::Array(required)),
+                ]),
+            ),
         ])
     }
 
@@ -107,20 +153,23 @@ impl ToolRegistry {
         match method {
             "tools/list" => {
                 let tools_list = self.list_tools();
-                self.json_rpc_result(id, Value::Object(vec![
-                    ("tools".into(), Value::Array(tools_list)),
-                ]))
+                self.json_rpc_result(
+                    id,
+                    Value::Object(vec![("tools".into(), Value::Array(tools_list))]),
+                )
             }
             "tools/call" => {
                 let params = match req.get("params") {
                     Some(Value::Object(pairs)) => pairs.clone(),
                     _ => vec![],
                 };
-                let tool_name = params.iter()
+                let tool_name = params
+                    .iter()
                     .find(|(k, _)| k == "name")
                     .and_then(|(_, v)| v.as_str())
                     .unwrap_or("");
-                let arguments: Vec<(String, Value)> = params.iter()
+                let arguments: Vec<(String, Value)> = params
+                    .iter()
                     .find(|(k, _)| k == "arguments")
                     .and_then(|(_, v)| match v {
                         Value::Object(pairs) => Some(pairs.clone()),
@@ -129,14 +178,16 @@ impl ToolRegistry {
                     .unwrap_or_default();
 
                 match self.execute(tool_name, &arguments) {
-                    Ok(result) => self.json_rpc_result(id, Value::Object(vec![
-                        ("content".into(), Value::Array(vec![
-                            Value::Object(vec![
+                    Ok(result) => self.json_rpc_result(
+                        id,
+                        Value::Object(vec![(
+                            "content".into(),
+                            Value::Array(vec![Value::Object(vec![
                                 ("type".into(), Value::Str("json".into())),
                                 ("text".into(), Value::Str(result.to_json())),
-                            ]),
-                        ])),
-                    ])),
+                            ])]),
+                        )]),
+                    ),
                     Err(e) => self.json_rpc_error(id, -32603, &format!("Tool error: {e}")),
                 }
             }
@@ -145,34 +196,38 @@ impl ToolRegistry {
                     Some(Value::Object(pairs)) => pairs.clone(),
                     _ => vec![],
                 };
-                let tool_name = params.iter()
+                let tool_name = params
+                    .iter()
                     .find(|(k, _)| k == "name")
                     .and_then(|(_, v)| v.as_str())
                     .unwrap_or("");
                 let tools_list = self.list_tools();
-                let tool = tools_list.into_iter().find(|t| {
-                    t.get("name").and_then(|v| v.as_str()) == Some(tool_name)
-                });
+                let tool = tools_list
+                    .into_iter()
+                    .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(tool_name));
                 match tool {
                     Some(t) => self.json_rpc_result(id, Value::Object(vec![("tool".into(), t)])),
                     None => self.json_rpc_error(id, -32602, &format!("Unknown tool: {tool_name}")),
                 }
             }
-            "initialize" => {
-                self.json_rpc_result(id, Value::Object(vec![
+            "initialize" => self.json_rpc_result(
+                id,
+                Value::Object(vec![
                     ("protocolVersion".into(), Value::Str("2025-03-26".into())),
-                    ("capabilities".into(), Value::Object(vec![
-                        ("tools".into(), Value::Object(vec![])),
-                    ])),
-                    ("serverInfo".into(), Value::Object(vec![
-                        ("name".into(), Value::Str("akurai-crm-mcp".into())),
-                        ("version".into(), Value::Str("0.1.0".into())),
-                    ])),
-                ]))
-            }
-            "notifications/initialized" => {
-                self.json_rpc_result(id, Value::Null)
-            }
+                    (
+                        "capabilities".into(),
+                        Value::Object(vec![("tools".into(), Value::Object(vec![]))]),
+                    ),
+                    (
+                        "serverInfo".into(),
+                        Value::Object(vec![
+                            ("name".into(), Value::Str("akurai-crm-mcp".into())),
+                            ("version".into(), Value::Str("0.1.0".into())),
+                        ]),
+                    ),
+                ]),
+            ),
+            "notifications/initialized" => self.json_rpc_result(id, Value::Null),
             _ => self.json_rpc_error(id, -32601, &format!("Method not found: {method}")),
         }
     }
@@ -190,10 +245,13 @@ impl ToolRegistry {
     fn json_rpc_error(&self, id: Option<Value>, code: i64, message: &str) -> String {
         let mut resp = Vec::new();
         resp.push(("jsonrpc".into(), Value::Str("2.0".into())));
-        resp.push(("error".into(), Value::Object(vec![
-            ("code".into(), Value::Int(code)),
-            ("message".into(), Value::Str(message.into())),
-        ])));
+        resp.push((
+            "error".into(),
+            Value::Object(vec![
+                ("code".into(), Value::Int(code)),
+                ("message".into(), Value::Str(message.into())),
+            ]),
+        ));
         if let Some(id_val) = id {
             resp.push(("id".into(), id_val));
         }
@@ -228,7 +286,9 @@ impl ToolRegistry {
         match db.get(&key).map_err(|e| format!("storage: {e}"))? {
             Some(bytes) => {
                 let s = String::from_utf8_lossy(&bytes).to_string();
-                akurai_json::parse(&s).map(Some).map_err(|e| format!("parse: {e}"))
+                akurai_json::parse(&s)
+                    .map(Some)
+                    .map_err(|e| format!("parse: {e}"))
             }
             None => Ok(None),
         }
@@ -245,7 +305,9 @@ impl ToolRegistry {
     }
 
     fn get_person(&self, args: &[(String, Value)]) -> Result<Value, String> {
-        let id = Self::get_arg(args, "id").and_then(|v| v.as_i64()).ok_or("missing id")?;
+        let id = Self::get_arg(args, "id")
+            .and_then(|v| v.as_i64())
+            .ok_or("missing id")?;
         self.get_record("people", id as u64)
             .map(|opt| opt.unwrap_or(Value::Null))
     }
@@ -253,10 +315,15 @@ impl ToolRegistry {
     fn create_person(&self, args: &[(String, Value)]) -> Result<Value, String> {
         let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
         let now = std::time::SystemTime::now()
-            .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
+            .duration_since(std::time::UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_secs() as i64;
 
         let counter_key = "people:_counter";
-        let next_id = match db.get(counter_key.as_bytes()).map_err(|e| format!("counter read: {e}"))? {
+        let next_id = match db
+            .get(counter_key.as_bytes())
+            .map_err(|e| format!("counter read: {e}"))?
+        {
             Some(bytes) => {
                 let s = String::from_utf8_lossy(&bytes).to_string();
                 match s.parse::<u64>() {
@@ -270,8 +337,12 @@ impl ToolRegistry {
             .map_err(|e| format!("counter write: {e}"))?;
         db.commit().map_err(|e| format!("commit: {e}"))?;
 
-        let first = Self::get_arg(args, "firstName").and_then(|v| v.as_str()).unwrap_or("");
-        let last = Self::get_arg(args, "lastName").and_then(|v| v.as_str()).unwrap_or("");
+        let first = Self::get_arg(args, "firstName")
+            .and_then(|v| v.as_str())
+            .unwrap_or("");
+        let last = Self::get_arg(args, "lastName")
+            .and_then(|v| v.as_str())
+            .unwrap_or("");
 
         let mut fields = vec![
             ("id".into(), Value::Int(next_id as i64)),
@@ -313,7 +384,9 @@ impl ToolRegistry {
     }
 
     fn get_company(&self, args: &[(String, Value)]) -> Result<Value, String> {
-        let id = Self::get_arg(args, "id").and_then(|v| v.as_i64()).ok_or("missing id")?;
+        let id = Self::get_arg(args, "id")
+            .and_then(|v| v.as_i64())
+            .ok_or("missing id")?;
         self.get_record("companies", id as u64)
             .map(|opt| opt.unwrap_or(Value::Null))
     }
@@ -321,9 +394,7 @@ impl ToolRegistry {
     fn list_opportunities(&self, args: &[(String, Value)]) -> Result<Value, String> {
         let mut opps = self.scan_collection("opportunities")?;
         if let Some(stage) = Self::get_arg(args, "stage").and_then(|v| v.as_str()) {
-            opps.retain(|o| {
-                o.get("stage").and_then(|v| v.as_str()) == Some(stage)
-            });
+            opps.retain(|o| o.get("stage").and_then(|v| v.as_str()) == Some(stage));
         }
         if let Some(limit_val) = Self::get_arg(args, "limit") {
             if let Some(limit) = limit_val.as_i64() {
@@ -334,29 +405,48 @@ impl ToolRegistry {
     }
 
     fn get_opportunity(&self, args: &[(String, Value)]) -> Result<Value, String> {
-        let id = Self::get_arg(args, "id").and_then(|v| v.as_i64()).ok_or("missing id")?;
+        let id = Self::get_arg(args, "id")
+            .and_then(|v| v.as_i64())
+            .ok_or("missing id")?;
         self.get_record("opportunities", id as u64)
             .map(|opt| opt.unwrap_or(Value::Null))
     }
 
     fn get_pipeline(&self) -> Result<Value, String> {
         let opps = self.scan_collection("opportunities")?;
-        let stages = ["new", "screening", "meeting", "proposal", "negotiation", "won", "lost"];
-        let stage_counts: Vec<Value> = stages.iter().map(|&stage| {
-            let count = opps.iter().filter(|o| {
-                o.get("stage").and_then(|v| v.as_str()) == Some(stage)
-            }).count();
-            let total_amount: i64 = opps.iter().filter_map(|o| {
-                if o.get("stage").and_then(|v| v.as_str()) == Some(stage) {
-                    o.get("amount").and_then(|v| v.as_i64())
-                } else { None }
-            }).sum();
-            Value::Object(vec![
-                ("stage".into(), Value::Str(stage.into())),
-                ("count".into(), Value::Int(count as i64)),
-                ("totalAmount".into(), Value::Int(total_amount)),
-            ])
-        }).collect();
+        let stages = [
+            "new",
+            "screening",
+            "meeting",
+            "proposal",
+            "negotiation",
+            "won",
+            "lost",
+        ];
+        let stage_counts: Vec<Value> = stages
+            .iter()
+            .map(|&stage| {
+                let count = opps
+                    .iter()
+                    .filter(|o| o.get("stage").and_then(|v| v.as_str()) == Some(stage))
+                    .count();
+                let total_amount: i64 = opps
+                    .iter()
+                    .filter_map(|o| {
+                        if o.get("stage").and_then(|v| v.as_str()) == Some(stage) {
+                            o.get("amount").and_then(|v| v.as_i64())
+                        } else {
+                            None
+                        }
+                    })
+                    .sum();
+                Value::Object(vec![
+                    ("stage".into(), Value::Str(stage.into())),
+                    ("count".into(), Value::Int(count as i64)),
+                    ("totalAmount".into(), Value::Int(total_amount)),
+                ])
+            })
+            .collect();
 
         Ok(Value::Object(vec![
             ("pipeline".into(), Value::Array(stage_counts)),
@@ -365,14 +455,23 @@ impl ToolRegistry {
     }
 
     fn search_crm(&self, args: &[(String, Value)]) -> Result<Value, String> {
-        let query = Self::get_arg(args, "query").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
+        let query = Self::get_arg(args, "query")
+            .and_then(|v| v.as_str())
+            .unwrap_or("")
+            .to_lowercase();
         if query.is_empty() {
             return Ok(Value::Array(vec![]));
         }
 
         let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
         let mut results = Vec::new();
-        let prefixes = ["people:", "companies:", "opportunities:", "tasks:", "notes:"];
+        let prefixes = [
+            "people:",
+            "companies:",
+            "opportunities:",
+            "tasks:",
+            "notes:",
+        ];
 
         for prefix_str in &prefixes {
             let prefix = prefix_str.as_bytes();
@@ -383,7 +482,10 @@ impl ToolRegistry {
                     if val.to_lowercase().contains(&query) {
                         if let Ok(json) = akurai_json::parse(&val) {
                             results.push(Value::Object(vec![
-                                ("entity".into(), Value::Str(prefix_str.trim_end_matches(':').into())),
+                                (
+                                    "entity".into(),
+                                    Value::Str(prefix_str.trim_end_matches(':').into()),
+                                ),
                                 ("record".into(), json),
                             ]));
                         }
@@ -398,9 +500,7 @@ impl ToolRegistry {
     fn list_tasks(&self, args: &[(String, Value)]) -> Result<Value, String> {
         let mut tasks = self.scan_collection("tasks")?;
         if let Some(status) = Self::get_arg(args, "status").and_then(|v| v.as_str()) {
-            tasks.retain(|t| {
-                t.get("status").and_then(|v| v.as_str()) == Some(status)
-            });
+            tasks.retain(|t| t.get("status").and_then(|v| v.as_str()) == Some(status));
         }
         if let Some(limit_val) = Self::get_arg(args, "limit") {
             if let Some(limit) = limit_val.as_i64() {
diff --git a/deploy.sh b/deploy.sh
index c5f362b..e4765b2 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -50,7 +50,13 @@ if [ -f CHANGELOG.md ]; then
   ' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
 fi
 
-# ── 4. Commit, tag, push ───────────────────────────────────────────────────
+# ── 4. Static musl build ────────────────────────────────────────────────────
+say "Build: static musl binary"
+rustup target add x86_64-unknown-linux-musl >/dev/null 2>&1 || true
+cargo build --release --target x86_64-unknown-linux-musl -p akurai-crm \
+  || die "musl build failed"
+
+# ── 5. Commit, tag, push ───────────────────────────────────────────────────
 say "Git: commit + tag v$NEW"
 git add -A
 git commit -q -m "release: v$NEW"
diff --git a/site/backend/page.json b/site/backend/page.json
index 2377f63..4899eee 100644
--- a/site/backend/page.json
+++ b/site/backend/page.json
@@ -3,7 +3,7 @@
     "title": "AkurAI-CRM",
     "description": "Pure Rust CRM built on AkurAI-Framework",
     "url": "https://akurai-crm.olibuijr.com",
-    "version": "v0.1.0"
+    "version": "v0.1.1"
   },
   "brand": {
     "name": "AkurAI-CRM",
diff --git a/site/frontend/companies.html b/site/frontend/companies.html
index dce460d..467b406 100644
--- a/site/frontend/companies.html
+++ b/site/frontend/companies.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Companies — AkurAI-CRM</title>
   <link rel="stylesheet" href="styles.css">
+  <link rel="stylesheet" href="themes.css">
+  <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+  <script type="module" src="theme.js"></script>
 </head>
 <body>
   <div class="app-shell">
@@ -62,6 +65,7 @@
           <input type="text" placeholder="Search companies..." id="search-companies">
         </div>
         <div class="topbar-actions">
+          <span class="theme-picker" data-theme-picker></span>
           <button class="icon-btn" id="notif-btn">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
           </button>
diff --git a/site/frontend/company-detail.html b/site/frontend/company-detail.html
index e2660c5..82744a6 100644
--- a/site/frontend/company-detail.html
+++ b/site/frontend/company-detail.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>NordicTech — AkurAI-CRM</title>
   <link rel="stylesheet" href="styles.css">
+  <link rel="stylesheet" href="themes.css">
+  <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+  <script type="module" src="theme.js"></script>
 </head>
 <body>
   <div class="app-shell">
@@ -62,6 +65,7 @@
         </div>
         <div class="topbar-spacer"></div>
         <div class="topbar-actions">
+          <span class="theme-picker" data-theme-picker></span>
           <button class="icon-btn" id="notif-btn">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
           </button>
diff --git a/site/frontend/dashboard.html b/site/frontend/dashboard.html
index 8a3f46c..289ac75 100644
--- a/site/frontend/dashboard.html
+++ b/site/frontend/dashboard.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Dashboard — AkurAI-CRM</title>
   <link rel="stylesheet" href="styles.css">
+  <link rel="stylesheet" href="themes.css">
+  <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+  <script type="module" src="theme.js"></script>
 </head>
 <body>
   <div class="app-shell">
@@ -63,6 +66,7 @@
         </div>
         <div class="topbar-spacer"></div>
         <div class="topbar-actions">
+          <span class="theme-picker" data-theme-picker></span>
           <button class="icon-btn" id="notif-btn">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
             <span class="dot"></span>
diff --git a/site/frontend/index.html b/site/frontend/index.html
index 60d9b3d..c21b13d 100644
--- a/site/frontend/index.html
+++ b/site/frontend/index.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Sign in — AkurAI-CRM</title>
   <link rel="stylesheet" href="styles.css">
+  <link rel="stylesheet" href="themes.css">
+  <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+  <script type="module" src="theme.js"></script>
 </head>
 <body>
   <div class="login-page">
diff --git a/site/frontend/notes.html b/site/frontend/notes.html
index 0a96ad6..01b450c 100644
--- a/site/frontend/notes.html
+++ b/site/frontend/notes.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Notes — AkurAI-CRM</title>
   <link rel="stylesheet" href="styles.css">
+  <link rel="stylesheet" href="themes.css">
+  <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+  <script type="module" src="theme.js"></script>
 </head>
 <body>
   <div class="app-shell">
@@ -62,6 +65,7 @@
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
             Add Note
           </button>
+          <span class="theme-picker" data-theme-picker></span>
         </div>
       </header>
 
diff --git a/site/frontend/opportunities.html b/site/frontend/opportunities.html
index 6353a9a..f46f504 100644
--- a/site/frontend/opportunities.html
+++ b/site/frontend/opportunities.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Opportunities — AkurAI-CRM</title>
   <link rel="stylesheet" href="styles.css">
+  <link rel="stylesheet" href="themes.css">
+  <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+  <script type="module" src="theme.js"></script>
 </head>
 <body>
   <div class="app-shell">
@@ -72,6 +75,7 @@
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
             Add Deal
           </button>
+          <span class="theme-picker" data-theme-picker></span>
         </div>
       </header>
 
diff --git a/site/frontend/opportunity-detail.html b/site/frontend/opportunity-detail.html
index 7f9e53b..2d229f4 100644
--- a/site/frontend/opportunity-detail.html
+++ b/site/frontend/opportunity-detail.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>NordicTech Platform Deal — AkurAI-CRM</title>
   <link rel="stylesheet" href="styles.css">
+  <link rel="stylesheet" href="themes.css">
+  <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+  <script type="module" src="theme.js"></script>
 </head>
 <body>
   <div class="app-shell">
@@ -62,6 +65,7 @@
         </div>
         <div class="topbar-spacer"></div>
         <div class="topbar-actions">
+          <span class="theme-picker" data-theme-picker></span>
           <button class="icon-btn" id="notif-btn">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
           </button>
diff --git a/site/frontend/people.html b/site/frontend/people.html
index 70eaaeb..f9885e9 100644
--- a/site/frontend/people.html
+++ b/site/frontend/people.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>People — AkurAI-CRM</title>
   <link rel="stylesheet" href="styles.css">
+  <link rel="stylesheet" href="themes.css">
+  <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+  <script type="module" src="theme.js"></script>
 </head>
 <body>
   <div class="app-shell">
@@ -62,6 +65,7 @@
           <input type="text" placeholder="Search people..." id="search-people">
         </div>
         <div class="topbar-actions">
+          <span class="theme-picker" data-theme-picker></span>
           <button class="icon-btn" id="notif-btn">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
           </button>
diff --git a/site/frontend/person-detail.html b/site/frontend/person-detail.html
index 14868aa..354978f 100644
--- a/site/frontend/person-detail.html
+++ b/site/frontend/person-detail.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Jóna Jónsdóttir — AkurAI-CRM</title>
   <link rel="stylesheet" href="styles.css">
+  <link rel="stylesheet" href="themes.css">
+  <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+  <script type="module" src="theme.js"></script>
 </head>
 <body>
   <div class="app-shell">
@@ -62,6 +65,7 @@
         </div>
         <div class="topbar-spacer"></div>
         <div class="topbar-actions">
+          <span class="theme-picker" data-theme-picker></span>
           <button class="icon-btn" id="notif-btn">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
           </button>
diff --git a/site/frontend/settings.html b/site/frontend/settings.html
index 42b3647..9da6a08 100644
--- a/site/frontend/settings.html
+++ b/site/frontend/settings.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Settings — AkurAI-CRM</title>
   <link rel="stylesheet" href="styles.css">
+  <link rel="stylesheet" href="themes.css">
+  <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+  <script type="module" src="theme.js"></script>
 </head>
 <body>
   <div class="app-shell">
@@ -58,6 +61,7 @@
         <div class="topbar-title">Settings</div>
         <div class="topbar-spacer"></div>
         <div class="topbar-actions">
+          <span class="theme-picker" data-theme-picker></span>
           <button class="icon-btn" id="notif-btn">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
           </button>
diff --git a/site/frontend/styles.css b/site/frontend/styles.css
index 1c95b23..27f3adc 100644
--- a/site/frontend/styles.css
+++ b/site/frontend/styles.css
@@ -3,37 +3,44 @@
    ============================================================ */
 
 /* ----- Design Tokens ----- */
+/* Color tokens aliased to AkurAI-Framework semantic tokens (themes.css).
+   Non-color tokens (spacing, type, layout) are independent and unchanged. */
 :root {
-  --color-sidebar-bg: #0f172a;
-  --color-sidebar-hover: #1e293b;
-  --color-sidebar-active: #312e81;
-  --color-sidebar-text: #94a3b8;
-  --color-sidebar-text-active: #f1f5f9;
-  --color-sidebar-brand: #818cf8;
+  /* Sidebar — uses deepest bg layer so it recedes behind content */
+  --color-sidebar-bg: var(--bg);
+  --color-sidebar-hover: var(--panel);
+  --color-sidebar-active: var(--panel-2);
+  --color-sidebar-text: var(--muted);
+  --color-sidebar-text-active: var(--fg);
+  --color-sidebar-brand: var(--accent);
 
-  --color-bg: #f8fafc;
-  --color-surface: #ffffff;
-  --color-surface-hover: #f1f5f9;
-  --color-border: #e2e8f0;
-  --color-border-light: #f1f5f9;
+  /* Page / surface */
+  --color-bg: var(--bg-2);
+  --color-surface: var(--panel);
+  --color-surface-hover: var(--panel-2);
+  --color-border: var(--border-2);
+  --color-border-light: var(--border);
 
-  --color-primary: #6366f1;
-  --color-primary-hover: #4f46e5;
-  --color-primary-light: #eef2ff;
-  --color-primary-subtle: rgba(99, 102, 241, 0.08);
+  /* Accent / primary */
+  --color-primary: var(--accent);
+  --color-primary-hover: var(--accent-2);
+  --color-primary-light: color-mix(in srgb, var(--accent) 14%, var(--panel));
+  --color-primary-subtle: color-mix(in srgb, var(--accent) 8%, transparent);
 
-  --color-success: #22c55e;
-  --color-success-light: #dcfce7;
-  --color-warning: #f59e0b;
-  --color-warning-light: #fef3c7;
-  --color-danger: #ef4444;
-  --color-danger-light: #fee2e2;
-  --color-info: #3b82f6;
-  --color-info-light: #dbeafe;
+  /* Status */
+  --color-success: var(--ok);
+  --color-success-light: color-mix(in srgb, var(--ok) 14%, var(--panel));
+  --color-warning: var(--warn);
+  --color-warning-light: color-mix(in srgb, var(--warn) 14%, var(--panel));
+  --color-danger: var(--danger);
+  --color-danger-light: color-mix(in srgb, var(--danger) 14%, var(--panel));
+  --color-info: var(--info);
+  --color-info-light: color-mix(in srgb, var(--info) 14%, var(--panel));
 
-  --color-text: #0f172a;
-  --color-text-secondary: #64748b;
-  --color-text-tertiary: #94a3b8;
+  /* Text */
+  --color-text: var(--fg);
+  --color-text-secondary: var(--muted);
+  --color-text-tertiary: var(--muted);
   --color-text-inverse: #ffffff;
 
   --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
@@ -217,15 +224,15 @@ table .cell-muted { color: var(--color-text-secondary); font-size: var(--text-xs
 
 /* ----- Badges / Tags ----- */
 .badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--radius-full); font-size: var(--text-xs); font-weight: var(--weight-medium); line-height: 1.5; white-space: nowrap; }
-.badge-slate { background: #f1f5f9; color: #475569; }
-.badge-blue { background: var(--color-info-light); color: #1d4ed8; }
-.badge-indigo { background: var(--color-primary-light); color: var(--color-primary); }
-.badge-violet { background: #ede9fe; color: #7c3aed; }
-.badge-amber { background: var(--color-warning-light); color: #b45309; }
-.badge-green { background: var(--color-success-light); color: #16a34a; }
-.badge-red { background: var(--color-danger-light); color: #dc2626; }
-.badge-gray { background: #f1f5f9; color: #64748b; }
-.badge-new { background: #f1f5f9; color: #475569; }
+.badge-slate { background: var(--panel-2); color: var(--muted); }
+.badge-blue { background: var(--color-info-light); color: var(--info); }
+.badge-indigo { background: var(--color-primary-light); color: var(--accent); }
+.badge-violet { background: color-mix(in srgb, var(--accent-2) 14%, var(--panel)); color: var(--accent-2); }
+.badge-amber { background: var(--color-warning-light); color: var(--warn); }
+.badge-green { background: var(--color-success-light); color: var(--ok); }
+.badge-red { background: var(--color-danger-light); color: var(--danger); }
+.badge-gray { background: var(--panel-2); color: var(--muted); }
+.badge-new { background: var(--panel-2); color: var(--muted); }
 
 /* ----- Kanban Board ----- */
 .kanban-board { display: grid; grid-template-columns: repeat(7, 1fr); gap: var(--space-4); min-height: 500px; overflow-x: auto; padding-bottom: var(--space-4); }
@@ -243,13 +250,13 @@ table .cell-muted { color: var(--color-text-secondary); font-size: var(--text-xs
 .kanban-dropzone { min-height: 60px; border: 2px dashed var(--color-border); border-radius: var(--radius-md); margin: var(--space-2); display: flex; align-items: center; justify-content: center; font-size: var(--text-xs); color: var(--color-text-tertiary); }
 
 /* Column stage colors */
-.kanban-column.new .kanban-card { border-left-color: #94a3b8; }
-.kanban-column.screening .kanban-card { border-left-color: #60a5fa; }
-.kanban-column.meeting .kanban-card { border-left-color: #818cf8; }
-.kanban-column.proposal .kanban-card { border-left-color: #a78bfa; }
-.kanban-column.negotiation .kanban-card { border-left-color: #f59e0b; }
-.kanban-column.won .kanban-card { border-left-color: #22c55e; }
-.kanban-column.lost .kanban-card { border-left-color: #ef4444; }
+.kanban-column.new .kanban-card { border-left-color: var(--muted); }
+.kanban-column.screening .kanban-card { border-left-color: var(--info); }
+.kanban-column.meeting .kanban-card { border-left-color: var(--accent); }
+.kanban-column.proposal .kanban-card { border-left-color: var(--accent-2); }
+.kanban-column.negotiation .kanban-card { border-left-color: var(--warn); }
+.kanban-column.won .kanban-card { border-left-color: var(--ok); }
+.kanban-column.lost .kanban-card { border-left-color: var(--danger); }
 
 /* ----- Pipeline visualization ----- */
 .pipeline-chart { display: flex; align-items: flex-end; gap: var(--space-3); height: 200px; padding: var(--space-4) 0; }
@@ -421,8 +428,8 @@ table .cell-muted { color: var(--color-text-secondary); font-size: var(--text-xs
 @keyframes slideUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
 
 /* ----- Login Page ----- */
-.login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%); position: relative; overflow: hidden; }
-.login-page::before { content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; background: radial-gradient(circle at 30% 50%, rgba(99,102,241,0.08) 0%, transparent 50%), radial-gradient(circle at 70% 50%, rgba(99,102,241,0.06) 0%, transparent 50%); pointer-events: none; }
+.login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); position: relative; overflow: hidden; }
+.login-page::before { content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; background: radial-gradient(circle at 30% 50%, color-mix(in srgb, var(--accent) 8%, transparent) 0%, transparent 50%), radial-gradient(circle at 70% 50%, color-mix(in srgb, var(--accent) 6%, transparent) 0%, transparent 50%); pointer-events: none; }
 .login-card { position: relative; background: var(--color-surface); border-radius: var(--radius-xl); padding: var(--space-10); width: 100%; max-width: 420px; box-shadow: var(--shadow-xl); }
 .login-brand { text-align: center; margin-bottom: var(--space-8); }
 .login-brand .brand-icon { width: 48px; height: 48px; background: var(--color-primary); border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; margin: 0 auto var(--space-4); color: #fff; font-size: var(--text-2xl); font-weight: var(--weight-bold); }
@@ -473,3 +480,29 @@ table .cell-muted { color: var(--color-text-secondary); font-size: var(--text-xs
   .stats-grid { grid-template-columns: 1fr; }
   .kanban-board { grid-template-columns: 1fr; }
 }
+
+/* ----- Theme Picker — token-driven, re-themes with everything else ----- */
+.theme-picker { display: inline-flex; align-items: center; }
+.theme-select {
+  appearance: none;
+  -webkit-appearance: none;
+  font: inherit;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--fg);
+  background: var(--panel);
+  border: 1px solid var(--border-2);
+  border-radius: 8px;
+  padding: 6px 28px 6px 11px;
+  cursor: pointer;
+  line-height: 1.2;
+  background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
+  background-position: calc(100% - 15px) center, calc(100% - 10px) center;
+  background-size: 5px 5px, 5px 5px;
+  background-repeat: no-repeat;
+  transition: border-color .15s ease, background-color .15s ease;
+}
+.theme-select:hover { border-color: var(--fg); }
+.theme-select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+.theme-select optgroup { color: var(--fg); background: var(--panel); font-weight: 700; }
+.theme-select option { color: var(--fg); background: var(--panel); font-weight: 500; }
diff --git a/site/frontend/tasks.html b/site/frontend/tasks.html
index 8db3074..cbb6338 100644
--- a/site/frontend/tasks.html
+++ b/site/frontend/tasks.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Tasks — AkurAI-CRM</title>
   <link rel="stylesheet" href="styles.css">
+  <link rel="stylesheet" href="themes.css">
+  <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+  <script type="module" src="theme.js"></script>
 </head>
 <body>
   <div class="app-shell">
@@ -62,6 +65,7 @@
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
             Add Task
           </button>
+          <span class="theme-picker" data-theme-picker></span>
         </div>
       </header>
 
diff --git a/site/frontend/theme.js b/site/frontend/theme.js
new file mode 100644
index 0000000..5f37166
--- /dev/null
+++ b/site/frontend/theme.js
@@ -0,0 +1,96 @@
+// AkurAI theme switcher — native ESM, zero deps.
+//
+// Themes are generated by the framework (`/themes.css`, registry `/themes.json`)
+// as `:root[data-theme="…"]` token blocks. This module only chooses the active
+// `data-theme` on <html>, persists it, and builds a small picker. To avoid a
+// flash of the wrong theme, set the attribute as early as possible — the inline
+// snippet in <head> (see `theme-init.js` contents below) runs before paint; this
+// module then renders the UI.
+
+const STORAGE_KEY = "akurai-theme";
+// Default per polarity when the user has never chosen — honors the OS setting.
+const DEFAULT_DARK = "akurai";
+const DEFAULT_LIGHT = "akurai-light";
+
+export function prefersLight() {
+  return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
+}
+
+export function defaultTheme() {
+  return prefersLight() ? DEFAULT_LIGHT : DEFAULT_DARK;
+}
+
+export function currentTheme() {
+  return localStorage.getItem(STORAGE_KEY) || document.documentElement.getAttribute("data-theme") || defaultTheme();
+}
+
+export function applyTheme(slug) {
+  document.documentElement.setAttribute("data-theme", slug);
+  try {
+    localStorage.setItem(STORAGE_KEY, slug);
+  } catch {
+    /* storage may be unavailable (private mode); theme still applies for the session */
+  }
+}
+
+// Fetch the registry and build a grouped picker. `mount` is the element the
+// <select> (and label) are appended to. Returns the registry array.
+export async function mountPicker(mount) {
+  if (!mount) return [];
+  let themes = [];
+  try {
+    const res = await fetch("/themes.json", { headers: { accept: "application/json" } });
+    themes = await res.json();
+  } catch {
+    return [];
+  }
+  if (!Array.isArray(themes) || themes.length === 0) return [];
+
+  const select = document.createElement("select");
+  select.className = "theme-select";
+  select.setAttribute("aria-label", "Color theme");
+
+  // Group by family so light/dark siblings sit together.
+  const families = [];
+  const byFamily = new Map();
+  for (const t of themes) {
+    if (!byFamily.has(t.family)) {
+      byFamily.set(t.family, []);
+      families.push(t.family);
+    }
+    byFamily.get(t.family).push(t);
+  }
+  for (const family of families) {
+    const group = document.createElement("optgroup");
+    group.label = family;
+    for (const t of byFamily.get(family)) {
+      const opt = document.createElement("option");
+      opt.value = t.slug;
+      // Label already carries the name; append a polarity hint when ambiguous.
+      opt.textContent = t.label;
+      group.appendChild(opt);
+    }
+    select.appendChild(group);
+  }
+
+  const active = currentTheme();
+  select.value = active;
+  applyTheme(active);
+
+  select.addEventListener("change", () => applyTheme(select.value));
+  mount.appendChild(select);
+  return themes;
+}
+
+// Auto-mount: if a `[data-theme-picker]` element exists, fill it on load.
+if (typeof document !== "undefined") {
+  const boot = () => {
+    const slot = document.querySelector("[data-theme-picker]");
+    if (slot) mountPicker(slot);
+  };
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", boot);
+  } else {
+    boot();
+  }
+}
diff --git a/site/frontend/themes.css b/site/frontend/themes.css
new file mode 100644
index 0000000..efbf7f4
--- /dev/null
+++ b/site/frontend/themes.css
@@ -0,0 +1,19 @@
+/* AkurAI-Framework themes — generated by akurai-css::theme. Do not edit by hand. */
+:root{--bg:#0a0c11;--bg-2:#0f131b;--panel:#141925;--panel-2:#1a2030;--border:#1a2030;--border-2:#8a93a8;--fg:#e7ebf3;--muted:#8a93a8;--dim:#1a2030;--accent:#5b8cff;--accent-2:#8a6bff;--ok:#3ddc84;--warn:#f5b945;--info:#58b2ff;--danger:#ff6b6b;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="akurai"]{--bg:#0a0c11;--bg-2:#0f131b;--panel:#141925;--panel-2:#1a2030;--border:#1a2030;--border-2:#8a93a8;--fg:#e7ebf3;--muted:#8a93a8;--dim:#1a2030;--accent:#5b8cff;--accent-2:#8a6bff;--ok:#3ddc84;--warn:#f5b945;--info:#58b2ff;--danger:#ff6b6b;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="akurai-light"]{--bg:#f7f8fb;--bg-2:#eef1f6;--panel:#e2e7f0;--panel-2:#cdd5e2;--border:#cdd5e2;--border-2:#5b657d;--fg:#1d2430;--muted:#5b657d;--dim:#cdd5e2;--accent:#2e56b8;--accent-2:#6d4bd8;--ok:#1f9d57;--warn:#b7791f;--info:#2570d4;--danger:#d92d20;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="claude-code"]{--bg:#1f1e1b;--bg-2:#262320;--panel:#2f2b27;--panel-2:#3a352f;--border:#3a352f;--border-2:#8a8279;--fg:#e6e0d6;--muted:#8a8279;--dim:#3a352f;--accent:#cc785c;--accent-2:#a87b9e;--ok:#8a9a5b;--warn:#d9a05b;--info:#5b9aa0;--danger:#bf4d43;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="claude-code-light"]{--bg:#f5f1e8;--bg-2:#ece7da;--panel:#e0dac9;--panel-2:#cfc8b5;--border:#cfc8b5;--border-2:#6b6457;--fg:#3d3a34;--muted:#6b6457;--dim:#cfc8b5;--accent:#c15f3c;--accent-2:#8a5a7e;--ok:#5f7a3a;--warn:#b07a2e;--info:#3f7a80;--danger:#bf4d43;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="nord"]{--bg:#2e3440;--bg-2:#3b4252;--panel:#434c5e;--panel-2:#4c566a;--border:#4c566a;--border-2:#d8dee9;--fg:#e5e9f0;--muted:#d8dee9;--dim:#4c566a;--accent:#81a1c1;--accent-2:#b48ead;--ok:#a3be8c;--warn:#ebcb8b;--info:#88c0d0;--danger:#bf616a;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="nord-light"]{--bg:#eceff4;--bg-2:#e5e9f0;--panel:#d8dee9;--panel-2:#aebacf;--border:#aebacf;--border-2:#4c566a;--fg:#2e3440;--muted:#4c566a;--dim:#aebacf;--accent:#5e81ac;--accent-2:#8a5a7e;--ok:#6e8b4f;--warn:#a9863a;--info:#2d7d8a;--danger:#b1444e;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="catppuccin-mocha"]{--bg:#1e1e2e;--bg-2:#181825;--panel:#313244;--panel-2:#45475a;--border:#45475a;--border-2:#585b70;--fg:#cdd6f4;--muted:#585b70;--dim:#45475a;--accent:#89b4fa;--accent-2:#cba6f7;--ok:#a6e3a1;--warn:#f9e2af;--info:#94e2d5;--danger:#f38ba8;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="catppuccin-latte"]{--bg:#eff1f5;--bg-2:#e6e9ef;--panel:#ccd0da;--panel-2:#bcc0cc;--border:#bcc0cc;--border-2:#acb0be;--fg:#4c4f69;--muted:#acb0be;--dim:#bcc0cc;--accent:#1e66f5;--accent-2:#8839ef;--ok:#40a02b;--warn:#df8e1d;--info:#179299;--danger:#d20f39;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="solarized-dark"]{--bg:#002b36;--bg-2:#073642;--panel:#586e75;--panel-2:#657b83;--border:#657b83;--border-2:#839496;--fg:#93a1a1;--muted:#839496;--dim:#657b83;--accent:#268bd2;--accent-2:#6c71c4;--ok:#859900;--warn:#b58900;--info:#2aa198;--danger:#dc322f;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="solarized-light"]{--bg:#fdf6e3;--bg-2:#eee8d5;--panel:#93a1a1;--panel-2:#839496;--border:#839496;--border-2:#657b83;--fg:#586e75;--muted:#657b83;--dim:#839496;--accent:#268bd2;--accent-2:#6c71c4;--ok:#859900;--warn:#b58900;--info:#2aa198;--danger:#dc322f;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="gruvbox-dark"]{--bg:#282828;--bg-2:#3c3836;--panel:#504945;--panel-2:#665c54;--border:#665c54;--border-2:#bdae93;--fg:#d5c4a1;--muted:#bdae93;--dim:#665c54;--accent:#83a598;--accent-2:#d3869b;--ok:#b8bb26;--warn:#fabd2f;--info:#8ec07c;--danger:#fb4934;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="gruvbox-light"]{--bg:#fbf1c7;--bg-2:#ebdbb2;--panel:#d5c4a1;--panel-2:#bdae93;--border:#bdae93;--border-2:#665c54;--fg:#504945;--muted:#665c54;--dim:#bdae93;--accent:#076678;--accent-2:#8f3f71;--ok:#79740e;--warn:#b57614;--info:#427b58;--danger:#9d0006;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="tokyo-night"]{--bg:#1a1b26;--bg-2:#16161e;--panel:#2f3549;--panel-2:#444b6a;--border:#444b6a;--border-2:#787c99;--fg:#a9b1d6;--muted:#787c99;--dim:#444b6a;--accent:#2ac3de;--accent-2:#bb9af7;--ok:#9ece6a;--warn:#0db9d7;--info:#b4f9f8;--danger:#c0caf5;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="tokyo-night-light"]{--bg:#d5d6db;--bg-2:#cbccd1;--panel:#dfe0e5;--panel-2:#9699a3;--border:#9699a3;--border-2:#4c505e;--fg:#343b59;--muted:#4c505e;--dim:#9699a3;--accent:#34548a;--accent-2:#5a4a78;--ok:#485e30;--warn:#166775;--info:#3e6968;--danger:#343b58;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="rose-pine"]{--bg:#191724;--bg-2:#1f1d2e;--panel:#26233a;--panel-2:#6e6a86;--border:#6e6a86;--border-2:#908caa;--fg:#e0def4;--muted:#908caa;--dim:#6e6a86;--accent:#c4a7e7;--accent-2:#f6c177;--ok:#31748f;--warn:#ebbcba;--info:#9ccfd8;--danger:#eb6f92;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="rose-pine-dawn"]{--bg:#faf4ed;--bg-2:#fffaf3;--panel:#f2e9de;--panel-2:#9893a5;--border:#9893a5;--border-2:#797593;--fg:#575279;--muted:#797593;--dim:#9893a5;--accent:#907aa9;--accent-2:#ea9d34;--ok:#286983;--warn:#d7827e;--info:#56949f;--danger:#b4637a;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="dracula"]{--bg:#282a36;--bg-2:#21222c;--panel:#44475a;--panel-2:#6272a4;--border:#6272a4;--border-2:#9ea8c7;--fg:#f8f8f2;--muted:#9ea8c7;--dim:#6272a4;--accent:#bd93f9;--accent-2:#ff79c6;--ok:#50fa7b;--warn:#f1fa8c;--info:#8be9fd;--danger:#ff5555;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}