This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/ai-history-and-enhancement
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 91e7f493beb6cb43a7ec80a3de6d9a4a944a7538
Author: Wu Sheng <[email protected]>
AuthorDate: Tue Jul 28 17:29:50 2026 +0800

    fix(ai): resolve 32 findings from the branch-wide review
    
    Adversarial review of the whole branch (8 areas, every finding independently
    verified) confirmed 32 issues. All fixed.
    
    Correctness / security:
    - analyze_profiling read OAP profiling data with NO verb check, so a role
      holding ai:read but not profile:read could read through the assistant what
      the routes deny it. It now gates on profile:read (profile:enable does not
      imply it).
    - The metric→trace drill watcher ran in embedded blocks: with ?dMode= on the
      host URL it armed a query, and refetch() bypasses `enabled`, so a frozen
      replay block hit OAP. The drill is route-only now.
    - pprof task durations were labelled seconds; OAP defines pprof in MINUTES
      (only async-profiler is seconds).
    - pprof/async analyze always returns a synthetic root frame, so an empty
      profile reported as a real 1-frame result and the "nothing collected" path
      never fired.
    - The "% self" figures fed to the model mixed units (ms over a dump count) 
and
      claimed self-time for eBPF, which carries none — now per-flavor and 
honest.
    - The "collected" heuristic could never be true for eBPF, so a 
still-collecting
      eBPF task was reported as "profiling unsupported at this deployment".
    - Trace proposals could be emitted with no endpoint, which OAP always 
rejects —
      a card that could only fail on approve. Guarded on both sides + a test.
    - show_traces ignored the operator's maxPageSize.traces cap; the v1 hydrate
      loop re-fetched the same trace once per segment row.
    - Network topology probed only the first instance, so a service whose
      Rover-monitored processes live elsewhere read as "no eBPF agent".
    
    Replay honesty (a captured block must never lie or re-query):
    - Captured reachable:false reads for Zipkin traces and logs replayed as 
benign
      "nothing in this window" instead of the real failure.
    - The traces block gated its own rendering on a LIVE layer query, so a 
snapshot
      refused to replay while that query was empty or in flight.
    - A reachable-but-errored profiling analysis dropped its reason; a captured
      profiled trace was discarded whenever the flame came back empty.
    - Log level chips stayed clickable in replay though the filter is 
server-side.
    
    History + i18n:
    - textOf() omitted the two new block kinds, so a turn carrying only a 
profiling
      or process-topology block was dropped from the LLM history.
    - Full i18n sweep: 14 keys added to en.json, 1 orphan pruned, and all 7
      non-English catalogs brought to parity (+25 keys each — 12 earlier branch
      keys had never been propagated). Two hardcoded strings wrapped in t().
    
    Prompts + docs:
    - triggers.yaml described network profiling as text-only after it started
      capturing a frozen process-topology block.
    - No playbook mentioned show_widget though it is the preferred render path, 
and
      five still claimed trace inspection was not an assistant tool.
    - The operator doc still advertised the removed service-list card and the
      sub-page block; CHANGELOG still carried its trailing caveat.
    
    Validated live on the demo: trace capture freezes 30 v2 rows; the profiling
    analyze returns a 74-frame flame carrying its profiled-trace waterfall.
---
 CHANGELOG.md                                       |  2 +-
 apps/bff/src/ai/resources/skills/rca/errors-sla.md | 10 ++--
 apps/bff/src/ai/resources/skills/rca/k8s.md        | 10 ++--
 apps/bff/src/ai/resources/skills/rca/latency.md    | 10 ++--
 apps/bff/src/ai/resources/skills/rca/mesh.md       | 14 +++---
 .../ai/resources/skills/rca/middleware-remote.md   | 10 ++--
 apps/bff/src/ai/resources/skills/rca/root-cause.md | 12 +++--
 apps/bff/src/ai/resources/skills/rca/saturation.md | 12 +++--
 apps/bff/src/ai/resources/tools/triggers.yaml      |  4 +-
 apps/bff/src/ai/skill/triggers/tools.test.ts       | 10 ++++
 apps/bff/src/ai/skill/triggers/tools.ts            | 58 +++++++++++++++++++---
 apps/bff/src/ai/skill/visualization/tools.ts       | 40 +++++++++++----
 apps/bff/src/logic/oap/profiling.ts                | 54 ++++++++++++++------
 apps/ui/src/ai/ChatProfilingBlock.vue              | 21 ++++----
 apps/ui/src/ai/ChatProposalBlock.vue               | 48 +++++++++++++-----
 apps/ui/src/ai/ChatTracesBlock.vue                 | 13 +++--
 apps/ui/src/ai/useAiConversations.ts               |  4 ++
 apps/ui/src/i18n/locales/de.json                   | 27 +++++++++-
 apps/ui/src/i18n/locales/en.json                   | 17 ++++++-
 apps/ui/src/i18n/locales/es.json                   | 27 +++++++++-
 apps/ui/src/i18n/locales/fr.json                   | 27 +++++++++-
 apps/ui/src/i18n/locales/ja.json                   | 27 +++++++++-
 apps/ui/src/i18n/locales/ko.json                   | 27 +++++++++-
 apps/ui/src/i18n/locales/pt.json                   | 27 +++++++++-
 apps/ui/src/i18n/locales/zh-CN.json                | 27 +++++++++-
 .../LayerEndpointDependencyView.vue                |  2 +-
 apps/ui/src/layer/logs/LayerLogsView.vue           | 22 +++++---
 apps/ui/src/layer/logs/useLayerLogs.ts             | 11 +++-
 apps/ui/src/layer/traces/LayerTracesView.vue       |  4 ++
 apps/ui/src/layer/traces/LayerZipkinTracesView.vue | 10 ++--
 apps/ui/src/layer/traces/useZipkinTraces.ts        | 11 +++-
 docs/operate/ai-assistant.md                       | 15 +++---
 32 files changed, 489 insertions(+), 124 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b4adf7d..c45e4bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,7 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 
 - **Ask about your system in plain language and get answers built from the 
real dashboard widgets — not just text.** A new floating **AI Assistant** 
launcher (right edge, after login) opens a chat: describe what you want to know 
("what's unhealthy right now?", "investigate latency for a service") and the 
assistant reads **live OAP data** through the same query path the dashboards 
use, then streams back an ordered narrative with **inline charts, top-N lists 
and tables** drawn by the same  [...]
 - **Read-only, and it inherits your permissions.** The assistant can list 
layers/services, read active **alarms** (the "what's unhealthy" signal), browse 
the per-layer **metric catalog** (the curated MQE for each metric), drill a 
service down to its instances/endpoints, and render those metrics — every data 
tool checks the same read verb you already hold, so the assistant never sees 
more than you can, and it never changes configuration, rules, or dashboards.
-- **It renders the real feature views inline — same components, focused on the 
service.** Rather than a link you open, the assistant mounts the *actual* 
product views inside the chat, read-only and scoped to the service: ask for 
**topology** and it embeds the real service map focused one hop (hex nodes, 
edges, RPM/latency, zoom controls); ask how a service maps across layers and it 
shows the real **Smartscape hierarchy** fan; ask for **traces** and it embeds 
the Traces view — the trace l [...]
+- **It renders the real feature views inline — same components, focused on the 
service.** Rather than a link you open, the assistant mounts the *actual* 
product views inside the chat, read-only and scoped to the service: ask for 
**topology** and it embeds the real service map focused one hop (hex nodes, 
edges, RPM/latency, zoom controls); ask how a service maps across layers and it 
shows the real **Smartscape hierarchy** fan; ask for **traces** and it embeds 
the Traces view — the trace l [...]
 - **Read live Kubernetes pod logs, right in the chat.** For a k8s workload the 
assistant pulls a pod container's on-demand logs (the error stack) and shows 
the fetched lines inline as a read-only result — the same on-demand-log path as 
the Pod Logs tab, so nothing is stored and it inherits your `logs:read` 
permission. It's a result, not a console: no tail or refresh controls (operate 
a live tail in the Pod Logs tab); when a content filter was applied the block 
shows it, so an empty resul [...]
 - **It can propose profiling — the right kind for the target — and only you 
start it.** When metrics and traces can't localise a cause, the assistant 
presents a **decision card** explaining what it found, why profiling would 
help, and what it expects to reveal; nothing runs until you **approve it in the 
popout**, and only if you hold the profiling permission. It picks the profiling 
flavour that fits the target — **trace** sampling, **async-profiler** for a JVM 
service, **pprof** for a Go [...]
 - **Guided root-cause analysis.** Ask "what's the root cause?" and the 
assistant follows built-in investigation playbooks — a master method (locate 
the root service → calling chain → error stack; walk the dependency topology 
upstream and fix a sick upstream first; a remote / Virtual_* dependency exposes 
only its client-side edge metric) plus latency, error-rate/SLA, saturation, 
middleware, **Kubernetes-workload**, and service-mesh specializations. It can 
also follow the **cross-layer hie [...]
diff --git a/apps/bff/src/ai/resources/skills/rca/errors-sla.md 
b/apps/bff/src/ai/resources/skills/rca/errors-sla.md
index eab51a0..85eb277 100644
--- a/apps/bff/src/ai/resources/skills/rca/errors-sla.md
+++ b/apps/bff/src/ai/resources/skills/rca/errors-sla.md
@@ -5,10 +5,12 @@ whenToUse: A service's success rate (SLA) falling or its 
error rate spiking —
 ---
 Refines the root-cause master for error-rate / SLA drops. Ordering: root 
service -> calling chain -> error stack; upstream-first; catalog MQE verbatim, 
never invented.
 
+RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — 
the entry/widget id kb_browse_catalog returned, plus instance/endpoint for a 
drilled scope. It draws the dashboard widget with its full config (type, unit, 
thresholds, explanation). Keep show_line / show_card / show_top / show_table / 
show_record for a DERIVED expression the catalog has no entry for.
+
 1. list_alarms — is an alarm firing for the affected service (an SLA / 
error-rate rule)? If yes, read it for the entity and threshold. If not, treat 
as a near-miss (a real drop can sit just under an edge-case bound) and 
continue; formal rule inspection is not yet a tool.
-2. Confirm the symptom at Service scope. kb_browse_catalog the service's page; 
kb_describe_metric the success-rate (SLA) and cpm entries and render with 
show_line. The signature is SLA dropping while cpm climbs. Use catalog MQE 
verbatim.
+2. Confirm the symptom at Service scope. kb_browse_catalog the service's page; 
kb_describe_metric the success-rate (SLA) and cpm entries and render each with 
show_widget by its catalog id. The signature is SLA dropping while cpm climbs. 
Use catalog MQE verbatim.
 3. show_topology -> walk UPSTREAM. For each upstream dependency, re-run the 
SLA/cpm check. If an upstream is also unhealthy, it is likely the real cause — 
fix the upstream first. Keep climbing to the root service. If an upstream is an 
unmanaged Virtual_* remote, only the client-side edge metric exists — check it 
and stop. If the root service is backed by an infra/database layer, 
kb_resolve_hierarchy into it and check its error/connection metrics.
-4. At the root service, drill scope — OAP does not roll up. 
kb_resolve_scope_drill to Endpoint scope and re-query the same SLA/cpm; 
show_top / show_table the top failing endpoints (outliers stand out).
-5. kb_resolve_scope_drill to ServiceInstance scope -> re-query SLA plus the 
instance runtime metrics kb_browse_catalog(<layer>, instance) exposes with 
show_line; pick a few abnormal instances. For a k8s workload, read their pod 
logs now — list_pod_containers + fetch_pod_logs (instance attributes are not an 
assistant tool yet).
-6. Error stack. On a k8s pod, fetch_pod_logs names the failure (filter 
status/errors with keywordsOfContent). Native trace queries (status=ERROR) + 
span logs are NOT an assistant tool yet — surface the failing-endpoint 
show_table and name trace inspection as the next step.
+4. At the root service, drill scope — OAP does not roll up. 
kb_resolve_scope_drill to Endpoint scope and re-query the same SLA/cpm; render 
with show_widget — an endpoint-ranking entry from the service page if the 
catalog has one, otherwise the endpoint-scope entry per endpoint 
(scope='endpoint', endpoint=<name>). The failing endpoints stand out.
+5. kb_resolve_scope_drill to ServiceInstance scope -> re-query SLA plus the 
instance runtime metrics kb_browse_catalog(<layer>, instance) exposes, each 
with show_widget (scope='instance', instance=<name>); pick a few abnormal 
instances. For a k8s workload, read their pod logs now — list_pod_containers + 
fetch_pod_logs (instance attributes are not an assistant tool yet).
+6. Error stack. On a k8s pod, fetch_pod_logs names the failure (filter 
status/errors with keywordsOfContent). SURFACE THE TRACES yourself — pick the 
tool from kb_layer_capabilities(layer).tracesSource, never from the layer name: 
native -> show_traces(layer, service); zipkin -> list_zipkin_services, match 
the service, then show_zipkin_traces. Either one captures a frozen trace list + 
span waterfall inline. You do not get span contents back, so name the failing 
endpoint + window in your pr [...]
 7. If the cause stays hidden. AVAILABLE now: show_deployment for a manifest / 
resource-limit fault; propose_profiling (JVM / Go / eBPF). NOT wired yet: the 
events feed (recent reboot?), git / code-line — name the relevant one rather 
than fabricating a call.
diff --git a/apps/bff/src/ai/resources/skills/rca/k8s.md 
b/apps/bff/src/ai/resources/skills/rca/k8s.md
index ccc5985..a966246 100644
--- a/apps/bff/src/ai/resources/skills/rca/k8s.md
+++ b/apps/bff/src/ai/resources/skills/rca/k8s.md
@@ -5,6 +5,8 @@ whenToUse: The symptom is on a Kubernetes workload — a 
K8S_SERVICE service/pod
 ---
 Refines the root-cause master for Kubernetes workloads. k8s pod-lifecycle 
signal is METRICS-FIRST — OAP does NOT natively collect k8s Event objects, so 
pod-lifecycle faults surface as METRICS (a status metric carrying a reason 
tag), not an events feed. Order: root service -> calling chain -> error stack; 
upstream-first; catalog MQE verbatim, never invented.
 
+RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — 
the entry/widget id kb_browse_catalog returned, plus instance/endpoint for a 
drilled scope. It draws the dashboard widget with its full config (type, unit, 
thresholds, explanation). Keep show_line / show_card / show_top / show_table / 
show_record for a DERIVED expression the catalog has no entry for.
+
 WHICH LAYER to read — resolve each one's vocabulary + metrics from 
kb_layer_capabilities + kb_browse_catalog, never from a hardcoded name:
 - The workload/pod layer (K8S_SERVICE — richest, start here): 
kb_layer_capabilities for what Service / Instance / Endpoint MEAN here, then 
kb_browse_catalog for its golden-signal + pod-resource + pod-status metrics.
 - The cluster/node layer (K8S): go here for node / cluster pressure — 
kb_layer_capabilities for its vocabulary, kb_browse_catalog for the node + 
cluster metrics it returns.
@@ -12,11 +14,11 @@ WHICH LAYER to read — resolve each one's vocabulary + 
metrics from kb_layer_ca
 - The EKS variant, where the workload runs on EKS: the same, read from its own 
catalog.
 
 1. list_alarms — a firing alarm on the k8s Service names the entity. None? 
