Browse Source

feat: add tool calling support, fix gRPC keepalive, improve chat UX

Tool Calling:
- Add ToolService with 21 permission-aware CRM tools
- Add tool execution endpoints in http_server
- Add ToolCallCard component for displaying tool calls in chat
- Add tools API client and types in frontend

gRPC Improvements:
- Add keepalive settings to LLM server to prevent "Too many pings" errors
- Add keepalive settings to LlmClient for stable connections
- Add GetProviderForModel to use workspace's configured provider

Chat UX:
- Hide chat widget FAB on dedicated /chat page
- Auto-resume last chat session on page load
- Reset userMessageCount on session create/switch for title generation
- Fix workspace model selection persistence with refs

Systemd:
- Update services to use EnvironmentFile from ~/.config/smartbotic-crm/
fszontagh 7 tháng trước cách đây
mục cha
commit
ce10bc26a3

+ 5 - 0
llm/include/smartbotic/llm/grpc/llm_service.hpp

@@ -40,6 +40,11 @@ private:
                                    const std::string& workspace_id)
         -> std::shared_ptr<provider::IProvider>;
 
+    /// Get provider based on model name (matches model prefix to provider)
+    [[nodiscard]] auto GetProviderForModel(const std::string& model_id,
+                                           const std::string& workspace_id)
+        -> std::shared_ptr<provider::IProvider>;
+
     /// Build chat request from proto
     [[nodiscard]] auto BuildChatRequest(const ::smartbotic::llm::ChatRequest& request,
                                         const session::Session& session)

+ 131 - 22
llm/src/grpc/llm_service.cpp

