diff --git a/.ai/spec/README.md b/.ai/spec/README.md
new file mode 100644
index 00000000..8e0320bd
--- /dev/null
+++ b/.ai/spec/README.md
@@ -0,0 +1,34 @@
+# OpenShift LightSpeed Console Plugin -- Specifications
+
+These specs define the requirements, behaviors, and architecture for the OLS console plugin (lightspeed-console). They are organized into two layers:
+
+- **[`what/`](what/README.md)** -- Behavioral rules: WHAT the plugin must do and WHY. Technology-neutral, testable assertions. Use these to understand requirements, fix bugs, or rebuild components.
+- **[`how/`](how/README.md)** -- Architecture specs: HOW the current implementation is structured. Module boundaries, data flow, design patterns. Use these to navigate, modify, and extend the codebase.
+
+## Scope
+
+These specs cover the **lightspeed-console** TypeScript/React dynamic plugin only. The service (lightspeed-service), operator (lightspeed-operator), and RAG content pipeline (lightspeed-rag-content) are separate projects.
+
+## Audience
+
+AI agents (Claude). Specs optimize for precision, unambiguous rules, and machine-parseable structure.
+
+## Quick Start
+
+| I want to... | Read |
+|--------------|------|
+| Understand what this plugin does | `what/system-overview.md` |
+| Fix a bug in chat or streaming | `what/chat.md` + `how/streaming.md` |
+| Add a new attachment type | `what/attachments.md` + `how/components.md` |
+| Understand tool approval UI | `what/tools.md` |
+| Understand the plugin API | `what/plugin-api.md` |
+| Navigate the codebase | `how/project-structure.md` |
+| Understand state management | `how/state-management.md` |
+| See what's planned | Look for `[PLANNED: OLS-XXXX]` in `what/` specs |
+
+## Conventions
+
+- `[PLANNED: OLS-XXXX]` markers in `what/` specs indicate existing rules about to change due to open Jira work
+- "Planned Changes" sections list new capabilities not yet in code
+- Internal constants are stated as behavioral rules without numeric values; `how/` specs may include specific values
+- User-configurable values are referenced by their user settings key path
diff --git a/.ai/spec/how/README.md b/.ai/spec/how/README.md
new file mode 100644
index 00000000..b5989c88
--- /dev/null
+++ b/.ai/spec/how/README.md
@@ -0,0 +1,23 @@
+# Architecture Specifications (how/)
+
+These specs describe HOW the OLS console plugin is structured -- module boundaries, data flow, design patterns, key abstractions, and implementation decisions. They are grounded in the current TypeScript/React codebase and should be updated when the code changes.
+
+## Spec Index
+
+| Spec | Description |
+|------|-------------|
+| [project-structure.md](project-structure.md) | Directory layout, module responsibilities, build system, dependencies, dev setup, testing |
+| [state-management.md](state-management.md) | Redux store shape, Immutable.js usage, actions, reducer, selectors |
+| [streaming.md](streaming.md) | SSE stream processing, event types, buffering, throttled dispatch, abort handling |
+| [components.md](components.md) | Component tree, Popover/GeneralPage/Prompt hierarchy, PatternFly Chatbot integration |
+
+## When to Read These
+
+- **Navigating the codebase**: Start with `project-structure.md` to understand where things live.
+- **Modifying a subsystem**: Read the relevant `how/` spec to understand the current architecture before making changes.
+- **Adding a new attachment type or tool UI**: The `components.md` spec includes extension points.
+- **Debugging streaming issues**: The `streaming.md` spec traces the exact event processing path.
+
+## Relationship to what/ Specs
+
+The [`what/` specs](../what/README.md) define behavioral contracts (technology-neutral). These `how/` specs describe the implementation that fulfills those contracts. When the two diverge, the `what/` spec is the source of truth for correct behavior, and the `how/` spec should be updated to reflect the current code.
diff --git a/.ai/spec/how/components.md b/.ai/spec/how/components.md
new file mode 100644
index 00000000..d3c92c5b
--- /dev/null
+++ b/.ai/spec/how/components.md
@@ -0,0 +1,195 @@
+# Components -- Architecture
+
+The plugin's UI is built from a component tree rooted in the `Popover`
+component, which is launched as a console modal. All components use
+PatternFly 6 and the PatternFly AI Chatbot library.
+
+## Module Map
+
+| Path | Layer | Purpose |
+|---|---|---|
+| `Popover.tsx` | Root | Modal container, open/close/expand, first-time UX, feedback status fetch |
+| `GeneralPage.tsx` | Layout | Chat interface with header, content, and footer sections |
+| `Prompt.tsx` | Input | Message input, attachment menu, stream processing |
+| `ResponseTools.tsx` | Response | Tool label group, MCP App and OLS Tool UI rendering |
+| `ResponseToolModal.tsx` | Modal | Tool detail viewer |
+| `ToolApproval.tsx` | Response | HITL approval card |
+| `MCPApp.tsx` | Response | MCP App iframe host with JSON-RPC |
+| `OlsToolUIs.tsx` | Response | OLS-native tool UI extension renderer |
+| `AttachmentModal.tsx` | Modal | Attachment editor (code editor) |
+| `AttachmentLabel.tsx` | Inline | Attachment display label |
+| `AttachEventsModal.tsx` | Modal | Event selection for attachment |
+| `AttachLogModal.tsx` | Modal | Log selection for attachment |
+| `AttachmentsSizeAlert.tsx` | Alert | Warning for large attachments |
+| `ErrorBoundary.tsx` | Utility | React error boundary |
+| `Modal.tsx` | Utility | Reusable modal wrapper |
+
+## Data Flow
+
+### Component tree
+
+```
+ErrorBoundary
+ └── Popover
+ ├── [isOpen && !isExpanded] GeneralPage (collapsed mode)
+ │ ├── ChatbotHeader (title, clear/copy/expand/minimize actions)
+ │ ├── ChatbotContent
+ │ │ └── MessageBox
+ │ │ ├── Welcome logo + intro text
+ │ │ ├── AuthAlert (if not authorized)
+ │ │ ├── PrivacyAlert
+ │ │ ├── WelcomeNotice (if first-time user)
+ │ │ ├── ChatHistoryEntry[] (memoized, one per entry)
+ │ │ │ ├── [user entry] Message (role=user, text, expandable context)
+ │ │ │ └── [ai entry] Message (role=bot, markdown, code blocks, actions)
+ │ │ │ ├── [error] Alert (danger)
+ │ │ │ ├── [historyCompression] Alert (info/success)
+ │ │ │ ├── [isTruncated] Alert (warning)
+ │ │ │ ├── [isCancelled] Alert (info)
+ │ │ │ ├── [pendingApproval] ToolApproval[]
+ │ │ │ ├── ResponseTools
+ │ │ │ │ ├── MCPApp[] (for tools with uiResourceUri)
+ │ │ │ │ ├── OlsToolUIs (for tools with olsToolUiID)
+ │ │ │ │ └── LabelGroup (tool summary labels)
+ │ │ │ └── [feedback] UserFeedbackForm
+ │ │ ├── AttachmentsSizeAlert
+ │ │ └── ReadinessAlert
+ │ └── ChatbotFooter (if authorized)
+ │ ├── Prompt
+ │ │ ├── MessageBar (textarea + attach menu + send/stop button)
+ │ │ ├── AttachmentLabel[] (current attachments)
+ │ │ ├── (hidden, for YAML upload)
+ │ │ ├── AttachmentModal (edit modal)
+ │ │ ├── ToolModal (tool detail modal)
+ │ │ ├── AttachEventsModal (event selection)
+ │ │ └── AttachLogModal (log selection)
+ │ ├── ChatbotFootnote ("Always review AI generated content")
+ │ ├── Contact link
+ │ └── NewChatModal (clear confirmation)
+ ├── [isOpen && isExpanded] GeneralPage (fullscreen mode, same tree)
+ └── Button (floating OLS button, always rendered)
+```
+
+### Popover lifecycle
+
+1. `useHideLightspeed()` -> if hidden, render nothing.
+2. `useFirstTimeUser()` -> if first-time and not hidden, auto-open after 500ms.
+3. Fetch `/v1/feedback/status` on mount -> if disabled, dispatch `userFeedbackDisable()`.
+4. Render: `isOpen` ? `GeneralPage` + close button : tooltip + open button.
+5. Close handler: if first-time user, call `markAsExperienced()`.
+
+### GeneralPage rendering
+
+1. `useAuth()` -> determines if footer (prompt) is shown.
+2. `useFirstTimeUser()` -> determines if welcome notice is shown.
+3. Chat history entries are rendered via `ChatHistoryEntry` (memoized with `React.memo`).
+4. Each `ChatHistoryEntry` handles its own feedback state and tool rendering.
+5. Header actions: trash (clear chat), copy, expand/collapse, minimize.
+
+### Prompt rendering
+
+1. `useLocationContext()` -> extracts K8s resource from URL.
+2. `useK8sWatchResource()` -> watches the detected resource (unless Alert).
+3. Builds attachment menu items based on detected context and resource type.
+4. `MessageBar` renders textarea with attach menu and send/stop toggle.
+5. `onSubmit` -> validates input, dispatches history entries, starts stream.
+6. `autoSubmit` effect -> programmatically clicks send button.
+
+## Key Abstractions
+
+### ChatHistoryEntry memoization
+
+`ChatHistoryEntry` uses `React.memo` to prevent re-renders when other
+entries change. Each entry reads its own slice of state via `useSelector`
+with index-based paths (`getIn(['chatHistory', entryIndex, ...])`).
+
+### Popover display modes
+
+The `GeneralPage` component receives `onExpand` OR `onCollapse` prop
+(never both) to determine its display mode:
+
+- `onExpand` present -> collapsed mode (`ChatbotDisplayMode.default`)
+- `onCollapse` present -> fullscreen mode (`ChatbotDisplayMode.fullscreen`)
+
+The Popover parent tracks `isExpanded` state and passes the appropriate
+callback.
+
+### MCPApp card states
+
+The MCP App card has three visual states managed by local `cardState`:
+
+- `normal`: Full card with iframe at dynamic height
+- `expanded`: Full card with `ols-plugin__mcp-app--expanded` CSS class
+- `minimized`: Header-only card with restore button
+
+### Message component integration
+
+The PatternFly `Message` component is used for both user and AI entries
+with different configurations:
+
+**User messages**: `role="user"`, user avatar, text in `extraContent.afterMainContent`
+with optional expandable attachment context section.
+
+**AI messages**: `role="bot"`, OLS logo avatar (theme-dependent), markdown
+`content` prop, `actions` (copy, thumbs up/down), `sources` (reference
+links), `codeBlockProps` (import action, expandable), `extraContent`
+(error alerts, tool approval cards, tool summaries, compression indicators),
+`userFeedbackForm`/`userFeedbackComplete` for inline feedback.
+
+### Attachment menu construction
+
+The attachment menu items are built in `Prompt.tsx` using a `useMemo`
+that depends on: detected resource context, events availability, loading
+state, troubleshooting mode, and resource kind. The menu structure is:
+
+1. "Currently viewing" section with resource label (if resource detected)
+2. "Attach" section with resource-specific options
+3. File upload option (always)
+4. Divider
+5. Query mode toggle (Ask or Troubleshooting)
+
+Special cases:
+- `ManagedCluster` kind -> "Attach cluster info" instead of YAML options
+- `Alert` kind -> "Alert" option fetching from Prometheus/Thanos
+- Non-workload kinds -> no Events or Logs options
+
+## Implementation Notes
+
+### GeneralPage.tsx ChatHistoryEntry reads adjacent entries
+
+`ChatHistoryEntry` at index `i` (an AI response) reads `chatHistory[i-1]`
+(the preceding user entry) to access the user's query text and attachments
+for the feedback submission payload. This coupling means user entries must
+always precede their corresponding AI entries in the history list.
+
+### Prompt.tsx modal co-location
+
+`AttachmentModal`, `ToolModal`, `AttachEventsModal`, and `AttachLogModal`
+are rendered inside `Prompt.tsx`. They read their open/close state from
+Redux (`openAttachment`, `openTool`) or local boolean state
+(`isEventsModalOpen`, `isLogModalOpen`). This means the modals are always
+mounted but conditionally visible.
+
+### Theme propagation to MCPApp iframe
+
+Theme changes are propagated to MCP App iframes via two mechanisms:
+1. On initial HTML load, a `data-theme` attribute is injected into the
+ `` tag via string replacement.
+2. On subsequent theme changes, a `ui/notifications/host-context-changed`
+ JSON-RPC notification is sent via `postMessage`.
+
+The theme `useEffect` in `MCPApp.tsx` intentionally excludes
+`uiResourceUri` and `serverName` from its dependency array to avoid
+re-fetching the HTML content on theme change -- only the notification
+is sent.
+
+### ErrorBoundary wrapping strategy
+
+The `ErrorBoundary` is used at two levels:
+1. Root level: wraps `Popover` to prevent plugin crashes from breaking
+ the console.
+2. Tool UI level: wraps each `OlsToolUI` component to isolate crashes
+ in third-party tool visualizations.
+
+The `MCPApp` iframe is inherently isolated and does not need an error
+boundary.
diff --git a/.ai/spec/how/project-structure.md b/.ai/spec/how/project-structure.md
new file mode 100644
index 00000000..9c78ae5e
--- /dev/null
+++ b/.ai/spec/how/project-structure.md
@@ -0,0 +1,224 @@
+# Project Structure -- Architecture
+
+The OpenShift LightSpeed console plugin is a TypeScript/React dynamic plugin
+built with Webpack Module Federation. It runs inside the OpenShift web console
+and communicates with the OLS backend service via the console's plugin proxy.
+
+## Module Map
+
+### `src/` -- Source root
+
+| Path | Purpose |
+|---|---|
+| `src/types.ts` | Core TypeScript type definitions: `Attachment`, `Tool`, `ChatEntry` (union of `ChatEntryUser` and `ChatEntryAI`), `ReferencedDoc`, `CodeBlock`, `HistoryCompression`, `OlsToolUIComponent`. Global declaration extends `Window` with `SERVER_FLAGS`. |
+| `src/redux-actions.ts` | All Redux action creators (27 actions) and `ActionType` enum. Uses `typesafe-actions` for type-safe action creation. Exports the union type `OLSAction`. |
+| `src/redux-reducers.ts` | Single reducer function handling all `ActionType` cases. Initializes default state on first call. Exports `OLSState` (ImmutableMap) and `State` interface (global store shape with `plugins.ols` and `sdkCore.user`). |
+| `src/config.ts` | API base URL constant (`/api/proxy/plugin/lightspeed-console-plugin/ols`) and `getApiUrl(path)` helper. |
+| `src/attachments.ts` | `AttachmentTypes` enum (Events, Log, YAML, YAML filtered, YAMLUpload), `toOLSAttachment()` conversion function, `isAttachmentChanged()` comparison helper. |
+| `src/error.ts` | `ErrorType` and `FetchError` types. `getFetchErrorMessage()` extracts structured error messages from fetch responses, handling both string and object `detail` fields. |
+| `src/flags.ts` | `FLAG_LIGHTSPEED_PLUGIN` constant and `enableLightspeedPluginFlag()` handler for the `console.flag` extension. |
+| `src/clipboard.ts` | `copyToClipboard(value)` utility wrapping `navigator.clipboard.writeText()`. |
+
+### `src/hooks/` -- Custom React hooks
+
+| Path | Purpose |
+|---|---|
+| `src/hooks/useBoolean.ts` | Generic boolean state hook. Returns `[value, toggle, setTrue, setFalse, set]`. Used extensively for modal/toggle state. |
+| `src/hooks/useAuth.ts` | Authorization check hook. POSTs to `/authorized` on mount. Returns `[AuthStatus]` enum (AuthorizedLoading, Authorized, NotAuthenticated, NotAuthorized, AuthorizedError). Also exports `getRequestInitWithAuthHeader()` for adding bearer token to requests. |
+| `src/hooks/useOpenOLS.ts` | Public API hook exposed as a console extension. Returns a callback `(prompt?, attachments?, submitImmediately?, hidePrompt?) => void` that dispatches Redux actions to open OLS with optional context. |
+| `src/hooks/usePopover.ts` | Side-effect hook that launches the Popover component as a console modal exactly once. Uses `useModal()` from the console SDK with a stable modal ID. |
+| `src/hooks/useLocationContext.ts` | URL parser that extracts the Kubernetes resource (kind, name, namespace) from the current page path. Handles standard K8s URLs, ACM ManagedCluster, ACM search, ACM Applications, ACM Policies, and Alert pages. Returns `[kind, name, namespace]`. |
+| `src/hooks/useFirstTimeUser.ts` | Tracks first-time user state via `lightspeed.hasClosedChat` user setting. Returns `[isFirstTimeUser, markAsExperienced, isLoaded]`. |
+| `src/hooks/useIsDarkTheme.ts` | Theme detection via `console.theme` user setting. Falls back to `prefers-color-scheme` media query for system default. Returns `[isDarkTheme]`. |
+| `src/hooks/useHideLightspeed.ts` | Reads `console.hideLightspeedButton` user setting. Returns `[isHidden]`. |
+| `src/hooks/useToolUIMapping.ts` | Resolves `ols.tool-ui` console extensions into a `Record` mapping. Returns `[mapping, resolved]`. |
+
+### `src/components/` -- React components
+
+| Path | Purpose |
+|---|---|
+| `src/components/Popover.tsx` | Root component wrapped in `ErrorBoundary`. Manages open/close/expand state, first-time user auto-open, feedback status fetch, and renders `GeneralPage` with appropriate mode props. Conditionally renders based on `isHidden` and `isOpen` state. |
+| `src/components/GeneralPage.tsx` | Main chat interface. Renders `Chatbot` (PF6) with header (title, actions), content (message history with `ChatHistoryEntry` per entry), and footer (Prompt, footnote, contact link). Handles auth alerts, privacy notice, welcome notice, new chat modal, and conversation copy. |
+| `src/components/Prompt.tsx` | Message input and stream processing. Contains: `MessageBar` (PF6 chatbot), attachment menu construction, file upload handler, YAML/events/logs/alert attachment logic, SSE stream reader with event parsing, abort controller, and auto-submit logic. This is the largest component -- handles query submission and all stream event processing. |
+| `src/components/ResponseTools.tsx` | Renders tool label group for completed tools. Filters tools by completion state, renders `MCPApp` for tools with UI, renders `OlsToolUIs` for OLS-native tool UIs, and renders `ToolLabel` components for the summary. |
+| `src/components/ResponseToolModal.tsx` | Modal for viewing tool details. Shows tool name, args, status, server name, UI resource URI, content (code block), and structured content (JSON code block). Handles denied tool display separately. |
+| `src/components/ToolApproval.tsx` | HITL approval card. Renders a warning card with tool description, expandable arguments, and Approve/Reject buttons. POSTs decisions to the tool approvals endpoint. |
+| `src/components/MCPApp.tsx` | MCP App interactive UI host. Loads HTML from MCP resources endpoint, renders in sandboxed iframe, handles JSON-RPC 2.0 bidirectional messaging (initialize, tools/call, notifications/initialized, notifications/size-changed). Supports expand/minimize/refresh controls. |
+| `src/components/OlsToolUIs.tsx` | Renders OLS-native tool UI components registered via `ols.tool-ui` extensions. Filters tools by `olsToolUiID` match, wraps each in `ErrorBoundary`. |
+| `src/components/AttachmentModal.tsx` | Modal for viewing/editing attachment content in a code editor. Supports undo to original value. |
+| `src/components/AttachmentLabel.tsx` | Inline label showing resource icon, name, and optional edit indicator. Click opens `AttachmentModal`. |
+| `src/components/AttachmentsSizeAlert.tsx` | Warning alert when total attachment size exceeds threshold. |
+| `src/components/AttachEventsModal.tsx` | Modal for selecting Kubernetes events to attach. |
+| `src/components/AttachLogModal.tsx` | Modal for selecting pod logs. Container selection, line count slider, live preview. |
+| `src/components/ErrorBoundary.tsx` | React error boundary. Catches component crashes and renders fallback. |
+| `src/components/Modal.tsx` | Reusable modal wrapper component. |
+| `src/components/ResourceIcon.tsx` | Renders Kubernetes resource kind icons. |
+| `src/components/CopyAction.tsx` | Copy-to-clipboard button with visual confirmation. |
+| `src/components/ImportAction.tsx` | "Import YAML" action button. Opens confirmation modal, then navigates to console's YAML import page with content. |
+| `src/components/NewChatModal.tsx` | Confirmation modal for clearing chat. |
+| `src/components/ReadinessAlert.tsx` | Alert shown when OLS service is not ready. |
+| `src/components/WelcomeNotice.tsx` | Welcome message for first-time users. |
+| `src/components/ConfirmationModal.tsx` | Generic confirmation dialog. |
+| `src/components/CloseButton.tsx` | Close icon button. |
+| `src/components/NullContextProvider.tsx` | No-op React context provider returning `null`. Used as the component for the `console.context-provider` extension. |
+| `src/components/OverviewDetail.tsx` | Dashboard detail item showing plugin version (1.0.12). |
+
+### `src/assets/` -- Static assets
+
+| Path | Purpose |
+|---|---|
+| `src/assets/logo.svg` | OLS logo for light theme |
+| `src/assets/logo-dark.svg` | OLS logo for dark theme |
+| `src/assets/user.png` | User avatar image |
+
+### Root configuration files
+
+| Path | Purpose |
+|---|---|
+| `console-extensions.json` | Declares 5 console extensions: flag, context-provider, dashboard detail, redux-reducer, action/provider |
+| `package.json` | Project metadata, dependencies, scripts. Name: `lightspeed-console-plugin`, version: 1.0.12 |
+| `webpack.config.ts` | Webpack 5 config using `ConsoleRemotePlugin` for Module Federation. Production: hashed bundle names, minimization, deterministic chunk IDs. Dev: source maps, HMR on port 9001. |
+| `tsconfig.json` | TypeScript config targeting ES2020, React JSX. Strict mode enabled. |
+| `.eslintrc.yml` | ESLint config: recommended + react + react-hooks + typescript + i18next + prettier |
+| `.prettierrc.yml` | Prettier config: 100 char width, single quotes, trailing commas |
+| `.stylelintrc.yaml` | Stylelint config: no hex colors (use PF design tokens), selector must match `ols-plugin__*` |
+| `start-console.sh` | Dev script: runs console in Docker/Podman on port 9000, proxies plugin from port 9001, proxies OLS API from localhost:8080 |
+
+### Test directories
+
+| Path | Purpose |
+|---|---|
+| `tests/` | Cypress e2e test specs |
+| `cypress/` | Cypress support files and fixtures |
+| `cypress.config.ts` | Cypress configuration |
+| `unit-tests/` | Unit tests using Node's built-in test runner. Tests for: redux-reducers, error handling, attachments |
+
+## Data Flow
+
+### Plugin initialization
+
+1. **Console discovers plugin**: Console loads `console-extensions.json` and resolves module references via Webpack Module Federation.
+
+2. **Feature flag**: `enableLightspeedPluginFlag` sets `LIGHTSPEED_PLUGIN = true`.
+
+3. **Redux reducer**: Registered under `ols` scope. First invocation with `undefined` state creates initial state.
+
+4. **Context provider mounts**: `NullContextProvider` renders `null`. `usePopover` hook fires, calling `useModal()` to launch `Popover` component as a console modal.
+
+5. **Popover initializes**: Fetches feedback status from `/v1/feedback/status`. Checks first-time user state. If first-time and not hidden, auto-opens after 500ms delay.
+
+6. **Auth check**: `GeneralPage` mounts and `useAuth` POSTs to `/authorized`. Auth status determines whether the prompt footer is shown.
+
+### Query submission and streaming
+
+See `how/streaming.md` for the detailed streaming data flow.
+
+## Key Abstractions
+
+### Immutable.js state
+
+All Redux state is stored as `ImmutableMap` and `ImmutableList` from the `immutable` package. Components access state via `.get()`, `.getIn()`, and `.toJS()`. This means:
+
+- State updates use `.set()`, `.setIn()`, `.mergeIn()`, `.push()` (all return new instances).
+- Selectors use `useSelector` with `.get()` / `.getIn()` paths, not property access.
+- When a component needs a plain JS object, it calls `.toJS()` (typically memoized).
+
+### Console SDK integration
+
+The plugin uses `@openshift-console/dynamic-plugin-sdk` for:
+
+- `useModal()`: Launching the popover as a console modal
+- `useK8sWatchResource()`: Watching the currently-viewed K8s resource
+- `useK8sModels()`: Resolving resource keys to K8s model definitions
+- `useUserSettings()`: Reading/writing user preferences
+- `useResolvedExtensions()`: Discovering `ols.tool-ui` extensions from other plugins
+- `consoleFetch()` / `consoleFetchJSON()`: Making API requests through the console proxy
+- `SetFeatureFlag`: Setting the plugin feature flag
+
+### PatternFly Chatbot
+
+The chat UI uses `@patternfly/chatbot` components:
+
+- `Chatbot`: Root container with display mode (default/fullscreen)
+- `ChatbotHeader` / `ChatbotContent` / `ChatbotFooter`: Layout sections
+- `MessageBox`: Scrollable message container
+- `Message`: Individual chat message (supports markdown, code blocks, actions, feedback, sources)
+- `MessageBar`: Input area with attach menu, send/stop buttons
+
+### API proxy pattern
+
+All OLS API calls go through `/api/proxy/plugin/lightspeed-console-plugin/ols/...`. The console proxy:
+
+- Adds the user's authentication credentials
+- Handles TLS termination
+- Routes to the OLS service backend
+
+In development, `start-console.sh` configures an additional proxy from the console container to `localhost:8080` where the OLS service runs.
+
+## Integration Points
+
+### Plugin -> Console
+
+- Console extensions in `console-extensions.json`
+- Redux reducer registration under `ols` scope
+- Feature flag `LIGHTSPEED_PLUGIN`
+- User settings (read/write): `lightspeed.hasClosedChat`, `console.hideLightspeedButton`, `console.theme`
+- Modal system via `useModal()`
+- K8s resource watching via `useK8sWatchResource()`
+- Navigation via React Router (code import redirect)
+
+### Plugin -> OLS Service (via proxy)
+
+| Method | Path | Purpose |
+|---|---|---|
+| POST | `/authorized` | Authorization check |
+| POST | `/v1/streaming_query` | Submit query, receive SSE stream |
+| GET | `/v1/feedback/status` | Check if feedback is enabled |
+| POST | `/v1/feedback` | Submit user feedback |
+| POST | `/v1/tool-approvals/decision` | Approve/deny tool execution |
+| POST | `/v1/mcp-apps/tools/call` | Direct MCP tool call (from MCP App iframe) |
+| POST | `/v1/mcp-apps/resources` | Load MCP App UI resource (HTML) |
+
+### Plugin -> Kubernetes API (via console proxy)
+
+- `/api/kubernetes/apis/internal.open-cluster-management.io/...` for ManagedClusterInfo
+- `/api/prometheus/api/v1/rules?type=alert` for Prometheus alerts
+- `/api/proxy/plugin/monitoring-console-plugin/thanos-proxy/...` for Thanos alerts
+- K8s resource watch API (via `useK8sWatchResource`)
+- Events API (via `AttachEventsModal`)
+- Pod logs API (via `AttachLogModal`)
+
+### Other plugins -> This plugin
+
+- Feature flag check: `LIGHTSPEED_PLUGIN`
+- Action provider: `ols-open-handler` context ID resolves `useOpenOLS` hook
+- Tool UI extensions: `ols.tool-ui` extension type
+
+## Implementation Notes
+
+### Prompt.tsx is the stream processing hub
+
+`Prompt.tsx` handles both the input UI and all streaming logic. The `onSubmit` callback creates an `AbortController`, reads the SSE stream, and dispatches Redux actions for every event type. This co-location means any change to streaming behavior or input handling requires modifying this single large component.
+
+### Immutable.js adds access ceremony
+
+All Redux state access uses `.get('key')` and `.getIn(['path', 'to', 'value'])` instead of property access. This applies to both selectors in components and within the reducer. The `ImmutableMap` type is `ImmutableMap`, so there is no compile-time key safety.
+
+### TypeScript type suppression for PatternFly Chatbot
+
+Multiple PatternFly Chatbot components use `@ts-expect-error: TS2786` comments because the chatbot library's type definitions are not fully compatible with the project's TypeScript version. This is a known issue and does not indicate a runtime problem.
+
+### Module Federation entry points
+
+`console-extensions.json` references these exposed modules (defined in `webpack.config.ts` via `ConsoleRemotePlugin`):
+
+- `OLSFlags` -> `src/flags.ts`
+- `NullContextProvider` -> `src/components/NullContextProvider.tsx`
+- `usePopover` -> `src/hooks/usePopover.ts`
+- `OverviewDetail` -> `src/components/OverviewDetail.tsx`
+- `OLSReducer` -> `src/redux-reducers.ts`
+- `useOpenOLS` -> `src/hooks/useOpenOLS.ts`
+
+### Tool call correlation uses name+args workaround
+
+The OLS service does not include a linking ID between `tool_call` and `approval_required` stream events. The plugin builds a `Map` keyed by `${toolName}:${JSON.stringify(args)}` to correlate them. This is a known limitation documented in a code comment.
diff --git a/.ai/spec/how/state-management.md b/.ai/spec/how/state-management.md
new file mode 100644
index 00000000..e972cf03
--- /dev/null
+++ b/.ai/spec/how/state-management.md
@@ -0,0 +1,188 @@
+# State Management -- Architecture
+
+All plugin state is managed in a single Redux reducer registered under the
+`ols` scope. State values are stored as Immutable.js data structures
+(`ImmutableMap` and `ImmutableList`).
+
+## Module Map
+
+| Path | Purpose |
+|---|---|
+| `src/redux-actions.ts` | Action type enum, action creators, union action type |
+| `src/redux-reducers.ts` | Reducer function, initial state, `OLSState` and `State` type exports |
+
+## Data Flow
+
+### State shape
+
+```
+state.plugins.ols = ImmutableMap({
+ // UI state
+ isOpen: boolean, // false -- chat popover visibility
+ hidePrompt: boolean, // false -- hide user's message from history
+ isTroubleshooting: boolean, // false -- query mode (false=Ask, true=Troubleshooting)
+ isUserFeedbackEnabled: boolean, // true -- feedback UI enabled
+ isContextEventsLoading: boolean, // false -- events fetch in progress
+ autoSubmit: boolean, // false -- programmatic submit trigger
+
+ // Chat content
+ chatHistory: ImmutableList, // [] -- ordered list of chat entries
+ query: string, // '' -- current prompt input text
+ conversationID: string | null, // null -- service-assigned conversation ID
+
+ // Attachments
+ attachments: ImmutableMap, // {} -- keyed by composite ID
+ openAttachment: Attachment | null, // null -- attachment in edit modal
+ contextEvents: object[], // [] -- K8s events for current resource
+ codeBlock: CodeBlock | null, // null -- imported code block
+
+ // Tool interaction
+ openTool: ImmutableMap({
+ chatEntryIndex: number | null, // null -- index of chat entry with open tool
+ id: string | null, // null -- tool ID within that entry
+ }),
+})
+```
+
+### Chat entry structure (within chatHistory)
+
+**User entry:**
+```
+ImmutableMap({
+ who: 'user',
+ text: string,
+ attachments: { [id]: Attachment },
+ hidden?: boolean,
+})
+```
+
+**AI entry:**
+```
+ImmutableMap({
+ who: 'ai',
+ id: string, // unique ID (e.g., 'ChatEntry_1')
+ text: string, // accumulated response text
+ tools: ImmutableMap, // tool calls keyed by tool ID
+ references: ReferencedDoc[], // documentation links
+ isStreaming: boolean, // true while SSE stream is active
+ isCancelled: boolean, // true if user cancelled
+ isTruncated: boolean, // true if history was truncated
+ error?: ErrorType, // error info if request failed
+ historyCompression?: { status: 'compressing' | 'done', durationMs?: number },
+ userFeedback?: ImmutableMap({
+ isOpen: boolean,
+ sentiment: number, // 1 (thumbs up) or -1 (thumbs down)
+ text: string,
+ }),
+})
+```
+
+### Actions
+
+| Action | Payload | Reducer behavior |
+|---|---|---|
+| `OpenOLS` | (none) | `isOpen = true` |
+| `CloseOLS` | (none) | `isOpen = false`, `hidePrompt = false` |
+| `SetQuery` | `query: string` | `query = payload` |
+| `SetAutoSubmit` | `autoSubmit: boolean` | `autoSubmit = payload` |
+| `SetHidePrompt` | `hidePrompt: boolean` | `hidePrompt = payload` |
+| `SetIsTroubleshooting` | `isTroubleshooting: boolean` | `isTroubleshooting = payload` |
+| `SetConversationID` | `id: string` | `conversationID = payload` |
+| `SetIsContextEventsLoading` | `isLoading: boolean` | `isContextEventsLoading = payload` |
+| `ChatHistoryPush` | `entry: ChatEntry` | Appends `ImmutableMap(entry)` to `chatHistory` |
+| `ChatHistoryUpdateByID` | `id: string, entry: Partial` | Finds entry by `id`, merges `entry` fields |
+| `ChatHistoryUpdateTool` | `id: string, toolID: string, tool: Partial` | Finds entry by `id`, merges `tool` into `tools[toolID]` |
+| `ChatHistoryClear` | (none) | `chatHistory = ImmutableList()` |
+| `AttachmentSet` | `attachmentType, kind, name, ownerName, namespace, value, originalValue?, id?` | Sets attachment at computed or explicit key |
+| `AttachmentDelete` | `id: string` | Removes attachment at key |
+| `AttachmentsClear` | (none) | `attachments = ImmutableMap()` |
+| `OpenAttachmentSet` | `attachment: Attachment` | `openAttachment = payload` |
+| `OpenAttachmentClear` | (none) | `openAttachment = null` |
+| `OpenToolSet` | `chatEntryIndex: number, id: string` | Sets `openTool.chatEntryIndex` and `openTool.id` |
+| `OpenToolClear` | (none) | Resets `openTool` to null/null |
+| `AddContextEvent` | `event: object` | Appends event to `contextEvents` array |
+| `ClearContextEvents` | (none) | `contextEvents = []` |
+| `ImportCodeBlock` | `code: CodeBlock` | `codeBlock = payload` |
+| `UserFeedbackOpen` | `entryIndex: number` | Sets `chatHistory[entryIndex].userFeedback.isOpen = true` |
+| `UserFeedbackClose` | `entryIndex: number` | Sets `chatHistory[entryIndex].userFeedback.isOpen = false` |
+| `UserFeedbackSetSentiment` | `entryIndex: number, sentiment: number` | Sets `chatHistory[entryIndex].userFeedback.sentiment` |
+| `UserFeedbackSetText` | `entryIndex: number, text: string` | Sets `chatHistory[entryIndex].userFeedback.text` |
+| `UserFeedbackDisable` | (none) | `isUserFeedbackEnabled = false` |
+
+### Attachment key generation
+
+When `AttachmentSet` is dispatched without an explicit `id`, the key is
+computed as: `{attachmentType}_{kind}_{name}_{ownerName ?? 'NO-OWNER'}`.
+This means attaching the same resource type and name for the same owner
+replaces the previous attachment (idempotent).
+
+### State access patterns
+
+Components access state via `useSelector` with Immutable.js accessors:
+
+```typescript
+// Single value
+const isOpen = useSelector((s: State) => s.plugins?.ols?.get('isOpen'));
+
+// Nested value
+const tool = useSelector((s: State) =>
+ s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'tools', toolID]),
+);
+
+// Convert to plain JS when needed
+const entry = entryMap.toJS() as ChatEntry;
+```
+
+## Key Abstractions
+
+### typesafe-actions
+
+Action creators use the `action()` function from `typesafe-actions`, which
+provides type-safe action creation with automatic type discrimination. The
+`OLSAction` union type enables exhaustive switch matching in the reducer.
+
+### ImmutableMap as OLSState
+
+The state type is `ImmutableMap`, providing structural sharing
+for efficient updates but no compile-time key validation. Runtime errors
+from typos in `.get()` / `.getIn()` keys return `undefined` silently.
+
+### State scoping
+
+The plugin's reducer is registered under `state.plugins.ols` via the
+`console.redux-reducer` extension with `scope: "ols"`. The console SDK
+handles the scoping -- the reducer receives and returns only the `ols`
+subtree, not the full store.
+
+## Implementation Notes
+
+### Initial state is created on first reducer call
+
+The reducer checks `if (!state)` and returns the initial `ImmutableMap`
+with all default values. This is the standard pattern for console plugin
+reducers where the initial state cannot be passed via `createStore`.
+
+### CloseOLS resets hidePrompt
+
+Closing the chat always resets `hidePrompt` to `false`. This ensures
+that if OLS was opened programmatically with a hidden prompt, the next
+manual open shows prompts normally.
+
+### ChatHistoryUpdateByID uses linear scan
+
+Finding a chat entry by ID uses `ImmutableList.findIndex()`, which is a
+linear scan through the list. This is acceptable because chat histories
+are small (typically under 100 entries per session).
+
+### Feedback state is per-entry, not global
+
+User feedback (open/close, sentiment, text) is stored within each AI
+chat history entry, not in a separate global state slice. This allows
+independent feedback on multiple responses.
+
+### Tool state uses nested ImmutableMap merging
+
+Tool updates use `state.mergeIn(['chatHistory', index, 'tools', toolID], tool)`,
+which deeply merges the tool properties. This means partial updates
+(e.g., adding `isApproved: true` to an existing tool) preserve all
+other tool properties.
diff --git a/.ai/spec/how/streaming.md b/.ai/spec/how/streaming.md
new file mode 100644
index 00000000..d179bfa1
--- /dev/null
+++ b/.ai/spec/how/streaming.md
@@ -0,0 +1,216 @@
+# Streaming -- Architecture
+
+The plugin processes OLS query responses as Server-Sent Events (SSE) streams.
+All stream processing logic lives in `src/components/Prompt.tsx` within the
+`onSubmit` callback.
+
+## Module Map
+
+| Path | Purpose |
+|---|---|
+| `src/components/Prompt.tsx` | Stream initiation, SSE reading, event parsing, Redux dispatch for all event types |
+| `src/redux-actions.ts` | Actions dispatched during streaming: `setConversationID`, `chatHistoryUpdateByID`, `chatHistoryUpdateTool` |
+| `src/config.ts` | `QUERY_ENDPOINT` = `getApiUrl('/v1/streaming_query')` |
+| `src/error.ts` | `getFetchErrorMessage()` for error extraction |
+
+## Data Flow
+
+### Request initiation
+
+```
+onSubmit()
+ |-- Validate: isStreaming? empty query? -> early return
+ |-- Dispatch: chatHistoryPush(user entry with text + attachments)
+ |-- Dispatch: chatHistoryPush(AI placeholder with isStreaming=true, unique chatEntryID)
+ |-- Dispatch: setQuery(''), attachmentsClear()
+ |-- Build request JSON: { query, conversation_id, media_type, mode, attachments }
+ |-- Create AbortController, store in state
+ |-- Call consoleFetch(QUERY_ENDPOINT, POST, JSON body, signal)
+```
+
+### Response validation
+
+```
+consoleFetch response
+ |-- response.ok === false?
+ | |-- Dispatch: chatHistoryUpdateByID(chatEntryID, { error, isStreaming: false })
+ | |-- Return
+ |-- Get reader: response.body.getReader()
+ |-- Create TextDecoder
+```
+
+### Stream reading loop
+
+```
+while (true):
+ |-- reader.read() -> { value, done }
+ |-- done? break
+ |-- Decode chunk, append to buffer
+ |-- Split buffer by '\n'
+ |-- Keep last element as new buffer (handles incomplete lines)
+ |-- Process complete lines
+```
+
+### Line processing
+
+Only lines starting with `data: ` are processed. The `data: ` prefix (5 chars)
+is stripped, and the remainder is parsed as JSON.
+
+Each JSON object has the shape `{ event: string, data: object }`.
+
+### Event dispatch
+
+```
+event === 'start'
+ |-- Dispatch: setConversationID(data.conversation_id)
+
+event === 'token'
+ |-- Append data.token to local responseText variable
+ |-- Call throttled dispatchTokens() -> chatHistoryUpdateByID(chatEntryID, { text: responseText })
+
+event === 'end'
+ |-- Flush throttled dispatchTokens
+ |-- Dispatch: chatHistoryUpdateByID(chatEntryID, {
+ | isStreaming: false,
+ | isTruncated: data.truncated === true,
+ | references: data.referenced_documents
+ | })
+
+event === 'tool_call'
+ |-- Extract: name, id, args from data
+ |-- Store in toolKeyToID map: makeToolKey(name, args) -> id
+ |-- Dispatch: chatHistoryUpdateTool(chatEntryID, id, { name, args })
+
+event === 'approval_required'
+ |-- Extract: approval_id, tool_name, tool_args, tool_description from data
+ |-- Look up toolCallID via toolKeyToID.get(makeToolKey(tool_name, tool_args))
+ |-- If found: Dispatch chatHistoryUpdateTool(chatEntryID, toolCallID, {
+ | approvalID, args, description, isUserApproval: true
+ | })
+
+event === 'tool_result'
+ |-- Extract: content, id, status, server_name, structured_content, tool_meta from data
+ |-- Extract UI metadata: tool_meta?.ui?.resourceUri, tool_meta?.olsUi?.id
+ |-- Dispatch: chatHistoryUpdateTool(chatEntryID, id, {
+ | content, isUserApproval: false, status,
+ | ...(uiResourceUri && { uiResourceUri }),
+ | ...(serverName && { serverName }),
+ | ...(structuredContent && { structuredContent }),
+ | ...(olsToolUiID && { olsToolUiID })
+ | })
+
+event === 'history_compression_start'
+ |-- Dispatch: chatHistoryUpdateByID(chatEntryID, {
+ | historyCompression: { status: 'compressing' }
+ | })
+
+event === 'history_compression_end'
+ |-- Parse duration_ms (validate as finite number)
+ |-- Dispatch: chatHistoryUpdateByID(chatEntryID, {
+ | historyCompression: { status: 'done', durationMs }
+ | })
+
+event === 'error'
+ |-- Flush throttled dispatchTokens
+ |-- Dispatch: chatHistoryUpdateByID(chatEntryID, {
+ | error: getFetchErrorMessage({ json: { detail: data } }, t),
+ | isStreaming: false
+ | })
+
+(unrecognized event)
+ |-- console.warn with event JSON
+```
+
+### Error handling
+
+```
+streamResponse().catch(streamError)
+ |-- AbortError (user cancelled)? -> skip
+ |-- Dispatch: chatHistoryUpdateByID(chatEntryID, {
+ | error: getFetchErrorMessage(streamError, t),
+ | isStreaming: false, isTruncated: false
+ | })
+ |-- scrollIntoView()
+```
+
+### Stream cancellation
+
+```
+onStreamCancel()
+ |-- streamController.abort()
+ |-- Dispatch: chatHistoryUpdateByID(streamingResponseID, {
+ | isCancelled: true, isStreaming: false
+ | })
+```
+
+## Key Abstractions
+
+### Token throttling
+
+Token updates use lodash `throttle()` with `{ leading: false, trailing: true }`
+at 100ms interval. This means:
+
+- The first token does NOT trigger an immediate dispatch.
+- After each 100ms window, the latest accumulated text is dispatched.
+- The final token is always dispatched (trailing: true).
+- `dispatchTokens.flush()` is called on `end` and `error` events to ensure
+ the final state is always committed.
+
+The throttle targets `chatHistoryUpdateByID`, which triggers a Redux state
+update and React re-render. Without throttling, each token (potentially
+hundreds per second) would cause a separate re-render.
+
+### Buffer management
+
+The stream reader produces chunks of arbitrary size. A `buffer` variable
+accumulates partial data between reads:
+
+1. New chunk is appended to buffer.
+2. Buffer is split by `\n`.
+3. All lines except the last are processed (they are complete).
+4. The last element becomes the new buffer (it may be incomplete or empty).
+
+This handles the case where a long SSE data line (e.g., tool call output)
+is split across multiple TCP chunks.
+
+### Tool call correlation
+
+The `toolKeyToID` map (local to the stream processing closure) maps
+`${toolName}:${JSON.stringify(args)}` to the tool call ID from the
+`tool_call` event. When `approval_required` arrives (which lacks the tool
+call ID), the map is used to find the corresponding tool.
+
+This is a workaround for the OLS service not providing a linking ID.
+
+### AbortController lifecycle
+
+A new `AbortController` is created for each query submission and stored
+in React state via `setStreamController`. The controller's `signal` is
+passed to `consoleFetch`. The stop button calls `streamController.abort()`.
+The `AbortError` catch clause distinguishes user cancellation from real
+errors.
+
+## Implementation Notes
+
+### All stream logic is in a single closure
+
+The `streamResponse` async function is defined inside `onSubmit`, giving it
+closure access to `chatEntryID`, `requestJSON`, `dispatch`, and other
+values. The `responseText` accumulator and `toolKeyToID` map are also local
+to this closure. This means the stream processing state is per-request and
+automatically cleaned up.
+
+### consoleFetch vs consoleFetchJSON
+
+The streaming endpoint uses `consoleFetch` (raw fetch with console auth),
+not `consoleFetchJSON` (which would parse the entire response as JSON).
+The raw response body is read incrementally via `response.body.getReader()`.
+
+### Auto-submit clicks the send button
+
+The auto-submit mechanism does not call `onSubmit` directly. Instead, it
+programmatically clicks the MessageBar's send button DOM element
+(`querySelector('.pf-chatbot__button--send')?.click()`). This is necessary
+because calling `onSubmit` alone would not clear the MessageBar component's
+internal state (the MessageBar is a controlled component from PatternFly
+that manages its own internal buffer alongside the external `value` prop).
diff --git a/.ai/spec/what/README.md b/.ai/spec/what/README.md
new file mode 100644
index 00000000..bd7441bd
--- /dev/null
+++ b/.ai/spec/what/README.md
@@ -0,0 +1,26 @@
+# Behavioral Specifications (what/)
+
+These specs define WHAT the OLS console plugin must do -- testable behavioral rules, configuration surface, constraints, and planned changes. They are technology-neutral where possible and survive a complete rewrite in a different framework.
+
+## Spec Index
+
+| Spec | Description |
+|------|-------------|
+| [system-overview.md](system-overview.md) | Plugin identity, scope, system boundaries, deployment model, relationship to OLS service |
+| [chat.md](chat.md) | Chat lifecycle, query submission, streaming responses, query modes, conversation management, first-time UX |
+| [attachments.md](attachments.md) | All attachment types (YAML, filtered YAML, events, logs, alerts, file upload, ManagedCluster), context detection, editing |
+| [tools.md](tools.md) | Tool call display, human-in-the-loop approval workflow, MCP App interactive UI, OLS tool UI extensions |
+| [feedback.md](feedback.md) | User feedback (thumbs up/down, free-text), feedback enabled/disabled, privacy notice |
+| [auth.md](auth.md) | Authorization check flow, auth status handling, bearer token forwarding |
+| [plugin-api.md](plugin-api.md) | Console extension points, useOpenOLS public API, ols.tool-ui extension type, user settings |
+
+## How to Use These Specs
+
+- **Fixing a bug**: Read the relevant spec to understand correct behavior, then compare against the code.
+- **Adding a feature**: Check if the spec covers the requirement. Update the spec before implementing.
+- **Refactoring**: Use the specs as acceptance criteria. The implementation can change freely as long as it meets the behavioral rules.
+- **Understanding planned work**: Look for `[PLANNED: OLS-XXXX]` markers inline and "Planned Changes" sections.
+
+## Relationship to how/ Specs
+
+These `what/` specs define the behavioral contract. The [`how/` specs](../how/README.md) describe the current implementation architecture. Read `what/` to understand requirements, read `how/` to understand the codebase structure.
diff --git a/.ai/spec/what/attachments.md b/.ai/spec/what/attachments.md
new file mode 100644
index 00000000..7627f52a
--- /dev/null
+++ b/.ai/spec/what/attachments.md
@@ -0,0 +1,142 @@
+# Attachments
+
+Attachments allow users to include Kubernetes resource context with their
+prompts. The plugin detects the user's current console page, offers relevant
+attachment options, and converts attachments to the OLS API format before
+sending.
+
+## Behavioral Rules
+
+### Context Detection
+
+1. The plugin must automatically detect the Kubernetes resource being viewed
+ based on the current URL path. Detection must support:
+
+ | URL pattern | Extracted context |
+ |---|---|
+ | `/k8s/ns/{namespace}/{resourceKey}/{name}` | Namespaced resource |
+ | `/k8s/cluster/{resourceKey}/{name}` | Cluster-scoped resource |
+ | `/multicloud/infrastructure/clusters/details/{name}/.../overview\|nodes\|settings` | ACM ManagedCluster |
+ | `/multicloud/search/resources?kind=...&name=...` | ACM search result |
+ | `/multicloud/applications/details/{namespace}/{name}/overview` | ACM Application/ApplicationSet |
+ | `/multicloud/governance/policies/{action}/{namespace}/{name}` | ACM Policy |
+ | `/monitoring/alerts/{id}?alertname=...` | Prometheus/Thanos Alert |
+
+2. Resource key resolution must use the console's K8s model registry. Both
+ direct model keys (e.g., `Deployment`) and plural-based keys (e.g.,
+ `deployments`) must resolve correctly.
+
+3. The plugin must never offer to attach Kubernetes Secrets. If the detected
+ resource kind is `Secret`, no resource context must be provided.
+
+4. When no resource is detected from the URL, the attachment menu must still
+ offer file upload and query mode switching.
+
+### Attachment Types
+
+5. **Full YAML**: The complete YAML representation of the detected resource,
+ excluding `metadata.managedFields`. Available for any detected non-Secret
+ resource.
+
+6. **Filtered YAML**: A subset of the resource containing only `kind`,
+ `metadata`, and `status` sections, excluding `metadata.managedFields`.
+ Available for any detected non-Secret, non-ManagedCluster resource.
+
+7. **Events**: Kubernetes events associated with the current workload
+ resource. Only available for workload kinds (Pod, Deployment, StatefulSet,
+ DaemonSet, Job, CronJob, ReplicaSet, ReplicationController,
+ DeploymentConfig, HorizontalPodAutoscaler, PodDisruptionBudget, and
+ KubeVirt VirtualMachine/VirtualMachineInstance). Selection is via a modal
+ dialog.
+
+8. **Logs**: Pod logs for the current workload resource. Only available for
+ workload kinds. Selection is via a modal dialog with container selection,
+ line count control, and live preview.
+
+9. **Alert**: The full alert definition YAML from Prometheus/Thanos alerting
+ rules, matched by alert labels from the URL query parameters. The plugin
+ checks both the standard Prometheus endpoint and the Thanos proxy endpoint
+ (for ACM multi-cluster alerts). Alert attachments are deduplicated by
+ generating a unique ID from sorted label key-value pairs.
+
+10. **File Upload**: User-uploaded YAML files from the local filesystem.
+ Files must be valid YAML and under a maximum file size. The file content
+ is parsed to extract `kind`, `metadata.name`, and `metadata.namespace`
+ for display.
+
+11. **ManagedCluster Info**: For ACM ManagedCluster resources, the plugin
+ attaches both the ManagedCluster object and the associated
+ ManagedClusterInfo object (fetched from the ACM internal API). Both have
+ `managedFields` stripped.
+
+### Attachment Menu
+
+12. The attachment menu ("+" button) must show the following sections in
+ order:
+ a. "Currently viewing" label (when a resource is detected).
+ b. "Attach" section with available attachment options.
+ c. "Upload from computer" option (always available).
+ d. Query mode toggle (Ask or Troubleshooting, whichever is not currently
+ active).
+
+13. The Events menu item must be disabled when no events are available for
+ the current resource.
+
+### Attachment Display
+
+14. Each attached item must display as a label showing a resource icon and
+ the resource name. Labels must be removable via a close button.
+
+15. Attachments must be viewable and editable in a modal dialog. The modal
+ must show the content in a code editor with the resource icon, kind, and
+ name in the header.
+
+16. Modified attachments (where current value differs from original value)
+ must show an edit indicator icon. The original value can be restored via
+ an undo action in the editor modal.
+
+17. A warning alert must be displayed when the total size of all attachments
+ exceeds a threshold.
+
+### API Conversion
+
+18. When sending attachments to the OLS API, the plugin must convert them
+ using the following mapping:
+
+ | Plugin attachment type | API `attachment_type` | API `content_type` |
+ |---|---|---|
+ | YAML, YAML filtered, YAMLUpload | `api object` | `application/yaml` |
+ | Events | `event` | `application/yaml` |
+ | Log | `log` | `text/plain` |
+
+### Attachment Keying
+
+19. Each attachment is stored in a map keyed by a composite ID:
+ `{attachmentType}_{kind}_{name}_{ownerName}`. An explicit ID can
+ override the default key (used for alerts to avoid duplicates).
+
+20. Attachments persist in Redux state across popover open/close cycles
+ within the same session.
+
+21. Attachments are cleared after query submission and when starting a new
+ chat.
+
+## Constraints
+
+1. File uploads are restricted to `.yaml` and `.yml` extensions.
+2. Uploaded files must parse as valid YAML objects (not scalars or arrays).
+3. File upload size is capped at a maximum per-file limit.
+4. The plugin has no server-side attachment storage. Attachments exist only
+ in Redux state until submitted with a query.
+5. Events and logs require the resource to be watchable via the console's
+ K8s API proxy.
+
+## Planned Changes
+
+| Jira Key | Summary |
+|---|---|
+| OLS-1401 | Upload local YAML files (completed) |
+| OLS-1896 | ACM: Attach ApplicationSet objects from Applications page |
+| OLS-2065 | ACM: Attach policy violations |
+| OLS-2116 | ACM: Attach cluster info in ACM-enabled environments |
+| OLS-2284 | ACM: Add "Attach cluster info" option for Nodes and Add-ons tabs |
diff --git a/.ai/spec/what/auth.md b/.ai/spec/what/auth.md
new file mode 100644
index 00000000..479f5103
--- /dev/null
+++ b/.ai/spec/what/auth.md
@@ -0,0 +1,63 @@
+# Authentication & Authorization
+
+The plugin verifies that the current user is authorized to use OLS before
+enabling the chat interface. Authentication itself is handled by the
+OpenShift Console and the OLS backend service; the plugin only checks
+the result.
+
+## Behavioral Rules
+
+### Authorization Check
+
+1. When the chat interface mounts, the plugin must POST to the
+ authorization endpoint to verify the user's access.
+
+2. The authorization check uses the console's built-in authentication
+ (cookies/token). In development mode, a bearer token from the
+ `OLS_API_BEARER_TOKEN` environment variable is included in the
+ `Authorization` header.
+
+3. The authorization endpoint returns the user's `user_id` and `username`
+ on success.
+
+### Auth Status Handling
+
+4. The plugin must track one of five authorization states:
+
+ | Status | Condition | UI behavior |
+ |---|---|---|
+ | `AuthorizedLoading` | Check in progress | No alerts, chat loading |
+ | `Authorized` | 200 response | Full chat functionality enabled |
+ | `NotAuthenticated` | 401 response | Error alert, prompt input hidden |
+ | `NotAuthorized` | 403 response | Error alert, prompt input hidden |
+ | `AuthorizedError` | Any other error | No alert, chat loading state remains |
+
+5. When the user is not authenticated (401) or not authorized (403), the
+ plugin must display an inline error alert in the chat area and must hide
+ the prompt input area (footer). The user cannot submit queries.
+
+6. The `AuthorizedError` state (non-401/403 failures) does not display an
+ error alert. This handles transient network errors gracefully by keeping
+ the loading state.
+
+### Bearer Token Forwarding
+
+7. For development without the console proxy, the plugin supports injecting
+ a bearer token via the `OLS_API_BEARER_TOKEN` environment variable. When
+ set, this token is included as a `Bearer` token in the `Authorization`
+ header of all API requests.
+
+8. In production (running inside the console), no explicit `Authorization`
+ header is set. The console's proxy handles authentication transparently.
+
+## Constraints
+
+1. The authorization check runs once when the chat component mounts. It is
+ not repeated during the session.
+
+2. The plugin does not handle token refresh or re-authentication. If the
+ user's session expires, the console itself handles re-authentication.
+
+3. The plugin does not extract or store the user's identity. The user_id
+ and username from the auth response are not used by the plugin (they are
+ used by the service for conversation scoping).
diff --git a/.ai/spec/what/chat.md b/.ai/spec/what/chat.md
new file mode 100644
index 00000000..0840a242
--- /dev/null
+++ b/.ai/spec/what/chat.md
@@ -0,0 +1,158 @@
+# Chat
+
+The chat system manages the full lifecycle of user-AI conversations: prompt
+input, query submission, streamed response rendering, conversation management,
+and first-time user experience.
+
+## Behavioral Rules
+
+### Prompt Input
+
+1. The prompt input is a text area at the bottom of the chat window. It
+ accepts free-text input and is focused automatically when the chat opens.
+
+2. The send button is disabled when the prompt is empty or contains only
+ whitespace. Submitting an empty prompt must show a validation error state
+ on the input.
+
+3. The prompt text is persisted in Redux state. If the user closes and
+ reopens the popover, any previously entered text must still be present.
+
+4. The prompt area includes an attachment menu (accessed via the "+" button)
+ that provides options for attaching context and switching query modes.
+
+### Query Submission
+
+5. When the user submits a prompt, the plugin must:
+ a. Push a user chat entry (with text and any attachments) to the history.
+ b. Push a placeholder AI chat entry (with `isStreaming: true`).
+ c. Send a POST request to the streaming query endpoint.
+ d. Clear the prompt input, clear attachments, and return focus to the
+ input.
+
+6. The request body must include: `query` (the prompt text),
+ `conversation_id` (null for the first message, the service-assigned ID
+ thereafter), `mode` (`ask` or `troubleshooting`), `attachments` (converted
+ to the OLS API format), and `media_type` (`application/json`).
+
+7. Submitting a new prompt while a response is still streaming must be
+ blocked. The send button must be disabled during streaming.
+
+### Streaming Response
+
+8. Responses are received as a Server-Sent Events (SSE) stream. The plugin
+ must process the following event types:
+
+ | Event | Payload | Behavior |
+ |---|---|---|
+ | `start` | `conversation_id` | Store the conversation ID for subsequent requests |
+ | `token` | `token` | Append the token text to the current response |
+ | `end` | `referenced_documents`, `truncated` | Mark response as complete, store references, set truncation flag |
+ | `tool_call` | `name`, `id`, `args` | Record a tool invocation in the response |
+ | `approval_required` | `approval_id`, `tool_name`, `tool_args`, `tool_description` | Show approval UI for a pending tool |
+ | `tool_result` | `id`, `content`, `status`, `server_name`, `structured_content`, `tool_meta` | Update tool with execution result |
+ | `history_compression_start` | (none) | Show compression-in-progress indicator |
+ | `history_compression_end` | `duration_ms` | Show compression-complete indicator with duration |
+ | `error` | error details | Display error alert, stop streaming |
+
+9. Token updates to the UI must be throttled to prevent excessive re-renders
+ during streaming. Updates must use trailing-edge throttling so the final
+ token is always rendered.
+
+10. The stream response must be buffered line-by-line. Incomplete lines
+ (chunks split mid-line) must be held in a buffer until the next chunk
+ completes them.
+
+11. Only lines prefixed with `data: ` are processed. Each data line contains
+ a JSON object with `event` and `data` fields.
+
+### Stream Cancellation
+
+12. While streaming, a stop button must replace the send button. Clicking it
+ must abort the HTTP request and mark the response as cancelled.
+
+13. When a stream is cancelled, the partial response text must be preserved
+ and a "Cancelled" indicator must be displayed.
+
+14. If the stream errors (non-abort), the error must be displayed as an
+ inline alert in the AI response entry.
+
+### Response Rendering
+
+15. AI responses must be rendered as markdown with support for: headings,
+ lists, links, bold/italic, and code blocks.
+
+16. Code blocks must support: syntax highlighting with language labels,
+ expandable content for long blocks, a copy-to-clipboard action, and an
+ import action for YAML code blocks (navigates to the console's YAML
+ import page).
+
+17. When the service returns `referenced_documents` in the `end` event,
+ these must be rendered as source links. Only entries with valid URLs
+ are displayed.
+
+18. A loading indicator must be shown for AI responses that have no text
+ yet and are not cancelled or errored.
+
+### History Indicators
+
+19. When the service signals history compression, the plugin must display:
+ a spinning indicator during compression and a success indicator with
+ duration on completion.
+
+20. When the service signals history truncation (via the `truncated` flag
+ in the `end` event), a warning alert must be displayed.
+
+### Conversation Management
+
+21. Clicking the "Clear chat" button must show a confirmation modal before
+ clearing. On confirmation, the plugin must: clear all chat history,
+ clear the conversation ID, and clear any pending attachments.
+
+22. The "Copy conversation" button must copy all visible (non-hidden) chat
+ entries to the clipboard in a `You: ... / OpenShift Lightspeed: ...`
+ text format. A brief visual indicator must confirm the copy.
+
+23. A per-response copy button must copy that individual response's text
+ to the clipboard.
+
+### First-Time User Experience
+
+24. The plugin must detect first-time users via the
+ `lightspeed.hasClosedChat` user setting. A user is "first-time" if this
+ setting is `false` (the default) and settings have finished loading.
+
+25. For first-time users, the chat popover must auto-open after a brief
+ delay once the page has loaded, provided the button is not hidden.
+
+26. When a first-time user closes the chat, the plugin must set
+ `lightspeed.hasClosedChat` to `true`, permanently disabling auto-open.
+
+27. First-time users see a welcome notice component in the chat history.
+
+### Auto-Submit
+
+28. The plugin supports programmatic prompt submission via the `autoSubmit`
+ Redux flag. When set to `true`, the plugin must programmatically click
+ the send button to trigger both submission and internal MessageBar state
+ cleanup, then reset the flag to `false`.
+
+### Hidden Prompt
+
+29. When `hidePrompt` is `true`, the user's message in chat history must be
+ hidden (not rendered). This is used when opening OLS programmatically
+ with a contextual prompt that the user should not see. The flag resets
+ to `false` after submission.
+
+## Constraints
+
+1. The plugin does not persist chat history across page refreshes. All chat
+ state is in-memory (Redux).
+
+2. The plugin does not implement its own retry logic for failed queries.
+ Users must resubmit.
+
+3. The conversation ID is assigned by the service (via the `start` event)
+ and stored in Redux. The plugin never generates its own conversation IDs.
+
+4. The plugin does not support multiple concurrent conversations.
diff --git a/.ai/spec/what/feedback.md b/.ai/spec/what/feedback.md
new file mode 100644
index 00000000..86ed6ff1
--- /dev/null
+++ b/.ai/spec/what/feedback.md
@@ -0,0 +1,79 @@
+# User Feedback
+
+Users can provide feedback on AI responses to help improve the service.
+Feedback is submitted to the OLS backend service and may be forwarded via
+the Insights telemetry pipeline.
+
+## Behavioral Rules
+
+### Feedback Status
+
+1. On plugin initialization, the plugin must check whether feedback
+ collection is enabled by calling the feedback status endpoint. If the
+ endpoint returns `enabled: false`, the plugin must disable all feedback
+ UI for the session.
+
+2. If the feedback status check fails, feedback must remain enabled
+ (optimistic default). The error must be logged to the console.
+
+3. The feedback enabled/disabled state is stored in Redux and affects all
+ responses in the session.
+
+### Feedback Actions
+
+4. When feedback is enabled, each AI response (except those with errors)
+ must show thumbs-up and thumbs-down action buttons.
+
+5. Clicking thumbs-up or thumbs-down must:
+ a. Open the feedback form for that specific response.
+ b. Record the sentiment (positive = 1, negative = -1).
+
+6. The feedback form must include:
+ - A privacy disclaimer warning users not to include personal or
+ sensitive information.
+ - An optional free-text area for detailed feedback.
+ - A submit button.
+ - A close button.
+
+7. The sentiment and free-text values must be persisted in Redux state per
+ response entry. If the user closes and reopens the popover, previously
+ entered feedback must still be present.
+
+### Feedback Submission
+
+8. On submit, the plugin must POST to the feedback endpoint with:
+ `conversation_id`, `user_question` (including serialized attachments if
+ any), `llm_response`, `sentiment` (1 or -1), and `user_feedback`
+ (free-text, empty string if not provided).
+
+9. On success, a "feedback submitted" confirmation must replace the form.
+
+10. On failure, an error alert must be displayed. The form must remain
+ accessible for retry.
+
+11. The feedback submission has a request timeout.
+
+### Privacy Notice
+
+12. A static privacy notice must be displayed in the chat welcome area at
+ all times, informing users that AI technology is used and that
+ interactions may improve Red Hat's products.
+
+13. Each feedback form must include a privacy warning about not including
+ personal or sensitive information.
+
+### Copy Response
+
+14. Each AI response (except those with errors) must include a copy action
+ that copies the response text to the clipboard.
+
+## Constraints
+
+1. Feedback state is per-response, not per-conversation. Users can submit
+ feedback on individual responses independently.
+
+2. Feedback is submitted once per response. After successful submission,
+ the form shows a confirmation and cannot be resubmitted.
+
+3. Feedback is not stored locally. It is sent to the OLS service which
+ handles persistence and telemetry forwarding.
diff --git a/.ai/spec/what/plugin-api.md b/.ai/spec/what/plugin-api.md
new file mode 100644
index 00000000..acea0f0f
--- /dev/null
+++ b/.ai/spec/what/plugin-api.md
@@ -0,0 +1,101 @@
+# Plugin API
+
+The plugin registers with the OpenShift Console via console extensions and
+exposes hooks and extension points for other plugins to interact with OLS.
+
+## Behavioral Rules
+
+### Console Extensions
+
+1. The plugin must register the following console extensions:
+
+ | Extension type | Purpose |
+ |---|---|
+ | `console.flag` | Sets the `LIGHTSPEED_PLUGIN` feature flag to `true`, allowing other plugins to detect OLS availability |
+ | `console.context-provider` | Mounts a context provider that invokes the `usePopover` hook, which launches the chat modal on first render |
+ | `console.dashboards/custom/overview/detail/item` | Displays the plugin version on the cluster overview dashboard |
+ | `console.redux-reducer` | Registers the OLS Redux reducer under the `ols` scope |
+ | `console.action/provider` | Exposes the `useOpenOLS` hook under the `ols-open-handler` context ID |
+
+2. The context provider uses a no-op React component that returns `null`.
+ Its sole purpose is to run the `usePopover` side-effect hook, which
+ calls `useModal` to launch the Popover component exactly once.
+
+3. The popover is launched using a stable modal ID to prevent duplicate
+ instances.
+
+### useOpenOLS Public API
+
+4. The `useOpenOLS` hook must be exposed as a console action provider so
+ other plugins and console pages can programmatically open the OLS chat.
+
+5. The hook returns a callback function with the following signature:
+
+ ```
+ (prompt?: string, attachments?: Attachment[], submitImmediately?: boolean, hidePrompt?: boolean) => void
+ ```
+
+6. The callback must:
+ a. Set the prompt text in Redux state (if provided).
+ b. Add all provided attachments to Redux state (if any).
+ c. Set the `autoSubmit` flag (if `submitImmediately` is true).
+ d. Set the `hidePrompt` flag (if `hidePrompt` is true).
+ e. Dispatch `openOLS` to show the chat window.
+
+7. When called with no arguments, the callback must simply open the chat
+ window without modifying the prompt or attachments.
+
+### ols.tool-ui Extension Type
+
+8. The plugin defines a custom extension type `ols.tool-ui` that allows
+ other console plugins to register tool visualization components.
+
+9. Each `ols.tool-ui` extension must declare:
+ - `id`: A string identifier matching the `olsToolUiID` value in tool
+ metadata from the service.
+ - `component`: A code reference to a React component.
+
+10. The plugin discovers registered `ols.tool-ui` extensions using the
+ console's `useResolvedExtensions` API, building a lookup map from ID
+ to component.
+
+11. When a tool result's `tool_meta.olsUi.id` matches a registered
+ extension ID, the corresponding component is rendered with the full
+ `Tool` object as a prop.
+
+### Feature Flag
+
+12. The `LIGHTSPEED_PLUGIN` feature flag is set to `true` when the plugin
+ loads. Other plugins can use this flag to conditionally enable
+ OLS-dependent features (e.g., "Ask Lightspeed" actions).
+
+### User Settings
+
+13. The plugin reads but does not write the `console.theme` user setting
+ for theme detection.
+
+14. The plugin reads and writes the `lightspeed.hasClosedChat` user
+ setting for first-time user tracking.
+
+15. The plugin reads the `console.hideLightspeedButton` user setting to
+ determine if the OLS button should be hidden.
+
+### Redux State Scope
+
+16. All plugin Redux state is scoped under `state.plugins.ols`. The plugin
+ must not read or write state outside this scope, except for
+ `state.sdkCore.user.username` (read-only, for display purposes).
+
+## Constraints
+
+1. The `useOpenOLS` hook is only usable within the OpenShift Console React
+ tree. It requires access to the Redux store via `useDispatch`.
+
+2. The `ols.tool-ui` extension type is resolved asynchronously. Components
+ may not be available immediately on first render.
+
+3. The popover modal is launched once per page load. The `usePopover` hook
+ uses a boolean guard to prevent re-launching.
+
+4. The plugin version displayed on the dashboard is a static string from
+ the build, not fetched from the service.
diff --git a/.ai/spec/what/system-overview.md b/.ai/spec/what/system-overview.md
new file mode 100644
index 00000000..2bcf1871
--- /dev/null
+++ b/.ai/spec/what/system-overview.md
@@ -0,0 +1,158 @@
+# System Overview
+
+The OpenShift LightSpeed console plugin is a dynamic plugin for the OpenShift
+web console that provides an AI chat assistant UI. It connects to the OLS
+backend service (lightspeed-service) via the console's built-in plugin proxy,
+enabling users to ask natural-language questions, attach Kubernetes resource
+context, and receive streamed AI-generated responses. [PLANNED: OLS-2743] The
+product is being rebranded to "Red Hat OpenShift Intelligent Assistant."
+
+## Behavioral Rules
+
+### Identity and Scope
+
+1. The plugin is an OpenShift Console dynamic plugin that adds an AI chat
+ assistant UI overlay to all console pages. It does NOT include the backend
+ service (lightspeed-service), the operator (lightspeed-operator), or the RAG
+ content pipeline (lightspeed-rag-content).
+
+2. The plugin registers with the OpenShift Console via a set of console
+ extensions (feature flag, context provider, Redux reducer, action provider,
+ dashboard detail item). The console discovers and loads the plugin at
+ runtime.
+
+3. The plugin renders as a floating popover window that appears over any
+ console page. The popover can be collapsed (default), expanded
+ (fullscreen), or minimized (hidden with button visible).
+
+4. The plugin communicates with the OLS backend service exclusively through
+ the console's built-in plugin proxy. All API requests are routed through
+ a proxy path that leverages the console's authentication.
+
+### UI Lifecycle
+
+5. The plugin displays a floating button on all console pages. Clicking the
+ button opens the chat popover. Clicking it again minimizes the popover.
+
+6. First-time users (those who have never closed the chat) see the popover
+ auto-open after a short delay. Once a user closes the chat for the first
+ time, the plugin records this in user settings and never auto-opens again.
+
+7. The plugin can be hidden entirely via a user setting. When hidden, neither
+ the button nor the popover renders.
+
+8. The plugin wraps its root component in an error boundary. If the plugin
+ crashes, it must not break the host console application.
+
+### Chat Interface
+
+9. The chat interface displays a scrollable message history with user messages
+ and AI responses. Each AI response supports: markdown rendering, code
+ blocks with copy and import actions, documentation references (source
+ links), tool call summaries, and inline alerts for errors, cancellation,
+ history truncation, and history compression.
+
+10. The chat header provides controls for: clearing the chat (with
+ confirmation), copying the entire conversation to clipboard, expanding to
+ fullscreen, collapsing from fullscreen, and minimizing the window.
+
+11. The chat footer contains the prompt input area, a disclaimer footnote,
+ and a contact link.
+
+12. A welcome section is always visible at the top of the chat, including the
+ product logo, an introductory message, authentication status alerts, and
+ a privacy notice.
+
+### Query Modes
+
+13. The plugin supports two query modes: **Ask** (default) and
+ **Troubleshooting**. The mode is selected via the attachment menu and sent
+ with each query request. Ask mode provides general product guidance.
+ Troubleshooting mode enables deeper diagnostic and remediation analysis.
+
+14. When Troubleshooting mode is active, a removable label is displayed in
+ the message bar. Removing the label switches back to Ask mode.
+
+### Relationship to OLS Service
+
+15. The plugin is a pure UI client of the OLS backend service. It has no
+ local AI processing, no local conversation storage, and no direct LLM
+ communication. All intelligence comes from the service.
+
+16. The plugin sends queries to the streaming endpoint and processes
+ server-sent events (SSE) for incremental response rendering.
+
+17. The plugin sends conversation IDs with requests to maintain multi-turn
+ context. The service manages conversation history; the plugin maintains
+ only the current session's chat entries in Redux state.
+
+### Internationalization
+
+18. All user-facing strings must be wrapped in translation calls using the
+ `plugin__lightspeed-console-plugin` namespace.
+
+19. Locale files must be updated when UI text changes.
+
+### Theme Support
+
+20. The plugin must support both light and dark console themes. Theme
+ detection uses the user's console theme setting, falling back to the
+ OS-level `prefers-color-scheme` media query when set to system default.
+
+21. Theme-dependent assets (logos, avatars) must switch based on the active
+ theme.
+
+## Configuration Surface
+
+The plugin has no configuration file. User-configurable values are stored in
+OpenShift Console user settings:
+
+| Setting key | Type | Default | Description |
+|---|---|---|---|
+| `lightspeed.hasClosedChat` | bool | `false` | Tracks whether user has ever closed the chat (disables auto-open) |
+| `console.hideLightspeedButton` | bool | `false` | Hides the OLS button and popover entirely |
+| `console.theme` | string | `null` | Console theme (`light`, `dark`, `systemDefault`, or `null`) |
+
+Environment variables (development only):
+
+| Variable | Description |
+|---|---|
+| `OLS_API_BEARER_TOKEN` | Bearer token for direct API access (bypasses console proxy auth) |
+
+## Constraints
+
+1. **Console-only deployment.** The plugin runs exclusively inside the
+ OpenShift web console. It cannot function as a standalone application.
+
+2. **Proxy-only API access.** All OLS API communication goes through the
+ console's plugin proxy. The plugin never connects directly to the OLS
+ service.
+
+3. **No persistent client-side storage.** The plugin uses Redux for
+ in-session state only. Chat history, conversation data, and feedback are
+ not persisted on the client between page refreshes.
+
+4. **Security boundary: no Secrets.** The plugin explicitly excludes
+ Kubernetes Secrets from context detection and attachment. It will never
+ offer to attach a Secret's YAML.
+
+5. **PatternFly design system.** All UI components must use PatternFly
+ components and design tokens. Custom CSS must be scoped to the
+ `ols-plugin__` prefix to avoid conflicts with console styles.
+
+6. **Single conversation per session.** The plugin supports one active
+ conversation at a time. Starting a new chat clears the current
+ conversation.
+
+## Planned Changes
+
+| Jira Key | Summary |
+|---|---|
+| OLS-2743 | Rebranding to "Red Hat OpenShift Intelligent Assistant" |
+| OLS-2598 | MCP Apps support in OLS console |
+| OLS-2700 | Allow users to choose agent mode (PF6 only) |
+| OLS-2722 | OLS Tool UI extensibility from external plugins |
+| OLS-2608 | Embed PromQL QueryBrowser in OLS responses |
+| OLS-2609 | Embed PromQL scalar values in OLS responses |
+| OLS-2816 | Option to immediately submit prompt when opening OLS programmatically |
+| OLS-2826 | Hide the initial prompt when opening OLS programmatically |
diff --git a/.ai/spec/what/tools.md b/.ai/spec/what/tools.md
new file mode 100644
index 00000000..b0890c7b
--- /dev/null
+++ b/.ai/spec/what/tools.md
@@ -0,0 +1,158 @@
+# Tools
+
+The plugin renders tool call information from the OLS service's agentic
+pipeline, provides a human-in-the-loop (HITL) approval workflow, and hosts
+interactive MCP App UIs and OLS-native tool visualizations.
+
+## Behavioral Rules
+
+### Tool Call Display
+
+1. When the AI response includes tool calls, the plugin must render a label
+ group summarizing all completed tools. Labels are clickable and open a
+ detail modal.
+
+2. Tool labels must be color-coded by status:
+
+ | Condition | Color | Icon |
+ |---|---|---|
+ | Denied by user | grey | ban icon |
+ | Error status | red | info icon |
+ | Truncated status | yellow | info icon |
+ | Has interactive UI | blue | external link icon |
+ | Normal (success) | default | code icon |
+
+3. The tool label group must show a configurable maximum number of labels
+ before collapsing behind a "+N more" indicator.
+
+4. Only completed tools (those that are not pending user approval) must
+ appear in the label group. Tools awaiting approval are rendered
+ separately as approval cards.
+
+### Tool Detail Modal
+
+5. Clicking a tool label must open a modal showing:
+ - Tool name and status indicator.
+ - Tool arguments formatted as `key=value` pairs.
+ - MCP server name (if available).
+ - UI resource URI (if available).
+ - Tool output content in a scrollable code block with copy action.
+ - Structured content (JSON-formatted) in a separate code block with
+ copy action (if available).
+
+6. For denied tools, the modal must show a rejection message with the tool
+ name and arguments, and must not display output content.
+
+7. For tools with error status, the modal must display an error alert.
+
+### Tool Approval (HITL)
+
+8. When the service sends an `approval_required` event, the plugin must
+ render a warning card in the chat for the user to review.
+
+9. The approval card must display:
+ - A warning icon and "Review required" heading.
+ - The tool description (if provided by the service).
+ - An expandable section showing the tool name and arguments.
+ - "Approve" (warning variant) and "Reject" (secondary variant) buttons.
+
+10. On approval, the plugin must POST to the tool approvals endpoint with
+ `approval_id` and `approved: true`. On success, the tool must be marked
+ as approved in Redux state.
+
+11. On denial, the plugin must POST to the tool approvals endpoint with
+ `approval_id` and `approved: false`. On success, the tool must be marked
+ as denied in Redux state.
+
+12. If the approval or denial API call fails, the plugin must update the
+ tool with an error status and display the error message as tool content.
+
+13. The approval request has a timeout matching the service-side approval
+ timeout.
+
+### Linking Tool Calls to Approval Requests
+
+14. The service does not provide a direct ID linking `approval_required`
+ events to prior `tool_call` events. The plugin must use a composite key
+ of tool name + JSON-serialized arguments to correlate them.
+
+### MCP App Interactive UI
+
+15. When a tool result includes a `uiResourceUri` and `serverName` in its
+ `tool_meta`, the plugin must render an interactive MCP App card for that
+ tool.
+
+16. The MCP App must load its HTML content from the OLS service's MCP
+ resources endpoint, passing the `resource_uri` and `server_name`.
+
+17. The loaded HTML must be rendered in a sandboxed iframe with only
+ `allow-scripts` permission. No other sandbox permissions are granted.
+
+18. The MCP App card must provide controls for: expand/collapse to
+ fullscreen, minimize to a compact header-only card, restore from
+ minimized, and refresh (re-send tool data).
+
+19. The plugin and MCP App iframe must communicate via bidirectional
+ JSON-RPC 2.0 messages using `postMessage`. The plugin must only process
+ messages from its own iframe's content window.
+
+20. The plugin must handle the following JSON-RPC methods from the iframe:
+
+ | Method | Behavior |
+ |---|---|
+ | `initialize` | Respond with protocol version, host info, capabilities, and context (theme, tool name, server name) |
+ | `notifications/initialized` | Send host context and initial tool data; on failure, send cached tool content |
+ | `tools/call` | Execute the requested tool via the MCP apps tools endpoint and return the result |
+ | `notifications/size-changed` | Resize the iframe height within min/max bounds |
+
+21. Unrecognized methods with a request ID must receive a JSON-RPC error
+ response (`-32601 Method not found`). Notifications (no ID) for
+ unrecognized methods must be silently ignored.
+
+22. On initial load, the plugin must send the tool input arguments and then
+ execute a fresh tool call and send the result. If the fresh call fails,
+ the plugin must fall back to sending the cached tool content from the
+ stream.
+
+23. When the console theme changes, the plugin must notify the iframe via
+ `ui/notifications/host-context-changed` with the new theme. The HTML
+ content must also include a `data-theme` attribute on the `` tag.
+
+### OLS Tool UI Extensions
+
+24. Other console plugins can register tool UI components via the
+ `ols.tool-ui` extension type. When a tool result includes an
+ `olsToolUiID` in its `tool_meta`, the plugin must look up the
+ registered component by ID and render it.
+
+25. Each OLS tool UI component receives the full `Tool` object as a prop.
+
+26. OLS tool UI components must be wrapped in an error boundary. A component
+ crash must not affect other tool UIs or the chat interface.
+
+27. Tools with error status must not render their OLS tool UI component.
+
+## Constraints
+
+1. The MCP App iframe is sandboxed to `allow-scripts` only. It cannot
+ navigate the parent page, access the parent's DOM, submit forms, or open
+ popups.
+
+2. MCP App iframe height is bounded between a minimum and maximum value.
+
+3. The tool approval timeout on the plugin side must match the service-side
+ timeout to avoid orphaned approval UI.
+
+4. The tool name + arguments composite key for approval correlation assumes
+ uniqueness within a single response. If the service invokes the same tool
+ with identical arguments twice in one response, correlation may be
+ incorrect.
+
+## Planned Changes
+
+| Jira Key | Summary |
+|---|---|
+| OLS-2683 | MVP for HITL approve/deny (completed) |
+| OLS-2722 | OLS Tool UI extensibility from external plugins |
+| OLS-2598 | MCP Apps support in OLS console |
+| OLS-1556 | Display info about tools called while generating OLS response |
diff --git a/AGENTS.md b/AGENTS.md
index 6b9e8f22..3d8377da 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -120,3 +120,29 @@ All conversation state (chat history, attachments, etc.) is managed in Redux.
- Ensure build works: `npm run build`
- Ensure unit tests pass: `npm run test:unit`
- Ensure tests pass: `npm run test-headless`
+
+## Git and PR Workflow
+
+### Commit Messages
+- Start with the Jira ticket reference: `OLS-XXXX description`
+- Keep the first line under 72 characters
+- Use imperative mood
+
+### Pull Requests
+This repo uses a **fork-based workflow**:
+
+1. **Push to your fork**, not to `origin` (openshift/lightspeed-console)
+2. **Create the PR** against `origin/main` using your fork's branch:
+ ```bash
+ git push
+ gh pr create --repo openshift/lightspeed-console --head : --base main
+ ```
+3. **PR title** must start with the Jira reference: `OLS-XXXX description`
+4. **Squash commits** before pushing -- one logical commit per PR unless the PR explicitly tracks multiple independent changes
+
+### Branch Completion
+When finishing a development branch:
+1. Remove any process artifacts (design docs, plans in `docs/superpowers/`)
+2. Squash commits with the Jira-prefixed message
+3. Push to the contributor's fork remote (not `origin`)
+4. Create the PR against `origin/main` using `--head :`