Near-miss; continue.
-2. Confirm the symptom at K8S_SERVICE Service scope. 
kb_browse_catalog(K8S_SERVICE, service); render its golden-signal series (the 
success-rate / throughput / response-time entries the catalog returns) with 
show_line, and render its pod-status entries (the catalog's latest()-wrapped 
labeled tables, not time series) with show_table. Restarts climbing or pods 
stuck in a waiting state is the k8s tell.
-3. Pod status REASON. show_table the catalog's pod-status tables — the waiting 
table carries a waiting-reason tag whose value comes over the wire (why the pod 
won't start). A climbing restart count plus a waiting reason is the first real 
clue and points at the next move: a restart storm or OOM -> logs (step 5) and 
memory-limit headroom (step 6). The catalog IS the closed world here: if a 
status table isn't what kb_browse_catalog returns for this layer, it doesn't 
exist — don't kb_search_ [...]
-4. Drill to the outlier pod (no auto-rollup). 
kb_resolve_scope_drill(serviceId=<id>, toScope='instance') for the pod 
{id,name}; re-query the same metric per pod with show_top / show_table to find 
the bad pod.
+2. Confirm the symptom at K8S_SERVICE Service scope. 
kb_browse_catalog(K8S_SERVICE, service); render its golden-signal entries (the 
success-rate / throughput / response-time entries the catalog returns) and its 
pod-status entries with show_widget by the ids kb_browse_catalog returned — 
each widget's own type (series vs labeled table) comes from the template, you 
do not choose it. Restarts climbing or pods stuck in a waiting state is the k8s 
tell.
+3. Pod status REASON. show_widget the catalog's pod-status entries — the 
waiting table carries a waiting-reason tag whose value comes over the wire (why 
the pod won't start). A climbing restart count plus a waiting reason is the 
first real clue and points at the next move: a restart storm or OOM -> logs 
(step 5) and memory-limit headroom (step 6). The catalog IS the closed world 
here: if a status table isn't what kb_browse_catalog returns for this layer, it 
doesn't exist — don't kb_searc [...]
+4. Drill to the outlier pod (no auto-rollup). 
kb_resolve_scope_drill(serviceId=<id>, toScope='instance') for the pod 
{id,name}; re-query the same metric per pod with show_widget (scope='instance', 
instance=<pod name>) to find the bad pod.
 5. Pod logs = the ERROR STACK. list_pod_containers(podId) then 
fetch_pod_logs(layer=K8S_SERVICE, serviceInstanceId=podId, container) — narrow 
with keywordsOfContent like ERROR / Exception / OOM. This is where a CrashLoop 
or an app exception is finally named. On-demand logs are OAP-gated; if disabled 
the tool says so — fall back to metrics + node pressure.
-6. Limits vs usage HEADROOM. Overlay the pod's resource entries the catalog 
returns — its requests/limits against its actual usage — with show_line: usage 
pinned at the limit = CPU throttle or the OOM cause; usage climbing 
monotonically = a leak. This is the saturation fork for a container.
+6. Limits vs usage HEADROOM. Overlay the pod's resource entries the catalog 
returns — its requests/limits against its actual usage — with show_widget 
(scope='instance'): usage pinned at the limit = CPU throttle or the OOM cause; 
usage climbing monotonically = a leak. This is the saturation fork for a 
container.
 7. NODE pressure. If several pods on one node misbehave, the node is suspect: 
kb_browse_catalog(K8S, instance) for that node's resource metrics (the CPU / 
memory / storage entries the catalog returns); a full-disk or memory-pressured 
node evicts and restarts pods. Cluster-wide: kb_browse_catalog(K8S, service) 
for the cluster capacity / headroom entries it returns.
 8. Cross-layer hierarchy — BOTH directions. UP: kb_resolve_hierarchy from the 
K8S_SERVICE to the app service (GENERAL / MESH) that owns the request, to tie 
the pod fault to the user-facing symptom. DOWN: if the workload IS a backing 
store (a PostgreSQL / MongoDB / Kafka pod), continue root-cause on that infra 
layer's own metrics (slow query, connections, disk).
 9. Network-side (optional). If HTTP/TCP errors look network-driven not 
app-driven, and an eBPF / network layer is present (or the K8S_SERVICE layer 
itself carries wire metrics), read the wire entries kb_browse_catalog returns 
there — an app that is up but unreachable shows on the network, not in its own 
metrics.
diff --git a/apps/bff/src/ai/resources/skills/rca/latency.md 
b/apps/bff/src/ai/resources/skills/rca/latency.md
index 2d567ff..343f8b4 100644
--- a/apps/bff/src/ai/resources/skills/rca/latency.md
+++ b/apps/bff/src/ai/resources/skills/rca/latency.md
@@ -5,9 +5,11 @@ whenToUse: Rising response time — percentile (p95/p99) or avg 
resp-time spikes
 ---
 Refines the root-cause master for latency. Ordering: root service -> calling 
chain -> error stack; upstream-first; catalog MQE verbatim, never invented.
 
+RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — 
the entry/widget id kb_browse_catalog returned, plus instance/endpoint for a 
drilled scope. It draws the dashboard widget with its full config (type, unit, 
thresholds, explanation). Keep show_line / show_card / show_top / show_table / 
show_record for a DERIVED expression the catalog has no entry for.
+
 1. list_alarms -> look for a response-time / percentile alarm on the suspect 
service. If one fired, read its entity + rule + threshold — that's your root 
pointer. If none fired, treat as a near-miss and proceed on the symptom; don't 
assume healthy.
-2. Frame the symptom at Service scope. kb_browse_catalog / kb_search_metrics 
the service's (layer, Service) page -> pull the verbatim percentile MQE 
(p50/p75/p90/p95/p99) and the avg resp-time MQE; show_line. Read the shape: a 
p95/p99 spike over a flat p50 = tail latency (GC, locks, a few slow calls); the 
whole curve shifting up = systemic. Overlay cpm and SLA: cpm climbing in 
lockstep => load-driven; latency up with flat or falling cpm => code / GC / 
dependency-driven.
+2. Frame the symptom at Service scope. kb_browse_catalog / kb_search_metrics 
the service's (layer, Service) page -> render the percentile 
(p50/p75/p90/p95/p99) and avg resp-time entries it returns with show_widget by 
their catalog ids. Read the shape: a p95/p99 spike over a flat p50 = tail 
latency (GC, locks, a few slow calls); the whole curve shifting up = systemic. 
Overlay cpm and SLA: cpm climbing in lockstep => load-driven; latency up with 
flat or falling cpm => code / GC / dependenc [...]
 3. show_topology -> walk UPSTREAM. Check each dependency's resp-time. If an 
upstream is slow, fix it first — your spike is inherited, not owned. If the 
slow dependency is a Virtual_* remote absent from list_services, you only hold 
the client-side edge metric — check that edge resp-time and stop there. If the 
root service is backed by an infra/database layer, kb_resolve_hierarchy into it 
and check that layer's latency/connection metrics.
-4. At the root service, drill (no auto-rollup). kb_resolve_scope_drill -> 
Endpoint scope, re-query the same percentile MQE, show_top / show_table for the 
worst endpoints. Then drill -> ServiceInstance scope, re-query resp-time, 
show_top — one hot instance = node-local cause.
-5. Correlate runtime at the hot instance. kb_browse_catalog(<layer>, instance) 
for the instance runtime metrics it exposes -> show_line; line a p99 spike up 
against GC pauses or CPU saturation. Cross-check instance attributes (JVM 
version, host, jar+version) — attributes tool coming.
-6. Deeper. AVAILABLE now: fetch_pod_logs on the worst pod's container for the 
error stack (k8s workloads); propose_profiling (JVM / Go / eBPF network-packet) 
for hot methods, lock contention, or network latency; show_deployment for a 
manifest / resource-limit fault. NOT an assistant tool yet: trace query + span 
logs, the events feed — name them as the operator's next step.
+4. At the root service, drill (no auto-rollup). kb_resolve_scope_drill -> 
Endpoint scope, re-query the same percentile metric and render with show_widget 
(an endpoint-ranking entry from the service page if the catalog has one, 
otherwise scope='endpoint', endpoint=<name>) for the worst endpoints. Then 
drill -> ServiceInstance scope, re-query resp-time with show_widget 
(scope='instance') — one hot instance = node-local cause.
+5. Correlate runtime at the hot instance. kb_browse_catalog(<layer>, instance) 
for the instance runtime metrics it exposes -> show_widget (scope='instance', 
instance=<name>); line a p99 spike up against GC pauses or CPU saturation. 
Cross-check instance attributes (JVM version, host, jar+version) — attributes 
tool coming.
+6. Deeper. AVAILABLE now: TRACES for the slow endpoint — pick the tool from 
kb_layer_capabilities(layer).tracesSource, not the layer name (native -> 
show_traces; zipkin -> list_zipkin_services then show_zipkin_traces); it 
captures the trace list + span waterfall inline, so surface it yourself instead 
of handing it off. Also fetch_pod_logs on the worst pod's container for the 
error stack (k8s workloads); propose_profiling (JVM / Go / eBPF network-packet) 
for hot methods, lock contention,  [...]
diff --git a/apps/bff/src/ai/resources/skills/rca/mesh.md 
b/apps/bff/src/ai/resources/skills/rca/mesh.md
index 2a42eca..7e03ce2 100644
--- a/apps/bff/src/ai/resources/skills/rca/mesh.md
+++ b/apps/bff/src/ai/resources/skills/rca/mesh.md
@@ -3,12 +3,14 @@ id: mesh
 title: Service mesh (Istio) root-cause
 whenToUse: The symptom is on a MESH / Istio service (sidecar-reported 
telemetry): SLA dropping or cpm climbing on a MESH-layer service, or you must 
decide whether a mesh fault sits in the app, the sidecar/network, or the Istio 
control plane.
 ---
-Refines the root-cause master for MESH. Mesh telemetry is sidecar-reported, 
not in-process agent — so every call has a client side and a server side, and 
there is a separate control plane whose health gates the whole data plane. 
Don't assume the reporter or the trace source by name (Envoy / ALS / Istiod) — 
confirm them from kb_layer_capabilities: who reports, and tracesSource (native 
| zipkin | both) to pick the trace tool. If tracesSource is zipkin, use 
list_zipkin_services -> show_zipk [...]
+Refines the root-cause master for MESH. Mesh telemetry is sidecar-reported, 
not in-process agent — so every call has a client side and a server side, and 
there is a separate control plane whose health gates the whole data plane. 
Don't assume the reporter or the trace source by name (Envoy / ALS / Istiod) — 
confirm them from kb_layer_capabilities: who reports, and tracesSource (native 
| zipkin | both) to pick the trace tool. If tracesSource is zipkin, use 
list_zipkin_services -> show_zipk [...]
+
+RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — 
the entry/widget id kb_browse_catalog returned, plus instance/endpoint for a 
drilled scope. It draws the dashboard widget with its full config (type, unit, 
thresholds, explanation). Keep show_line / show_card / show_top / show_table / 
show_record for a DERIVED expression the catalog has no entry for.
 
 1. list_alarms — look for a firing MESH alarm (SLA drop / cpm spike). If none 
fired, treat as a near-miss and continue.
-2. Confirm the MESH service. list_layers / list_services to find it, then 
kb_browse_catalog(MESH, service) and render the success-rate, cpm, apdex and 
response-time entries it returns with show_card / show_line (use the catalog's 
expressions, don't retype ids). This is the symptom, not yet the cause.
+2. Confirm the MESH service. list_layers / list_services to find it, then 
kb_browse_catalog(MESH, service) and render the success-rate, cpm, apdex and 
response-time entries it returns with show_widget by their catalog ids (each 
widget's type + config comes from the template — don't retype ids or rebuild 
the figure). This is the symptom, not yet the cause.
 3. show_topology -> walk toward the DEPENDENCIES this service calls (the 
callee / "server" side of each edge — see the master playbook's Direction 
note). The topology view surfaces each edge's health — client-vs-server 
relation/edge metrics are NOT renderable as standalone figures, so read them ON 
the topology. If the CLIENT side shows errors/latency the SERVER side never 
recorded, the fault is in the sidecar / network (mTLS, connection pool), not 
app code. If an upstream service is itse [...]
-4. Unmanaged remote: if the bad upstream is a Virtual_* (Virtual Database / MQ 
/ Cache), it appears as its OWN service — kb_browse_catalog that Virtual 
service and render its service-scope client metrics, or read its edge on 
show_topology, and stop there (no server side exists).
-5. Control plane: if many services degrade at once, find the control-plane 
layer (resolve it via kb_layer_capabilities / kb_resolve_hierarchy — don't 
hardcode its key) and render the control-plane health entries its catalog 
returns. When those go bad it's a whole-mesh root cause — the control plane 
gates every sidecar.
-6. Root service -> scope drill (no auto-rollup): 
kb_resolve_scope_drill(serviceId=<id>, toScope='instance'), then re-query the 
same catalog metric per sidecar with show_top to find the outlier; 
kb_browse_catalog the ServiceInstance scope and render the sidecar / proxy 
health entries it returns. Then kb_resolve_scope_drill(serviceId=<id>, 
toScope='endpoint') for the failing API.
-7. Error stack + deeper. AVAILABLE now: show_deployment for a 
sidecar-injection / config manifest fault; fetch_pod_logs on the sidecar or app 
container (mesh pods run in k8s); propose_profiling (eBPF network) to inspect 
the sidecar path. NOT an assistant tool yet: trace query + span logs, the 
events feed, instance attributes (Envoy/Istio version) — name these as the 
operator's next step.
+4. Unmanaged remote: if the bad upstream is a Virtual_* (Virtual Database / MQ 
/ Cache), it appears as its OWN service — kb_browse_catalog that Virtual 
service and show_widget its service-scope client entries, or read its edge on 
show_topology, and stop there (no server side exists).
+5. Control plane: if many services degrade at once, find the control-plane 
layer (resolve it via kb_layer_capabilities / kb_resolve_hierarchy — don't 
hardcode its key) and show_widget the control-plane health entries its catalog 
returns. When those go bad it's a whole-mesh root cause — the control plane 
gates every sidecar.
+6. Root service -> scope drill (no auto-rollup): 
kb_resolve_scope_drill(serviceId=<id>, toScope='instance'), then re-query the 
same catalog metric per sidecar with show_widget (scope='instance', 
instance=<name>) to find the outlier; kb_browse_catalog the ServiceInstance 
scope and show_widget the sidecar / proxy health entries it returns. Then 
kb_resolve_scope_drill(serviceId=<id>, toScope='endpoint') for the failing API.
+7. Error stack + deeper. AVAILABLE now: TRACES — surface them yourself with 
the tool kb_layer_capabilities(layer).tracesSource names (native -> 
show_traces; zipkin -> list_zipkin_services then show_zipkin_traces), which 
captures the trace list + span waterfall inline; show_deployment for a 
sidecar-injection / config manifest fault; fetch_pod_logs on the sidecar or app 
container (mesh pods run in k8s); propose_profiling (eBPF network) to inspect 
the sidecar path. NOT an assistant tool yet [...]
diff --git a/apps/bff/src/ai/resources/skills/rca/middleware-remote.md 
b/apps/bff/src/ai/resources/skills/rca/middleware-remote.md
index facb01f..61aabc2 100644
--- a/apps/bff/src/ai/resources/skills/rca/middleware-remote.md
+++ b/apps/bff/src/ai/resources/skills/rca/middleware-remote.md
@@ -5,13 +5,15 @@ whenToUse: A service's latency or errors trace back to a 
middleware or remote de
 ---
 Refines the root-cause master toward a middleware / remote dependency (a 
database, cache, MQ, broker). The load-bearing fact: MOST middleware is a 
topology LEAF — it has no downstream dependency of its own, so the upstream 
calling-chain walk BOTTOMS OUT here. Do NOT try to dig deeper through 
traces/topology past a middleware node; there is nothing below it. Instead 
pivot to what you can observe from OUTSIDE it: LOGS, the k8s HIERARCHY down to 
its pods/infra, and the NETWORK edge. Catalog [...]
 
+RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — 
the entry/widget id kb_browse_catalog returned, plus instance/endpoint for a 
drilled scope. It draws the dashboard widget with its full config (type, unit, 
thresholds, explanation). Keep show_line / show_card / show_top / show_table / 
show_record for a DERIVED expression the catalog has no entry for.
+
 1. list_alarms — read the alarm on the symptomatic caller (dropping SLA, 
climbing latency/cpm). None? Near-miss; continue.
 2. show_topology — walk UPSTREAM from the symptom to the middleware/remote 
node. Classify it with list_layers / list_services: an unmanaged Virtual_* 
remote (Virtual Database / MQ / Cache — NOT in the service list), or a MANAGED 
middleware layer (a real MYSQL / POSTGRESQL / KAFKA / REDIS service)?
-   - Virtual_* remote: unmanaged, so only the CLIENT-side view exists. The 
Virtual remote appears as its OWN service — kb_browse_catalog that Virtual 
service (service scope) and show_line its client-side latency / error-rate / 
cpm; or read its edge on show_topology (the raw client-relation metric is not 
renderable as a figure). There is no server-side metric and no logs — that IS 
the evidence; stop here.
+   - Virtual_* remote: unmanaged, so only the CLIENT-side view exists. The 
Virtual remote appears as its OWN service — kb_browse_catalog that Virtual 
service (service scope) and show_widget its client-side latency / error-rate / 
cpm entries; or read its edge on show_topology (the raw client-relation metric 
is not renderable as a figure). There is no server-side metric and no logs — 
that IS the evidence; stop here.
    - Managed middleware: kb_browse_catalog that layer's Service scope for 
broker/server metrics; kb_search_metrics for the symptom class (connection 
pool, queue depth, consumer lag, slow query). If the middleware itself is 
unhealthy, IT is the root — fix it before the caller.
 3. THE LEAF PIVOT — three moves, because there is nothing downstream to walk:
    a. LOGS. If the middleware runs in Kubernetes, its own log is usually the 
fastest name for the fault (slow query, disk-full, connection-refused, 
election/rebalance, auth error). kb_resolve_hierarchy from the middleware 
service to its K8S_SERVICE, kb_resolve_scope_drill to the pod, then 
list_pod_containers + fetch_pod_logs (keywordsOfContent ERROR / slow / 
timeout). On-demand logs are OAP-gated; if off the tool says so.
    b. k8s HIERARCHY / infra. kb_resolve_hierarchy from the middleware down to 
its backing K8S_SERVICE / host layer and read THAT layer's pod/node metrics — 
memory pressure, DISK / volume full, CPU throttle, connection limits. This is 
how you cross from "the app is slow" to "its database node is out of disk". 
Infra causes live on the infra layer, not the middleware's golden metrics.
-   c. NETWORK. Read the client→remote edge on show_topology (client/server 
relation metrics are not renderable as standalone figures); where an eBPF / 
network layer is present its wire metrics ARE service-scope and renderable — 
kb_browse_catalog + show_line the wire entries it returns. A broker that is up 
but unreachable shows as network errors on the wire, not broker errors.
-4. Broker outlier (managed only). If the managed middleware IS unhealthy on 
its own metrics, kb_resolve_scope_drill to ServiceInstance scope 
(brokers/nodes) and re-query the SAME metric — OAP does NOT auto-roll-up — 
show_top / show_table for the outlier broker/partition; drill to Endpoint for 
per-topic / per-command hotspots.
-5. Deeper (name as next moves): events feed for a recent broker 
reboot/rebalance; instance attributes for client-driver + broker version; 
show_deployment for a connection-limit / resource misconfig. Trace + span logs 
on the slow endpoint remain the caller-side error stack.
+   c. NETWORK. Read the client→remote edge on show_topology (client/server 
relation metrics are not renderable as standalone figures); where an eBPF / 
network layer is present its wire metrics ARE service-scope and renderable — 
kb_browse_catalog + show_widget the wire entries it returns. A broker that is 
up but unreachable shows as network errors on the wire, not broker errors.
+4. Broker outlier (managed only). If the managed middleware IS unhealthy on 
its own metrics, kb_resolve_scope_drill to ServiceInstance scope 
(brokers/nodes) and re-query the SAME metric — OAP does NOT auto-roll-up — 
show_widget (scope='instance', instance=<broker name>) for the outlier 
broker/partition; drill to Endpoint for per-topic / per-command hotspots.
+5. Caller-side error stack. Surface the CALLER's traces yourself — pick the 
tool from kb_layer_capabilities(<caller layer>).tracesSource, not the layer 
name (native -> show_traces; zipkin -> list_zipkin_services then 
show_zipkin_traces) — the captured list + span waterfall show where the call 
into the middleware spends its time. Still NOT tools (name as next moves, do 
not fabricate): reading span contents / span logs yourself, the events feed for 
a recent broker reboot/rebalance, instanc [...]
diff --git a/apps/bff/src/ai/resources/skills/rca/root-cause.md 
b/apps/bff/src/ai/resources/skills/rca/root-cause.md
index 009f393..24923ac 100644
--- a/apps/bff/src/ai/resources/skills/rca/root-cause.md
+++ b/apps/bff/src/ai/resources/skills/rca/root-cause.md
@@ -5,12 +5,14 @@ whenToUse: The default entry point for any "why is X wrong / 
what is the root ca
 ---
 This is the spine. Ordering: root service -> calling chain -> error stack. 
Upstream-first — never diagnose a downstream service before proving its 
upstream is healthy; the symptom is usually downstream of the real cause. 
Always render catalog MQE verbatim via kb_browse_catalog / kb_describe_metric; 
never invent a metric name.
 
+RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — 
the entry/widget id kb_browse_catalog returned, plus instance/endpoint for a 
drilled scope. It draws the dashboard widget with its full config (type, unit, 
thresholds, explanation), so it always beats rebuilding the figure. Keep 
show_line / show_card / show_top / show_table / show_record for a DERIVED 
expression the catalog has no entry for.
+
 1. list_alarms — read active alarms; the firing entity is the first suspect. 
If the symptom is real but nothing fired, treat it as a near-miss (a threshold 
that just missed) and continue on the symptom — do not assume healthy. (Formal 
alarm-rule inspection is not yet a tool.)
-2. Confirm the symptom at Service scope. Use list_layers / list_services to 
orient, then kb_browse_catalog the (layer, Service) page and render the 
success-rate + throughput entries it returns — falling SLA + climbing cpm is 
the classic signature. Add the response-time percentile entries if latency is 
the complaint.
+2. Confirm the symptom at Service scope. Use list_layers / list_services to 
orient, then kb_browse_catalog the (layer, Service) page and show_widget the 
success-rate + throughput entries it returns — falling SLA + climbing cpm is 
the classic signature. Add the response-time percentile entries if latency is 
the complaint.
 3. show_topology -> walk toward the DEPENDENCIES. Direction (used throughout 
these playbooks): from the symptomatic service, follow its OUTGOING topology 
edges to the services it CALLS — the callee / "server" side of each edge. A 
slow or failing dependency is the usual inherited cause, so the symptom sits 
downstream of it. (That is what "walk upstream to the cause" means here — NOT 
the callers sending traffic IN; those matter only for a load surge, see the 
saturation playbook.) For each  [...]
    - If an upstream is a Virtual_* remote (Virtual MQ / Database / Cache — 
unmanaged, no server-side metric), read only the client-side edge metric from 
its catalog page and stop the walk there. That edge is all you have.
 4. Cross-layer hierarchy. If the root service is backed by an INFRA / database 
layer (a K8S_SERVICE down to its PostgreSQL / MongoDB, a service to its host/VM 
layer), kb_resolve_hierarchy to follow it INTO that layer and continue 
root-cause THERE — infra causes (memory pressure, disk/volume, connection 
limits) surface on the infra layer's own metrics, not the app service's. 
Re-query that layer via kb_browse_catalog. (kb_resolve_hierarchy crosses 
layers; kb_resolve_scope_drill stays withi [...]
-5. At the root service, find outliers. kb_resolve_scope_drill to 
ServiceInstance scope and re-query the same catalog metric — OAP does NOT roll 
up between scopes — then show_top / show_table to spot abnormal instances. 
Repeat the drill to Endpoint scope for the worst API.
-6. Correlate runtime + logs. show_line the instance runtime metrics 
kb_browse_catalog(<layer>, instance) exposes. For a Kubernetes workload, pod 
logs are AVAILABLE now — list_pod_containers + fetch_pod_logs on the outlier 
pod for the error stack. (Instance attributes / dependency versions are not an 
assistant tool yet.)
-7. Error stack for the specific endpoint. Native trace query + span logs are 
NOT an assistant tool yet — surface the endpoint + time window and name trace 
inspection as the operator's next step.
-8. Deeper, if hidden. AVAILABLE now: show_deployment for a k8s/helm manifest / 
resource-limit fault; propose_profiling (JVM / Go / eBPF network-packet) when a 
profile would confirm a specific hypothesis. NOT an assistant tool yet: the 
events feed (recent reboot/deploy), git / code-line inspection — name them as 
the operator's next move.
+5. At the root service, find outliers. kb_resolve_scope_drill to 
ServiceInstance scope and re-query the same catalog metric — OAP does NOT roll 
up between scopes — then show_widget (scope='instance', instance=<name>) to 
spot abnormal instances. Repeat the drill to Endpoint scope for the worst API.
+6. Correlate runtime + logs. show_widget (scope='instance') the instance 
runtime metrics kb_browse_catalog(<layer>, instance) exposes. For a Kubernetes 
workload, pod logs are AVAILABLE now — list_pod_containers + fetch_pod_logs on 
the outlier pod for the error stack. (Instance attributes / dependency versions 
are not an assistant tool yet.)
+7. Error stack for the specific endpoint. SURFACE THE TRACES yourself — read 
kb_layer_capabilities(layer).tracesSource and let it pick the tool, never the 
layer name: native -> show_traces(layer, service); zipkin -> 
list_zipkin_services, match the service, then show_zipkin_traces. Both capture 
a frozen trace list + span waterfall inline. You cannot read span contents 
yourself, so name the endpoint + window in your prose and let the operator read 
the waterfall.
+8. Deeper, if hidden. AVAILABLE now: show_deployment for a k8s/helm manifest / 
resource-limit fault; propose_profiling (JVM / Go / eBPF network-packet) when a 
profile would confirm a specific hypothesis. NOT an assistant tool yet: 
span-log contents, the events feed (recent reboot/deploy), git / code-line 
inspection — name them as the operator's next move.
diff --git a/apps/bff/src/ai/resources/skills/rca/saturation.md 
b/apps/bff/src/ai/resources/skills/rca/saturation.md
index d4fa8e8..649670e 100644
--- a/apps/bff/src/ai/resources/skills/rca/saturation.md
+++ b/apps/bff/src/ai/resources/skills/rca/saturation.md
@@ -5,11 +5,13 @@ whenToUse: Traffic (cpm) climbing with resource pressure — 
latency/errors risi
 ---
 Refines the root-cause master for load/saturation. Ordering: root service -> 
calling chain -> error stack; upstream-first; catalog MQE verbatim, never 
invented.
 
+RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — 
the entry/widget id kb_browse_catalog returned, plus instance/endpoint for a 
drilled scope. It draws the dashboard widget with its full config (type, unit, 
thresholds, explanation). Keep show_line / show_card / show_top / show_table / 
show_record for a DERIVED expression the catalog has no entry for.
+
 1. list_alarms — pull active alarms for the symptom service. Expect an 
SLA-drop or cpm/latency alarm; if it fired, read it. If none did, treat the 
surge as a near-miss and continue.
-2. list_services / list_layers -> confirm the service and its layer. Pull 
Service-scope MQE verbatim from kb_browse_catalog: render SLA and cpm with 
show_line. Confirm SLA falling while cpm climbs — the saturation signature.
+2. list_services / list_layers -> confirm the service and its layer. Pull the 
Service-scope entries from kb_browse_catalog: render SLA and cpm with 
show_widget by their catalog ids. Confirm SLA falling while cpm climbs — the 
saturation signature.
 3. show_topology -> walk UPSTREAM. If an upstream caller is unhealthy, it is 
the real root — fix it first. If an upstream dependency is a Virtual_* remote, 
only the client-side edge metric exists — check it and stop. Land on the 
service that actually owns the load = root service. If it is backed by an 
infra/database layer, kb_resolve_hierarchy into it (memory/disk/connection 
saturation often lives there).
-4. kb_resolve_scope_drill -> ServiceInstance scope. OAP does not roll up 
between scopes — re-query the same cpm/latency metric per instance. show_top / 
show_table to rank instances and surface the saturated outlier(s).
-5. For each outlier, kb_browse_catalog(<layer>, instance) for the instance 
runtime metrics it exposes, then kb_describe_metric and show_line against the 
cpm curve. Decision fork: resources tracking cpm linearly across all instances 
= genuine load surge (scale out); one instance's heap climbing monotonically 
with rising GC while cpm is flat = leak; resources pinned at a ceiling = 
capacity limit.
-6. kb_resolve_scope_drill -> Endpoint scope; show_top endpoints by cpm to see 
if one API drives the surge.
+4. kb_resolve_scope_drill -> ServiceInstance scope. OAP does not roll up 
between scopes — re-query the same cpm/latency metric per instance. show_widget 
(scope='instance', instance=<name>) to rank instances and surface the saturated 
outlier(s).
+5. For each outlier, kb_browse_catalog(<layer>, instance) for the instance 
runtime metrics it exposes, then kb_describe_metric and show_widget 
(scope='instance') against the cpm curve. Decision fork: resources tracking cpm 
linearly across all instances = genuine load surge (scale out); one instance's 
heap climbing monotonically with rising GC while cpm is flat = leak; resources 
pinned at a ceiling = capacity limit.
+6. kb_resolve_scope_drill -> Endpoint scope; show_widget the endpoint-ranking 
cpm entry the catalog returns (or the endpoint-scope entry per endpoint) to see 
if one API drives the surge.
 7. show_deployment (available) — inspect for under-provisioned limits / 
missing HPA that turns a surge into saturation.
-8. Error stack + deeper. AVAILABLE now: fetch_pod_logs on the outlier pod — 
the error stack (OOM, thread-pool or connection-pool exhaustion) usually names 
the cause; propose_profiling (JVM async / pprof / eBPF network) if a profile 
would confirm it. NOT an assistant tool yet (do not fabricate): the events feed 
(recent reboot/rescale), instance attributes, trace + span logs, git code-line 
— name them as the operator's next move.
+8. Error stack + deeper. AVAILABLE now: fetch_pod_logs on the outlier pod — 
the error stack (OOM, thread-pool or connection-pool exhaustion) usually names 
the cause; TRACES for the saturated endpoint, surfaced by you with the tool 
kb_layer_capabilities(layer).tracesSource names (native -> show_traces; zipkin 
-> list_zipkin_services then show_zipkin_traces), which captures the list + 
span waterfall inline; propose_profiling (JVM async / pprof / eBPF network) if 
a profile would confirm it. [...]
diff --git a/apps/bff/src/ai/resources/tools/triggers.yaml 
b/apps/bff/src/ai/resources/tools/triggers.yaml
index 0922521..06b7658 100644
--- a/apps/bff/src/ai/resources/tools/triggers.yaml
+++ b/apps/bff/src/ai/resources/tools/triggers.yaml
@@ -49,13 +49,13 @@ propose_profiling:
 
 analyze_profiling:
   description: >-
-    Read a COMPLETED profiling task and render its flame graph. Call this in a 
LATER turn, after a task the user approved has finished collecting (or when the 
user asks you to analyze an existing profile). It fetches the most recent task 
of the given type for the service, analyses it, and shows the flame graph 
inline; the reply also lists the hottest frames by self time so you can name 
the cause. If it reports no data collected yet, tell the user to wait for the 
task to finish, then anal [...]
+    Read a COMPLETED profiling task and render its result inline as a CAPTURED 
block. Call this in a LATER turn, after a task the user approved has finished 
collecting (or when the user asks you to analyze an existing profile). It 
fetches the most recent task of the given type for the service and analyses it. 
trace/async/pprof/ebpf render the flame graph (a trace profile also shows the 
profiled trace's span waterfall beside the flame), and the reply lists the 
hottest frames so you can na [...]
   params:
     layer: >-
       OAP layer key, e.g. GENERAL
     service: >-
       service NAME to analyze the profile for
     profilingType: >-
-      which profiling result to read — trace (in-process sampling), pprof 
(Go), async (Java async-profiler), ebpf (on/off-CPU), or network (renders a 
text read-out of the process-conversation graph, not a flame)
+      which profiling result to read — trace (in-process sampling; flame plus 
the profiled trace's span waterfall), pprof (Go), async (Java async-profiler), 
ebpf (on/off-CPU), or network (captures the process-conversation graph and 
renders it as a frozen graph block, not a flame; text-only when no process 
reports)
     taskId: >-
       a specific task id to analyze; omit to use the most recent task of this 
type
diff --git a/apps/bff/src/ai/skill/triggers/tools.test.ts 
b/apps/bff/src/ai/skill/triggers/tools.test.ts
index 59877de..11d6cbb 100644
--- a/apps/bff/src/ai/skill/triggers/tools.test.ts
+++ b/apps/bff/src/ai/skill/triggers/tools.test.ts
@@ -66,6 +66,16 @@ describe('propose_profiling', () => {
     });
   });
 
+  // OAP's ProfileTaskCreationRequest.endpointName is String! and rejects an 
empty
+  // one, so an endpoint-less trace card could only ever fail on approve.
+  it('refuses a trace proposal with no endpoint instead of emitting a doomed 
card', async () => {
+    const { ctx, emitProposal } = mockCtx(true);
+    const [propose] = triggerTools(ctx);
+    const out = await propose.invoke({ ...base, profilingType: 'trace' });
+    expect(emitProposal).not.toHaveBeenCalled();
+    expect(String(out)).toMatch(/endpoint/i);
+  });
+
   it('emits a trace decision card (no instance resolution) with 
profile:enable', async () => {
     const { ctx, emitProposal } = mockCtx(true);
     const [propose] = triggerTools(ctx);
diff --git a/apps/bff/src/ai/skill/triggers/tools.ts 
b/apps/bff/src/ai/skill/triggers/tools.ts
index 7cc3244..2770d88 100644
--- a/apps/bff/src/ai/skill/triggers/tools.ts
+++ b/apps/bff/src/ai/skill/triggers/tools.ts
@@ -56,18 +56,39 @@ const PPROF_EVENTS = ['CPU', 'HEAP', 'BLOCK', 'MUTEX', 
'GOROUTINE', 'ALLOCS', 'T
 // and the fired task agree (trace/pprof are minutes; eBPF's 30-min cap is 
looser).
 const MAX_ASYNC_MINUTES = 10;
 
-// Top self-heavy frames as text so the agent can reason about the hot path
-// (the flame itself is rendered for the user, not readable by the model).
+// Top frames as text so the agent can reason about the hot path (the flame
+// itself is rendered for the user, not readable by the model). The ranking
+// metric is flavor-specific and MUST be labelled as what it is: trace's
+// durationChildExcluded is self MILLISECONDS, pprof/async's is a self SAMPLE
+// count, and eBPF has no self time at all (durationChildExcluded is the
+// inclusive dumpCount). Numerator and denominator always share one unit.
 function summarizeProfile(a: ProfilingAnalysis): string {
   const all = a.trees.flatMap((t) => t.elements);
   if (!all.length) return '';
-  const total = Math.max(...all.map((e) => e.count), 1);
+  const pct = (v: number, total: number): string => `${Math.round((v / total) 
* 100)}%`;
+  if (a.profilingType === 'ebpf') {
+    const total = Math.max(...all.map((e) => e.count), 1);
+    const top = [...all]
+      .filter((e) => e.count > 0)
+      .sort((x, y) => y.count - x.count)
+      .slice(0, 8)
+      .map((e) => `${e.codeSignature} (${pct(e.count, total)})`);
+    return top.length
+      ? ` Heaviest frames by INCLUSIVE sample share (eBPF carries no self 
time, so entry/root frames rank highest — read the tree, not the order, for the 
hot leaf): ${top.join('; ')}.`
+      : '';
+  }
+  const totalSelf = all.reduce((n, e) => n + Math.max(e.durationChildExcluded, 
0), 0);
+  if (totalSelf <= 0) return '';
   const top = [...all]
     .filter((e) => e.durationChildExcluded > 0)
     .sort((x, y) => y.durationChildExcluded - x.durationChildExcluded)
     .slice(0, 8)
-    .map((e) => `${e.codeSignature} (${Math.round((e.durationChildExcluded / 
total) * 100)}% self)`);
-  return top.length ? ` Hottest frames by self time: ${top.join('; ')}.` : '';
+    .map((e) => `${e.codeSignature} (${pct(e.durationChildExcluded, 
totalSelf)})`);
+  const basis =
+    a.profilingType === 'trace'
+      ? `self time (share of ${Math.round(totalSelf)}ms total self time)`
+      : `self samples (share of ${totalSelf} total self samples)`;
+  return top.length ? ` Hottest frames by ${basis}: ${top.join('; ')}.` : '';
 }
 
 export function triggerTools(ctx: AiRequestContext): StructuredToolInterface[] 
{
@@ -85,6 +106,12 @@ export function triggerTools(ctx: AiRequestContext): 
StructuredToolInterface[] {
           return `The ${layerKey} layer does not support ${profilingType} 
profiling (it supports: ${supported.join(', ') || 'none'}). Read 
kb_layer_capabilities and propose a supported type, or tell the user profiling 
is unavailable here.`;
         }
       }
+      // Trace profiling monitors ONE endpoint: OAP's 
ProfileTaskCreationRequest
+      // takes `endpointName: String!` and rejects an empty one, so a card 
without
+      // it can only fail on approve. Refuse to emit it and ask for the 
endpoint.
+      if (profilingType === 'trace' && !endpoint?.trim()) {
+        return `Trace profiling monitors ONE endpoint, and OAP requires its 
name — you did not supply one, so no card was shown. Pick the endpoint of 
${service} you want profiled (kb_resolve_scope_drill with toScope "endpoint", 
or the endpoint you already identified in this investigation), then call 
propose_profiling again with it.`;
+      }
       // async / pprof / network target instances — resolve them server-side so
       // the card can fire without the agent enumerating ids.
       let instanceIds: string[] | undefined;
@@ -164,6 +191,11 @@ export function triggerTools(ctx: AiRequestContext): 
StructuredToolInterface[] {
   const at = toolPrompt('triggers', 'analyze_profiling');
   const analyze = tool(
     async ({ layer, service, profilingType, taskId }): Promise<string> => {
+      // Same read verb the profiling routes require — the assistant never 
widens
+      // the caller's read scope (profile:enable does NOT imply profile:read).
+      if (!ctx.hasVerb('profile:read')) {
+        return 'Permission denied: the current user lacks profile:read. Do not 
analyze; say profiling results are not readable for this user.';
+      }
       // Network profiling has no flame — its result is a process-conversation
       // graph. CAPTURE it and render a frozen block (never a live tab, which 
would
       // drift). When no processes report — Rover/eBPF absent — say it out in 
text.
@@ -201,10 +233,20 @@ export function triggerTools(ctx: AiRequestContext): 
StructuredToolInterface[] {
       });
       if (!a.reachable) return `Could not read the ${profilingType} profile 
for ${service}: ${a.error ?? 'unreachable'}.`;
       if (!a.trees.length) {
+        const why = a.error ? ` (${a.error})` : '';
+        // Empty result ≠ unsupported. Only trace fills segmentCount, and eBPF
+        // fills neither logs nor segments — the one signal every flavor 
carries
+        // is that a task was RESOLVED (its id + facts land on the summary), so
+        // branch on that before blaming the deployment.
         const collected = a.logs.length > 0 || (a.summary.segmentCount ?? 0) > 
0;
-        return collected
-          ? `The ${profilingType} profiling task for ${service} ran but 
produced no analyzable stacks${a.error ? ` (${a.error})` : ''} — nothing met 
the sampling threshold. Tell the user; do not retry indefinitely.`
-          : `The ${profilingType} profiling task for ${service} returned no 
data${a.error ? ` (${a.error})` : ''}. If it was created a few minutes ago and 
still has nothing, ${profilingType} profiling is likely unsupported at this 
deployment (the agent may lack the profiling plugin) — tell the user that 
rather than retrying. If it was JUST created, give it 2–4 minutes to collect, 
then analyze once more.`;
+        const taskFound = !!a.taskId && (a.summary.startTime != null || 
a.summary.durationLabel != null);
+        if (collected) {
+          return `The ${profilingType} profiling task for ${service} ran but 
produced no analyzable stacks${why} — nothing met the sampling threshold. Tell 
the user; do not retry indefinitely.`;
+        }
+        if (taskFound) {
+          return `The ${profilingType} profiling task for ${service} (task 
${a.taskId}) exists but has reported no stacks yet${why}. If it was JUST 
created, give it 2–4 minutes to collect, then analyze once more. If it has been 
running well past its window with nothing, say the agent likely cannot collect 
${profilingType} profiles here (missing plugin / eBPF host access) — do not 
retry indefinitely.`;
+        }
+        return `No ${profilingType} profiling task was found for 
${service}${why} — nothing has been analyzed. Either none has been created yet 
(propose one with propose_profiling and tell the user to approve it), or 
${profilingType} profiling is unavailable at this deployment. Do not retry 
blindly.`;
       }
       return `Rendered the ${profilingType} profile for ${service}: 
${a.summary.frameCount} stack frames${a.tip ? ` (partial — ${a.tip})` : 
''}.${summarizeProfile(a)}`;
     },
diff --git a/apps/bff/src/ai/skill/visualization/tools.ts 
b/apps/bff/src/ai/skill/visualization/tools.ts
index 6209990..be9e562 100644
--- a/apps/bff/src/ai/skill/visualization/tools.ts
+++ b/apps/bff/src/ai/skill/visualization/tools.ts
@@ -33,6 +33,7 @@ import type {
   DashboardWidget,
   DashboardWidgetResult,
   DashboardWidgetType,
+  NativeSpan,
   TopologyResponse,
   TraceListResponse,
   ZipkinTraceListResponse,
@@ -68,9 +69,10 @@ import { fetchBrowserErrors } from 
'../../../http/query/browser-errors.js';
 import { getServerOffsetMinutes, fmtSecond } from '../../../util/window.js';
 import { toolPrompt } from '../../resources/loader.js';
 
-// Capture caps for the frozen triage lists. Native v2 (queryTraces) + Zipkin
-// carry spans inline so 30 replays cheaply; v1 (queryBasicTraces) needs a
-// per-trace span fetch, so cap to 10. Logs/browser freeze up to 100 rows.
+// Capture caps for the frozen triage lists — each is further clamped by the
+// operator's `performance.limits.maxPageSize.*`. Native v2 (queryTraces) +
+// Zipkin carry spans inline so 30 replays cheaply; v1 (queryBasicTraces) needs
+// a per-trace span fetch, so cap to 10. Logs/browser freeze up to 100 rows.
 const TRACE_CAP = 30;
 const V1_TRACE_CAP = 10;
 const LIST_CAP = 100;
@@ -714,24 +716,35 @@ export function visualizationTools(ctx: 
AiRequestContext): StructuredToolInterfa
       // Freeze the native list (frozen-always) so the block replays offline.
       const windowMinutes = rangeWindowMinutes(ctx.range);
       const offsetMinutes = await getServerOffsetMinutes(ctx.config, 
ctx.fetch);
+      const cfgTraceCap = 
ctx.config.current.performance.limits.maxPageSize.traces;
+      const maxTraces = Math.min(TRACE_CAP, cfgTraceCap);
       const native = await fetchNativeList(
         ctx.opts,
-        { service, serviceId: row.id, startMs: ctx.range.startMs, endMs: 
ctx.range.endMs, pageSize: TRACE_CAP },
+        { service, serviceId: row.id, startMs: ctx.range.startMs, endMs: 
ctx.range.endMs, pageSize: maxTraces },
         layer.toUpperCase(),
         false,
         offsetMinutes,
-        TRACE_CAP,
+        cfgTraceCap,
       );
-      // v2 rows carry inline spans (keep 30); v1 rows have none, so cap to 10 
and
-      // hydrate each with its spans so the waterfall replays offline.
+      // v2 rows carry inline spans (keep the capture cap); v1 rows have none, 
so
+      // cap tighter and hydrate with spans so the waterfall replays offline.
       if (native.api === 'queryBasicTraces') {
-        native.traces = native.traces.slice(0, V1_TRACE_CAP);
+        native.traces = native.traces.slice(0, Math.min(V1_TRACE_CAP, 
maxTraces));
+        // v1 rows are SEGMENT-shaped: several rows can share one traceId, so
+        // fetch each distinct trace once and share its spans across its rows.
+        const spansByTrace = new Map<string, NativeSpan[]>();
         for (const t of native.traces) {
           const tid = t.traceIds[0];
-          if (tid) t.spans = await fetchNativeTraceSpans(ctx.opts, tid);
+          if (!tid) continue;
+          let spans = spansByTrace.get(tid);
+          if (!spans) {
+            spans = await fetchNativeTraceSpans(ctx.opts, tid);
+            spansByTrace.set(tid, spans);
+          }
+          t.spans = spans;
         }
       } else {
-        native.traces = native.traces.slice(0, TRACE_CAP);
+        native.traces = native.traces.slice(0, maxTraces);
       }
       const replayData: TraceListResponse = { generatedAt: Date.now(), source: 
'native', native };
       ctx.emitTraces({
@@ -804,7 +817,12 @@ export function visualizationTools(ctx: AiRequestContext): 
StructuredToolInterfa
       try {
         const rows = await zipkinFetchTraces(
           zopts,
-          { serviceName: service, endTs: ctx.range.endMs, lookback: 
ctx.range.endMs - ctx.range.startMs, limit: TRACE_CAP },
+          {
+            serviceName: service,
+            endTs: ctx.range.endMs,
+            lookback: ctx.range.endMs - ctx.range.startMs,
+            limit: Math.min(TRACE_CAP, 
ctx.config.current.performance.limits.maxPageSize.traces),
+          },
           true,
         );
         replayData = { source: 'zipkin', traces: rows, reachable: true };
diff --git a/apps/bff/src/logic/oap/profiling.ts 
b/apps/bff/src/logic/oap/profiling.ts
index 408ce59..8b6e4b7 100644
--- a/apps/bff/src/logic/oap/profiling.ts
+++ b/apps/bff/src/logic/oap/profiling.ts
@@ -257,9 +257,14 @@ export interface NetworkProfilingResult {
   topology: ProcessTopologyResponse;
 }
 
-/** Network profiling's result is a process-conversation graph. Read it (one
- *  instance's process topology) and return it as CAPTURED render data — the 
block
- *  freezes + replays it, never a live tab. Empty ⇒ no Rover eBPF agent 
reporting. */
+// Rover watches processes per instance, so only part of a fleet may report a
+// graph — probe a few instances instead of judging the service by its first.
+const MAX_NETWORK_TOPOLOGY_PROBES = 5;
+
+/** Network profiling's result is a process-conversation graph. Read it (the 
first
+ *  probed instance whose processes report) and return it as CAPTURED render 
data —
+ *  the block freezes + replays it, never a live tab. Empty ⇒ no Rover eBPF 
agent
+ *  reporting on any probed instance. */
 export async function analyzeNetworkProfiling(
   opts: GraphqlOptions,
   layerKey: string,
@@ -275,16 +280,28 @@ export async function analyzeNetworkProfiling(
     if (!serviceId) return empty(false, `Unknown service "${service}" in layer 
${layerKey}.`);
     const insts = await listServiceInstances(opts, serviceId, window);
     if (!insts.length) return empty(true);
-    const inst = insts[0];
-    const data = await graphqlPost<{ topology: { nodes: ProcessNode[]; calls: 
ProcessCall[] } | null }>(
-      opts,
-      GET_PROCESS_TOPOLOGY,
-      { serviceInstanceId: inst.id, duration: { start: window.start, end: 
window.end, step: window.step } },
-    );
-    return {
-      instanceName: inst.name,
-      topology: { nodes: data.topology?.nodes ?? [], calls: 
data.topology?.calls ?? [], reachable: true },
-    };
+    let probeError: string | null = null;
+    for (const inst of insts.slice(0, MAX_NETWORK_TOPOLOGY_PROBES)) {
+      try {
+        const data = await graphqlPost<{ topology: { nodes: ProcessNode[]; 
calls: ProcessCall[] } | null }>(
+          opts,
+          GET_PROCESS_TOPOLOGY,
+          { serviceInstanceId: inst.id, duration: { start: window.start, end: 
window.end, step: window.step } },
+        );
+        const nodes = data.topology?.nodes ?? [];
+        if (nodes.length) {
+          return {
+            instanceName: inst.name,
+            topology: { nodes, calls: data.topology?.calls ?? [], reachable: 
true },
+          };
+        }
+      } catch (err) {
+        // One bad instance doesn't condemn the fleet — keep probing, report 
the
+        // first failure only if no instance produced a graph.
+        if (!probeError) probeError = err instanceof Error ? err.message : 
String(err);
+      }
+    }
+    return probeError ? empty(false, probeError) : empty(true);
   } catch (err) {
     return empty(false, err instanceof Error ? err.message : String(err));
   }
@@ -496,7 +513,9 @@ async function analyzeStackList(
     service: base.summary.service,
     instances: task.serviceInstanceIds ?? [],
     events,
-    durationLabel: durationSecLabel(task.duration),
+    // Different units on the wire: async-profiler's task duration is seconds,
+    // pprof's is minutes (OAP task-creation schemas).
+    durationLabel: isAsync ? durationSecLabel(task.duration) : 
durationMinLabel(task.duration),
     startTime: task.createTime || null,
     frameCount: 0,
   };
@@ -515,8 +534,11 @@ async function analyzeStackList(
     analyzeQuery,
     { request },
   );
-  const tree = an.analysisResult?.tree;
-  base.trees = tree && tree.elements.length ? [mapWireTree(tree.elements)] : 
[];
+  // OAP merges the dumps under a synthesized zero-sample root, so an 
uncollected
+  // task still analyzes to one all-zero element — that's empty, not a 1-frame 
profile.
+  const elements = an.analysisResult?.tree?.elements ?? [];
+  const collected = elements.length > 1 && elements.some((e) => e.dumpCount > 
0);
+  base.trees = collected ? [mapWireTree(elements)] : [];
   base.summary.frameCount = frameCount(base.trees);
   return base;
 }
diff --git a/apps/ui/src/ai/ChatProfilingBlock.vue 
b/apps/ui/src/ai/ChatProfilingBlock.vue
index 1289e28..50c2a2b 100644
--- a/apps/ui/src/ai/ChatProfilingBlock.vue
+++ b/apps/ui/src/ai/ChatProfilingBlock.vue
@@ -55,20 +55,21 @@ const facts = computed<string[]>(() => {
       <span v-for="(f, i) in facts" :key="i" class="cpf__fact">{{ f }}</span>
     </div>
     <p v-if="spec.tip" class="cpf__tip">{{ spec.tip }}</p>
-    <template v-if="hasData">
-      <template v-if="trace">
-        <div class="cpf__label">{{ t('Profiled trace') }} · <span 
class="cpf__tid">{{ trace.traceId }}</span></div>
-        <div class="cpf__wf">
-          <NativeTraceWaterfall :spans="trace.spans" :mark-profiled="true" />
-        </div>
-        <div class="cpf__label">{{ t('Flame graph') }}</div>
-      </template>
-      <div class="cpf__flame">
-        <ProfileFlameGraph :trees="spec.trees" :metric-key="spec.metricKey" />
+    <!-- The captured waterfall is frozen data — show it even when the analyze
+         returned no stacks; only the flame section falls back to the empty 
state. -->
+    <template v-if="trace">
+      <div class="cpf__label">{{ t('Profiled trace') }} · <span 
class="cpf__tid">{{ trace.traceId }}</span></div>
+      <div class="cpf__wf">
+        <NativeTraceWaterfall :spans="trace.spans" :mark-profiled="true" />
       </div>
+      <div class="cpf__label">{{ t('Flame graph') }}</div>
     </template>
+    <div v-if="hasData" class="cpf__flame">
+      <ProfileFlameGraph :trees="spec.trees" :metric-key="spec.metricKey" />
+    </div>
     <div v-else class="cpf__empty">
       <template v-if="!spec.reachable">{{ t('Could not read the profile.') 
}}<span v-if="spec.error"> — {{ spec.error }}</span></template>
+      <template v-else-if="spec.error">{{ spec.error }}</template>
       <template v-else>{{ t('No profile data was collected in this task yet.') 
}}</template>
     </div>
   </div>
diff --git a/apps/ui/src/ai/ChatProposalBlock.vue 
b/apps/ui/src/ai/ChatProposalBlock.vue
index 50549d6..c59b036 100644
--- a/apps/ui/src/ai/ChatProposalBlock.vue
+++ b/apps/ui/src/ai/ChatProposalBlock.vue
@@ -36,13 +36,15 @@ const { t } = useI18n({ useScope: 'global' });
 const conv = useAiConversations();
 const busy = ref(false);
 
-const TYPE_LABEL: Record<ProposalSpec['profilingType'], string> = {
-  trace: 'trace profiling',
-  async: 'JVM async-profiler',
-  pprof: 'Go pprof profiling',
-  ebpf: 'eBPF profiling',
-  network: 'network profiling',
-};
+const pprofEvent = (s: ProposalSpec): string => s.events?.[0] ?? 'CPU';
+
+// Whether the task this spec creates actually carries a collection window, so
+// the card never advertises a duration the create call omits.
+function hasDuration(s: ProposalSpec): boolean {
+  if (s.profilingType === 'network') return false;
+  if (s.profilingType === 'pprof') return 
PPROF_DURATION_EVENTS.includes(pprofEvent(s));
+  return true;
+}
 
 // The five create calls share nothing but serviceId — fire the right one per
 // type, converting the agent-facing minutes to each call's unit. Returns the
@@ -51,8 +53,11 @@ async function fireTask(s: ProposalSpec): Promise<{ ok: 
boolean; taskId?: string
   const layer = s.layer.toLowerCase();
   const mins = s.durationMinutes;
   if (s.profilingType === 'trace') {
+    // OAP's endpointName is non-null and rejects an empty string — an 
endpoint-less
+    // proposal can only fail on create, so fail the card instead of firing it.
+    if (!s.endpoint) return { ok: false, error: t('Trace profiling requires an 
endpoint, and this proposal carries none.') };
     const r = await bff.profile.create(layer, {
-      serviceId: s.serviceId, endpointName: s.endpoint ?? '', startTime: 
Date.now(),
+      serviceId: s.serviceId, endpointName: s.endpoint, startTime: Date.now(),
       duration: mins, minDurationThreshold: 0, dumpPeriod: 10, 
maxSamplingCount: 5,
     });
     return { ok: r.reachable && !r.errorReason, taskId: r.id, error: 
r.errorReason ?? r.error };
@@ -65,9 +70,9 @@ async function fireTask(s: ProposalSpec): Promise<{ ok: 
boolean; taskId?: string
     return { ok: r.reachable && !r.errorReason && r.code !== 'ARGUMENT_ERROR', 
taskId: r.id, error: r.errorReason ?? r.error };
   }
   if (s.profilingType === 'pprof') {
-    const ev = s.events?.[0] ?? 'CPU';
+    const ev = pprofEvent(s);
     const body: PprofTaskCreationRequest = { serviceId: s.serviceId, 
serviceInstanceIds: s.instanceIds ?? [], events: ev };
-    if (PPROF_DURATION_EVENTS.includes(ev)) body.duration = mins;
+    if (hasDuration(s)) body.duration = mins;
     if (PPROF_DUMP_PERIOD_EVENTS.includes(ev)) body.dumpPeriod = 1;
     const r = await bff.pprof.create(layer, body);
     return { ok: r.reachable && !r.errorReason, taskId: r.id, error: 
r.errorReason ?? r.error };
@@ -102,7 +107,24 @@ function dismiss(): void {
   conv.resolveProposal(props.block, 'dismissed');
 }
 
-const typeLabel = computed<string>(() => 
TYPE_LABEL[props.block.spec.profilingType]);
+// One whole sentence per type — interpolating a type name into a generic 
header
+// leaves the card half-English. Product nouns (JVM, async-profiler, pprof, 
eBPF)
+// stay verbatim in every locale.
+const headerText = computed<string>(() => {
+  switch (props.block.spec.profilingType) {
+    case 'trace':
+      return t('Suggested action: start trace profiling');
+    case 'async':
+      return t('Suggested action: start JVM async-profiler profiling');
+    case 'pprof':
+      return t('Suggested action: start Go pprof profiling');
+    case 'ebpf':
+      return t('Suggested action: start eBPF profiling');
+    case 'network':
+      return t('Suggested action: start network profiling');
+  }
+});
+const showDuration = computed<boolean>(() => hasDuration(props.block.spec));
 // One target line adapted to the type: endpoint for trace, resolved instances
 // for async/pprof/network, the CPU target for eBPF.
 const targetDetail = computed<string>(() => {
@@ -118,7 +140,7 @@ const targetDetail = computed<string>(() => {
   <div class="prop" :class="`is-${block.status}`">
     <div class="prop__head">
       <Icon name="ai" :size="15" />
-      <span>{{ t('Suggested action: start {type}', { type: typeLabel }) 
}}</span>
+      <span>{{ headerText }}</span>
     </div>
     <dl class="prop__facts">
       <div><dt>{{ t('Cause') }}</dt><dd>{{ block.spec.cause }}</dd></div>
@@ -126,7 +148,7 @@ const targetDetail = computed<string>(() => {
       <div><dt>{{ t('Expected') }}</dt><dd>{{ block.spec.expectation 
}}</dd></div>
     </dl>
     <div class="prop__target">
-      {{ block.spec.service }}<template v-if="targetDetail"> · {{ targetDetail 
}}</template><template v-if="block.spec.profilingType !== 'network'"> · {{ 
block.spec.durationMinutes }}m</template>
+      {{ block.spec.service }}<template v-if="targetDetail"> · {{ targetDetail 
}}</template><template v-if="showDuration"> · {{ block.spec.durationMinutes 
}}m</template>
     </div>
     <div v-if="block.status === 'pending'" class="prop__actions">
       <button type="button" class="prop__btn" :disabled="busy" 
@click="dismiss">{{ t('Dismiss') }}</button>
diff --git a/apps/ui/src/ai/ChatTracesBlock.vue 
b/apps/ui/src/ai/ChatTracesBlock.vue
index 73c27b2..5651f62 100644
--- a/apps/ui/src/ai/ChatTracesBlock.vue
+++ b/apps/ui/src/ai/ChatTracesBlock.vue
@@ -36,12 +36,19 @@ const { t } = useI18n({ useScope: 'global' });
 const router = useRouter();
 const { layers } = useLayers();
 
+// A captured block is a static file of what was read — it replays from its own
+// snapshot and must NOT gate on the live layer list (empty or still in flight
+// would refuse to render an offline-safe block). The layer info is consulted 
for
+// the non-captured path only.
+const captured = computed(() => Boolean(props.spec.replayData));
 const layerDef = computed(() =>
   layers.value.find((L) => L.key.toUpperCase() === 
props.spec.layer.toUpperCase()),
 );
-const hasTraces = computed(() => Boolean(layerDef.value?.caps?.traces));
+const noTraces = computed(() => !captured.value && 
!layerDef.value?.caps?.traces);
 // 'native' | 'zipkin' | 'both' — only pure-'zipkin' can't be embedded here.
-const isZipkinOnly = computed(() => (layerDef.value?.traces?.source ?? 
'native') === 'zipkin');
+const isZipkinOnly = computed(
+  () => !captured.value && (layerDef.value?.traces?.source ?? 'native') === 
'zipkin',
+);
 
 function openZipkinTab(): void {
   const href = router.resolve({ path: 
`/layer/${props.spec.layer.toLowerCase()}/zipkin-trace` }).href;
@@ -55,7 +62,7 @@ function openZipkinTab(): void {
       {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag 
:at="capturedAt" />
     </div>
 
-    <div v-if="!hasTraces" class="ctr__note">{{ t('This layer has no traces 
component.') }}</div>
+    <div v-if="noTraces" class="ctr__note">{{ t('This layer has no traces 
component.') }}</div>
     <div v-else-if="isZipkinOnly" class="ctr__note ctr__note--act">
       <span>{{ t('This layer uses Zipkin tracing.') }}</span>
       <button type="button" class="ctr__btn" @click="openZipkinTab">{{ t('Open 
in a new tab') }}</button>
diff --git a/apps/ui/src/ai/useAiConversations.ts 
b/apps/ui/src/ai/useAiConversations.ts
index d974839..661b310 100644
--- a/apps/ui/src/ai/useAiConversations.ts
+++ b/apps/ui/src/ai/useAiConversations.ts
@@ -37,6 +37,10 @@ function textOf(blocks: Block[]): string {
     else if (b.kind === 'figure') {
       const titles = b.figures.map((f) => f.spec.title).filter(Boolean);
       parts.push(`(rendered figure${titles.length > 1 ? 's' : ''}: 
${titles.join(', ') || b.title || 'chart'})`);
+    } else if (b.kind === 'profiling') {
+      parts.push(`(profiling: ${b.spec.profilingType} for ${b.spec.service})`);
+    } else if (b.kind === 'process-topology') {
+      parts.push(`(process map: ${b.spec.service})`);
     } else if (b.kind === 'podlogs') {
       parts.push(`(pod logs: ${b.spec.title})`);
     } else if (b.kind === 'hierarchy') {
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 7852374..383b28c 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1639,6 +1639,22 @@
   "Stop": "Stopp",
   "Model": "Modell",
   "Suggested action: start trace profiling": "Vorgeschlagene Aktion: 
Trace-Profiling starten",
+  "Suggested action: start JVM async-profiler profiling": "Vorgeschlagene 
Aktion: JVM-async-profiler-Profiling starten",
+  "Suggested action: start Go pprof profiling": "Vorgeschlagene Aktion: 
Go-pprof-Profiling starten",
+  "Suggested action: start eBPF profiling": "Vorgeschlagene Aktion: 
eBPF-Profiling starten",
+  "Suggested action: start network profiling": "Vorgeschlagene Aktion: 
Netzwerk-Profiling starten",
+  "captured {when}": "erfasst {when}",
+  "captured": "erfasst",
+  "Captured snapshot — replayed from history, not live data": "Erfasster 
Snapshot — aus dem Verlauf wiedergegeben, keine Live-Daten",
+  "{n} segments": "{n} Segmente",
+  "{n} frames": "{n} Frames",
+  "{n} processes": "{n} Prozesse",
+  "No process-conversation data was captured.": "Es wurden keine Daten zur 
Prozesskommunikation erfasst.",
+  "Profiled trace": "Profilierter Trace",
+  "Flame graph": "Flame Graph",
+  "all endpoints": "alle Endpoints",
+  "Could not read the profile.": "Das Profil konnte nicht gelesen werden.",
+  "No profile data was collected in this task yet.": "In dieser Aufgabe wurden 
noch keine Profildaten erfasst.",
   "Cause": "Ursache",
   "Why profiling": "Warum Profiling",
   "Expected": "Erwartet",
@@ -1647,6 +1663,7 @@
   "Profiling started — ask me to analyze the results once it has collected 
data.": "Profiling gestartet – bitten Sie mich, die Ergebnisse zu analysieren, 
sobald Daten gesammelt wurden.",
   "Dismissed.": "Verworfen.",
   "Could not start profiling.": "Profiling konnte nicht gestartet werden.",
+  "Trace profiling requires an endpoint, and this proposal carries none.": 
"Trace-Profiling erfordert einen Endpoint, dieser Vorschlag enthält jedoch 
keinen.",
   "No log lines in this window.": "Keine Log-Zeilen in diesem Zeitfenster.",
   "Resize panel": "Panelbreite ändern",
   "drop reason": "Verwerfungsgrund",
@@ -1657,5 +1674,13 @@
   "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"Hefte die OAP-Ebenen an, die oben auf {alarms} eine eigene KPI-Kachel 
erhalten.",
   "The AI Assistant is not set up yet": "Der KI-Assistent ist noch nicht 
eingerichtet",
   "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "Er beantwortet 
Fragen zu Ihren Services, Metriken, Traces und Logs aus Live-Daten — aber 
zuerst muss ein Modellanbieter konfiguriert werden.",
-  "Ask your administrator to enable it and configure a model provider and API 
key.": "Bitten Sie Ihren Administrator, ihn zu aktivieren und einen 
Modellanbieter sowie einen API-Schlüssel zu konfigurieren."
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "Bitten Sie Ihren Administrator, ihn zu aktivieren und einen 
Modellanbieter sowie einen API-Schlüssel zu konfigurieren.",
+  "Save history": "Verlauf speichern",
+  "History is stored unencrypted in this browser.": "Der Verlauf wird 
unverschlüsselt in diesem Browser gespeichert.",
+  "Confirm clear all": "Alle löschen bestätigen",
+  "This Zipkin read failed when it was captured.": "Dieser Zipkin-Abruf ist 
zum Zeitpunkt der Erfassung fehlgeschlagen.",
+  "Flows": "Flows",
+  "Page size": "Seitengröße",
+  "Callee outside this layer — no instance dashboard here.": "Aufgerufener 
außerhalb dieser Ebene — hier gibt es kein Instance-Dashboard.",
+  "No API-dependency data was captured for {serviceName} in this window.": "In 
diesem Zeitfenster wurden keine API-Abhängigkeitsdaten für {serviceName} 
erfasst."
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 1d26c08..fcea5cf 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1639,7 +1639,11 @@
   "Cannot reach the server.": "Cannot reach the server.",
   "Stop": "Stop",
   "Model": "Model",
-  "Suggested action: start {type}": "Suggested action: start {type}",
+  "Suggested action: start trace profiling": "Suggested action: start trace 
profiling",
+  "Suggested action: start JVM async-profiler profiling": "Suggested action: 
start JVM async-profiler profiling",
+  "Suggested action: start Go pprof profiling": "Suggested action: start Go 
pprof profiling",
+  "Suggested action: start eBPF profiling": "Suggested action: start eBPF 
profiling",
+  "Suggested action: start network profiling": "Suggested action: start 
network profiling",
   "captured {when}": "captured {when}",
   "captured": "captured",
   "Captured snapshot — replayed from history, not live data": "Captured 
snapshot — replayed from history, not live data",
@@ -1660,6 +1664,7 @@
   "Profiling started — ask me to analyze the results once it has collected 
data.": "Profiling started — ask me to analyze the results once it has 
collected data.",
   "Dismissed.": "Dismissed.",
   "Could not start profiling.": "Could not start profiling.",
+  "Trace profiling requires an endpoint, and this proposal carries none.": 
"Trace profiling requires an endpoint, and this proposal carries none.",
   "No log lines in this window.": "No log lines in this window.",
   "Resize panel": "Resize panel",
   "Content filter": "Content filter",
@@ -1669,5 +1674,13 @@
   "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"Pin the OAP layers that get their own KPI tile at the top of {alarms}.",
   "The AI Assistant is not set up yet": "The AI Assistant is not set up yet",
   "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "It answers 
questions about your services, metrics, traces and logs from live data — but a 
model provider must be configured first.",
-  "Ask your administrator to enable it and configure a model provider and API 
key.": "Ask your administrator to enable it and configure a model provider and 
API key."
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "Ask your administrator to enable it and configure a model provider and 
API key.",
+  "Save history": "Save history",
+  "History is stored unencrypted in this browser.": "History is stored 
unencrypted in this browser.",
+  "Confirm clear all": "Confirm clear all",
+  "This Zipkin read failed when it was captured.": "This Zipkin read failed 
when it was captured.",
+  "Flows": "Flows",
+  "Page size": "Page size",
+  "Callee outside this layer — no instance dashboard here.": "Callee outside 
this layer — no instance dashboard here.",
+  "No API-dependency data was captured for {serviceName} in this window.": "No 
API-dependency data was captured for {serviceName} in this window."
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 1d87a5f..16a0eea 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1639,6 +1639,22 @@
   "Stop": "Detener",
   "Model": "Modelo",
   "Suggested action: start trace profiling": "Acción sugerida: iniciar el 
perfilado de trazas",
+  "Suggested action: start JVM async-profiler profiling": "Acción sugerida: 
iniciar el perfilado con JVM async-profiler",
+  "Suggested action: start Go pprof profiling": "Acción sugerida: iniciar el 
perfilado con Go pprof",
+  "Suggested action: start eBPF profiling": "Acción sugerida: iniciar el 
perfilado con eBPF",
+  "Suggested action: start network profiling": "Acción sugerida: iniciar el 
perfilado de red",
+  "captured {when}": "capturado {when}",
+  "captured": "capturado",
+  "Captured snapshot — replayed from history, not live data": "Instantánea 
capturada — reproducida desde el historial, no son datos en vivo",
+  "{n} segments": "{n} segmentos",
+  "{n} frames": "{n} frames",
+  "{n} processes": "{n} procesos",
+  "No process-conversation data was captured.": "No se capturaron datos de 
comunicación entre procesos.",
+  "Profiled trace": "Traza con profiling",
+  "Flame graph": "Flame graph",
+  "all endpoints": "todos los endpoints",
+  "Could not read the profile.": "No se pudo leer el perfil.",
+  "No profile data was collected in this task yet.": "Todavía no se han 
recopilado datos de perfil en esta tarea.",
   "Cause": "Causa",
   "Why profiling": "Por qué el perfilado",
   "Expected": "Esperado",
@@ -1647,6 +1663,7 @@
   "Profiling started — ask me to analyze the results once it has collected 
data.": "Perfilado iniciado: pídeme que analice los resultados cuando haya 
recopilado datos.",
   "Dismissed.": "Descartado.",
   "Could not start profiling.": "No se pudo iniciar el perfilado.",
+  "Trace profiling requires an endpoint, and this proposal carries none.": "El 
perfilado de trazas requiere un endpoint y esta propuesta no incluye ninguno.",
   "No log lines in this window.": "No hay líneas de registro en esta ventana.",
   "Resize panel": "Redimensionar panel",
   "drop reason": "motivo de descarte",
@@ -1657,5 +1674,13 @@
   "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"Fija las capas de OAP que obtienen su propia tarjeta KPI en la parte superior 
de {alarms}.",
   "The AI Assistant is not set up yet": "El asistente de IA aún no está 
configurado",
   "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "Responde 
preguntas sobre tus servicios, métricas, trazas y registros a partir de datos 
en vivo, pero primero se debe configurar un proveedor de modelo.",
-  "Ask your administrator to enable it and configure a model provider and API 
key.": "Pide a tu administrador que lo habilite y configure un proveedor de 
modelo y una clave de API."
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "Pide a tu administrador que lo habilite y configure un proveedor de 
modelo y una clave de API.",
+  "Save history": "Guardar el historial",
+  "History is stored unencrypted in this browser.": "El historial se guarda 
sin cifrar en este navegador.",
+  "Confirm clear all": "Confirmar borrar todo",
+  "This Zipkin read failed when it was captured.": "Esta lectura de Zipkin 
falló en el momento de la captura.",
+  "Flows": "Flujos",
+  "Page size": "Tamaño de página",
+  "Callee outside this layer — no instance dashboard here.": "El destino está 
fuera de esta capa: aquí no hay panel de instancia.",
+  "No API-dependency data was captured for {serviceName} in this window.": "No 
se capturaron datos de dependencias de API para {serviceName} en esta ventana."
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index fff8b37..e9bc6a6 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1639,6 +1639,22 @@
   "Stop": "Arrêter",
   "Model": "Modèle",
   "Suggested action: start trace profiling": "Action suggérée : démarrer le 
profilage de traces",
+  "Suggested action: start JVM async-profiler profiling": "Action suggérée : 
démarrer le profilage JVM async-profiler",
+  "Suggested action: start Go pprof profiling": "Action suggérée : démarrer le 
profilage Go pprof",
+  "Suggested action: start eBPF profiling": "Action suggérée : démarrer le 
profilage eBPF",
+  "Suggested action: start network profiling": "Action suggérée : démarrer le 
profilage réseau",
+  "captured {when}": "capturé {when}",
+  "captured": "capturé",
+  "Captured snapshot — replayed from history, not live data": "Instantané 
capturé — rejoué depuis l'historique, ce ne sont pas des données en direct",
+  "{n} segments": "{n} segments",
+  "{n} frames": "{n} frames",
+  "{n} processes": "{n} processus",
+  "No process-conversation data was captured.": "Aucune donnée de 
communication entre processus n'a été capturée.",
+  "Profiled trace": "Trace profilée",
+  "Flame graph": "Flame graph",
+  "all endpoints": "tous les endpoints",
+  "Could not read the profile.": "Impossible de lire le profil.",
+  "No profile data was collected in this task yet.": "Aucune donnée de profil 
n'a encore été collectée dans cette tâche.",
   "Cause": "Cause",
   "Why profiling": "Pourquoi le profilage",
   "Expected": "Attendu",
@@ -1647,6 +1663,7 @@
   "Profiling started — ask me to analyze the results once it has collected 
data.": "Profilage démarré — demandez-moi d’analyser les résultats une fois les 
données collectées.",
   "Dismissed.": "Ignoré.",
   "Could not start profiling.": "Impossible de démarrer le profilage.",
+  "Trace profiling requires an endpoint, and this proposal carries none.": "Le 
profilage de traces nécessite un endpoint, or cette proposition n'en contient 
aucun.",
   "No log lines in this window.": "Aucune ligne de journal dans cette 
fenêtre.",
   "Resize panel": "Redimensionner le panneau",
   "drop reason": "raison du rejet",
@@ -1657,5 +1674,13 @@
   "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"Épinglez les couches OAP qui obtiennent leur propre tuile KPI en haut de 
{alarms}.",
   "The AI Assistant is not set up yet": "L'assistant IA n'est pas encore 
configuré",
   "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "Il répond aux 
questions sur vos services, métriques, traces et logs à partir de données en 
direct, mais un fournisseur de modèle doit d'abord être configuré.",
-  "Ask your administrator to enable it and configure a model provider and API 
key.": "Demandez à votre administrateur de l'activer et de configurer un 
fournisseur de modèle et une clé d'API."
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "Demandez à votre administrateur de l'activer et de configurer un 
fournisseur de modèle et une clé d'API.",
+  "Save history": "Enregistrer l'historique",
+  "History is stored unencrypted in this browser.": "L'historique est stocké 
non chiffré dans ce navigateur.",
+  "Confirm clear all": "Confirmer tout effacer",
+  "This Zipkin read failed when it was captured.": "Cette lecture Zipkin a 
échoué au moment de la capture.",
+  "Flows": "Flux",
+  "Page size": "Taille de page",
+  "Callee outside this layer — no instance dashboard here.": "Appelé hors de 
cette couche — pas de tableau de bord d'instance ici.",
+  "No API-dependency data was captured for {serviceName} in this window.": 
"Aucune donnée de dépendance d'API n'a été capturée pour {serviceName} dans 
cette fenêtre."
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index f218286..8822c47 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1639,6 +1639,22 @@
   "Stop": "停止",
   "Model": "モデル",
   "Suggested action: start trace profiling": "提案されたアクション: トレースプロファイリングを開始",
+  "Suggested action: start JVM async-profiler profiling": "提案されたアクション: JVM 
async-profiler プロファイリングを開始",
+  "Suggested action: start Go pprof profiling": "提案されたアクション: Go pprof 
プロファイリングを開始",
+  "Suggested action: start eBPF profiling": "提案されたアクション: eBPF プロファイリングを開始",
+  "Suggested action: start network profiling": "提案されたアクション: ネットワークプロファイリングを開始",
+  "captured {when}": "{when} にキャプチャ",
+  "captured": "キャプチャ済み",
+  "Captured snapshot — replayed from history, not live data": 
"キャプチャされたスナップショット — 履歴から再生されたもので、リアルタイムデータではありません",
+  "{n} segments": "{n} セグメント",
+  "{n} frames": "{n} フレーム",
+  "{n} processes": "{n} プロセス",
+  "No process-conversation data was captured.": "プロセス間通信のデータはキャプチャされませんでした。",
+  "Profiled trace": "プロファイル対象のトレース",
+  "Flame graph": "フレームグラフ",
+  "all endpoints": "すべての Endpoint",
+  "Could not read the profile.": "プロファイルを読み取れませんでした。",
+  "No profile data was collected in this task yet.": 
"このタスクではまだプロファイルデータが収集されていません。",
   "Cause": "原因",
   "Why profiling": "なぜプロファイリングか",
   "Expected": "期待される結果",
@@ -1647,6 +1663,7 @@
   "Profiling started — ask me to analyze the results once it has collected 
data.": "プロファイリングを開始しました。データが収集されたら結果の分析を依頼してください。",
   "Dismissed.": "却下しました。",
   "Could not start profiling.": "プロファイリングを開始できませんでした。",
+  "Trace profiling requires an endpoint, and this proposal carries none.": 
"トレースプロファイリングには Endpoint が必要ですが、この提案には含まれていません。",
   "No log lines in this window.": "この時間枠にログ行はありません。",
   "Resize panel": "パネルサイズを変更",
   "drop reason": "破棄理由",
@@ -1657,5 +1674,13 @@
   "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"{alarms} の上部に独自の KPI タイルを持つ OAP レイヤーをピン留めします。",
   "The AI Assistant is not set up yet": "AI アシスタントはまだセットアップされていません",
   "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": 
"サービス、メトリクス、トレース、ログに関する質問にライブデータから回答しますが、先にモデルプロバイダーを構成する必要があります。",
-  "Ask your administrator to enable it and configure a model provider and API 
key.": "管理者に有効化とモデルプロバイダー・API キーの構成を依頼してください。"
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "管理者に有効化とモデルプロバイダー・API キーの構成を依頼してください。",
+  "Save history": "履歴を保存",
+  "History is stored unencrypted in this browser.": 
"履歴はこのブラウザーに暗号化されずに保存されます。",
+  "Confirm clear all": "すべてクリアを確認",
+  "This Zipkin read failed when it was captured.": "この Zipkin 
の読み取りはキャプチャ時に失敗しました。",
+  "Flows": "フロー",
+  "Page size": "ページサイズ",
+  "Callee outside this layer — no instance dashboard here.": "呼び出し先はこのレイヤーの外です 
— ここにインスタンスダッシュボードはありません。",
+  "No API-dependency data was captured for {serviceName} in this window.": 
"この時間範囲では {serviceName} の API 依存データはキャプチャされませんでした。"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index ded3542..711240e 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1639,6 +1639,22 @@
   "Stop": "중지",
   "Model": "모델",
   "Suggested action: start trace profiling": "제안된 작업: 트레이스 프로파일링 시작",
+  "Suggested action: start JVM async-profiler profiling": "제안된 작업: JVM 
async-profiler 프로파일링 시작",
+  "Suggested action: start Go pprof profiling": "제안된 작업: Go pprof 프로파일링 시작",
+  "Suggested action: start eBPF profiling": "제안된 작업: eBPF 프로파일링 시작",
+  "Suggested action: start network profiling": "제안된 작업: 네트워크 프로파일링 시작",
+  "captured {when}": "{when}에 캡처됨",
+  "captured": "캡처됨",
+  "Captured snapshot — replayed from history, not live data": "캡처된 스냅샷 — 기록에서 
재생된 것이며 실시간 데이터가 아닙니다",
+  "{n} segments": "{n}개 세그먼트",
+  "{n} frames": "{n}개 프레임",
+  "{n} processes": "{n}개 프로세스",
+  "No process-conversation data was captured.": "프로세스 간 통신 데이터가 캡처되지 않았습니다.",
+  "Profiled trace": "프로파일링된 트레이스",
+  "Flame graph": "플레임 그래프",
+  "all endpoints": "모든 엔드포인트",
+  "Could not read the profile.": "프로파일을 읽을 수 없습니다.",
+  "No profile data was collected in this task yet.": "이 작업에서는 아직 프로파일 데이터가 
수집되지 않았습니다.",
   "Cause": "원인",
   "Why profiling": "프로파일링을 하는 이유",
   "Expected": "예상 결과",
@@ -1647,6 +1663,7 @@
   "Profiling started — ask me to analyze the results once it has collected 
data.": "프로파일링을 시작했습니다. 데이터가 수집되면 결과 분석을 요청하세요.",
   "Dismissed.": "무시했습니다.",
   "Could not start profiling.": "프로파일링을 시작할 수 없습니다.",
+  "Trace profiling requires an endpoint, and this proposal carries none.": 
"트레이스 프로파일링에는 엔드포인트가 필요하지만 이 제안에는 포함되어 있지 않습니다.",
   "No log lines in this window.": "이 기간에 로그 항목이 없습니다.",
   "Resize panel": "패널 크기 조정",
   "drop reason": "드롭 사유",
@@ -1657,5 +1674,13 @@
   "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"{alarms} 상단에 전용 KPI 타일이 표시되는 OAP 레이어를 고정합니다.",
   "The AI Assistant is not set up yet": "AI 어시스턴트가 아직 설정되지 않았습니다",
   "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "실시간 데이터를 기반으로 
서비스, 메트릭, 트레이스, 로그에 대한 질문에 답하지만 먼저 모델 제공자를 구성해야 합니다.",
-  "Ask your administrator to enable it and configure a model provider and API 
key.": "관리자에게 활성화 및 모델 제공자와 API 키 구성을 요청하세요."
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "관리자에게 활성화 및 모델 제공자와 API 키 구성을 요청하세요.",
+  "Save history": "기록 저장",
+  "History is stored unencrypted in this browser.": "기록은 이 브라우저에 암호화되지 않은 상태로 
저장됩니다.",
+  "Confirm clear all": "모두 지우기 확인",
+  "This Zipkin read failed when it was captured.": "이 Zipkin 조회는 캡처 시점에 
실패했습니다.",
+  "Flows": "플로우",
+  "Page size": "페이지 크기",
+  "Callee outside this layer — no instance dashboard here.": "피호출 대상이 이 레이어 밖에 
있습니다 — 여기에는 인스턴스 대시보드가 없습니다.",
+  "No API-dependency data was captured for {serviceName} in this window.": "이 
기간에는 {serviceName}의 API 의존성 데이터가 캡처되지 않았습니다."
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index a5dfe72..aa2b7d0 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1639,6 +1639,22 @@
   "Stop": "Parar",
   "Model": "Modelo",
   "Suggested action: start trace profiling": "Ação sugerida: iniciar o 
profiling de traces",
+  "Suggested action: start JVM async-profiler profiling": "Ação sugerida: 
iniciar o profiling com JVM async-profiler",
+  "Suggested action: start Go pprof profiling": "Ação sugerida: iniciar o 
profiling com Go pprof",
+  "Suggested action: start eBPF profiling": "Ação sugerida: iniciar o 
profiling com eBPF",
+  "Suggested action: start network profiling": "Ação sugerida: iniciar o 
profiling de rede",
+  "captured {when}": "capturado {when}",
+  "captured": "capturado",
+  "Captured snapshot — replayed from history, not live data": "Snapshot 
capturado — reproduzido do histórico, não são dados ao vivo",
+  "{n} segments": "{n} segmentos",
+  "{n} frames": "{n} frames",
+  "{n} processes": "{n} processos",
+  "No process-conversation data was captured.": "Nenhum dado de comunicação 
entre processos foi capturado.",
+  "Profiled trace": "Trace com profiling",
+  "Flame graph": "Flame graph",
+  "all endpoints": "todos os endpoints",
+  "Could not read the profile.": "Não foi possível ler o perfil.",
+  "No profile data was collected in this task yet.": "Ainda não foram 
coletados dados de perfil nesta tarefa.",
   "Cause": "Causa",
   "Why profiling": "Por que o profiling",
   "Expected": "Esperado",
@@ -1647,6 +1663,7 @@
   "Profiling started — ask me to analyze the results once it has collected 
data.": "Profiling iniciado — peça-me para analisar os resultados quando houver 
dados coletados.",
   "Dismissed.": "Dispensado.",
   "Could not start profiling.": "Não foi possível iniciar o profiling.",
+  "Trace profiling requires an endpoint, and this proposal carries none.": "O 
profiling de traces exige um endpoint, e esta proposta não traz nenhum.",
   "No log lines in this window.": "Nenhuma linha de log nesta janela.",
   "Resize panel": "Redimensionar painel",
   "drop reason": "motivo do descarte",
@@ -1657,5 +1674,13 @@
   "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"Fixe as camadas do OAP que ganham seu próprio tile de KPI no topo de 
{alarms}.",
   "The AI Assistant is not set up yet": "O assistente de IA ainda não está 
configurado",
   "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "Ele responde a 
perguntas sobre seus serviços, métricas, traces e logs a partir de dados ao 
vivo — mas um provedor de modelo precisa ser configurado primeiro.",
-  "Ask your administrator to enable it and configure a model provider and API 
key.": "Peça ao seu administrador para habilitá-lo e configurar um provedor de 
modelo e uma chave de API."
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "Peça ao seu administrador para habilitá-lo e configurar um provedor de 
modelo e uma chave de API.",
+  "Save history": "Salvar o histórico",
+  "History is stored unencrypted in this browser.": "O histórico é armazenado 
sem criptografia neste navegador.",
+  "Confirm clear all": "Confirmar limpar tudo",
+  "This Zipkin read failed when it was captured.": "Esta leitura do Zipkin 
falhou no momento da captura.",
+  "Flows": "Fluxos",
+  "Page size": "Tamanho da página",
+  "Callee outside this layer — no instance dashboard here.": "Chamado fora 
desta camada — não há dashboard de instância aqui.",
+  "No API-dependency data was captured for {serviceName} in this window.": 
"Nenhum dado de dependência de API foi capturado para {serviceName} nesta 
janela."
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json 
b/apps/ui/src/i18n/locales/zh-CN.json
index bfc7630..5bc721d 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1640,6 +1640,22 @@
   "Stop": "停止",
   "Model": "模型",
   "Suggested action: start trace profiling": "建议操作:启动 trace profiling",
+  "Suggested action: start JVM async-profiler profiling": "建议操作:启动 JVM 
async-profiler profiling",
+  "Suggested action: start Go pprof profiling": "建议操作:启动 Go pprof profiling",
+  "Suggested action: start eBPF profiling": "建议操作:启动 eBPF profiling",
+  "Suggested action: start network profiling": "建议操作:启动网络 profiling",
+  "captured {when}": "捕获于 {when}",
+  "captured": "已捕获",
+  "Captured snapshot — replayed from history, not live data": "已捕获的快照 —— 
回放自历史记录,并非实时数据",
+  "{n} segments": "{n} 个 segment",
+  "{n} frames": "{n} 个栈帧",
+  "{n} processes": "{n} 个进程",
+  "No process-conversation data was captured.": "未捕获到进程间通信数据。",
+  "Profiled trace": "已剖析的 trace",
+  "Flame graph": "火焰图",
+  "all endpoints": "全部 Endpoint",
+  "Could not read the profile.": "无法读取该剖析结果。",
+  "No profile data was collected in this task yet.": "该任务尚未采集到剖析数据。",
   "Cause": "原因",
   "Why profiling": "为什么要 profiling",
   "Expected": "预期结果",
@@ -1648,6 +1664,7 @@
   "Profiling started — ask me to analyze the results once it has collected 
data.": "已启动 profiling —— 收集到数据后可让我分析结果。",
   "Dismissed.": "已忽略。",
   "Could not start profiling.": "无法启动 profiling。",
+  "Trace profiling requires an endpoint, and this proposal carries none.": 
"trace profiling 需要指定 Endpoint,而此建议未提供。",
   "No log lines in this window.": "此时间窗口内没有日志。",
   "Resize panel": "调整面板宽度",
   "Content filter": "内容过滤",
@@ -1657,5 +1674,13 @@
   "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"固定要在 {alarms} 顶部拥有独立 KPI 图块的 OAP layer。",
   "The AI Assistant is not set up yet": "AI 助手尚未配置",
   "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": 
"它基于实时数据回答有关服务、指标、trace 和日志的问题,但需要先配置模型提供方。",
-  "Ask your administrator to enable it and configure a model provider and API 
key.": "请让管理员启用它并配置模型提供方和 API 密钥。"
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "请让管理员启用它并配置模型提供方和 API 密钥。",
+  "Save history": "保存历史记录",
+  "History is stored unencrypted in this browser.": "历史记录以未加密的形式保存在此浏览器中。",
+  "Confirm clear all": "确认全部清除",
+  "This Zipkin read failed when it was captured.": "此 Zipkin 查询在捕获时失败。",
+  "Flows": "流量",
+  "Page size": "每页条数",
+  "Callee outside this layer — no instance dashboard here.": "被调方不在此层级内 —— 
这里没有实例仪表盘。",
+  "No API-dependency data was captured for {serviceName} in this window.": 
"此时间窗口内未捕获到 {serviceName} 的 API 依赖数据。"
 }
diff --git 
a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue 
b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
index afc5c17..3a75e24 100644
--- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
@@ -1193,7 +1193,7 @@ function edgeRowCrosshair(rowId: string): number | null {
     </section>
 
     <section v-else-if="serviceName" class="empty">
-      <template v-if="embedded">No API-dependency data was captured for {{ 
serviceName }} in this window.</template>
+      <template v-if="embedded">{{ t('No API-dependency data was captured for 
{serviceName} in this window.', { serviceName }) }}</template>
       <template v-else>Select an endpoint above to see its dependency 
chain.</template>
     </section>
   </div>
diff --git a/apps/ui/src/layer/logs/LayerLogsView.vue 
b/apps/ui/src/layer/logs/LayerLogsView.vue
index 5a7e536..5804582 100644
--- a/apps/ui/src/layer/logs/LayerLogsView.vue
+++ b/apps/ui/src/layer/logs/LayerLogsView.vue
@@ -289,7 +289,7 @@ const aWindowMinutes = computed(() => 
applied.value.windowMinutes);
 const aStartMs = computed(() => applied.value.startMs);
 const aEndMs = computed(() => applied.value.endMs);
 
-const { logs, total, isFetching, error, refetch } = useLayerLogs(layerKey, {
+const { logs, total, isFetching, reachable, error, refetch } = 
useLayerLogs(layerKey, {
   service: aService,
   instanceId: aInstanceId,
   endpointId: aEndpointId,
@@ -305,6 +305,10 @@ const { logs, total, isFetching, error, refetch } = 
useLayerLogs(layerKey, {
   replayData: replayDataRef,
 });
 
+// An unreachable read carries no rows — say so rather than let it read as an
+// empty scope (in replay that's the failure the capture recorded).
+const failed = computed<boolean>(() => !reachable.value || error.value !== 
null);
+
 const { facets, refetch: refetchFacets } = useLayerLogFacets(layerKey, {
   service: aService,
   instanceId: aInstanceId,
@@ -513,8 +517,9 @@ watch(
       </div>
     </header>
 
-    <div v-if="error" class="banner err">
-      <strong>Logs feed failed.</strong> {{ String(error) }}
+    <div v-if="failed" class="banner err">
+      <strong>{{ replay ? 'This log read failed when it was captured.' : 'Logs 
feed failed.' }}</strong>
+      <template v-if="error"> {{ error }}</template>
     </div>
 
     <section class="lg-body sw-card">
@@ -531,7 +536,8 @@ watch(
             type="button"
             class="lg-legend-chip"
             :class="{ on: selectedLevel === l, disabled: l === 'other' }"
-            :disabled="l === 'other'"
+            :disabled="l === 'other' || replay"
+            :title="replay ? 'Captured counts — the level filter runs on OAP, 
so it is inert in replay.' : undefined"
             @click="toggleLevel(l)"
           >
             <span class="lvl-dot" :style="{ background: LEVEL_COLOR[l] }" />
@@ -546,7 +552,9 @@ watch(
         <DensityHistogram :data="histogram" :keys="LEVEL_ORDER" 
:colors="LEVEL_COLOR" />
 
         <div v-if="filteredLogs.length === 0" class="lg-empty">
-          {{ logs.length === 0 ? 'No logs returned for this scope.' : 'No logs 
match the active filters.' }}
+          <template v-if="failed">The log read failed — nothing to 
show.</template>
+          <template v-else-if="logs.length === 0">No logs returned for this 
scope.</template>
+          <template v-else>No logs match the active filters.</template>
         </div>
         <LogStreamPanel
           v-else
@@ -731,12 +739,14 @@ watch(
   font-size: 11.5px;
   cursor: pointer;
 }
-.lg-legend-chip:hover { color: var(--sw-fg-0); border-color: var(--sw-line); }
+.lg-legend-chip:hover:not(:disabled) { color: var(--sw-fg-0); border-color: 
var(--sw-line); }
 .lg-legend-chip.on {
   color: var(--sw-accent-2);
   background: var(--sw-accent-soft);
   border-color: var(--sw-accent-line);
 }
+/* Replay disables every chip — it stays a full-strength legend, only inert. */
+.lg-legend-chip:disabled { cursor: default; }
 .lg-legend-chip.disabled { opacity: 0.45; cursor: not-allowed; }
 .lg-legend-name { text-transform: capitalize; }
 .lg-legend-count {
diff --git a/apps/ui/src/layer/logs/useLayerLogs.ts 
b/apps/ui/src/layer/logs/useLayerLogs.ts
index da1e068..3627e45 100644
--- a/apps/ui/src/layer/logs/useLayerLogs.ts
+++ b/apps/ui/src/layer/logs/useLayerLogs.ts
@@ -87,13 +87,22 @@ export function useLayerLogs(layerKey: Ref<string>, params: 
LogListParams) {
   const data = computed<LogsResponse | null>(() =>
     replay.value ? (params.replayData?.value ?? null) : (q.data.value ?? null),
   );
+  // A read fails two ways: the request itself (live only) or a 
`reachable:false`
+  // payload. Both are derived from whichever payload is in play, so a captured
+  // failure replays as a failure instead of an innocent "no logs" empty state.
+  const reachable = computed<boolean>(() => data.value?.reachable ?? true);
+  const error = computed<string | null>(() => {
+    if (!replay.value && q.error.value) return String(q.error.value);
+    return data.value && !data.value.reachable ? (data.value.error ?? null) : 
null;
+  });
   return {
     data,
     logs: computed(() => data.value?.logs ?? []),
     total: computed(() => data.value?.total ?? 0),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
-    error: q.error,
+    reachable,
+    error,
     refetch: q.refetch,
   };
 }
diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue 
b/apps/ui/src/layer/traces/LayerTracesView.vue
index 5f25969..84007f4 100644
--- a/apps/ui/src/layer/traces/LayerTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerTracesView.vue
@@ -321,6 +321,10 @@ function maybeRunDrill(): void {
   runQuery();
 }
 function applyDrillFromRoute(): void {
+  // The metric→trace drill belongs to the ROUTE. An embedded (chat) block must
+  // never consume the host page's drill params: it would arm a query — and
+  // refetch() bypasses `enabled`, so a frozen replay block would hit OAP.
+  if (embedded.value) return;
   const q = route.query;
   const mode = typeof q.dMode === 'string' ? q.dMode : null;
   if (mode !== 'latency' && mode !== 'error') return;
diff --git a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue 
b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
index 8687c20..a22cccc 100644
--- a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
@@ -138,7 +138,7 @@ const cAnno = ref<string | null>(null);
 const hasQueried = ref<boolean>(false);
 const queryEnabled = computed(() => hasQueried.value);
 
-const { traces, isFetching, error, refetch } = useLayerZipkinTraces({
+const { traces, isFetching, reachable, error, refetch } = 
useLayerZipkinTraces({
   serviceName: cService,
   remoteServiceName: cRemote,
   spanName: cSpan,
@@ -152,6 +152,9 @@ const { traces, isFetching, error, refetch } = 
useLayerZipkinTraces({
   replayData: computed(() => props.replayData ?? null),
 });
 const { openTrace } = useZipkinTracePopout();
+// An unreachable read carries no rows — say so rather than let it read as an
+// empty window (in replay that's the failure the capture recorded).
+const failed = computed<boolean>(() => !reachable.value || error.value !== 
null);
 
 // hasQueried gate: a layer switch leaves cached `traces` stale until refetch.
 const shownTraces = computed<ZipkinTraceListRow[]>(() => (hasQueried.value ? 
traces.value : []));
@@ -486,8 +489,9 @@ const visibleRows = computed<NativeTraceListRow[]>(() => {
           <span class="kicker">{{ t('Results') }}</span>
           <span class="hint">{{ t('{n} traces', { n: visibleRows.length }) 
}}</span>
         </header>
-        <div v-if="error" class="banner err">
-          <strong>{{ t('Zipkin query failed.') }}</strong> {{ String(error) }}
+        <div v-if="failed" class="banner err">
+          <strong>{{ replay ? t('This Zipkin read failed when it was 
captured.') : t('Zipkin query failed.') }}</strong>
+          <template v-if="error"> {{ error }}</template>
         </div>
         <div v-else-if="!hasQueried" class="ztr-empty">
           {{ t('Click Run query to fetch Zipkin traces for this service.') }}
diff --git a/apps/ui/src/layer/traces/useZipkinTraces.ts 
b/apps/ui/src/layer/traces/useZipkinTraces.ts
index f873933..1f12480 100644
--- a/apps/ui/src/layer/traces/useZipkinTraces.ts
+++ b/apps/ui/src/layer/traces/useZipkinTraces.ts
@@ -98,12 +98,21 @@ export function useLayerZipkinTraces(params: 
ZipkinTracesParams) {
   // query cache. Seeding initialData under the live query key would let a chat
   // snapshot serve a live view during staleTime (and vice-versa).
   const data = computed(() => (replay.value ? (params.replayData?.value ?? 
null) : (q.data.value ?? null)));
+  // A read fails two ways: the request itself (live only) or a 
`reachable:false`
+  // payload. Both are derived from whichever payload is in play, so a captured
+  // failure replays as a failure instead of an innocent empty window.
+  const reachable = computed<boolean>(() => data.value?.reachable ?? true);
+  const error = computed<string | null>(() => {
+    if (!replay.value && q.error.value) return String(q.error.value);
+    return data.value && !data.value.reachable ? (data.value.error ?? null) : 
null;
+  });
   return {
     data,
     traces: computed(() => data.value?.traces ?? []),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
-    error: q.error,
+    reachable,
+    error,
     refetch: q.refetch,
   };
 }
diff --git a/docs/operate/ai-assistant.md b/docs/operate/ai-assistant.md
index 92e99d1..1ce6a09 100644
--- a/docs/operate/ai-assistant.md
+++ b/docs/operate/ai-assistant.md
@@ -1,6 +1,6 @@
 # AI Assistant
 
-The AI Assistant is an in-app chat that answers questions about your system in 
plain language. It reads **live data from your OAP backend** through the same 
query path the dashboards use, and streams back an ordered narrative with 
**inline figures — line charts, single-value cards, top-N lists, tables and 
record lists — drawn by the same widgets** you see across the UI (grouped into 
tabs, a stack or a grid when an answer needs several). It can also embed the 
real feature views inline — n [...]
+The AI Assistant is an in-app chat that answers questions about your system in 
plain language. It reads **live data from your OAP backend** through the same 
query path the dashboards use, and streams back an ordered narrative with 
**inline figures — line charts, single-value cards, top-N lists, tables and 
record lists — drawn by the same widgets** you see across the UI (grouped into 
tabs, a stack or a grid when an answer needs several). It can also embed the 
real feature views inline — n [...]
 
 ## What it can do
 
@@ -8,12 +8,13 @@ The AI Assistant is an in-app chat that answers questions 
about your system in p
 - Read active **alarms** (the health signal), list layers and services (naming 
a service is enough — it searches every layer to find it), browse the per-layer 
**metric catalog** (the curated MQE behind each metric), drill a service down 
to its instances or endpoints, and render any of those metrics as a figure. 
Kubernetes layers — **K8S** (cluster / node) and **K8S_SERVICE** (service / 
pod) — read through this same catalog like any other layer.
 - Draw a service's dependencies inline — a focused **one-hop topology** graph 
(direct upstream callers and downstream dependencies, not the whole-layer map) 
you can zoom, fit and filter in place, and its **cross-layer hierarchy** (the 
same Smartscape fan as the topology page, projecting the service up and down 
into its mesh / infra / database layers). Both need your `topology:read` 
permission.
 - Draw the deeper topology views inline too, each the real page focused for 
you (all `topology:read`): the **Deployment** graph — one service's own 
instances and how they call each other; the **Instance map** for a **source → 
destination** service pair — the instances of each and the calls between them 
(name both services in your question); and the **API dependency** chain — a 
service's busiest endpoint and its upstream/downstream callers/callees, where 
you can expand any node. Each keep [...]
-- Open the real **Traces** explorer inline — focused on a service, it embeds 
the trace list read-only and, on a row click, the span **waterfall**, the same 
view as the Traces tab. It hands you the traces to read; it does not read span 
contents itself, so trace exploration stays your call. It follows the layer's 
trace configuration and supports **both** trace modes: a **native** 
SkyWalking-tracing layer embeds directly by service; a **Zipkin**-tracing layer 
(mesh / Kubernetes — Envoy ALS, [...]
-- Open the real **Logs** view inline — the service's stored log stream with 
row → detail, the same view as the layer Logs tab; distinct from the Kubernetes 
live tail below (these are **stored** logs), gated by your `logs:read` 
permission. For a **browser** app it can likewise embed the **Browser errors** 
list — the client-side JS error stream with a row → stack-trace detail, gated 
by your `browser-errors:read` permission.
-- Read a Kubernetes pod's **on-demand logs** — pull a container's recent logs 
(the error stack) and show the fetched lines inline as a read-only result. This 
is the same on-demand-log path as the Pod Logs tab: logs are streamed live from 
the cluster and never stored, and it requires your `logs:read` permission. The 
block is a result, not a console — it doesn't refresh on its own; ask again to 
fetch a newer window, or open the **Pod Logs** tab to keep a live tail running. 
When a content f [...]
-- Surface a layer's **service list** as a card — with an **Open in a new tab** 
button to the real page.
+- Show a service's **traces** inline — the trace list it read, and on a row 
click that trace's span **waterfall**, the same views as the Traces tab. It 
hands you the traces to read; it does not read span contents itself, so trace 
exploration stays your call. It follows the layer's trace configuration and 
supports **both** trace modes: on a **native** SkyWalking-tracing layer it 
reads traces by service; on a **Zipkin**-tracing layer (mesh / Kubernetes — 
Envoy ALS, rover) it first lists th [...]
+- Show a service's **logs** inline — the stored log stream with row → detail, 
the same view as the layer Logs tab; distinct from the Kubernetes live tail 
below (these are **stored** logs), gated by your `logs:read` permission. For a 
**browser** app it can likewise show the **Browser errors** list — the 
client-side JS error stream with a row → stack-trace detail, gated by your 
`browser-errors:read` permission. Both capture their rows, so the list and each 
row's detail open from what was read.
+- Read a Kubernetes pod's **on-demand logs** — pull a container's recent logs 
(the error stack) and show the fetched lines inline as a read-only result. This 
is the same on-demand-log path as the Pod Logs tab: logs are streamed live from 
the cluster and never stored, and it requires your `logs:read` permission. The 
block is a captured result, not a console — it doesn't refresh on its own; ask 
again to fetch a newer window, or open the **Pod Logs** tab to keep a live tail 
running. When a  [...]
+- Show a finished **profiling** result inline — for a code profile the **flame 
graph** the Profiling tab draws, with the hottest methods by self time called 
out in the prose; for a **trace** profile, the profiled trace's span 
**waterfall** beside the flame; for **network** profiling, the 
**process-conversation graph** (the processes and the conversations between 
them). When there is nothing to show — no eBPF agent reporting any process, or 
a task that ran but sampled nothing — it says so [...]
 - Run a **guided root-cause investigation** — it loads a matching playbook (a 
master **root-cause** method plus focused variants for latency, error-rate/SLA, 
saturation, middleware, Kubernetes-workload and service-mesh) and works root 
service → calling chain → error stack, walking the topology upstream and 
following the layer hierarchy down into backing infra/database layers. A 
middleware dependency (database / cache / MQ) is a topology leaf with nothing 
downstream, so the investigation  [...]
-- Narrate an ordered answer: a sentence or two, then a numbered figure, then 
interpretation, then the next figure — referencing the figures in the prose. A 
single running **Figure N** counter numbers *every* inline block — figure, 
topology, hierarchy, deployment, instance map, API dependency, traces, logs, 
browser errors, pod-logs, sub-page or proposal — so the prose can point at any 
of them.
+- Narrate an ordered answer: a sentence or two, then a numbered figure, then 
interpretation, then the next figure — referencing the figures in the prose. A 
single running **Figure N** counter numbers *every* inline block — figure, 
topology, hierarchy, deployment, instance map, API dependency, traces, Zipkin 
traces, logs, browser errors, pod logs, profiling flame, network process graph 
or profiling proposal — so the prose can point at any of them.
+- Every data block is a **captured snapshot**, not a live view. The assistant 
reads the data once, freezes it into the block, and the block stamps **captured 
&lt;time&gt;** beside a small **replay** badge. Figures, all five dependency 
maps (topology, hierarchy, deployment, instance map, API dependency), traces 
and Zipkin traces, logs, browser errors, pod logs, the profiling flame (with 
its trace waterfall) and the network process graph all replay from what was 
read — so reopening a conve [...]
 
 It is **read-only by default**: it observes and explains, and never changes 
configuration, rules, or dashboards. Every data action it takes checks the 
**same read permission you already hold**, so the assistant can never see more 
than you can. The one exception is **profiling**: when metrics and traces can't 
localise a cause, the assistant may *propose* a profiling task as a decision 
card (what it found, why profiling, what it expects) — nothing runs until **you 
approve it** in the popou [...]
 
@@ -89,7 +90,7 @@ Both prompts ship with sensible bundled defaults and can be 
replaced entirely on
 
 ## Permissions
 
-Reaching the assistant requires the `ai:read` permission, granted to the 
viewer / maintainer / operator / admin roles by default and enforced on every 
chat request by the server. `ai:read` alone only opens the chat — it grants no 
data access. Each data tool independently re-checks its **own** read verb 
before it runs: `metrics:read` (layers, services, the metric catalog and every 
figure), `alarms:read` (alarms), `topology:read` (the topology graph, the 
cross-layer hierarchy, and the depl [...]
+Reaching the assistant requires the `ai:read` permission, granted to the 
viewer / maintainer / operator / admin roles by default and enforced on every 
chat request by the server. `ai:read` alone only opens the chat — it grants no 
data access. Each data tool independently re-checks its **own** read verb 
before it runs: `metrics:read` (layers, services, the metric catalog and every 
figure), `alarms:read` (alarms), `topology:read` (the topology graph, the 
cross-layer hierarchy, and the depl [...]
 
 ## Conversations
 

Reply via email to