@@ -18,12 +18,92 @@ LLMServiceImpl::LLMServiceImpl(std::shared_ptr<provider::ProviderFactory> provid
 auto LLMServiceImpl::GetProvider(const std::string& provider_id,
                                  const std::string& workspace_id)
     -> std::shared_ptr<provider::IProvider> {
-    // TODO: Implement provider selection with workspace BYOK support
-    // For now, return first available provider
     auto providers = provider_factory_->GetAll();
-    if (!providers.empty()) {
-        return providers.front();
+
+    // If a specific provider_id is requested, use it directly
+    if (!provider_id.empty()) {
+        auto provider = provider_factory_->Get(provider_id);
+        if (provider) {
+            return provider;
+        }
     }
+
+    // Return first enabled provider
+    for (const auto& provider : providers) {
+        if (provider && provider->GetConfig().enabled) {
+            return provider;
+        }
+    }
+
+    return nullptr;
+}
+
+auto LLMServiceImpl::GetProviderForModel(const std::string& model_id,
+                                          const std::string& workspace_id)
+    -> std::shared_ptr<provider::IProvider> {
+    auto providers = provider_factory_->GetAll();
+
+    // Check if model has a provider prefix (e.g., "zai/GLM-4.7" -> provider "zai")
+    auto slash_pos = model_id.find('/');
+    if (slash_pos != std::string::npos) {
+        std::string provider_prefix = model_id.substr(0, slash_pos);
+        spdlog::info("Looking for provider with prefix '{}' from model '{}'", provider_prefix, model_id);
+
+        // Find provider whose ID or name matches the prefix (case-insensitive for name)
+        for (const auto& provider : providers) {
+            if (provider && provider->GetConfig().enabled) {
+                const auto& config = provider->GetConfig();
+                spdlog::info("  Checking provider: id='{}', name='{}'", config.id, config.name);
+
+                // Case-sensitive check for ID, case-insensitive for name
+                bool id_match = (config.id == provider_prefix);
+                bool name_match = false;
+
+                // Case-insensitive name comparison
+                if (config.name.size() == provider_prefix.size()) {
+                    name_match = std::equal(config.name.begin(), config.name.end(),
+                                           provider_prefix.begin(),
+                                           [](char a, char b) { return std::tolower(a) == std::tolower(b); });
+                }
+
+                if (id_match || name_match) {
+                    spdlog::info("Matched model '{}' to provider '{}' (name='{}')", model_id, config.id, config.name);
+                    return provider;
+                }
+            }
+        }
+    }
+
+    // For models without prefix, check common OpenAI model patterns
+    if (model_id.starts_with("gpt-") || model_id.starts_with("o1") ||
+        model_id.starts_with("text-") || model_id.starts_with("davinci")) {
+        for (const auto& provider : providers) {
+            if (provider && provider->GetConfig().enabled &&
+                provider->GetType() == provider::ProviderType::kOpenAI) {
+                return provider;
+            }
+        }
+    }
+
+    // For Anthropic models
+    if (model_id.starts_with("claude")) {
+        for (const auto& provider : providers) {
+            if (provider && provider->GetConfig().enabled &&
+                provider->GetType() == provider::ProviderType::kAnthropic) {
+                return provider;
+            }
+        }
+    }
+
+    // Fall back to first enabled provider
+    for (const auto& provider : providers) {
+        if (provider && provider->GetConfig().enabled) {
+            spdlog::warn("No specific provider found for model '{}', using default provider '{}'",
+                model_id, provider->GetConfig().id);
+            return provider;
+        }
+    }
+
     return nullptr;
 }
 
@@ -37,6 +117,8 @@ auto LLMServiceImpl::BuildChatRequest(const ::smartbotic::llm::ChatRequest& requ
     if (chat_request.model.empty()) {
         chat_request.model = default_model_;
     }
+    spdlog::info("BuildChatRequest: request.model_id='{}', session.model_id='{}', default_model='{}', final='{}'",
+        request.model_id(), session.model_id, default_model_, chat_request.model);
 
     // Set system prompt
     if (!session.system_prompt.empty()) {
@@ -183,14 +265,23 @@ auto LLMServiceImpl::BuildChatRequest(const ::smartbotic::llm::ChatRequest& requ
     }
     session = std::move(add_result.value);
 
-    // Get provider
-    auto provider = GetProvider(request->provider_id(), session.workspace_id);
+    // Build chat request first to determine the model
+    auto chat_request = BuildChatRequest(*request, session);
+
+    // Get provider - prefer explicit provider_id from request, fall back to model-based lookup
+    std::shared_ptr<provider::IProvider> provider;
+    if (!request->provider_id().empty()) {
+        provider = provider_factory_->Get(request->provider_id());
+        if (provider) {
+            spdlog::info("Using explicit provider_id '{}' from request", request->provider_id());
+        }
+    }
     if (!provider) {
-        return ::grpc::Status(::grpc::StatusCode::UNAVAILABLE, "No provider available");
+        provider = GetProviderForModel(chat_request.model, session.workspace_id);
+    }
+    if (!provider) {
+        return ::grpc::Status(::grpc::StatusCode::UNAVAILABLE, "No provider available for model: " + chat_request.model);
     }
-
-    // Build and send chat request
-    auto chat_request = BuildChatRequest(*request, session);
     auto chat_result = provider->Chat(chat_request);
 
     if (!chat_result.success) {
@@ -276,14 +367,23 @@ auto LLMServiceImpl::BuildChatRequest(const ::smartbotic::llm::ChatRequest& requ
     }
     session = std::move(add_result.value);
 
-    // Get provider
-    auto provider = GetProvider(request->provider_id(), session.workspace_id);
+    // Build chat request first to determine the model
+    auto chat_request = BuildChatRequest(*request, session);
+
+    // Get provider - prefer explicit provider_id from request, fall back to model-based lookup
+    std::shared_ptr<provider::IProvider> provider;
+    if (!request->provider_id().empty()) {
+        provider = provider_factory_->Get(request->provider_id());
+        if (provider) {
+            spdlog::info("Using explicit provider_id '{}' from request", request->provider_id());
+        }
+    }
     if (!provider) {
-        return ::grpc::Status(::grpc::StatusCode::UNAVAILABLE, "No provider available");
+        provider = GetProviderForModel(chat_request.model, session.workspace_id);
+    }
+    if (!provider) {
+        return ::grpc::Status(::grpc::StatusCode::UNAVAILABLE, "No provider available for model: " + chat_request.model);
     }
-
-    // Build chat request
-    auto chat_request = BuildChatRequest(*request, session);
 
     // Accumulated response
     std::string accumulated_content;
@@ -402,12 +502,6 @@ auto LLMServiceImpl::BuildChatRequest(const ::smartbotic::llm::ChatRequest& requ
         }
     }
 
-    // Get provider and continue conversation
-    auto provider = GetProvider(request->provider_id(), session.workspace_id);
-    if (!provider) {
-        return ::grpc::Status(::grpc::StatusCode::UNAVAILABLE, "No provider available");
-    }
-
     // Build request from session with tool results
     provider::ChatRequest chat_request;
     chat_request.model = request->model_id().empty() ? session.model_id : request->model_id();
@@ -415,6 +509,21 @@ auto LLMServiceImpl::BuildChatRequest(const ::smartbotic::llm::ChatRequest& requ
         chat_request.model = default_model_;
     }
 
+    // Get provider - prefer explicit provider_id from request, fall back to model-based lookup
+    std::shared_ptr<provider::IProvider> provider;
+    if (!request->provider_id().empty()) {
+        provider = provider_factory_->Get(request->provider_id());
+        if (provider) {
+            spdlog::info("Using explicit provider_id '{}' from request", request->provider_id());
+        }
+    }
+    if (!provider) {
+        provider = GetProviderForModel(chat_request.model, session.workspace_id);
+    }
+    if (!provider) {
+        return ::grpc::Status(::grpc::StatusCode::UNAVAILABLE, "No provider available for model: " + chat_request.model);
+    }
+
     if (!session.system_prompt.empty()) {
         chat_request.system_prompt = session.system_prompt;
     }

+ 6 - 0
llm/src/grpc/server.cpp

@@ -59,6 +59,12 @@ auto GrpcServer::Start() -> bool {
     builder.AddListeningPort(config_.GetListenAddress(),
                              ::grpc::InsecureServerCredentials());
 
+    // Configure keepalive settings to allow client pings
+    builder.AddChannelArgument(GRPC_ARG_KEEPALIVE_TIME_MS, 60000);  // 60 seconds
+    builder.AddChannelArgument(GRPC_ARG_KEEPALIVE_TIMEOUT_MS, 20000);  // 20 seconds
+    builder.AddChannelArgument(GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS, 1);
+    builder.AddChannelArgument(GRPC_ARG_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS, 5000);  // 5 seconds
+
     // Register services
     builder.RegisterService(llm_service_.get());
     builder.RegisterService(session_service_.get());

+ 14 - 2
llm/src/provider/anthropic_provider.cpp

@@ -594,8 +594,20 @@ auto AnthropicProvider::ChatStream(const ChatRequest& request, StreamCallback ca
         }
 
         if (res->status != 200) {
-            return Result<ChatResponse>::Error(
-                "Stream failed: HTTP " + std::to_string(res->status));
+            // Try to extract error message from response body
+            std::string error_msg = "Stream failed: HTTP " + std::to_string(res->status);
+            try {
+                auto err_json = nlohmann::json::parse(res->body);
+                if (err_json.contains("error")) {
+                    error_msg = err_json["error"].value("message", error_msg);
+                }
+            } catch (...) {
+                // If body is not JSON, include it as-is if short
+                if (!res->body.empty() && res->body.size() < 500) {
+                    error_msg += " - " + res->body;
+                }
+            }
+            return Result<ChatResponse>::Error(error_msg);
         }
 
         // Build final response

+ 18 - 2
llm/src/provider/openai_provider.cpp

@@ -475,9 +475,12 @@ auto OpenAIProvider::ChatStream(const ChatRequest& request, StreamCallback callb
         std::string accumulated_content;
         std::unordered_map<int, ToolCall> tool_calls;  // index -> ToolCall
         int last_tool_index = -1;
+        std::string raw_response;  // Capture raw response for error handling
+        bool is_sse_response = false;
 
         auto content_receiver = [&](const char* data, size_t data_length) -> bool {
             std::string chunk_data(data, data_length);
+            raw_response += chunk_data;  // Capture for error handling
 
             // SSE format: "data: {...}\n\n"
             std::istringstream stream(chunk_data);
@@ -494,6 +497,7 @@ auto OpenAIProvider::ChatStream(const ChatRequest& request, StreamCallback callb
                 }
 
                 if (line.starts_with("data: ")) {
+                    is_sse_response = true;
                     std::string json_data = line.substr(6);
 
                     auto chunk = ParseStreamChunk(json_data);
@@ -554,8 +558,20 @@ auto OpenAIProvider::ChatStream(const ChatRequest& request, StreamCallback callb
         }
 
         if (res->status != 200) {
-            return Result<ChatResponse>::Error(
-                "Stream failed: HTTP " + std::to_string(res->status));
+            // Extract error message from captured response
+            std::string error_msg = "HTTP " + std::to_string(res->status);
+            try {
+                auto err_json = nlohmann::json::parse(raw_response);
+                if (err_json.contains("error")) {
+                    error_msg = err_json["error"].value("message", error_msg);
+                }
+            } catch (...) {
+                // If response is not JSON, include it as-is if short
+                if (!raw_response.empty() && raw_response.size() < 500) {
+                    error_msg += ": " + raw_response;
+                }
+            }
+            return Result<ChatResponse>::Error(error_msg);
         }
 
         // Build final response

+ 2 - 3
systemd/smartbotic-crm-database.service

@@ -13,9 +13,8 @@ StandardOutput=journal
 StandardError=journal
 TimeoutStopSec=10
 
-# Environment
-Environment="SMARTBOTIC_CRM_DB_PORT=50151"
-Environment="SMARTBOTIC_CRM_DB_DATA_DIR=/data/smartbotic-crm/data"
+# Environment from config file
+EnvironmentFile=%h/.config/smartbotic-crm/database.env
 
 [Install]
 WantedBy=default.target

+ 2 - 6
systemd/smartbotic-crm-llm.service

@@ -15,12 +15,8 @@ StandardOutput=journal
 StandardError=journal
 TimeoutStopSec=10
 
-# Environment
-Environment="SMARTBOTIC_CRM_LLM_ADDRESS=0.0.0.0"
-Environment="SMARTBOTIC_CRM_LLM_PORT=50152"
-Environment="SMARTBOTIC_CRM_LLM_DATABASE_ADDRESS=localhost:50151"
-Environment="SMARTBOTIC_CRM_LLM_RUNNER_ADDRESS=localhost:50153"
-Environment="SMARTBOTIC_CRM_LLM_LOG_LEVEL=info"
+# Environment from config file
+EnvironmentFile=%h/.config/smartbotic-crm/llm.env
 
 [Install]
 WantedBy=default.target

+ 2 - 7
systemd/smartbotic-crm-webserver.service

@@ -15,13 +15,8 @@ StandardOutput=journal
 StandardError=journal
 TimeoutStopSec=10
 
-# Environment
-Environment="SMARTBOTIC_CRM_SERVER_HTTP_PORT=18080"
-Environment="SMARTBOTIC_CRM_SERVER_WS_PORT=18081"
-Environment="SMARTBOTIC_CRM_SERVER_DATABASE_HOST=localhost"
-Environment="SMARTBOTIC_CRM_SERVER_DATABASE_PORT=50151"
-Environment="SMARTBOTIC_CRM_SERVER_LLM_ADDRESS=localhost:50152"
-Environment="SMARTBOTIC_CRM_SERVER_STATIC_PATH=/data/smartbotic-crm/webui/dist"
+# Environment from config file
+EnvironmentFile=%h/.config/smartbotic-crm/webserver.env
 
 [Install]
 WantedBy=default.target

+ 1 - 0
webserver/CMakeLists.txt

@@ -30,6 +30,7 @@ add_library(smartbotic_webserver STATIC
     src/page_service.cpp
     src/permissions.cpp
     src/authorization_service.cpp
+    src/tool_service.cpp
 )
 
 target_include_directories(smartbotic_webserver

+ 7 - 0
webserver/include/smartbotic/webserver/http_server.hpp

@@ -28,6 +28,7 @@
 #include "smartbotic/webserver/workspace_service.hpp"
 #include "smartbotic/webserver/ws_handler.hpp"
 #include "smartbotic/webserver/llm_client.hpp"
+#include "smartbotic/webserver/tool_service.hpp"
 
 namespace smartbotic::webserver {
 
@@ -324,6 +325,11 @@ private:
     void HandleLlmGetWorkspaceKey(const httplib::Request& req, httplib::Response& res);
     void HandleLlmDeleteWorkspaceKey(const httplib::Request& req, httplib::Response& res);
 
+    // LLM Tool endpoints
+    void HandleLlmGetTools(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmExecuteTool(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmSubmitToolResults(const httplib::Request& req, httplib::Response& res);
+
     // Authentication middleware helper
     [[nodiscard]] auto AuthenticateRequest(const httplib::Request& req) -> std::optional<AuthUser>;
 
@@ -355,6 +361,7 @@ private:
     std::unique_ptr<AuthorizationService> authorizationService_;
     std::unique_ptr<WsHandler> wsHandler_;
     std::unique_ptr<LlmClient> llmClient_;
+    std::unique_ptr<ToolService> toolService_;
 
     std::thread httpThread_;
 

+ 4 - 0
webserver/include/smartbotic/webserver/llm_client.hpp

@@ -72,6 +72,10 @@ public:
     [[nodiscard]] auto SubmitToolResults(const ::smartbotic::llm::SubmitToolResultsRequest& request)
         -> std::pair<grpc::Status, ::smartbotic::llm::ChatResponse>;
 
+    /// Submit tool results with streaming response
+    [[nodiscard]] auto SubmitToolResultsStream(const ::smartbotic::llm::SubmitToolResultsRequest& request,
+                                               StreamChunkCallback callback) -> grpc::Status;
+
     // =========================================================================
     // Session Service Operations
     // =========================================================================

+ 252 - 0
webserver/include/smartbotic/webserver/tool_service.hpp

@@ -0,0 +1,252 @@
+#pragma once
+
+#include <functional>
+#include <memory>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "smartbotic/webserver/auth_service.hpp"
+#include "smartbotic/webserver/authorization_service.hpp"
+
+namespace smartbotic::webserver {
+
+// Forward declarations
+class WorkspaceService;
+class ViewService;
+class PageService;
+class DocumentService;
+class CollectionService;
+
+/// Permission requirements for a tool
+struct ToolPermissions {
+    /// User needs at least one of these permissions
+    std::vector<std::string> any_of;
+    /// User needs all of these permissions
+    std::vector<std::string> all_of;
+    /// Tool requires workspace context (workspace_id must be provided)
+    bool requires_workspace_context = false;
+};
+
+/// Definition of a CRM tool that can be used by the LLM
+struct CrmToolDefinition {
+    std::string name;
+    std::string description;
+    std::string input_schema;  // JSON Schema string
+    std::string category;      // "navigation", "workspace", "view", "page", "document"
+    ToolPermissions permissions;
+};
+
+/// Result of tool execution
+struct ToolExecutionResult {
+    bool success = false;
+    std::string content;       // Result content or error message
+    bool is_error = false;
+
+    /// Optional navigation data for client-side navigation tools
+    struct NavigationData {
+        std::string type;      // "page", "view", "document_create", "document_edit", "dashboard", "settings"
+        std::string slug;      // For page navigation
+        std::string view_id;   // For view/document navigation
+        std::string document_id; // For document edit
+        nlohmann::json filter; // Optional filter for views
+    };
+    std::optional<NavigationData> navigation;
+};
+
+/// Context provided to tool executors
+struct ToolExecutionContext {
+    std::string user_id;
+    std::string workspace_id;
+    std::string session_id;
+    AuthUser user;
+};
+
+/// Function type for tool executors
+using CrmToolExecutor = std::function<ToolExecutionResult(
+    const nlohmann::json& arguments,
+    const ToolExecutionContext& context)>;
+
+/// Service for managing CRM tools available to the LLM
+class ToolService {
+public:
+    /// Create a tool service with access to other services
+    ToolService(AuthorizationService& authorization_service,
+                WorkspaceService& workspace_service,
+                ViewService& view_service,
+                PageService& page_service,
+                DocumentService& document_service,
+                CollectionService& collection_service);
+    ~ToolService();
+
+    // Disable copy
+    ToolService(const ToolService&) = delete;
+    auto operator=(const ToolService&) -> ToolService& = delete;
+
+    // Disable move (reference members)
+    ToolService(ToolService&&) = delete;
+    auto operator=(ToolService&&) -> ToolService& = delete;
+
+    /// Initialize the tool service (register built-in tools)
+    void Initialize();
+
+    // =========================================================================
+    // Tool Registration
+    // =========================================================================
+
+    /// Register a tool definition with its executor
+    void RegisterTool(const CrmToolDefinition& definition, CrmToolExecutor executor);
+
+    /// Register a tool definition only (for navigation tools executed client-side)
+    void RegisterTool(const CrmToolDefinition& definition);
+
+    // =========================================================================
+    // Tool Access
+    // =========================================================================
+
+    /// Get all tools filtered by user permissions
+    /// @param user The authenticated user
+    /// @param workspace_id Current workspace context (optional)
+    /// @return List of tool definitions the user can access
+    [[nodiscard]] auto GetAvailableTools(const AuthUser& user,
+                                          const std::string& workspace_id = "")
+        -> std::vector<CrmToolDefinition>;
+
+    /// Get a specific tool definition by name
+    /// @param name Tool name
+    /// @return Tool definition if found
+    [[nodiscard]] auto GetTool(const std::string& name)
+        -> std::optional<CrmToolDefinition>;
+
+    /// Check if user can use a specific tool
+    /// @param user The authenticated user
+    /// @param tool_name Tool name
+    /// @param workspace_id Workspace context (optional)
+    /// @return True if user has permission to use the tool
+    [[nodiscard]] auto CanUseTool(const AuthUser& user,
+                                   const std::string& tool_name,
+                                   const std::string& workspace_id = "") -> bool;
+
+    // =========================================================================
+    // Tool Execution
+    // =========================================================================
+
+    /// Execute a tool with permission re-validation
+    /// @param tool_name Name of the tool to execute
+    /// @param arguments JSON arguments for the tool
+    /// @param context Execution context (user, workspace, session)
+    /// @return Execution result
+    [[nodiscard]] auto ExecuteTool(const std::string& tool_name,
+                                    const nlohmann::json& arguments,
+                                    const ToolExecutionContext& context)
+        -> ToolExecutionResult;
+
+    /// Check if a tool is a navigation tool (executed client-side)
+    /// @param tool_name Tool name
+    /// @return True if the tool handles navigation
+    [[nodiscard]] auto IsNavigationTool(const std::string& tool_name) -> bool;
+
+    // =========================================================================
+    // Tool Definitions for LLM
+    // =========================================================================
+
+    /// Convert tool definitions to JSON format for LLM API
+    /// @param tools List of tool definitions
+    /// @return JSON array of tool definitions
+    [[nodiscard]] static auto ToLlmToolsJson(const std::vector<CrmToolDefinition>& tools)
+        -> nlohmann::json;
+
+private:
+    /// Register all built-in navigation tools
+    void RegisterNavigationTools();
+
+    /// Register all built-in workspace tools
+    void RegisterWorkspaceTools();
+
+    /// Register all built-in view tools
+    void RegisterViewTools();
+
+    /// Register all built-in page tools
+    void RegisterPageTools();
+
+    /// Register all built-in document tools
+    void RegisterDocumentTools();
+
+    /// Check if user has required permissions for a tool
+    [[nodiscard]] auto HasToolPermissions(const AuthUser& user,
+                                           const CrmToolDefinition& tool,
+                                           const std::string& workspace_id) -> bool;
+
+    // Navigation tool handlers (return navigation data for client)
+    [[nodiscard]] auto HandleNavigateToPage(const nlohmann::json& args,
+                                             const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleNavigateToView(const nlohmann::json& args,
+                                             const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleNavigateToDocument(const nlohmann::json& args,
+                                                 const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleNavigateToDashboard(const nlohmann::json& args,
+                                                  const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleNavigateToWorkspaceSettings(const nlohmann::json& args,
+                                                          const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleNavigateToUserManagement(const nlohmann::json& args,
+                                                       const ToolExecutionContext& ctx) -> ToolExecutionResult;
+
+    // Workspace tool handlers
+    [[nodiscard]] auto HandleCreateWorkspace(const nlohmann::json& args,
+                                              const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleListWorkspaces(const nlohmann::json& args,
+                                             const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleGetWorkspaceSettings(const nlohmann::json& args,
+                                                   const ToolExecutionContext& ctx) -> ToolExecutionResult;
+
+    // View tool handlers
+    [[nodiscard]] auto HandleCreateView(const nlohmann::json& args,
+                                         const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleUpdateView(const nlohmann::json& args,
+                                         const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleListViews(const nlohmann::json& args,
+                                        const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleGetView(const nlohmann::json& args,
+                                      const ToolExecutionContext& ctx) -> ToolExecutionResult;
+
+    // Page tool handlers
+    [[nodiscard]] auto HandleCreatePage(const nlohmann::json& args,
+                                         const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleUpdatePage(const nlohmann::json& args,
+                                         const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleListPages(const nlohmann::json& args,
+                                        const ToolExecutionContext& ctx) -> ToolExecutionResult;
+
+    // Document tool handlers
+    [[nodiscard]] auto HandleCreateDocument(const nlohmann::json& args,
+                                             const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleUpdateDocument(const nlohmann::json& args,
+                                             const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleGetDocument(const nlohmann::json& args,
+                                          const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleListDocuments(const nlohmann::json& args,
+                                            const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleDeleteDocument(const nlohmann::json& args,
+                                             const ToolExecutionContext& ctx) -> ToolExecutionResult;
+
+    AuthorizationService& authorization_service_;
+    WorkspaceService& workspace_service_;
+    ViewService& view_service_;
+    PageService& page_service_;
+    DocumentService& document_service_;
+    CollectionService& collection_service_;
+
+    /// Registered tools: name -> definition
+    std::unordered_map<std::string, CrmToolDefinition> tools_;
+
+    /// Tool executors: name -> executor function
+    std::unordered_map<std::string, CrmToolExecutor> executors_;
+
+    /// Navigation tools (executed client-side, but validated server-side)
+    std::vector<std::string> navigation_tools_;
+};
+
+}  // namespace smartbotic::webserver

+ 383 - 0
webserver/src/http_server.cpp

@@ -570,6 +570,17 @@ auto HttpServer::InitializeServices() -> bool {
         spdlog::warn("LLM service not available at {} - LLM features will be disabled", config_.llm_address);
     }
 
+    // Initialize ToolService for LLM tool calling
+    toolService_ = std::make_unique<ToolService>(
+        *authorizationService_,
+        *workspaceService_,
+        *viewService_,
+        *pageService_,
+        *documentService_,
+        *collectionService_);
+    toolService_->Initialize();
+    spdlog::info("ToolService initialized successfully");
+
     // Note: WebSocket message handler is set up in Start() after wsServer_ is created
 
     return true;
@@ -5689,6 +5700,22 @@ void HttpServer::SetupLlmRoutes() {
             HandleLlmDeleteWorkspaceKey(req, res);
         });
 
+    // Tool calling routes
+    httpServer_->Get("/api/llm/tools",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmGetTools(req, res);
+        });
+
+    httpServer_->Post(R"(/api/llm/sessions/([^/]+)/tools/execute)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmExecuteTool(req, res);
+        });
+
+    httpServer_->Post(R"(/api/llm/sessions/([^/]+)/tools/results)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmSubmitToolResults(req, res);
+        });
+
     spdlog::info("LLM routes configured");
 }
 
@@ -6161,6 +6188,24 @@ void HttpServer::HandleLlmStreamMessage(const httplib::Request& req, httplib::Re
         auto* stream_content_part = request.add_content();
         stream_content_part->set_text(body["content"].get<std::string>());
 
+        // Get workspace settings to find provider_id
+        if (!session.workspace_id().empty() && workspaceService_) {
+            auto ws_result = workspaceService_->GetWorkspace(session.workspace_id(), false);
+            if (ws_result.success && ws_result.workspace) {
+                try {
+                    if (ws_result.workspace->settings.contains("llm_provider_id")) {
+                        std::string provider_id = ws_result.workspace->settings["llm_provider_id"].get<std::string>();
+                        if (!provider_id.empty()) {
+                            request.set_provider_id(provider_id);
+                            spdlog::debug("Using provider_id '{}' from workspace settings", provider_id);
+                        }
+                    }
+                } catch (...) {
+                    // Ignore JSON errors
+                }
+            }
+        }
+
         if (body.contains("page_context") && body["page_context"].is_object()) {
             auto* ctx = request.mutable_page_context();
             const auto& pc = body["page_context"];
@@ -6205,6 +6250,9 @@ void HttpServer::HandleLlmStreamMessage(const httplib::Request& req, httplib::Re
             });
 
         if (!stream_status.ok()) {
+            spdlog::error("LLM ChatStream failed: code={}, message={}",
+                static_cast<int>(stream_status.error_code()),
+                stream_status.error_message());
             res.status = 500;
             nlohmann::json error = {{"error", stream_status.error_message()}};
             res.set_content(error.dump(), "application/json");
@@ -7136,4 +7184,339 @@ void HttpServer::HandleLlmDeleteWorkspaceKey(const httplib::Request& req, httpli
     }
 }
 
+// ============================================================================
+// LLM Tool Endpoints
+// ============================================================================
+
+void HttpServer::HandleLlmGetTools(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!toolService_) {
+        res.status = 503;
+        res.set_content(R"({"error":"Tool service not available"})", "application/json");
+        return;
+    }
+
+    // Get workspace_id from query parameter
+    std::string workspace_id;
+    if (req.has_param("workspace_id")) {
+        workspace_id = req.get_param_value("workspace_id");
+    }
+
+    try {
+        auto tools = toolService_->GetAvailableTools(*auth_user, workspace_id);
+
+        nlohmann::json response;
+        response["tools"] = nlohmann::json::array();
+
+        for (const auto& tool : tools) {
+            nlohmann::json tool_json;
+            tool_json["name"] = tool.name;
+            tool_json["description"] = tool.description;
+            tool_json["category"] = tool.category;
+
+            // Parse input_schema string to JSON
+            try {
+                tool_json["input_schema"] = nlohmann::json::parse(tool.input_schema);
+            } catch (...) {
+                tool_json["input_schema"] = nlohmann::json::object();
+            }
+
+            // Include permission info (for debugging)
+            tool_json["permissions"] = {
+                {"any_of", tool.permissions.any_of},
+                {"all_of", tool.permissions.all_of},
+                {"requires_workspace_context", tool.permissions.requires_workspace_context}
+            };
+
+            response["tools"].push_back(tool_json);
+        }
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("GetTools error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmExecuteTool(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!toolService_) {
+        res.status = 503;
+        res.set_content(R"({"error":"Tool service not available"})", "application/json");
+        return;
+    }
+
+    std::string session_id = req.matches[1].str();
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const nlohmann::json::parse_error&) {
+        res.status = 400;
+        res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
+        return;
+    }
+
+    // Validate required fields
+    if (!body.contains("tool_name") || !body["tool_name"].is_string()) {
+        res.status = 400;
+        res.set_content(R"({"error":"tool_name is required"})", "application/json");
+        return;
+    }
+    if (!body.contains("arguments") || !body["arguments"].is_object()) {
+        res.status = 400;
+        res.set_content(R"({"error":"arguments is required"})", "application/json");
+        return;
+    }
+
+    std::string tool_name = body["tool_name"].get<std::string>();
+    nlohmann::json arguments = body["arguments"];
+
+    // Get workspace_id from body or context
+    std::string workspace_id;
+    if (body.contains("workspace_id") && body["workspace_id"].is_string()) {
+        workspace_id = body["workspace_id"].get<std::string>();
+    }
+
+    try {
+        // Build execution context
+        ToolExecutionContext ctx;
+        ctx.user_id = auth_user->user_id;
+        ctx.workspace_id = workspace_id;
+        ctx.session_id = session_id;
+        ctx.user = *auth_user;
+
+        // Check if tool is a navigation tool (handled client-side)
+        if (toolService_->IsNavigationTool(tool_name)) {
+            // For navigation tools, we still validate on server but return navigation data
+            // The actual navigation happens client-side
+            auto tool = toolService_->GetTool(tool_name);
+            if (!tool) {
+                res.status = 404;
+                res.set_content(R"({"error":"Tool not found"})", "application/json");
+                return;
+            }
+
+            if (!toolService_->CanUseTool(*auth_user, tool_name, workspace_id)) {
+                res.status = 403;
+                nlohmann::json error = {{"error", "Permission denied for tool: " + tool_name}};
+                res.set_content(error.dump(), "application/json");
+                return;
+            }
+
+            // Build navigation response
+            nlohmann::json response;
+            response["success"] = true;
+            response["is_navigation"] = true;
+            response["tool_name"] = tool_name;
+            response["arguments"] = arguments;
+
+            // Determine navigation target based on tool and arguments
+            nlohmann::json navigation;
+            if (tool_name == "navigate_to_dashboard") {
+                navigation["type"] = "dashboard";
+            } else if (tool_name == "navigate_to_page") {
+                navigation["type"] = "page";
+                if (arguments.contains("page_slug")) {
+                    navigation["slug"] = arguments["page_slug"];
+                }
+                if (arguments.contains("page_id")) {
+                    navigation["page_id"] = arguments["page_id"];
+                }
+            } else if (tool_name == "navigate_to_view") {
+                navigation["type"] = "view";
+                if (arguments.contains("view_id")) {
+                    navigation["view_id"] = arguments["view_id"];
+                }
+                if (arguments.contains("collection_name")) {
+                    navigation["collection_name"] = arguments["collection_name"];
+                }
+                if (arguments.contains("filter")) {
+                    navigation["filter"] = arguments["filter"];
+                }
+            } else if (tool_name == "navigate_to_document") {
+                std::string action = arguments.value("action", "create");
+                navigation["type"] = action == "create" ? "document_create" : "document_edit";
+                navigation["view_id"] = arguments.value("view_id", "");
+                if (arguments.contains("document_id")) {
+                    navigation["document_id"] = arguments["document_id"];
+                }
+            } else if (tool_name == "navigate_to_workspace_settings") {
+                navigation["type"] = "settings";
+                navigation["slug"] = "workspace";
+            } else if (tool_name == "navigate_to_user_management") {
+                navigation["type"] = "settings";
+                navigation["slug"] = "users";
+            }
+
+            response["navigation"] = navigation;
+            response["content"] = "Navigation to " + tool_name;
+
+            res.set_content(response.dump(), "application/json");
+            return;
+        }
+
+        // Execute non-navigation tool
+        auto result = toolService_->ExecuteTool(tool_name, arguments, ctx);
+
+        nlohmann::json response;
+        response["success"] = result.success;
+        response["content"] = result.content;
+        response["is_error"] = result.is_error;
+
+        if (result.navigation) {
+            response["navigation"] = {
+                {"type", result.navigation->type},
+                {"slug", result.navigation->slug},
+                {"view_id", result.navigation->view_id},
+                {"document_id", result.navigation->document_id},
+                {"filter", result.navigation->filter}
+            };
+        }
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("ExecuteTool error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmSubmitToolResults(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string session_id = req.matches[1].str();
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const nlohmann::json::parse_error&) {
+        res.status = 400;
+        res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
+        return;
+    }
+
+    if (!body.contains("tool_results") || !body["tool_results"].is_array()) {
+        res.status = 400;
+        res.set_content(R"({"error":"tool_results array is required"})", "application/json");
+        return;
+    }
+
+    try {
+        // Verify session ownership
+        ::smartbotic::llm::GetSessionRequest get_request;
+        get_request.set_session_id(session_id);
+        get_request.set_user_id(auth_user->user_id);
+        get_request.set_include_messages(false);
+
+        auto [get_status, session] = llmClient_->GetSession(get_request);
+        if (!get_status.ok()) {
+            res.status = 404;
+            res.set_content(R"({"error":"Session not found"})", "application/json");
+            return;
+        }
+
+        // Build SubmitToolResults request
+        ::smartbotic::llm::SubmitToolResultsRequest tool_request;
+        tool_request.set_session_id(session_id);
+        tool_request.set_user_id(auth_user->user_id);
+
+        for (const auto& result : body["tool_results"]) {
+            auto* tool_result = tool_request.add_tool_results();
+            tool_result->set_tool_call_id(result.value("tool_call_id", ""));
+            tool_result->set_content(result.value("content", ""));
+            tool_result->set_is_error(result.value("is_error", false));
+        }
+
+        // Setup SSE response for streaming
+        res.set_header("Content-Type", "text/event-stream");
+        res.set_header("Cache-Control", "no-cache");
+        res.set_header("Connection", "keep-alive");
+
+        std::string sse_content;
+
+        auto stream_status = llmClient_->SubmitToolResultsStream(tool_request,
+            [&sse_content](const ::smartbotic::llm::ChatStreamChunk& chunk) -> bool {
+                nlohmann::json event;
+                event["session_id"] = chunk.session_id();
+
+                if (chunk.has_content_delta()) {
+                    event["delta"] = chunk.content_delta();
+                }
+                if (chunk.has_tool_call()) {
+                    event["tool_call"] = {
+                        {"id", chunk.tool_call().id()},
+                        {"name", chunk.tool_call().name()},
+                        {"arguments", chunk.tool_call().arguments()}
+                    };
+                }
+                if (chunk.has_metadata()) {
+                    const auto& meta = chunk.metadata();
+                    event["finish_reason"] = static_cast<int>(meta.finish_reason());
+                    event["message_id"] = meta.assistant_message().id();
+                }
+
+                sse_content += "data: " + event.dump() + "\n\n";
+                return true;
+            });
+
+        if (!stream_status.ok()) {
+            spdlog::error("SubmitToolResults stream error: {}", stream_status.error_message());
+            sse_content += "data: {\"error\":\"Stream error\"}\n\n";
+        }
+
+        sse_content += "data: [DONE]\n\n";
+        res.set_content(sse_content, "text/event-stream");
+
+    } catch (const std::exception& e) {
+        spdlog::error("SubmitToolResults error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
 }  // namespace smartbotic::webserver

+ 24 - 3
webserver/src/llm_client.cpp

@@ -12,9 +12,12 @@ LlmClient::~LlmClient() {
 
 void LlmClient::CreateChannel() {
     grpc::ChannelArguments args;
-    args.SetInt(GRPC_ARG_KEEPALIVE_TIME_MS, 10000);
-    args.SetInt(GRPC_ARG_KEEPALIVE_TIMEOUT_MS, 5000);
-    args.SetInt(GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS, 1);
+
+    // Set keepalive parameters for long-lived connections
+    args.SetInt(GRPC_ARG_KEEPALIVE_TIME_MS, 30000);           // 30 seconds
+    args.SetInt(GRPC_ARG_KEEPALIVE_TIMEOUT_MS, 10000);        // 10 seconds
+    args.SetInt(GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS, 1);  // Allow keepalive without active calls
+    args.SetInt(GRPC_ARG_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS, 5000);  // 5 seconds
 
     channel_ = grpc::CreateCustomChannel(
         config_.address, grpc::InsecureChannelCredentials(), args);
@@ -111,6 +114,24 @@ auto LlmClient::SubmitToolResults(const ::smartbotic::llm::SubmitToolResultsRequ
     return {status, response};
 }
 
+auto LlmClient::SubmitToolResultsStream(const ::smartbotic::llm::SubmitToolResultsRequest& request,
+                                        StreamChunkCallback callback) -> grpc::Status {
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.stream_timeout);
+
+    auto reader = llm_stub_->SubmitToolResultsStream(&context, request);
+
+    ::smartbotic::llm::ChatStreamChunk chunk;
+    while (reader->Read(&chunk)) {
+        if (!callback(chunk)) {
+            context.TryCancel();
+            break;
+        }
+    }
+
+    return reader->Finish();
+}
+
 // =========================================================================
 // Session Service Operations
 // =========================================================================

+ 1594 - 0
webserver/src/tool_service.cpp

@@ -0,0 +1,1594 @@
+#include "smartbotic/webserver/tool_service.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include "smartbotic/webserver/collection_service.hpp"
+#include "smartbotic/webserver/document_service.hpp"
+#include "smartbotic/webserver/page_service.hpp"
+#include "smartbotic/webserver/permissions.hpp"
+#include "smartbotic/webserver/view_service.hpp"
+#include "smartbotic/webserver/workspace_service.hpp"
+
+namespace smartbotic::webserver {
+
+// Helper to build JSON schema strings
+namespace {
+
+std::string MakeObjectSchema(const nlohmann::json& properties,
+                              const std::vector<std::string>& required = {}) {
+    nlohmann::json schema;
+    schema["type"] = "object";
+    schema["properties"] = properties;
+    if (!required.empty()) {
+        schema["required"] = required;
+    }
+    return schema.dump();
+}
+
+}  // namespace
+
+ToolService::ToolService(AuthorizationService& authorization_service,
+                         WorkspaceService& workspace_service,
+                         ViewService& view_service,
+                         PageService& page_service,
+                         DocumentService& document_service,
+                         CollectionService& collection_service)
+    : authorization_service_(authorization_service),
+      workspace_service_(workspace_service),
+      view_service_(view_service),
+      page_service_(page_service),
+      document_service_(document_service),
+      collection_service_(collection_service) {}
+
+ToolService::~ToolService() = default;
+
+void ToolService::Initialize() {
+    spdlog::info("Initializing Tool Service...");
+
+    RegisterNavigationTools();
+    RegisterWorkspaceTools();
+    RegisterViewTools();
+    RegisterPageTools();
+    RegisterDocumentTools();
+
+    spdlog::info("Tool Service initialized with {} tools", tools_.size());
+}
+
+void ToolService::RegisterTool(const CrmToolDefinition& definition, CrmToolExecutor executor) {
+    tools_[definition.name] = definition;
+    executors_[definition.name] = std::move(executor);
+}
+
+void ToolService::RegisterTool(const CrmToolDefinition& definition) {
+    tools_[definition.name] = definition;
+    // No executor - navigation tools are handled client-side
+    navigation_tools_.push_back(definition.name);
+}
+
+auto ToolService::GetAvailableTools(const AuthUser& user, const std::string& workspace_id)
+    -> std::vector<CrmToolDefinition> {
+    std::vector<CrmToolDefinition> available;
+
+    for (const auto& [name, tool] : tools_) {
+        if (HasToolPermissions(user, tool, workspace_id)) {
+            available.push_back(tool);
+        }
+    }
+
+    return available;
+}
+
+auto ToolService::GetTool(const std::string& name) -> std::optional<CrmToolDefinition> {
+    auto it = tools_.find(name);
+    if (it != tools_.end()) {
+        return it->second;
+    }
+    return std::nullopt;
+}
+
+auto ToolService::CanUseTool(const AuthUser& user, const std::string& tool_name,
+                              const std::string& workspace_id) -> bool {
+    auto tool = GetTool(tool_name);
+    if (!tool) {
+        return false;
+    }
+    return HasToolPermissions(user, *tool, workspace_id);
+}
+
+auto ToolService::ExecuteTool(const std::string& tool_name,
+                               const nlohmann::json& arguments,
+                               const ToolExecutionContext& context) -> ToolExecutionResult {
+    // Find the tool
+    auto tool = GetTool(tool_name);
+    if (!tool) {
+        return {false, "Tool not found: " + tool_name, true, std::nullopt};
+    }
+
+    // Re-validate permissions at execution time
+    if (!HasToolPermissions(context.user, *tool, context.workspace_id)) {
+        return {false, "Permission denied for tool: " + tool_name, true, std::nullopt};
+    }
+
+    // Check workspace context requirement
+    if (tool->permissions.requires_workspace_context && context.workspace_id.empty()) {
+        return {false, "Tool requires workspace context: " + tool_name, true, std::nullopt};
+    }
+
+    // Find and execute the executor
+    auto executor_it = executors_.find(tool_name);
+    if (executor_it == executors_.end()) {
+        // Navigation tools don't have executors
+        if (IsNavigationTool(tool_name)) {
+            return {false, "Navigation tools must be executed client-side", true, std::nullopt};
+        }
+        return {false, "No executor registered for tool: " + tool_name, true, std::nullopt};
+    }
+
+    try {
+        return executor_it->second(arguments, context);
+    } catch (const std::exception& e) {
+        spdlog::error("Tool execution failed for {}: {}", tool_name, e.what());
+        return {false, std::string("Tool execution error: ") + e.what(), true, std::nullopt};
+    }
+}
+
+auto ToolService::IsNavigationTool(const std::string& tool_name) -> bool {
+    return std::find(navigation_tools_.begin(), navigation_tools_.end(), tool_name) !=
+           navigation_tools_.end();
+}
+
+auto ToolService::ToLlmToolsJson(const std::vector<CrmToolDefinition>& tools) -> nlohmann::json {
+    nlohmann::json result = nlohmann::json::array();
+
+    for (const auto& tool : tools) {
+        nlohmann::json tool_json;
+        tool_json["name"] = tool.name;
+        tool_json["description"] = tool.description;
+
+        // Parse input_schema string to JSON
+        try {
+            tool_json["input_schema"] = nlohmann::json::parse(tool.input_schema);
+        } catch (...) {
+            tool_json["input_schema"] = nlohmann::json::object();
+        }
+
+        result.push_back(tool_json);
+    }
+
+    return result;
+}
+
+auto ToolService::HasToolPermissions(const AuthUser& user,
+                                      const CrmToolDefinition& tool,
+                                      const std::string& workspace_id) -> bool {
+    // Check if user is superadmin (can use any tool)
+    if (authorization_service_.IsSuperadmin(user)) {
+        return true;
+    }
+
+    // Check workspace context requirement
+    if (tool.permissions.requires_workspace_context && workspace_id.empty()) {
+        return false;
+    }
+
+    // Check any_of permissions (need at least one)
+    if (!tool.permissions.any_of.empty()) {
+        bool has_any = false;
+        for (const auto& perm : tool.permissions.any_of) {
+            // Replace * with workspace_id if present
+            std::string resolved_perm = perm;
+            if (!workspace_id.empty()) {
+                size_t pos = resolved_perm.find(":*:");
+                while (pos != std::string::npos) {
+                    resolved_perm.replace(pos, 3, ":" + workspace_id + ":");
+                    pos = resolved_perm.find(":*:", pos + workspace_id.length() + 2);
+                }
+            }
+
+            if (authorization_service_.HasPermission(user, resolved_perm)) {
+                has_any = true;
+                break;
+            }
+        }
+        if (!has_any) {
+            return false;
+        }
+    }
+
+    // Check all_of permissions (need all)
+    if (!tool.permissions.all_of.empty()) {
+        for (const auto& perm : tool.permissions.all_of) {
+            std::string resolved_perm = perm;
+            if (!workspace_id.empty()) {
+                size_t pos = resolved_perm.find(":*:");
+                while (pos != std::string::npos) {
+                    resolved_perm.replace(pos, 3, ":" + workspace_id + ":");
+                    pos = resolved_perm.find(":*:", pos + workspace_id.length() + 2);
+                }
+            }
+
+            if (!authorization_service_.HasPermission(user, resolved_perm)) {
+                return false;
+            }
+        }
+    }
+
+    return true;
+}
+
+// =============================================================================
+// Navigation Tools Registration
+// =============================================================================
+
+void ToolService::RegisterNavigationTools() {
+    // navigate_to_page
+    {
+        nlohmann::json props;
+        props["page_slug"] = {{"type", "string"}, {"description", "Page slug (URL-friendly name, e.g., 'dashboard', 'reports')"}};
+        props["page_id"] = {{"type", "string"}, {"description", "Alternative: Page ID if slug is unknown"}};
+
+        RegisterTool({
+            .name = "navigate_to_page",
+            .description = "Navigate the user to a custom page in the current workspace. Use this when the user wants to see a specific dashboard or page.",
+            .input_schema = MakeObjectSchema(props),
+            .category = "navigation",
+            .permissions = {
+                .any_of = {std::string(permissions::kPageReadAll), std::string(permissions::kPageReadOwn)},
+                .all_of = {},
+                .requires_workspace_context = true
+            }
+        });
+    }
+
+    // navigate_to_view
+    {
+        nlohmann::json props;
+        props["view_id"] = {{"type", "string"}, {"description", "View ID to navigate to"}};
+        props["collection_name"] = {{"type", "string"}, {"description", "Alternative: Collection name to find the default view"}};
+        props["filter"] = {{"type", "object"}, {"description", "Optional filter to apply to the view"}};
+
+        RegisterTool({
+            .name = "navigate_to_view",
+            .description = "Navigate the user to a view to see collection data. Use this when the user wants to browse, search, or filter records.",
+            .input_schema = MakeObjectSchema(props),
+            .category = "navigation",
+            .permissions = {
+                .any_of = {std::string(permissions::kCollectionReadAll), std::string(permissions::kCollectionReadOwn)},
+                .all_of = {},
+                .requires_workspace_context = true
+            }
+        });
+    }
+
+    // navigate_to_document
+    {
+        nlohmann::json props;
+        props["view_id"] = {{"type", "string"}, {"description", "View ID for the document"}};
+        props["document_id"] = {{"type", "string"}, {"description", "Document ID to edit (omit for create)"}};
+        props["action"] = {{"type", "string"}, {"enum", nlohmann::json::array({"create", "edit"})}, {"description", "Create new or edit existing document"}};
+
+        RegisterTool({
+            .name = "navigate_to_document",
+            .description = "Navigate to create a new document or edit an existing one. Use this when the user wants to add or modify a record.",
+            .input_schema = MakeObjectSchema(props, {"view_id", "action"}),
+            .category = "navigation",
+            .permissions = {
+                .any_of = {
+                    std::string(permissions::kCollectionCreate),
+                    std::string(permissions::kCollectionWriteAll),
+                    std::string(permissions::kCollectionWriteOwn)
+                },
+                .all_of = {},
+                .requires_workspace_context = true
+            }
+        });
+    }
+
+    // navigate_to_dashboard
+    {
+        RegisterTool({
+            .name = "navigate_to_dashboard",
+            .description = "Navigate the user to the main dashboard. Use this when the user wants to go to the home page or main view.",
+            .input_schema = MakeObjectSchema(nlohmann::json::object()),
+            .category = "navigation",
+            .permissions = {
+                .any_of = {},
+                .all_of = {},
+                .requires_workspace_context = false
+            }
+        });
+    }
+
+    // navigate_to_workspace_settings
+    {
+        RegisterTool({
+            .name = "navigate_to_workspace_settings",
+            .description = "Navigate to workspace settings. Use this when the user wants to configure workspace options.",
+            .input_schema = MakeObjectSchema(nlohmann::json::object()),
+            .category = "navigation",
+            .permissions = {
+                .any_of = {std::string(permissions::kWorkspaceAdmin)},
+                .all_of = {},
+                .requires_workspace_context = true
+            }
+        });
+    }
+
+    // navigate_to_user_management
+    {
+        RegisterTool({
+            .name = "navigate_to_user_management",
+            .description = "Navigate to user management. Use this when the user wants to manage users and groups.",
+            .input_schema = MakeObjectSchema(nlohmann::json::object()),
+            .category = "navigation",
+            .permissions = {
+                .any_of = {std::string(permissions::kUsersRead)},
+                .all_of = {},
+                .requires_workspace_context = false
+            }
+        });
+    }
+
+    spdlog::debug("Registered {} navigation tools", 6);
+}
+
+// =============================================================================
+// Workspace Tools Registration
+// =============================================================================
+
+void ToolService::RegisterWorkspaceTools() {
+    // create_workspace
+    {
+        nlohmann::json props;
+        props["name"] = {{"type", "string"}, {"description", "Name for the new workspace"}};
+        props["settings"] = {{"type", "object"}, {"description", "Optional workspace settings"}};
+
+        RegisterTool(
+            {
+                .name = "create_workspace",
+                .description = "Create a new workspace. Use this when the user wants to set up a new project or organization space.",
+                .input_schema = MakeObjectSchema(props, {"name"}),
+                .category = "workspace",
+                .permissions = {
+                    .any_of = {std::string(permissions::kWorkspacesCreate)},
+                    .all_of = {},
+                    .requires_workspace_context = false
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleCreateWorkspace(args, ctx);
+            });
+    }
+
+    // list_workspaces
+    {
+        RegisterTool(
+            {
+                .name = "list_workspaces",
+                .description = "List all workspaces the user has access to. Use this to help the user find or switch workspaces.",
+                .input_schema = MakeObjectSchema(nlohmann::json::object()),
+                .category = "workspace",
+                .permissions = {
+                    .any_of = {std::string(permissions::kWorkspacesRead)},
+                    .all_of = {},
+                    .requires_workspace_context = false
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleListWorkspaces(args, ctx);
+            });
+    }
+
+    // get_workspace_settings
+    {
+        nlohmann::json props;
+        props["workspace_id"] = {{"type", "string"}, {"description", "Workspace ID to get settings from"}};
+
+        RegisterTool(
+            {
+                .name = "get_workspace_settings",
+                .description = "Get settings from a workspace. Use this to retrieve configuration that can be copied to a new workspace.",
+                .input_schema = MakeObjectSchema(props, {"workspace_id"}),
+                .category = "workspace",
+                .permissions = {
+                    .any_of = {std::string(permissions::kWorkspacesRead)},
+                    .all_of = {},
+                    .requires_workspace_context = false
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleGetWorkspaceSettings(args, ctx);
+            });
+    }
+
+    spdlog::debug("Registered {} workspace tools", 3);
+}
+
+// =============================================================================
+// View Tools Registration
+// =============================================================================
+
+void ToolService::RegisterViewTools() {
+    // create_view
+    {
+        nlohmann::json props;
+        props["name"] = {{"type", "string"}, {"description", "Name for the view"}};
+        props["collection_name"] = {{"type", "string"}, {"description", "Collection name this view displays"}};
+
+        nlohmann::json field_props;
+        field_props["name"] = {{"type", "string"}};
+        field_props["type"] = {{"type", "string"}, {"enum", nlohmann::json::array({"text", "number", "date", "datetime", "boolean", "select", "reference", "email", "url", "color"})}};
+        field_props["label"] = {{"type", "string"}};
+        field_props["required"] = {{"type", "boolean"}};
+        field_props["options"] = {{"type", "array"}};
+
+        nlohmann::json schema_props;
+        schema_props["title"] = {{"type", "string"}};
+        schema_props["description"] = {{"type", "string"}};
+        schema_props["fields"] = {{"type", "array"}, {"items", {{"type", "object"}, {"properties", field_props}}}};
+
+        props["schema"] = {{"type", "object"}, {"description", "View schema with fields, title, description"}, {"properties", schema_props}};
+        props["settings"] = {{"type", "object"}, {"description", "View settings (is_default, show_in_sidebar, icon, etc.)"}};
+
+        RegisterTool(
+            {
+                .name = "create_view",
+                .description = "Create a new view with a schema for a collection. Use this to define how data should be displayed and edited.",
+                .input_schema = MakeObjectSchema(props, {"name", "collection_name"}),
+                .category = "view",
+                .permissions = {
+                    .any_of = {std::string(permissions::kWorkspaceManageViews)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleCreateView(args, ctx);
+            });
+    }
+
+    // update_view
+    {
+        nlohmann::json props;
+        props["view_id"] = {{"type", "string"}, {"description", "View ID to update"}};
+        props["name"] = {{"type", "string"}, {"description", "New name for the view"}};
+        props["schema"] = {{"type", "object"}, {"description", "Updated view schema"}};
+        props["settings"] = {{"type", "object"}, {"description", "Updated view settings"}};
+
+        RegisterTool(
+            {
+                .name = "update_view",
+                .description = "Update an existing view's schema or settings.",
+                .input_schema = MakeObjectSchema(props, {"view_id"}),
+                .category = "view",
+                .permissions = {
+                    .any_of = {std::string(permissions::kWorkspaceManageViews)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleUpdateView(args, ctx);
+            });
+    }
+
+    // list_views
+    {
+        nlohmann::json props;
+        props["collection_name"] = {{"type", "string"}, {"description", "Optional: filter to views for a specific collection"}};
+
+        RegisterTool(
+            {
+                .name = "list_views",
+                .description = "List all views in the current workspace. Use this to see available data views.",
+                .input_schema = MakeObjectSchema(props),
+                .category = "view",
+                .permissions = {
+                    .any_of = {std::string(permissions::kViewsRead)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleListViews(args, ctx);
+            });
+    }
+
+    // get_view
+    {
+        nlohmann::json props;
+        props["view_id"] = {{"type", "string"}, {"description", "View ID to retrieve"}};
+
+        RegisterTool(
+            {
+                .name = "get_view",
+                .description = "Get details of a specific view including its schema.",
+                .input_schema = MakeObjectSchema(props, {"view_id"}),
+                .category = "view",
+                .permissions = {
+                    .any_of = {std::string(permissions::kViewsRead)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleGetView(args, ctx);
+            });
+    }
+
+    spdlog::debug("Registered {} view tools", 4);
+}
+
+// =============================================================================
+// Page Tools Registration
+// =============================================================================
+
+void ToolService::RegisterPageTools() {
+    // create_page
+    {
+        nlohmann::json props;
+        props["name"] = {{"type", "string"}, {"description", "Name for the page"}};
+        props["slug"] = {{"type", "string"}, {"description", "URL-friendly slug (auto-generated if not provided)"}};
+        props["layout"] = {{"type", "object"}, {"description", "Page layout with components"}, {"properties", {{"components", {{"type", "array"}, {"description", "Layout components"}}}}}};
+        props["settings"] = {{"type", "object"}, {"description", "Page settings (show_in_sidebar, icon, menu_order)"}};
+
+        RegisterTool(
+            {
+                .name = "create_page",
+                .description = "Create a new dashboard page with a layout. Use this to build custom dashboards.",
+                .input_schema = MakeObjectSchema(props, {"name"}),
+                .category = "page",
+                .permissions = {
+                    .any_of = {std::string(permissions::kPageCreate)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleCreatePage(args, ctx);
+            });
+    }
+
+    // update_page
+    {
+        nlohmann::json props;
+        props["page_id"] = {{"type", "string"}, {"description", "Page ID to update"}};
+        props["name"] = {{"type", "string"}, {"description", "New name for the page"}};
+        props["layout"] = {{"type", "object"}, {"description", "Updated page layout"}};
+        props["settings"] = {{"type", "object"}, {"description", "Updated page settings"}};
+
+        RegisterTool(
+            {
+                .name = "update_page",
+                .description = "Update an existing page's layout or settings.",
+                .input_schema = MakeObjectSchema(props, {"page_id"}),
+                .category = "page",
+                .permissions = {
+                    .any_of = {std::string(permissions::kPageWriteAll), std::string(permissions::kPageWriteOwn)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleUpdatePage(args, ctx);
+            });
+    }
+
+    // list_pages
+    {
+        RegisterTool(
+            {
+                .name = "list_pages",
+                .description = "List all pages in the current workspace.",
+                .input_schema = MakeObjectSchema(nlohmann::json::object()),
+                .category = "page",
+                .permissions = {
+                    .any_of = {std::string(permissions::kPageReadAll), std::string(permissions::kPageReadOwn)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleListPages(args, ctx);
+            });
+    }
+
+    spdlog::debug("Registered {} page tools", 3);
+}
+
+// =============================================================================
+// Document Tools Registration
+// =============================================================================
+
+void ToolService::RegisterDocumentTools() {
+    // create_document
+    {
+        nlohmann::json props;
+        props["collection"] = {{"type", "string"}, {"description", "Collection name to create document in"}};
+        props["data"] = {{"type", "object"}, {"description", "Document data fields"}};
+
+        RegisterTool(
+            {
+                .name = "create_document",
+                .description = "Create a new document/record in a collection. Use this to add data to the system.",
+                .input_schema = MakeObjectSchema(props, {"collection", "data"}),
+                .category = "document",
+                .permissions = {
+                    .any_of = {std::string(permissions::kCollectionCreate)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleCreateDocument(args, ctx);
+            });
+    }
+
+    // update_document
+    {
+        nlohmann::json props;
+        props["collection"] = {{"type", "string"}, {"description", "Collection name"}};
+        props["document_id"] = {{"type", "string"}, {"description", "Document ID to update"}};
+        props["data"] = {{"type", "object"}, {"description", "Updated data fields (merged with existing)"}};
+
+        RegisterTool(
+            {
+                .name = "update_document",
+                .description = "Update an existing document/record.",
+                .input_schema = MakeObjectSchema(props, {"collection", "document_id", "data"}),
+                .category = "document",
+                .permissions = {
+                    .any_of = {std::string(permissions::kCollectionWriteAll), std::string(permissions::kCollectionWriteOwn)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleUpdateDocument(args, ctx);
+            });
+    }
+
+    // get_document
+    {
+        nlohmann::json props;
+        props["collection"] = {{"type", "string"}, {"description", "Collection name"}};
+        props["document_id"] = {{"type", "string"}, {"description", "Document ID to retrieve"}};
+
+        RegisterTool(
+            {
+                .name = "get_document",
+                .description = "Get a specific document by ID.",
+                .input_schema = MakeObjectSchema(props, {"collection", "document_id"}),
+                .category = "document",
+                .permissions = {
+                    .any_of = {std::string(permissions::kCollectionReadAll), std::string(permissions::kCollectionReadOwn)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleGetDocument(args, ctx);
+            });
+    }
+
+    // list_documents
+    {
+        nlohmann::json props;
+        props["collection"] = {{"type", "string"}, {"description", "Collection name to query"}};
+        props["filter"] = {{"type", "object"}, {"description", "Filter conditions (field: value pairs)"}};
+        props["sort_field"] = {{"type", "string"}, {"description", "Field to sort by"}};
+        props["sort_ascending"] = {{"type", "boolean"}, {"description", "Sort ascending (default: true)"}};
+        props["limit"] = {{"type", "integer"}, {"description", "Maximum documents to return (default: 20)"}};
+
+        RegisterTool(
+            {
+                .name = "list_documents",
+                .description = "Query and list documents in a collection with optional filtering.",
+                .input_schema = MakeObjectSchema(props, {"collection"}),
+                .category = "document",
+                .permissions = {
+                    .any_of = {std::string(permissions::kCollectionReadAll), std::string(permissions::kCollectionReadOwn)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleListDocuments(args, ctx);
+            });
+    }
+
+    // delete_document
+    {
+        nlohmann::json props;
+        props["collection"] = {{"type", "string"}, {"description", "Collection name"}};
+        props["document_id"] = {{"type", "string"}, {"description", "Document ID to delete"}};
+
+        RegisterTool(
+            {
+                .name = "delete_document",
+                .description = "Delete a document from a collection.",
+                .input_schema = MakeObjectSchema(props, {"collection", "document_id"}),
+                .category = "document",
+                .permissions = {
+                    .any_of = {std::string(permissions::kCollectionDeleteAll), std::string(permissions::kCollectionDeleteOwn)},
+                    .all_of = {},
+                    .requires_workspace_context = true
+                }
+            },
+            [this](const nlohmann::json& args, const ToolExecutionContext& ctx) {
+                return HandleDeleteDocument(args, ctx);
+            });
+    }
+
+    spdlog::debug("Registered {} document tools", 5);
+}
+
+// =============================================================================
+// Navigation Tool Handlers
+// =============================================================================
+
+auto ToolService::HandleNavigateToPage(const nlohmann::json& args,
+                                        const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    std::string slug;
+    std::string page_id;
+
+    if (args.contains("page_slug") && args["page_slug"].is_string()) {
+        slug = args["page_slug"].get<std::string>();
+    }
+    if (args.contains("page_id") && args["page_id"].is_string()) {
+        page_id = args["page_id"].get<std::string>();
+    }
+
+    if (slug.empty() && page_id.empty()) {
+        return {false, "Either page_slug or page_id is required", true, std::nullopt};
+    }
+
+    // Verify page exists and user can access it
+    PageResult page_result;
+    if (!slug.empty()) {
+        page_result = page_service_.GetPageBySlug(ctx.workspace_id, slug);
+    } else {
+        page_result = page_service_.GetPage(ctx.workspace_id, page_id);
+    }
+
+    if (!page_result.success || !page_result.page) {
+        return {false, "Page not found", true, std::nullopt};
+    }
+
+    // Check permission
+    if (!authorization_service_.CanViewPage(ctx.user, ctx.workspace_id,
+                                            page_result.page->created_by,
+                                            page_result.page->shared_with_groups)) {
+        return {false, "Permission denied to view this page", true, std::nullopt};
+    }
+
+    ToolExecutionResult result;
+    result.success = true;
+    result.content = "Navigating to page: " + page_result.page->name;
+    result.navigation = ToolExecutionResult::NavigationData{
+        .type = "page",
+        .slug = page_result.page->slug,
+        .view_id = "",
+        .document_id = "",
+        .filter = {}
+    };
+
+    return result;
+}
+
+auto ToolService::HandleNavigateToView(const nlohmann::json& args,
+                                        const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    std::string view_id;
+    std::string collection_name;
+    nlohmann::json filter;
+
+    if (args.contains("view_id") && args["view_id"].is_string()) {
+        view_id = args["view_id"].get<std::string>();
+    }
+    if (args.contains("collection_name") && args["collection_name"].is_string()) {
+        collection_name = args["collection_name"].get<std::string>();
+    }
+    if (args.contains("filter") && args["filter"].is_object()) {
+        filter = args["filter"];
+    }
+
+    // Get view by ID or find by collection
+    ViewResult view_result;
+    if (!view_id.empty()) {
+        view_result = view_service_.GetView(ctx.workspace_id, view_id);
+    } else if (!collection_name.empty()) {
+        // Find default view for collection
+        auto views = view_service_.ListViewsForCollection(ctx.workspace_id, collection_name);
+        if (views.success && !views.views.empty()) {
+            // Find default or use first
+            for (const auto& v : views.views) {
+                if (v.settings.is_default) {
+                    view_result.success = true;
+                    view_result.view = v;
+                    break;
+                }
+            }
+            if (!view_result.view) {
+                view_result.success = true;
+                view_result.view = views.views[0];
+            }
+        }
+    }
+
+    if (!view_result.success || !view_result.view) {
+        return {false, "View not found", true, std::nullopt};
+    }
+
+    // Check permission for collection
+    if (!authorization_service_.CanReadCollection(ctx.user, ctx.workspace_id,
+                                                   view_result.view->collection_name)) {
+        return {false, "Permission denied to view this collection", true, std::nullopt};
+    }
+
+    ToolExecutionResult result;
+    result.success = true;
+    result.content = "Navigating to view: " + view_result.view->name;
+    result.navigation = ToolExecutionResult::NavigationData{
+        .type = "view",
+        .slug = "",
+        .view_id = view_result.view->id,
+        .document_id = "",
+        .filter = filter
+    };
+
+    return result;
+}
+
+auto ToolService::HandleNavigateToDocument(const nlohmann::json& args,
+                                            const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("view_id") || !args["view_id"].is_string()) {
+        return {false, "view_id is required", true, std::nullopt};
+    }
+    if (!args.contains("action") || !args["action"].is_string()) {
+        return {false, "action is required", true, std::nullopt};
+    }
+
+    std::string view_id = args["view_id"].get<std::string>();
+    std::string action = args["action"].get<std::string>();
+    std::string document_id;
+
+    if (args.contains("document_id") && args["document_id"].is_string()) {
+        document_id = args["document_id"].get<std::string>();
+    }
+
+    if (action != "create" && action != "edit") {
+        return {false, "action must be 'create' or 'edit'", true, std::nullopt};
+    }
+
+    if (action == "edit" && document_id.empty()) {
+        return {false, "document_id is required for edit action", true, std::nullopt};
+    }
+
+    // Verify view exists
+    auto view_result = view_service_.GetView(ctx.workspace_id, view_id);
+    if (!view_result.success || !view_result.view) {
+        return {false, "View not found", true, std::nullopt};
+    }
+
+    // Check permission
+    if (action == "create") {
+        if (!authorization_service_.CanCreateDocument(ctx.user, ctx.workspace_id,
+                                                       view_result.view->collection_name)) {
+            return {false, "Permission denied to create documents", true, std::nullopt};
+        }
+    } else {
+        // For edit, we need to check the document owner
+        auto doc_result = document_service_.GetDocument(ctx.workspace_id,
+                                                        view_result.view->collection_name,
+                                                        document_id);
+        if (!doc_result.success || !doc_result.document) {
+            return {false, "Document not found", true, std::nullopt};
+        }
+
+        std::string owner;
+        if (doc_result.document->data.contains("_created_by")) {
+            owner = doc_result.document->data["_created_by"].get<std::string>();
+        }
+
+        if (!authorization_service_.CanWriteDocument(ctx.user, ctx.workspace_id,
+                                                      view_result.view->collection_name, owner)) {
+            return {false, "Permission denied to edit this document", true, std::nullopt};
+        }
+    }
+
+    ToolExecutionResult result;
+    result.success = true;
+    result.content = action == "create"
+        ? "Navigating to create new document"
+        : "Navigating to edit document: " + document_id;
+    result.navigation = ToolExecutionResult::NavigationData{
+        .type = action == "create" ? "document_create" : "document_edit",
+        .slug = "",
+        .view_id = view_id,
+        .document_id = document_id,
+        .filter = {}
+    };
+
+    return result;
+}
+
+auto ToolService::HandleNavigateToDashboard(const nlohmann::json& /*args*/,
+                                             const ToolExecutionContext& /*ctx*/) -> ToolExecutionResult {
+    ToolExecutionResult result;
+    result.success = true;
+    result.content = "Navigating to dashboard";
+    result.navigation = ToolExecutionResult::NavigationData{
+        .type = "dashboard",
+        .slug = "",
+        .view_id = "",
+        .document_id = "",
+        .filter = {}
+    };
+    return result;
+}
+
+auto ToolService::HandleNavigateToWorkspaceSettings(const nlohmann::json& /*args*/,
+                                                     const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!authorization_service_.IsWorkspaceAdmin(ctx.user, ctx.workspace_id)) {
+        return {false, "Permission denied - workspace admin required", true, std::nullopt};
+    }
+
+    ToolExecutionResult result;
+    result.success = true;
+    result.content = "Navigating to workspace settings";
+    result.navigation = ToolExecutionResult::NavigationData{
+        .type = "settings",
+        .slug = "workspace",
+        .view_id = "",
+        .document_id = "",
+        .filter = {}
+    };
+    return result;
+}
+
+auto ToolService::HandleNavigateToUserManagement(const nlohmann::json& /*args*/,
+                                                  const ToolExecutionContext& /*ctx*/) -> ToolExecutionResult {
+    ToolExecutionResult result;
+    result.success = true;
+    result.content = "Navigating to user management";
+    result.navigation = ToolExecutionResult::NavigationData{
+        .type = "settings",
+        .slug = "users",
+        .view_id = "",
+        .document_id = "",
+        .filter = {}
+    };
+    return result;
+}
+
+// =============================================================================
+// Workspace Tool Handlers
+// =============================================================================
+
+auto ToolService::HandleCreateWorkspace(const nlohmann::json& args,
+                                         const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("name") || !args["name"].is_string()) {
+        return {false, "name is required", true, std::nullopt};
+    }
+
+    CreateWorkspaceRequest request;
+    request.name = args["name"].get<std::string>();
+    request.actor_id = ctx.user_id;
+
+    if (args.contains("settings") && args["settings"].is_object()) {
+        request.settings = args["settings"];
+    }
+
+    auto result = workspace_service_.CreateWorkspace(request);
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    nlohmann::json response;
+    response["id"] = result.workspace->id;
+    response["name"] = result.workspace->name;
+    response["message"] = "Workspace created successfully";
+
+    return {true, response.dump(), false, std::nullopt};
+}
+
+auto ToolService::HandleListWorkspaces(const nlohmann::json& /*args*/,
+                                        const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    // List workspaces the user has access to
+    auto result = workspace_service_.ListWorkspacesByIds(ctx.user.workspace_ids);
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    nlohmann::json response = nlohmann::json::array();
+    for (const auto& ws : result.workspaces) {
+        response.push_back({
+            {"id", ws.id},
+            {"name", ws.name}
+        });
+    }
+
+    return {true, response.dump(), false, std::nullopt};
+}
+
+auto ToolService::HandleGetWorkspaceSettings(const nlohmann::json& args,
+                                              const ToolExecutionContext& /*ctx*/) -> ToolExecutionResult {
+    if (!args.contains("workspace_id") || !args["workspace_id"].is_string()) {
+        return {false, "workspace_id is required", true, std::nullopt};
+    }
+
+    std::string workspace_id = args["workspace_id"].get<std::string>();
+    auto result = workspace_service_.GetWorkspace(workspace_id);
+
+    if (!result.success || !result.workspace) {
+        return {false, result.error.empty() ? "Workspace not found" : result.error, true, std::nullopt};
+    }
+
+    nlohmann::json response;
+    response["id"] = result.workspace->id;
+    response["name"] = result.workspace->name;
+    response["settings"] = result.workspace->settings;
+
+    return {true, response.dump(), false, std::nullopt};
+}
+
+// =============================================================================
+// View Tool Handlers
+// =============================================================================
+
+auto ToolService::HandleCreateView(const nlohmann::json& args,
+                                    const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("name") || !args["name"].is_string()) {
+        return {false, "name is required", true, std::nullopt};
+    }
+    if (!args.contains("collection_name") || !args["collection_name"].is_string()) {
+        return {false, "collection_name is required", true, std::nullopt};
+    }
+
+    CreateViewRequest request;
+    request.workspace_id = ctx.workspace_id;
+    request.name = args["name"].get<std::string>();
+    request.collection_name = args["collection_name"].get<std::string>();
+
+    // Parse schema if provided
+    if (args.contains("schema") && args["schema"].is_object()) {
+        const auto& schema = args["schema"];
+        if (schema.contains("title")) {
+            request.schema.title = schema["title"].get<std::string>();
+        }
+        if (schema.contains("description")) {
+            request.schema.description = schema["description"].get<std::string>();
+        }
+        if (schema.contains("fields") && schema["fields"].is_array()) {
+            for (const auto& field : schema["fields"]) {
+                SchemaField sf;
+                if (field.contains("name")) sf.name = field["name"].get<std::string>();
+                if (field.contains("label")) sf.label = field["label"].get<std::string>();
+                if (field.contains("type")) {
+                    sf.type = StringToFieldType(field["type"].get<std::string>());
+                }
+                if (field.contains("required")) sf.required = field["required"].get<bool>();
+                if (field.contains("options")) sf.options = field["options"];
+                request.schema.fields.push_back(sf);
+            }
+        }
+    }
+
+    // Parse settings if provided
+    if (args.contains("settings") && args["settings"].is_object()) {
+        const auto& settings = args["settings"];
+        if (settings.contains("is_default")) {
+            request.settings.is_default = settings["is_default"].get<bool>();
+        }
+        if (settings.contains("show_in_sidebar")) {
+            request.settings.show_in_sidebar = settings["show_in_sidebar"].get<bool>();
+        }
+        if (settings.contains("icon")) {
+            request.settings.icon = settings["icon"].get<std::string>();
+        }
+    }
+
+    auto result = view_service_.CreateView(request);
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    nlohmann::json response;
+    response["id"] = result.view->id;
+    response["name"] = result.view->name;
+    response["collection_name"] = result.view->collection_name;
+    response["message"] = "View created successfully";
+
+    return {true, response.dump(), false, std::nullopt};
+}
+
+auto ToolService::HandleUpdateView(const nlohmann::json& args,
+                                    const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("view_id") || !args["view_id"].is_string()) {
+        return {false, "view_id is required", true, std::nullopt};
+    }
+
+    UpdateViewRequest request;
+    request.workspace_id = ctx.workspace_id;
+    request.id = args["view_id"].get<std::string>();
+
+    if (args.contains("name") && args["name"].is_string()) {
+        request.name = args["name"].get<std::string>();
+    }
+
+    // Parse schema if provided
+    if (args.contains("schema") && args["schema"].is_object()) {
+        ViewSchema schema;
+        const auto& s = args["schema"];
+        if (s.contains("title")) schema.title = s["title"].get<std::string>();
+        if (s.contains("description")) schema.description = s["description"].get<std::string>();
+        if (s.contains("fields") && s["fields"].is_array()) {
+            for (const auto& field : s["fields"]) {
+                SchemaField sf;
+                if (field.contains("name")) sf.name = field["name"].get<std::string>();
+                if (field.contains("label")) sf.label = field["label"].get<std::string>();
+                if (field.contains("type")) {
+                    sf.type = StringToFieldType(field["type"].get<std::string>());
+                }
+                if (field.contains("required")) sf.required = field["required"].get<bool>();
+                if (field.contains("options")) sf.options = field["options"];
+                schema.fields.push_back(sf);
+            }
+        }
+        request.schema = schema;
+    }
+
+    // Parse settings if provided
+    if (args.contains("settings") && args["settings"].is_object()) {
+        ViewSettings settings;
+        const auto& s = args["settings"];
+        if (s.contains("is_default")) settings.is_default = s["is_default"].get<bool>();
+        if (s.contains("show_in_sidebar")) settings.show_in_sidebar = s["show_in_sidebar"].get<bool>();
+        if (s.contains("icon")) settings.icon = s["icon"].get<std::string>();
+        request.settings = settings;
+    }
+
+    auto result = view_service_.UpdateView(request);
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    return {true, "View updated successfully", false, std::nullopt};
+}
+
+auto ToolService::HandleListViews(const nlohmann::json& args,
+                                   const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    ViewListResult result;
+
+    if (args.contains("collection_name") && args["collection_name"].is_string()) {
+        result = view_service_.ListViewsForCollection(ctx.workspace_id,
+                                                      args["collection_name"].get<std::string>());
+    } else {
+        result = view_service_.ListViews(ctx.workspace_id);
+    }
+
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    nlohmann::json response = nlohmann::json::array();
+    for (const auto& view : result.views) {
+        response.push_back({
+            {"id", view.id},
+            {"name", view.name},
+            {"collection_name", view.collection_name},
+            {"is_default", view.settings.is_default}
+        });
+    }
+
+    return {true, response.dump(), false, std::nullopt};
+}
+
+auto ToolService::HandleGetView(const nlohmann::json& args,
+                                 const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("view_id") || !args["view_id"].is_string()) {
+        return {false, "view_id is required", true, std::nullopt};
+    }
+
+    auto result = view_service_.GetView(ctx.workspace_id, args["view_id"].get<std::string>());
+    if (!result.success || !result.view) {
+        return {false, result.error.empty() ? "View not found" : result.error, true, std::nullopt};
+    }
+
+    nlohmann::json response;
+    response["id"] = result.view->id;
+    response["name"] = result.view->name;
+    response["collection_name"] = result.view->collection_name;
+
+    // Include schema
+    nlohmann::json schema;
+    schema["title"] = result.view->schema.title;
+    schema["description"] = result.view->schema.description;
+
+    nlohmann::json fields = nlohmann::json::array();
+    for (const auto& field : result.view->schema.fields) {
+        fields.push_back({
+            {"name", field.name},
+            {"type", FieldTypeToString(field.type)},
+            {"label", field.label},
+            {"required", field.required}
+        });
+    }
+    schema["fields"] = fields;
+    response["schema"] = schema;
+
+    return {true, response.dump(), false, std::nullopt};
+}
+
+// =============================================================================
+// Page Tool Handlers
+// =============================================================================
+
+auto ToolService::HandleCreatePage(const nlohmann::json& args,
+                                    const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("name") || !args["name"].is_string()) {
+        return {false, "name is required", true, std::nullopt};
+    }
+
+    CreatePageRequest request;
+    request.workspace_id = ctx.workspace_id;
+    request.name = args["name"].get<std::string>();
+    request.created_by = ctx.user_id;
+
+    if (args.contains("slug") && args["slug"].is_string()) {
+        request.slug = args["slug"].get<std::string>();
+    }
+
+    if (args.contains("layout") && args["layout"].is_object()) {
+        const auto& layout = args["layout"];
+        if (layout.contains("components") && layout["components"].is_array()) {
+            for (const auto& comp : layout["components"]) {
+                LayoutComponent lc;
+                if (comp.contains("id")) lc.id = comp["id"].get<std::string>();
+                if (comp.contains("type")) lc.type = comp["type"].get<std::string>();
+                if (comp.contains("config")) lc.config = comp["config"];
+                if (comp.contains("position") && comp["position"].is_object()) {
+                    const auto& pos = comp["position"];
+                    if (pos.contains("x")) lc.position.x = pos["x"].get<int>();
+                    if (pos.contains("y")) lc.position.y = pos["y"].get<int>();
+                    if (pos.contains("width")) lc.position.width = pos["width"].get<int>();
+                    if (pos.contains("height")) lc.position.height = pos["height"].get<int>();
+                }
+                request.layout.components.push_back(lc);
+            }
+        }
+    }
+
+    if (args.contains("settings") && args["settings"].is_object()) {
+        const auto& settings = args["settings"];
+        if (settings.contains("show_in_sidebar")) {
+            request.settings.show_in_sidebar = settings["show_in_sidebar"].get<bool>();
+        }
+        if (settings.contains("icon")) {
+            request.settings.icon = settings["icon"].get<std::string>();
+        }
+        if (settings.contains("menu_order")) {
+            request.settings.menu_order = settings["menu_order"].get<int>();
+        }
+    }
+
+    auto result = page_service_.CreatePage(request);
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    nlohmann::json response;
+    response["id"] = result.page->id;
+    response["name"] = result.page->name;
+    response["slug"] = result.page->slug;
+    response["message"] = "Page created successfully";
+
+    return {true, response.dump(), false, std::nullopt};
+}
+
+auto ToolService::HandleUpdatePage(const nlohmann::json& args,
+                                    const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("page_id") || !args["page_id"].is_string()) {
+        return {false, "page_id is required", true, std::nullopt};
+    }
+
+    std::string page_id = args["page_id"].get<std::string>();
+
+    // Check ownership for write_own permission
+    auto existing = page_service_.GetPage(ctx.workspace_id, page_id);
+    if (!existing.success || !existing.page) {
+        return {false, "Page not found", true, std::nullopt};
+    }
+
+    if (!authorization_service_.CanEditPage(ctx.user, ctx.workspace_id, existing.page->created_by)) {
+        return {false, "Permission denied to edit this page", true, std::nullopt};
+    }
+
+    UpdatePageRequest request;
+    request.workspace_id = ctx.workspace_id;
+    request.id = page_id;
+
+    if (args.contains("name") && args["name"].is_string()) {
+        request.name = args["name"].get<std::string>();
+    }
+
+    if (args.contains("layout") && args["layout"].is_object()) {
+        PageLayout layout;
+        const auto& l = args["layout"];
+        if (l.contains("components") && l["components"].is_array()) {
+            for (const auto& comp : l["components"]) {
+                LayoutComponent lc;
+                if (comp.contains("id")) lc.id = comp["id"].get<std::string>();
+                if (comp.contains("type")) lc.type = comp["type"].get<std::string>();
+                if (comp.contains("config")) lc.config = comp["config"];
+                if (comp.contains("position") && comp["position"].is_object()) {
+                    const auto& pos = comp["position"];
+                    if (pos.contains("x")) lc.position.x = pos["x"].get<int>();
+                    if (pos.contains("y")) lc.position.y = pos["y"].get<int>();
+                    if (pos.contains("width")) lc.position.width = pos["width"].get<int>();
+                    if (pos.contains("height")) lc.position.height = pos["height"].get<int>();
+                }
+                layout.components.push_back(lc);
+            }
+        }
+        request.layout = layout;
+    }
+
+    if (args.contains("settings") && args["settings"].is_object()) {
+        PageSettings settings;
+        const auto& s = args["settings"];
+        if (s.contains("show_in_sidebar")) settings.show_in_sidebar = s["show_in_sidebar"].get<bool>();
+        if (s.contains("icon")) settings.icon = s["icon"].get<std::string>();
+        if (s.contains("menu_order")) settings.menu_order = s["menu_order"].get<int>();
+        request.settings = settings;
+    }
+
+    auto result = page_service_.UpdatePage(request);
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    return {true, "Page updated successfully", false, std::nullopt};
+}
+
+auto ToolService::HandleListPages(const nlohmann::json& /*args*/,
+                                   const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    auto result = page_service_.ListPages(ctx.workspace_id);
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    nlohmann::json response = nlohmann::json::array();
+    for (const auto& page : result.pages) {
+        // Filter by permission
+        if (authorization_service_.CanViewPage(ctx.user, ctx.workspace_id,
+                                                page.created_by, page.shared_with_groups)) {
+            response.push_back({
+                {"id", page.id},
+                {"name", page.name},
+                {"slug", page.slug},
+                {"show_in_sidebar", page.settings.show_in_sidebar}
+            });
+        }
+    }
+
+    return {true, response.dump(), false, std::nullopt};
+}
+
+// =============================================================================
+// Document Tool Handlers
+// =============================================================================
+
+auto ToolService::HandleCreateDocument(const nlohmann::json& args,
+                                        const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("collection") || !args["collection"].is_string()) {
+        return {false, "collection is required", true, std::nullopt};
+    }
+    if (!args.contains("data") || !args["data"].is_object()) {
+        return {false, "data is required", true, std::nullopt};
+    }
+
+    std::string collection = args["collection"].get<std::string>();
+
+    // Re-check permission for this specific collection
+    if (!authorization_service_.CanCreateDocument(ctx.user, ctx.workspace_id, collection)) {
+        return {false, "Permission denied to create documents in collection: " + collection, true, std::nullopt};
+    }
+
+    CreateDocumentRequest request;
+    request.workspace_id = ctx.workspace_id;
+    request.collection = collection;
+    request.data = args["data"];
+    request.user_id = ctx.user_id;
+
+    auto result = document_service_.CreateDocument(request);
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    nlohmann::json response;
+    response["id"] = result.document->id;
+    response["collection"] = result.document->collection;
+    response["message"] = "Document created successfully";
+
+    return {true, response.dump(), false, std::nullopt};
+}
+
+auto ToolService::HandleUpdateDocument(const nlohmann::json& args,
+                                        const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("collection") || !args["collection"].is_string()) {
+        return {false, "collection is required", true, std::nullopt};
+    }
+    if (!args.contains("document_id") || !args["document_id"].is_string()) {
+        return {false, "document_id is required", true, std::nullopt};
+    }
+    if (!args.contains("data") || !args["data"].is_object()) {
+        return {false, "data is required", true, std::nullopt};
+    }
+
+    std::string collection = args["collection"].get<std::string>();
+    std::string document_id = args["document_id"].get<std::string>();
+
+    // Get existing document to check ownership
+    auto existing = document_service_.GetDocument(ctx.workspace_id, collection, document_id);
+    if (!existing.success || !existing.document) {
+        return {false, "Document not found", true, std::nullopt};
+    }
+
+    std::string owner;
+    if (existing.document->data.contains("_created_by")) {
+        owner = existing.document->data["_created_by"].get<std::string>();
+    }
+
+    if (!authorization_service_.CanWriteDocument(ctx.user, ctx.workspace_id, collection, owner)) {
+        return {false, "Permission denied to update this document", true, std::nullopt};
+    }
+
+    UpdateDocumentRequest request;
+    request.workspace_id = ctx.workspace_id;
+    request.collection = collection;
+    request.id = document_id;
+    request.data = args["data"];
+    request.merge = true;
+    request.user_id = ctx.user_id;
+
+    auto result = document_service_.UpdateDocument(request);
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    return {true, "Document updated successfully", false, std::nullopt};
+}
+
+auto ToolService::HandleGetDocument(const nlohmann::json& args,
+                                     const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("collection") || !args["collection"].is_string()) {
+        return {false, "collection is required", true, std::nullopt};
+    }
+    if (!args.contains("document_id") || !args["document_id"].is_string()) {
+        return {false, "document_id is required", true, std::nullopt};
+    }
+
+    std::string collection = args["collection"].get<std::string>();
+    std::string document_id = args["document_id"].get<std::string>();
+
+    auto result = document_service_.GetDocument(ctx.workspace_id, collection, document_id);
+    if (!result.success || !result.document) {
+        return {false, result.error.empty() ? "Document not found" : result.error, true, std::nullopt};
+    }
+
+    std::string owner;
+    if (result.document->data.contains("_created_by")) {
+        owner = result.document->data["_created_by"].get<std::string>();
+    }
+
+    if (!authorization_service_.CanReadDocument(ctx.user, ctx.workspace_id, collection, owner)) {
+        return {false, "Permission denied to read this document", true, std::nullopt};
+    }
+
+    // Filter fields based on permissions
+    authorization_service_.FilterDocumentFields(ctx.user, ctx.workspace_id, collection,
+                                                 result.document->data);
+
+    nlohmann::json response;
+    response["id"] = result.document->id;
+    response["collection"] = result.document->collection;
+    response["data"] = result.document->data;
+    response["created_at"] = result.document->created_at;
+    response["updated_at"] = result.document->updated_at;
+
+    return {true, response.dump(), false, std::nullopt};
+}
+
+auto ToolService::HandleListDocuments(const nlohmann::json& args,
+                                       const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("collection") || !args["collection"].is_string()) {
+        return {false, "collection is required", true, std::nullopt};
+    }
+
+    std::string collection = args["collection"].get<std::string>();
+
+    // Check read permission and determine owner filter
+    DocumentQuery query;
+    query.workspace_id = ctx.workspace_id;
+    query.collection = collection;
+
+    // If user only has read_own, filter to their documents
+    if (!authorization_service_.CanReadAllDocuments(ctx.user, ctx.workspace_id, collection)) {
+        if (!authorization_service_.CanReadCollection(ctx.user, ctx.workspace_id, collection)) {
+            return {false, "Permission denied to read collection: " + collection, true, std::nullopt};
+        }
+        query.owner_filter = ctx.user_id;
+    }
+
+    if (args.contains("filter") && args["filter"].is_object()) {
+        query.filter = args["filter"];
+    }
+    if (args.contains("sort_field") && args["sort_field"].is_string()) {
+        query.sort_field = args["sort_field"].get<std::string>();
+    }
+    if (args.contains("sort_ascending") && args["sort_ascending"].is_boolean()) {
+        query.sort_ascending = args["sort_ascending"].get<bool>();
+    }
+    if (args.contains("limit") && args["limit"].is_number_integer()) {
+        query.limit = args["limit"].get<int32_t>();
+        if (query.limit > 100) query.limit = 100;  // Cap at 100
+    } else {
+        query.limit = 20;  // Default limit
+    }
+
+    auto result = document_service_.ListDocuments(query);
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    nlohmann::json response = nlohmann::json::array();
+    for (auto& doc : result.documents) {
+        // Filter fields for each document
+        authorization_service_.FilterDocumentFields(ctx.user, ctx.workspace_id, collection, doc.data);
+
+        response.push_back({
+            {"id", doc.id},
+            {"data", doc.data}
+        });
+    }
+
+    return {true, response.dump(), false, std::nullopt};
+}
+
+auto ToolService::HandleDeleteDocument(const nlohmann::json& args,
+                                        const ToolExecutionContext& ctx) -> ToolExecutionResult {
+    if (!args.contains("collection") || !args["collection"].is_string()) {
+        return {false, "collection is required", true, std::nullopt};
+    }
+    if (!args.contains("document_id") || !args["document_id"].is_string()) {
+        return {false, "document_id is required", true, std::nullopt};
+    }
+
+    std::string collection = args["collection"].get<std::string>();
+    std::string document_id = args["document_id"].get<std::string>();
+
+    // Get existing document to check ownership
+    auto existing = document_service_.GetDocument(ctx.workspace_id, collection, document_id);
+    if (!existing.success || !existing.document) {
+        return {false, "Document not found", true, std::nullopt};
+    }
+
+    std::string owner;
+    if (existing.document->data.contains("_created_by")) {
+        owner = existing.document->data["_created_by"].get<std::string>();
+    }
+
+    if (!authorization_service_.CanDeleteDocument(ctx.user, ctx.workspace_id, collection, owner)) {
+        return {false, "Permission denied to delete this document", true, std::nullopt};
+    }
+
+    auto result = document_service_.DeleteDocument(ctx.workspace_id, collection, document_id);
+    if (!result.success) {
+        return {false, result.error, true, std::nullopt};
+    }
+
+    return {true, "Document deleted successfully", false, std::nullopt};
+}
+
+}  // namespace smartbotic::webserver

+ 1 - 1
webui/src/api/chat.ts

@@ -56,7 +56,7 @@ export async function sendMessage(sessionId: string, request: SendMessageRequest
 export async function streamMessage(
   sessionId: string,
   request: SendMessageRequest,
-  onChunk: (chunk: { delta?: string; finish_reason?: string; message_id?: string; session_id: string }) => void,
+  onChunk: (chunk: { delta?: string; finish_reason?: string | number; message_id?: string; session_id: string; tool_call?: { id: string; name: string; arguments: string } }) => void,
   onError?: (error: Error) => void
 ): Promise<void> {
   const accessToken = apiClient.getAccessToken()

+ 133 - 0
webui/src/api/tools.ts

@@ -0,0 +1,133 @@
+// Tools API client for LLM tool calling
+
+import { apiClient } from './client'
+import type {
+  ToolDefinition,
+  ToolResult,
+  ExecuteToolResponse,
+  SubmitToolResultsRequest,
+} from '../types/tools'
+
+/**
+ * Get available tools filtered by user permissions
+ * @param workspaceId - Optional workspace ID for workspace-scoped tools
+ */
+export async function getAvailableTools(workspaceId?: string): Promise<{ tools: ToolDefinition[] }> {
+  const params = new URLSearchParams()
+  if (workspaceId) params.set('workspace_id', workspaceId)
+  const query = params.toString()
+  return apiClient.get<{ tools: ToolDefinition[] }>(`/llm/tools${query ? `?${query}` : ''}`)
+}
+
+/**
+ * Execute a tool call server-side
+ * @param sessionId - Chat session ID
+ * @param toolCallId - Tool call ID from LLM response
+ * @param toolName - Name of the tool to execute
+ * @param args - Tool arguments (parsed JSON)
+ */
+export async function executeTool(
+  sessionId: string,
+  toolCallId: string,
+  toolName: string,
+  args: Record<string, unknown>
+): Promise<ExecuteToolResponse> {
+  return apiClient.post<ExecuteToolResponse>(
+    `/llm/sessions/${sessionId}/tools/execute`,
+    {
+      tool_call_id: toolCallId,
+      tool_name: toolName,
+      arguments: args,
+    }
+  )
+}
+
+/**
+ * Submit tool results to continue the conversation
+ * @param sessionId - Chat session ID
+ * @param results - Array of tool results
+ */
+export async function submitToolResults(
+  sessionId: string,
+  results: ToolResult[]
+): Promise<void> {
+  const request: SubmitToolResultsRequest = { tool_results: results }
+  await apiClient.post(`/llm/sessions/${sessionId}/tools/results`, request)
+}
+
+/**
+ * Stream message with tool results to continue conversation
+ * @param sessionId - Chat session ID
+ * @param results - Array of tool results
+ * @param onChunk - Callback for each stream chunk
+ * @param onError - Error callback
+ */
+export async function streamWithToolResults(
+  sessionId: string,
+  results: ToolResult[],
+  onChunk: (chunk: {
+    delta?: string
+    finish_reason?: string
+    message_id?: string
+    session_id: string
+    tool_call?: { id: string; name: string; arguments: string }
+  }) => void,
+  onError?: (error: Error) => void
+): Promise<void> {
+  const accessToken = apiClient.getAccessToken()
+
+  const response = await fetch(`/api/llm/sessions/${sessionId}/tools/results`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
+    },
+    body: JSON.stringify({ tool_results: results }),
+  })
+
+  if (!response.ok) {
+    const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
+    throw new Error(errorData.error || `HTTP ${response.status}`)
+  }
+
+  const reader = response.body?.getReader()
+  if (!reader) {
+    throw new Error('No response body')
+  }
+
+  const decoder = new TextDecoder()
+  let buffer = ''
+
+  try {
+    while (true) {
+      const { done, value } = await reader.read()
+      if (done) break
+
+      buffer += decoder.decode(value, { stream: true })
+
+      // Process SSE events
+      const lines = buffer.split('\n')
+      buffer = lines.pop() || ''
+
+      for (const line of lines) {
+        if (line.startsWith('data: ')) {
+          const data = line.slice(6).trim()
+          if (data === '[DONE]') {
+            return
+          }
+          try {
+            const chunk = JSON.parse(data)
+            onChunk(chunk)
+          } catch {
+            // Ignore parse errors
+          }
+        }
+      }
+    }
+  } catch (error) {
+    if (onError && error instanceof Error) {
+      onError(error)
+    }
+    throw error
+  }
+}

+ 30 - 1
webui/src/components/Chat/ChatMessage.tsx

@@ -4,6 +4,8 @@ import { useMemo } from 'react'
 import ReactMarkdown from 'react-markdown'
 import remarkGfm from 'remark-gfm'
 import type { ChatMessage as ChatMessageType } from '@/types/chat'
+import type { ToolExecutionState } from '@/types/tools'
+import { ToolCallCard } from './ToolCallCard'
 
 function UserIcon({ className = 'h-4 w-4' }: { className?: string }) {
   return (
@@ -25,9 +27,17 @@ interface ChatMessageProps {
   message: ChatMessageType
   isStreaming?: boolean
   streamingContent?: string
+  toolStates?: ToolExecutionState[]
+  onExecuteTool?: (toolCallId: string) => void
 }
 
-export function ChatMessage({ message, isStreaming, streamingContent }: ChatMessageProps) {
+export function ChatMessage({
+  message,
+  isStreaming,
+  streamingContent,
+  toolStates = [],
+  onExecuteTool,
+}: ChatMessageProps) {
   const isUser = message.role === 'user'
 
   const content = useMemo(() => {
@@ -118,6 +128,25 @@ export function ChatMessage({ message, isStreaming, streamingContent }: ChatMess
           )}
         </div>
 
+        {/* Tool calls */}
+        {message.tool_calls && message.tool_calls.length > 0 && (
+          <div className="mt-2 w-full max-w-md">
+            {message.tool_calls.map((toolCall) => {
+              const toolState = toolStates.find((t) => t.tool_call.id === toolCall.id) || {
+                tool_call: toolCall,
+                status: 'completed' as const,
+              }
+              return (
+                <ToolCallCard
+                  key={toolCall.id}
+                  toolState={toolState}
+                  onExecute={onExecuteTool ? () => onExecuteTool(toolCall.id) : undefined}
+                />
+              )
+            })}
+          </div>
+        )}
+
         {/* Timestamp and context info */}
         <div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
           <span>{formattedTime}</span>

+ 192 - 0
webui/src/components/Chat/ToolCallCard.tsx

@@ -0,0 +1,192 @@
+// Tool call card component for displaying tool execution status
+
+import { useMemo } from 'react'
+import type { ToolExecutionState } from '@/types/tools'
+
+interface ToolCallCardProps {
+  toolState: ToolExecutionState
+  onExecute?: () => void
+}
+
+function ToolIcon({ className = 'h-4 w-4' }: { className?: string }) {
+  return (
+    <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        strokeWidth={2}
+        d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
+      />
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+    </svg>
+  )
+}
+
+function SpinnerIcon({ className = 'h-4 w-4' }: { className?: string }) {
+  return (
+    <svg className={`${className} animate-spin`} fill="none" viewBox="0 0 24 24">
+      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+      <path
+        className="opacity-75"
+        fill="currentColor"
+        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+      />
+    </svg>
+  )
+}
+
+function CheckIcon({ className = 'h-4 w-4' }: { className?: string }) {
+  return (
+    <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
+    </svg>
+  )
+}
+
+function ErrorIcon({ className = 'h-4 w-4' }: { className?: string }) {
+  return (
+    <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+    </svg>
+  )
+}
+
+function NavigationIcon({ className = 'h-4 w-4' }: { className?: string }) {
+  return (
+    <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        strokeWidth={2}
+        d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
+      />
+    </svg>
+  )
+}
+
+export function ToolCallCard({ toolState, onExecute }: ToolCallCardProps) {
+  const { tool_call, status, result } = toolState
+
+  const statusConfig = useMemo(() => {
+    switch (status) {
+      case 'pending':
+        return {
+          icon: <ToolIcon className="h-4 w-4 text-gray-500" />,
+          bgColor: 'bg-gray-50 dark:bg-gray-800',
+          borderColor: 'border-gray-200 dark:border-gray-700',
+          statusText: 'Pending',
+          statusColor: 'text-gray-500',
+        }
+      case 'executing':
+        return {
+          icon: <SpinnerIcon className="h-4 w-4 text-blue-500" />,
+          bgColor: 'bg-blue-50 dark:bg-blue-900/20',
+          borderColor: 'border-blue-200 dark:border-blue-800',
+          statusText: 'Executing...',
+          statusColor: 'text-blue-500',
+        }
+      case 'completed':
+        return {
+          icon: result?.navigation ? (
+            <NavigationIcon className="h-4 w-4 text-green-500" />
+          ) : (
+            <CheckIcon className="h-4 w-4 text-green-500" />
+          ),
+          bgColor: 'bg-green-50 dark:bg-green-900/20',
+          borderColor: 'border-green-200 dark:border-green-800',
+          statusText: result?.navigation ? 'Navigated' : 'Completed',
+          statusColor: 'text-green-500',
+        }
+      case 'error':
+        return {
+          icon: <ErrorIcon className="h-4 w-4 text-red-500" />,
+          bgColor: 'bg-red-50 dark:bg-red-900/20',
+          borderColor: 'border-red-200 dark:border-red-800',
+          statusText: 'Error',
+          statusColor: 'text-red-500',
+        }
+      default:
+        return {
+          icon: <ToolIcon className="h-4 w-4 text-gray-500" />,
+          bgColor: 'bg-gray-50 dark:bg-gray-800',
+          borderColor: 'border-gray-200 dark:border-gray-700',
+          statusText: 'Unknown',
+          statusColor: 'text-gray-500',
+        }
+    }
+  }, [status, result?.navigation])
+
+  const parsedArgs = useMemo(() => {
+    try {
+      return JSON.parse(tool_call.arguments)
+    } catch {
+      return null
+    }
+  }, [tool_call.arguments])
+
+  const toolDisplayName = useMemo(() => {
+    // Convert snake_case to Title Case
+    return tool_call.name
+      .split('_')
+      .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+      .join(' ')
+  }, [tool_call.name])
+
+  return (
+    <div className={`rounded-lg border ${statusConfig.borderColor} ${statusConfig.bgColor} p-3 my-2`}>
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <div className="flex items-center gap-2">
+          {statusConfig.icon}
+          <span className="font-medium text-sm text-gray-900 dark:text-gray-100">{toolDisplayName}</span>
+        </div>
+        <span className={`text-xs ${statusConfig.statusColor}`}>{statusConfig.statusText}</span>
+      </div>
+
+      {/* Arguments */}
+      {parsedArgs && Object.keys(parsedArgs).length > 0 && (
+        <div className="mt-2">
+          <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Arguments:</div>
+          <div className="bg-gray-100 dark:bg-gray-900 rounded p-2 text-xs font-mono overflow-x-auto">
+            {Object.entries(parsedArgs).map(([key, value]) => (
+              <div key={key} className="text-gray-700 dark:text-gray-300">
+                <span className="text-gray-500">{key}:</span>{' '}
+                <span>{typeof value === 'object' ? JSON.stringify(value) : String(value)}</span>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {/* Result */}
+      {result && (
+        <div className="mt-2">
+          <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
+            {result.is_error ? 'Error:' : 'Result:'}
+          </div>
+          <div
+            className={`text-xs p-2 rounded ${
+              result.is_error
+                ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
+                : 'bg-gray-100 dark:bg-gray-900 text-gray-700 dark:text-gray-300'
+            }`}
+          >
+            {result.content}
+          </div>
+        </div>
+      )}
+
+      {/* Execute button for pending tools (if manual mode) */}
+      {status === 'pending' && onExecute && (
+        <div className="mt-2">
+          <button
+            onClick={onExecute}
+            className="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
+          >
+            Execute
+          </button>
+        </div>
+      )}
+    </div>
+  )
+}

+ 7 - 3
webui/src/components/layout/DashboardLayout.tsx

@@ -1,7 +1,7 @@
 // Dashboard layout with sidebar and top bar
 
 import { useState } from 'react'
-import { Outlet } from 'react-router-dom'
+import { Outlet, useLocation } from 'react-router-dom'
 import Sidebar from './Sidebar'
 import TopBar from './TopBar'
 import { ChatWidget } from '@/components/Chat'
@@ -9,10 +9,14 @@ import { usePageContext } from '@/hooks/usePageContext'
 
 function DashboardLayout() {
   const [isSidebarOpen, setIsSidebarOpen] = useState(false)
+  const location = useLocation()
 
   // Track current page context for the LLM assistant
   usePageContext()
 
+  // Hide chat widget on the dedicated chat page
+  const isOnChatPage = location.pathname === '/chat'
+
   return (
     <div className="flex h-screen bg-gray-50">
       {/* Sidebar */}
@@ -29,8 +33,8 @@ function DashboardLayout() {
         </main>
       </div>
 
-      {/* Chat widget (fixed position) */}
-      <ChatWidget />
+      {/* Chat widget (fixed position) - hidden on dedicated chat page */}
+      {!isOnChatPage && <ChatWidget />}
     </div>
   )
 }

+ 213 - 2
webui/src/contexts/ChatContext.tsx

@@ -1,8 +1,12 @@
 // Chat context for LLM assistant state management
 
 import { createContext, useContext, useState, useCallback, useEffect, ReactNode, useRef } from 'react'
+import { useNavigate } from 'react-router-dom'
 import * as chatApi from '@/api/chat'
-import type { ChatSession, ChatMessage, PageContext, ContextInfo } from '@/types/chat'
+import * as toolsApi from '@/api/tools'
+import type { ChatSession, ChatMessage, PageContext, ContextInfo, ToolCall } from '@/types/chat'
+import type { ToolExecutionState, ToolResult, NavigationData } from '@/types/tools'
+// Note: isNavigationTool can be imported from '@/types/tools' when needed for client-side tool execution
 import { useAuth } from './AuthContext'
 import { useWorkspace } from './WorkspaceContext'
 
@@ -40,6 +44,11 @@ interface ChatContextValue {
   sendMessage: (content: string) => Promise<void>
   clearMessages: () => Promise<void>
 
+  // Tool Calling
+  pendingToolCalls: ToolExecutionState[]
+  toolExecutionMode: 'auto' | 'confirm'
+  setToolExecutionMode: (mode: 'auto' | 'confirm') => void
+
   // Context & Compression
   contextInfo: ContextInfo | null
   compactionThreshold: number
@@ -60,6 +69,7 @@ interface ChatProviderProps {
 export function ChatProvider({ children }: ChatProviderProps) {
   const { isAuthenticated, isLoading: isAuthLoading } = useAuth()
   const { currentWorkspace } = useWorkspace()
+  const navigate = useNavigate()
 
   // Get workspace LLM settings
   const workspaceSettings = currentWorkspace?.settings as WorkspaceSettings | undefined
@@ -80,6 +90,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
   const [isStreaming, setIsStreaming] = useState(false)
   const [streamingContent, setStreamingContent] = useState('')
 
+  // Tool Calling State
+  const [pendingToolCalls, setPendingToolCalls] = useState<ToolExecutionState[]>([])
+  const [toolExecutionMode, setToolExecutionMode] = useState<'auto' | 'confirm'>('auto')
+
   // Context & Compression State
   const [contextInfo, setContextInfo] = useState<ContextInfo | null>(null)
   const [isCompressing, setIsCompressing] = useState(false)
@@ -142,6 +156,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
     setSessions(prev => [session, ...prev])
     setCurrentSession(session)
     setMessages([])
+    setUserMessageCount(0) // Reset count for new session
     return session
   }, [workspaceModelId, currentWorkspace?.id])
 
@@ -150,6 +165,8 @@ export function ChatProvider({ children }: ChatProviderProps) {
       const session = await chatApi.getSession(sessionId)
       setCurrentSession(session)
       setMessages(session.messages || [])
+      // Reset user message count - will be used for periodic title regeneration
+      setUserMessageCount(0)
     } catch (error) {
       console.error('Failed to switch session:', error)
       throw error
@@ -204,6 +221,166 @@ export function ChatProvider({ children }: ChatProviderProps) {
     }
   }, [])
 
+  // Handle navigation from tool results
+  const handleNavigation = useCallback((nav: NavigationData) => {
+    switch (nav.type) {
+      case 'page':
+        navigate(`/p/${nav.slug}`)
+        break
+      case 'view':
+        if (nav.view_id) {
+          const params = new URLSearchParams()
+          if (nav.filter) {
+            params.set('filter', JSON.stringify(nav.filter))
+          }
+          const query = params.toString()
+          navigate(`/views/${nav.view_id}${query ? `?${query}` : ''}`)
+        }
+        break
+      case 'document_create':
+        if (nav.view_id) {
+          navigate(`/views/${nav.view_id}/create`)
+        }
+        break
+      case 'document_edit':
+        if (nav.view_id && nav.document_id) {
+          navigate(`/views/${nav.view_id}/edit/${nav.document_id}`)
+        }
+        break
+      case 'dashboard':
+        navigate('/dashboard')
+        break
+      case 'settings':
+        if (nav.slug === 'workspace') {
+          navigate('/settings/workspace')
+        } else if (nav.slug === 'users') {
+          navigate('/settings/users')
+        }
+        break
+    }
+  }, [navigate])
+
+  // Execute a tool call
+  const executeTool = useCallback(async (
+    sessionId: string,
+    toolCall: ToolCall,
+    _workspaceId: string  // Reserved for future permission context
+  ): Promise<ToolResult> => {
+    // Update tool state to executing
+    setPendingToolCalls(prev =>
+      prev.map(t =>
+        t.tool_call.id === toolCall.id
+          ? { ...t, status: 'executing' as const, started_at: Date.now() }
+          : t
+      )
+    )
+
+    try {
+      // Parse arguments
+      let args: Record<string, unknown> = {}
+      try {
+        args = JSON.parse(toolCall.arguments)
+      } catch {
+        // If parsing fails, use empty object
+      }
+
+      // Execute via API
+      const response = await toolsApi.executeTool(sessionId, toolCall.id, toolCall.name, args)
+
+      // Handle navigation if present
+      if (response.navigation) {
+        handleNavigation(response.navigation as NavigationData)
+      }
+
+      const result: ToolResult = {
+        tool_call_id: toolCall.id,
+        success: response.success,
+        content: response.content,
+        is_error: response.is_error,
+        navigation: response.navigation as NavigationData | undefined,
+      }
+
+      // Update tool state to completed
+      setPendingToolCalls(prev =>
+        prev.map(t =>
+          t.tool_call.id === toolCall.id
+            ? { ...t, status: 'completed' as const, result, completed_at: Date.now() }
+            : t
+        )
+      )
+
+      return result
+    } catch (error) {
+      const errorResult: ToolResult = {
+        tool_call_id: toolCall.id,
+        success: false,
+        content: error instanceof Error ? error.message : 'Tool execution failed',
+        is_error: true,
+      }
+
+      // Update tool state to error
+      setPendingToolCalls(prev =>
+        prev.map(t =>
+          t.tool_call.id === toolCall.id
+            ? { ...t, status: 'error' as const, result: errorResult, completed_at: Date.now() }
+            : t
+        )
+      )
+
+      return errorResult
+    }
+  }, [handleNavigation])
+
+  // Process tool calls from stream
+  const processToolCalls = useCallback(async (
+    sessionId: string,
+    toolCalls: ToolCall[],
+    workspaceId: string
+  ) => {
+    // Add pending tool calls
+    const newToolStates: ToolExecutionState[] = toolCalls.map(tc => ({
+      tool_call: tc,
+      status: 'pending' as const,
+    }))
+    setPendingToolCalls(newToolStates)
+
+    // Execute tools based on mode
+    if (toolExecutionMode === 'auto') {
+      // Execute all tools
+      const results: ToolResult[] = []
+      for (const tc of toolCalls) {
+        const result = await executeTool(sessionId, tc, workspaceId)
+        results.push(result)
+      }
+
+      // Submit results to continue conversation
+      if (results.length > 0) {
+        try {
+          await toolsApi.streamWithToolResults(
+            sessionId,
+            results,
+            (chunk) => {
+              if (chunk.delta) {
+                setStreamingContent(prev => prev + chunk.delta)
+              }
+              // Handle nested tool calls
+              if (chunk.tool_call) {
+                // Recursively process new tool calls
+                processToolCalls(sessionId, [chunk.tool_call as ToolCall], workspaceId)
+              }
+            },
+            (error) => {
+              console.error('Tool results stream error:', error)
+            }
+          )
+        } catch (error) {
+          console.error('Failed to submit tool results:', error)
+        }
+      }
+    }
+    // If mode is 'confirm', the UI will show pending tools and user can trigger execution
+  }, [toolExecutionMode, executeTool])
+
   // Message Actions
   const sendMessage = useCallback(async (content: string) => {
     let sessionId = currentSession?.id
@@ -228,6 +405,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
     setIsStreaming(true)
     setStreamingContent('')
 
+    // Collect tool calls during streaming
+    const collectedToolCalls: ToolCall[] = []
+    const workspaceId = currentWorkspace?.id || ''
+
     try {
       await chatApi.streamMessage(
         sessionId,
@@ -239,6 +420,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
           if (chunk.delta) {
             setStreamingContent(prev => prev + chunk.delta)
           }
+          // Collect tool calls
+          if (chunk.tool_call) {
+            collectedToolCalls.push(chunk.tool_call)
+          }
           if (chunk.finish_reason && chunk.message_id) {
             // Stream complete, add the full message
             const assistantMessage: ChatMessage = {
@@ -246,12 +431,22 @@ export function ChatProvider({ children }: ChatProviderProps) {
               role: 'assistant',
               content: '', // Will be set below
               created_at: Date.now() / 1000,
+              tool_calls: collectedToolCalls.length > 0 ? collectedToolCalls : undefined,
             }
             setMessages(prev => {
               // Get current streaming content
               const fullContent = prev.length > 0 ? streamingContent : ''
               return [...prev, { ...assistantMessage, content: fullContent }]
             })
+
+            // Process tool calls if finish_reason indicates tool use
+            // finish_reason 2 = FINISH_REASON_TOOL_USE in proto enum
+            const finishReason = typeof chunk.finish_reason === 'string'
+              ? parseInt(chunk.finish_reason, 10)
+              : chunk.finish_reason
+            if (finishReason === 2 && collectedToolCalls.length > 0) {
+              processToolCalls(sessionId!, collectedToolCalls, workspaceId)
+            }
           }
         },
         (error) => {
@@ -298,7 +493,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
       setIsStreaming(false)
       setStreamingContent('')
     }
-  }, [currentSession?.id, currentPageContext, createSession, streamingContent, userMessageCount, generateTitle, compactionThreshold, compressConversation])
+  }, [currentSession?.id, currentPageContext, currentWorkspace?.id, createSession, streamingContent, userMessageCount, generateTitle, compactionThreshold, compressConversation, processToolCalls])
 
   const clearMessages = useCallback(async () => {
     if (!currentSession) return
@@ -316,6 +511,17 @@ export function ChatProvider({ children }: ChatProviderProps) {
     }
   }, [isAuthenticated, isAuthLoading, refreshSessions])
 
+  // Auto-select the most recent session when sessions load and no session is selected
+  useEffect(() => {
+    if (!isLoadingSessions && sessions.length > 0 && !currentSession) {
+      // Sessions are sorted by updated_at desc, so first one is the most recent
+      const mostRecentSession = sessions[0]
+      switchSession(mostRecentSession.id).catch(error => {
+        console.error('Failed to auto-resume last session:', error)
+      })
+    }
+  }, [isLoadingSessions, sessions, currentSession, switchSession])
+
   // Reset current session when workspace changes
   useEffect(() => {
     // Clear current session when workspace changes, sessions will be refreshed
@@ -357,6 +563,11 @@ export function ChatProvider({ children }: ChatProviderProps) {
     sendMessage,
     clearMessages,
 
+    // Tool Calling
+    pendingToolCalls,
+    toolExecutionMode,
+    setToolExecutionMode,
+
     // Context & Compression
     contextInfo,
     compactionThreshold,

+ 53 - 8
webui/src/pages/Workspaces.tsx

@@ -1,6 +1,6 @@
 // Workspaces management page (US-029)
 
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect, useCallback, useRef } from 'react'
 import apiClient from '@/api/client'
 import { useWorkspace } from '@/contexts/WorkspaceContext'
 import Button from '@/components/Button'
@@ -64,6 +64,14 @@ function WorkspaceModal({
     (workspace?.settings as WorkspaceFormData['settings'])?.llm_context_compaction_threshold ?? 80
   )
 
+  // Track initial model values to prevent clearing on first load
+  const initialModelIdRef = useRef<string | null>(
+    (workspace?.settings as WorkspaceFormData['settings'])?.llm_model_id || null
+  )
+  const initialHelperModelIdRef = useRef<string | null>(
+    (workspace?.settings as WorkspaceFormData['settings'])?.llm_helper_model_id || null
+  )
+
   // Load providers on mount
   useEffect(() => {
     const loadProviders = async () => {
@@ -91,10 +99,30 @@ function WorkspaceModal({
       setIsLoadingModels(true)
       try {
         const response = await chatApi.listModels(selectedProviderId)
-        setModels(response.models || [])
-        // If currently selected model isn't in the new list, clear it
-        if (selectedModelId && !response.models?.some(m => m.id === selectedModelId)) {
-          setSelectedModelId('')
+        const modelList = response.models || []
+        setModels(modelList)
+
+        // Debug logging
+        console.log('Loaded models for provider:', selectedProviderId, modelList.map(m => m.id))
+        console.log('Current selectedModelId:', selectedModelId)
+        console.log('Initial modelId from settings:', initialModelIdRef.current)
+
+        // Only clear the model if:
+        // 1. There's a selected model AND
+        // 2. It's not in the new list AND
+        // 3. It's not the initial model from settings (preserve on first load)
+        if (selectedModelId && !modelList.some(m => m.id === selectedModelId)) {
+          // If this is the initial model from settings, keep it (might be a valid model not in list)
+          if (selectedModelId === initialModelIdRef.current) {
+            console.log('Keeping initial model from settings:', selectedModelId)
+          } else {
+            console.log('Clearing model not in list:', selectedModelId)
+            setSelectedModelId('')
+          }
+        }
+        // Clear the ref after first check so subsequent provider changes clear properly
+        if (initialModelIdRef.current) {
+          initialModelIdRef.current = null
         }
       } catch (err) {
         console.error('Failed to load models:', err)
@@ -118,9 +146,26 @@ function WorkspaceModal({
       setIsLoadingHelperModels(true)
       try {
         const response = await chatApi.listModels(helperProviderId)
-        setHelperModels(response.models || [])
-        if (helperModelId && !response.models?.some(m => m.id === helperModelId)) {
-          setHelperModelId('')
+        const modelList = response.models || []
+        setHelperModels(modelList)
+
+        // Debug logging
+        console.log('Loaded helper models for provider:', helperProviderId, modelList.map(m => m.id))
+        console.log('Current helperModelId:', helperModelId)
+        console.log('Initial helperModelId from settings:', initialHelperModelIdRef.current)
+
+        // Only clear if not the initial model from settings
+        if (helperModelId && !modelList.some(m => m.id === helperModelId)) {
+          if (helperModelId === initialHelperModelIdRef.current) {
+            console.log('Keeping initial helper model from settings:', helperModelId)
+          } else {
+            console.log('Clearing helper model not in list:', helperModelId)
+            setHelperModelId('')
+          }
+        }
+        // Clear the ref after first check
+        if (initialHelperModelIdRef.current) {
+          initialHelperModelIdRef.current = null
         }
       } catch (err) {
         console.error('Failed to load helper models:', err)

+ 2 - 1
webui/src/types/chat.ts

@@ -63,8 +63,9 @@ export interface ChatResponse {
 export interface ChatStreamChunk {
   session_id: string
   delta?: string
-  finish_reason?: string
+  finish_reason?: string | number
   message_id?: string
+  tool_call?: ToolCall
 }
 
 export interface LlmProvider {

+ 79 - 0
webui/src/types/tools.ts

@@ -0,0 +1,79 @@
+// Tool types for LLM tool calling
+
+export interface ToolPermissions {
+  any_of: string[]
+  all_of: string[]
+  requires_workspace_context: boolean
+}
+
+export interface ToolDefinition {
+  name: string
+  description: string
+  input_schema: Record<string, unknown>
+  category: 'navigation' | 'workspace' | 'view' | 'page' | 'document'
+  permissions: ToolPermissions
+}
+
+export interface ToolCall {
+  id: string
+  name: string
+  arguments: string  // JSON string
+}
+
+export interface NavigationData {
+  type: 'page' | 'view' | 'document_create' | 'document_edit' | 'dashboard' | 'settings'
+  slug?: string
+  view_id?: string
+  document_id?: string
+  filter?: Record<string, unknown>
+}
+
+export interface ToolResult {
+  tool_call_id: string
+  success: boolean
+  content: string
+  is_error: boolean
+  navigation?: NavigationData
+}
+
+export type ToolExecutionStatus = 'pending' | 'executing' | 'completed' | 'error'
+
+export interface ToolExecutionState {
+  tool_call: ToolCall
+  status: ToolExecutionStatus
+  result?: ToolResult
+  started_at?: number
+  completed_at?: number
+}
+
+export interface ExecuteToolRequest {
+  tool_name: string
+  arguments: Record<string, unknown>
+}
+
+export interface ExecuteToolResponse {
+  success: boolean
+  content: string
+  is_error: boolean
+  navigation?: NavigationData
+}
+
+export interface SubmitToolResultsRequest {
+  tool_results: ToolResult[]
+}
+
+// Navigation tool names that execute client-side
+export const NAVIGATION_TOOLS = [
+  'navigate_to_page',
+  'navigate_to_view',
+  'navigate_to_document',
+  'navigate_to_dashboard',
+  'navigate_to_workspace_settings',
+  'navigate_to_user_management',
+] as const
+
+export type NavigationToolName = typeof NAVIGATION_TOOLS[number]
+
+export function isNavigationTool(toolName: string): toolName is NavigationToolName {
+  return NAVIGATION_TOOLS.includes(toolName as NavigationToolName)
+}