diff --git a/cmd/gateway.go b/cmd/gateway.go
index 66ac2dc6..e18efc85 100644
--- a/cmd/gateway.go
+++ b/cmd/gateway.go
@@ -12,6 +12,7 @@ import (
"time"
"github.com/nextlevelbuilder/goclaw/internal/agent"
+ "github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
@@ -201,6 +202,7 @@ func runGateway() {
SystemConfigs: pgStores.SystemConfigs,
Registry: providerRegistry,
Extractor: kgExtractor,
+ AlertDeps: bgalert.AlertDeps{SystemConfigs: pgStores.SystemConfigs, MsgBus: msgBus},
AgentStore: pgStores.Agents,
})
defer cleanupConsolidation()
@@ -222,6 +224,7 @@ func runGateway() {
EventBus: domainBus,
MsgBus: msgBus,
TeamStore: pgStores.Teams,
+ AlertDeps: bgalert.AlertDeps{SystemConfigs: pgStores.SystemConfigs, MsgBus: msgBus},
})
enrichProgress = ep
enrichWorker = ew
diff --git a/internal/bgalert/report.go b/internal/bgalert/report.go
new file mode 100644
index 00000000..e32093dc
--- /dev/null
+++ b/internal/bgalert/report.go
@@ -0,0 +1,120 @@
+// Package bgalert reports non-retryable LLM errors from background workers
+// to the web UI via system_configs + WS events.
+package bgalert
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "regexp"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/providers"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/pkg/protocol"
+)
+
+// AlertKeyProviderError is the system_configs key for background provider errors.
+const AlertKeyProviderError = "alert.background.provider_error"
+
+// AlertDeps bundles dependencies for error reporting.
+type AlertDeps struct {
+ SystemConfigs store.SystemConfigStore
+ MsgBus bus.EventPublisher
+}
+
+// ProviderErrorPayload is stored in system_configs and sent via WS event.
+type ProviderErrorPayload struct {
+ Reason string `json:"reason"`
+ Worker string `json:"worker"`
+ Message string `json:"message"`
+ Timestamp string `json:"timestamp"`
+}
+
+// alertableReasons are non-retryable errors that should surface to the user.
+var alertableReasons = map[providers.FailoverReason]bool{
+ providers.FailoverAuth: true,
+ providers.FailoverAuthPermanent: true,
+ providers.FailoverBilling: true,
+ providers.FailoverModelNotFound: true,
+}
+
+// IsAlertableError returns true if the error is non-retryable and should
+// be surfaced to the user (auth, billing, model_not_found).
+func IsAlertableError(err error) (providers.FailoverReason, bool) {
+ if err == nil {
+ return "", false
+ }
+ cls := providers.ClassifyHTTPError(providers.NewDefaultClassifier(), err)
+ if cls.Kind != "reason" {
+ return "", false
+ }
+ return cls.Reason, alertableReasons[cls.Reason]
+}
+
+// ReportProviderError stores an alert in system_configs and broadcasts a
+// WS event when the error is non-retryable (auth/billing/model_not_found).
+// Safe to call with nil deps or for retryable errors — returns silently.
+func ReportProviderError(ctx context.Context, deps AlertDeps, workerName string, err error) {
+ if deps.SystemConfigs == nil || err == nil {
+ return
+ }
+ reason, alertable := IsAlertableError(err)
+ if !alertable {
+ return
+ }
+
+ payload := ProviderErrorPayload{
+ Reason: string(reason),
+ Worker: workerName,
+ Message: sanitizeErrorMessage(err.Error()),
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ }
+ data, jsonErr := json.Marshal(payload)
+ if jsonErr != nil {
+ slog.Warn("bgalert: marshal failed", "err", jsonErr)
+ return
+ }
+
+ if setErr := deps.SystemConfigs.Set(ctx, AlertKeyProviderError, string(data)); setErr != nil {
+ slog.Warn("bgalert: store alert failed", "err", setErr)
+ return
+ }
+
+ slog.Warn("bgalert: provider error stored",
+ "worker", workerName, "reason", reason, "err", err)
+
+ if deps.MsgBus != nil {
+ tenantID := store.TenantIDFromContext(ctx)
+ deps.MsgBus.Broadcast(bus.Event{
+ Name: protocol.EventBackgroundError,
+ TenantID: tenantID,
+ Payload: payload,
+ })
+ }
+}
+
+// ClearProviderError removes the alert key from system_configs.
+// Ignores "not found" errors. Safe to call when no alert exists.
+func ClearProviderError(ctx context.Context, configs store.SystemConfigStore) {
+ if configs == nil {
+ return
+ }
+ _ = configs.Delete(ctx, AlertKeyProviderError)
+}
+
+// apiKeyPattern matches common API key formats in error messages.
+var apiKeyPattern = regexp.MustCompile(`(?i)(sk-|Bearer\s+|api[_-]?key[=:]\s*)[a-zA-Z0-9_-]{4,}`)
+
+// sanitizeErrorMessage strips potentially sensitive content from error messages.
+// Keeps the message useful for diagnostics while removing API keys and long bodies.
+func sanitizeErrorMessage(msg string) string {
+ msg = apiKeyPattern.ReplaceAllString(msg, "${1}****")
+ // Rune-safe truncation to avoid corrupting multi-byte UTF-8 characters.
+ const maxLen = 200
+ if runes := []rune(msg); len(runes) > maxLen {
+ msg = string(runes[:maxLen]) + "..."
+ }
+ return msg
+}
diff --git a/internal/consolidation/dreaming_worker.go b/internal/consolidation/dreaming_worker.go
index 36b86bc7..4a97bf76 100644
--- a/internal/consolidation/dreaming_worker.go
+++ b/internal/consolidation/dreaming_worker.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/providerresolve"
@@ -29,6 +30,7 @@ type dreamingWorker struct {
memoryStore store.MemoryStore
systemConfigs store.SystemConfigStore // per-tenant provider config
registry *providers.Registry // provider resolution
+ alertDeps bgalert.AlertDeps
// threshold/debounce are the global defaults. Per-agent overrides come
// from resolveConfig which reads the agent's MemoryConfig.Dreaming JSONB.
@@ -160,6 +162,7 @@ func (w *dreamingWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
// Build LLM prompt and call provider.
synthesis, err := w.synthesize(ctx, provider, model, entries)
if err != nil {
+ bgalert.ReportProviderError(ctx, w.alertDeps, "dreaming", err)
slog.Warn("dreaming: LLM synthesis failed", "err", err, "agent", agentID)
return nil
}
diff --git a/internal/consolidation/episodic_worker.go b/internal/consolidation/episodic_worker.go
index dfa519f1..a2f5c184 100644
--- a/internal/consolidation/episodic_worker.go
+++ b/internal/consolidation/episodic_worker.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/providerresolve"
@@ -21,6 +22,7 @@ type episodicWorker struct {
systemConfigs store.SystemConfigStore // per-tenant provider config
registry *providers.Registry // provider resolution
eventBus eventbus.DomainEventBus
+ alertDeps bgalert.AlertDeps
}
// resolveProvider delegates to shared background provider resolution.
@@ -45,6 +47,8 @@ func (w *episodicWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
if err != nil {
return fmt.Errorf("episodic: invalid tenant_id %q: %w", event.TenantID, err)
}
+ // Inject tenant context so store queries and bgalert scope correctly.
+ ctx = store.WithTenantID(ctx, tenantUUID)
agentUUID, err := uuid.Parse(event.AgentID)
if err != nil {
return fmt.Errorf("episodic: invalid agent_id %q: %w", event.AgentID, err)
@@ -68,6 +72,7 @@ func (w *episodicWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
if provider != nil {
summary, err = w.summarizeSession(ctx, provider, model, payload)
if err != nil {
+ bgalert.ReportProviderError(ctx, w.alertDeps, "episodic", err)
return fmt.Errorf("episodic: summarize: %w", err)
}
}
diff --git a/internal/consolidation/semantic_worker.go b/internal/consolidation/semantic_worker.go
index c6b75f01..fe243082 100644
--- a/internal/consolidation/semantic_worker.go
+++ b/internal/consolidation/semantic_worker.go
@@ -6,6 +6,8 @@ import (
"log/slog"
"time"
+ "github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -15,6 +17,7 @@ type semanticWorker struct {
kgStore store.KnowledgeGraphStore
extractor EntityExtractor
eventBus eventbus.DomainEventBus
+ alertDeps bgalert.AlertDeps
}
// Handle extracts entities and relations from an episodic summary.
@@ -24,6 +27,13 @@ func (w *semanticWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
return fmt.Errorf("semantic: unexpected payload type %T", event.Payload)
}
+ // Inject tenant context so bgalert scopes correctly.
+ if event.TenantID != "" {
+ if tid, err := uuid.Parse(event.TenantID); err == nil {
+ ctx = store.WithTenantID(ctx, tid)
+ }
+ }
+
if w.extractor == nil || payload.Summary == "" {
return nil
}
@@ -31,6 +41,7 @@ func (w *semanticWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
// Extract entities/relations from summary (much cheaper than full session)
result, err := w.extractor.Extract(ctx, payload.Summary)
if err != nil {
+ bgalert.ReportProviderError(ctx, w.alertDeps, "kg_extraction", err)
slog.Warn("semantic: extraction failed", "episodic_id", payload.EpisodicID, "err", err)
return nil // non-fatal: extraction failure doesn't block pipeline
}
diff --git a/internal/consolidation/workers.go b/internal/consolidation/workers.go
index 1078fa42..1c5fb82a 100644
--- a/internal/consolidation/workers.go
+++ b/internal/consolidation/workers.go
@@ -9,6 +9,7 @@ import (
"log/slog"
"time"
+ "github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -24,6 +25,7 @@ type ConsolidationDeps struct {
SystemConfigs store.SystemConfigStore // per-tenant provider config
Registry *providers.Registry // provider resolution
Extractor EntityExtractor
+ AlertDeps bgalert.AlertDeps // for reporting non-retryable LLM errors
// AgentStore is optional: when present, the dreaming worker reads
// per-agent overrides from MemoryConfig.Dreaming. If nil, the worker
// uses its built-in defaults for every agent.
@@ -39,11 +41,13 @@ func Register(deps ConsolidationDeps) func() {
systemConfigs: deps.SystemConfigs,
registry: deps.Registry,
eventBus: deps.EventBus,
+ alertDeps: deps.AlertDeps,
}
semantic := &semanticWorker{
kgStore: deps.KGStore,
extractor: deps.Extractor,
eventBus: deps.EventBus,
+ alertDeps: deps.AlertDeps,
}
dedup := &dedupWorker{
kgStore: deps.KGStore,
@@ -54,6 +58,7 @@ func Register(deps ConsolidationDeps) func() {
memoryStore: deps.MemoryStore,
systemConfigs: deps.SystemConfigs,
registry: deps.Registry,
+ alertDeps: deps.AlertDeps,
threshold: dreamingDefaultThreshold,
debounce: dreamingDefaultDebounce,
resolveConfig: newAgentStoreResolver(deps.AgentStore),
diff --git a/internal/gateway/event_filter.go b/internal/gateway/event_filter.go
index e7ac9d01..157d0ff5 100644
--- a/internal/gateway/event_filter.go
+++ b/internal/gateway/event_filter.go
@@ -147,7 +147,8 @@ func isAdminOnlyEvent(name string) bool {
case protocol.EventNodePairRequested, protocol.EventNodePairResolved,
protocol.EventDevicePairReq, protocol.EventDevicePairRes,
protocol.EventAgentLinkCreated, protocol.EventAgentLinkUpdated, protocol.EventAgentLinkDeleted,
- protocol.EventWorkspaceFileChanged:
+ protocol.EventWorkspaceFileChanged,
+ protocol.EventBackgroundError:
return true
}
return false
diff --git a/internal/http/system_configs.go b/internal/http/system_configs.go
index da4a0b9d..ebf68150 100644
--- a/internal/http/system_configs.go
+++ b/internal/http/system_configs.go
@@ -6,6 +6,7 @@ import (
"net/http"
"regexp"
+ "github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -24,6 +25,17 @@ func NewSystemConfigsHandler(s store.SystemConfigStore, msgBus *bus.MessageBus)
// validKeyRe allows alphanumeric, dots, underscores, hyphens (1-100 chars).
var validKeyRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,100}$`)
+// providerRelatedKeys lists system config keys whose changes should clear
+// background worker error alerts (user is likely fixing the root cause).
+var providerRelatedKeys = map[string]bool{
+ "background.provider": true,
+ "background.model": true,
+ "agent.default_provider": true,
+ "agent.default_model": true,
+ "embedding.provider": true,
+ "embedding.model": true,
+}
+
// RegisterRoutes registers system config endpoints on the given mux.
func (h *SystemConfigsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/system-configs", requireAuth("", h.handleList))
@@ -74,6 +86,11 @@ func (h *SystemConfigsHandler) handleSet(w http.ResponseWriter, r *http.Request)
return
}
+ // Auto-clear background error alert when provider settings change.
+ if providerRelatedKeys[key] {
+ bgalert.ClearProviderError(r.Context(), h.store)
+ }
+
// Broadcast change with tenant context so in-memory config refreshes for the right tenant.
// Use fresh context with tenant ID — request context may be canceled before subscriber runs.
if h.msgBus != nil {
diff --git a/internal/vault/enrich_worker.go b/internal/vault/enrich_worker.go
index 1fcf12ca..9494352b 100644
--- a/internal/vault/enrich_worker.go
+++ b/internal/vault/enrich_worker.go
@@ -13,6 +13,7 @@ import (
"time"
"github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
@@ -45,6 +46,7 @@ type EnrichWorkerDeps struct {
EventBus eventbus.DomainEventBus
MsgBus bus.EventPublisher // for WS event broadcast
TeamStore store.TaskCommentStore // for Phase 2.5 task-based auto-linking (nil-safe)
+ AlertDeps bgalert.AlertDeps // for reporting non-retryable LLM errors
}
// RegisterEnrichWorker subscribes the enrichment worker to vault doc events.
@@ -57,6 +59,7 @@ func RegisterEnrichWorker(deps EnrichWorkerDeps) (func(), *EnrichProgress, *Enri
systemConfigs: deps.SystemConfigs,
registry: deps.Registry,
msgBus: deps.MsgBus,
+ alertDeps: deps.AlertDeps,
dedup: make(map[string]string),
sem: semaphore.NewWeighted(enrichMaxConcurrent),
progress: progress,
@@ -75,6 +78,7 @@ type EnrichWorker struct {
systemConfigs store.SystemConfigStore // per-tenant provider config
registry *providers.Registry // provider resolution
msgBus bus.EventPublisher // for error event broadcast
+ alertDeps bgalert.AlertDeps // for reporting non-retryable LLM errors
queue enrichBatchQueue
progress *EnrichProgress
@@ -201,6 +205,11 @@ func (w *EnrichWorker) Handle(ctx context.Context, event eventbus.DomainEvent) e
return nil // another goroutine already processing this agent's queue
}
+ // Inject tenant context so store queries and bgalert scope correctly.
+ if tid, parseErr := uuid.Parse(payload.TenantID); parseErr == nil {
+ ctx = store.WithTenantID(ctx, tid)
+ }
+
// Create per-tenant cancel context for stop capability.
cancelCtx, cancel := context.WithCancel(ctx)
w.cancelFuncs.Store(payload.TenantID, cancel)
@@ -517,6 +526,7 @@ func (w *EnrichWorker) chatWithRetry(ctx context.Context, provider providers.Pro
}
return strings.TrimSpace(resp.Content), nil
}
+ bgalert.ReportProviderError(ctx, w.alertDeps, "vault_enrich", lastErr)
return "", fmt.Errorf("%s exhausted %d retries: %w", logPrefix, enrichMaxRetries, lastErr)
}
diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go
index 3333c134..95a01982 100644
--- a/pkg/protocol/events.go
+++ b/pkg/protocol/events.go
@@ -108,6 +108,9 @@ const (
// Vault enrichment pipeline progress.
EventVaultEnrichProgress = "vault.enrich.progress"
+
+ // Background worker alerts (non-retryable LLM errors).
+ EventBackgroundError = "background.error"
)
// Agent event subtypes (in payload.type)
diff --git a/ui/web/src/components/layout/app-layout.tsx b/ui/web/src/components/layout/app-layout.tsx
index 6095d542..8e0ad6b7 100644
--- a/ui/web/src/components/layout/app-layout.tsx
+++ b/ui/web/src/components/layout/app-layout.tsx
@@ -1,9 +1,10 @@
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { Outlet, useLocation } from "react-router";
import { WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sidebar } from "./sidebar";
import { Topbar } from "./topbar";
+import { BackgroundErrorBanner } from "./background-error-banner";
import { ErrorBoundary } from "@/components/shared/error-boundary";
import { useUiStore } from "@/stores/use-ui-store";
import { useAuthStore } from "@/stores/use-auth-store";
@@ -29,6 +30,7 @@ export function AppLayout() {
const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen);
const connected = useAuthStore((s) => s.connected);
const isMobile = useIsTablet();
+ const [settingsOpen, setSettingsOpen] = useState(false);
// Close mobile sidebar on route change (e.g. programmatic navigation, back button)
useEffect(() => {
@@ -62,7 +64,8 @@ export function AppLayout() {