{"owner":"ccfos","repo":"nightingale","hasSkills":true,"totalSkillsCount":24,"totalTokensCount":44087,"categories":["anthropic-skill","claude-rule"],"hasMcp":false,"mcpConfig":null,"found":["aiagent/skill/embedded/builtin/create-alert-rule/SKILL.md","aiagent/skill/embedded/builtin/categraf-deploy-guide/SKILL.md","aiagent/skill/embedded/builtin/alert-rule-troubleshoot/SKILL.md","aiagent/skill/embedded/builtin/analyze-dashboard/SKILL.md","aiagent/skill/embedded/builtin/alert-mute-copilot/SKILL.md","aiagent/skill/embedded/builtin/alert-subscribe-copilot/SKILL.md","aiagent/skill/embedded/builtin/explorer-query/SKILL.md","aiagent/skill/embedded/builtin/doc-qa/SKILL.md","aiagent/skill/embedded/builtin/host-onboard-diagnose/SKILL.md","aiagent/skill/embedded/builtin/generate-message-template/SKILL.md","aiagent/skill/embedded/builtin/host-health-diagnose/SKILL.md","aiagent/skill/embedded/builtin/create-dashboard/SKILL.md","aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md","aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md","aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md","aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md","aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md","aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md","aiagent/skill/embedded/builtin/promql-generator/SKILL.md","aiagent/skill/embedded/builtin/query-alert-events/SKILL.md","aiagent/skill/embedded/builtin/query-datasource/SKILL.md","aiagent/skill/embedded/builtin/skill-creator/SKILL.md","aiagent/skill/embedded/builtin/sql-generator/SKILL.md","CLAUDE.md"],"skills":{"aiagent/skill/embedded/builtin/create-alert-rule/SKILL.md":"---\nname: create-alert-rule\ndescription: |\n  **Create alert rules**. Prefer reusing the validated rules in integrations (standard components like Linux/MySQL/Redis/Kafka/PostgreSQL/Elasticsearch all ship ready-made rule packs); import as many rules as the user needs—one rule, a batch, or a whole pack. Only hand-write a custom rule when integrations has nothing that fits. Supports all data sources: Prometheus / Loki / ES / OpenSearch / MySQL / PG / TDengine / ClickHouse / Doris / VictoriaLogs / Host.\n  ⚠️ **Do NOT use this skill for bulk YAML imports**—when the user provides a URL or a YAML file, awesome-prometheus-alerts, node-exporter.yml, and the like, use import-prom-rule instead.\n  Triggers: create an alert / add an alert / help me set up a CPU alert / add a set of alert rules for MySQL / configure common alerts for a host / I want to monitor a metric.\nexamples:\n  - \"Configure a set of common alert rules for the host\"\n  - \"Add a set of alert rules for MySQL\"\n  - \"Help me create an alert for CPU usage exceeding 80%\"\n  - \"Add a MySQL slow query alert rule\"\n  - \"Add a memory alert for the host, fire when it exceeds 90%\"\nmax_iterations: 20\nbuiltin_tools:\n  - preview_alert_rule_template\n  - import_alert_rule_template\n  - create_alert_rule\n  - list_busi_groups\n  - list_datasources\n  - list_metrics\n  - list_notify_rules\n  - list_files\n  - read_file\n  - list_databases\n  - list_tables\n  - describe_table\ntags:\n  - export\n---\n\n# Skill: Nightingale (N9E) Alert Rule Creation\n\nTwo tools: `import_alert_rule_template` (reuse the validated rules in integrations) and `create_alert_rule` (hand-write rules).\n\n**Prefer reusing the rules in integrations**: the rules under `integrations/<component>/alerts/` are finished products that have been hand-tuned and validated (severity, duration, evaluation interval, appended tags, annotations, and effective time windows are all configured), and their quality far exceeds hand-crafted rules. **But import as many as the user needs—do not blindly import the whole pack**:\n\n- The user wants \"a set / common\" alerts for some component → import the whole pack (do not pass `names`)\n- The user names only a few categories (e.g. \"disk and memory alerts\") → import only those (pass them in `names`)\n- The user wants a **single** alert for a specific metric → if the pack happens to contain a matching rule, import just that one (pass one entry in `names`) or borrow its expression; if the pack has nothing that fits, hand-write with `create_alert_rule`\n\nOnly go to Approach B (`create_alert_rule`) when integrations has no matching rule, or when the user provides a fully custom query condition.\n\nWhen a component ships both a categraf and an exporter rule pack, **prefer categraf**: first probe whether the categraf metrics have data; if found, use the categraf pack, otherwise fall back to the exporter pack (see Approach A step 3 for details).\n\n| User need | What to do |\n|------|-----------|\n| Configure a set / a few alerts for a standard component (Linux / MySQL / Redis / Kafka / PostgreSQL / Elasticsearch / Ceph / Oracle / Windows / MongoDB …) | Approach A: `preview_alert_rule_template` to see what's in the pack → `import_alert_rule_template` to import as needed |\n| A single alert for a specific metric, and the integration pack has a matching rule | Approach A: `import_alert_rule_template` + `names` to import just that one |\n| Fully custom rules that integrations doesn't cover (including niche conditions like Loki / ES / SQL query values / log counts) | Approach B: `create_alert_rule` |\n\n> ⚠️ **Do NOT use this skill for bulk YAML imports**—when the user provides a URL or a YAML file, awesome-prometheus-alerts, node-exporter.yml, and the like, use import-prom-rule instead.\n\n## Step 1 (common to both paths): determine the business group and data source\n\n- Call `list_busi_groups` to get the list of business groups\n- Call `list_datasources` to get the list of data sources, and find the data source ID of the matching type (rule packs are mostly the Prometheus type)\n- If the session context already has a preselected `busi_group_id` / `datasource_id`, use it directly—**do not** call `list_*` again\n- `cate=host` (host-unreachable class) does not need a data source\n\n### Business group selection rules\n\n1. If the user explicitly specified a business group name or ID, use it directly\n2. Otherwise, by priority:\n   a. **Prefer the business group with `is_default: true`** (usually \"Default Busi Group\" or a group whose name contains \"Default\")\n   b. If there is only one business group, use it directly\n   c. When there are multiple candidates and none is the default, **do not blindly take the first one**; list them in your reply and ask the user to confirm\n\n---\n\n## Approach A: reuse validated rules in integrations (as needed: single / batch / whole pack)\n\n`import_alert_rule_template` imports rules from the rule pack under `integrations/<component>/alerts/` (each rule's severity, duration, evaluation interval, appended tags, annotations, and effective time window are all preserved), binds the rules to the data source you select, and uniformly switches them to the enabled state. **How many rules are imported is controlled by the `names` parameter**, driven by the user's need.\n\n### Steps\n\n1. See which integration components are available:\n   ```\n   list_files(base=\"integrations\")\n   ```\n2. See which rule pack files exist under that component:\n   ```\n   list_files(base=\"integrations/Linux\", path=\"alerts\")\n   ```\n3. **Pick the right rule pack file (prefer categraf)**. Rule packs often come in two collection styles, categraf and exporter, and the filename convention varies per component (for Linux it's `linux_by_categraf.json` / `linux_by_exporter.json`, but it may use other suffixes). **Prefer the pack whose filename contains `categraf`**:\n   - First probe whether the categraf metrics have data in the environment: `list_metrics(datasource_id=<X>, keyword=\"<categraf metric keyword>\")`.\n     - Use a categraf-specific metric name that distinguishes the two styles as the keyword, e.g. for Linux use `cpu_usage` (categraf uses `cpu_usage_idle`, which node_exporter does not have), `mem_used_percent`, `disk_used_percent`; for Redis use `redis_used_memory`; for MySQL use `mysql_global_status_queries`.\n   - **As long as the categraf metric returns data (a non-empty array), use the pack whose filename contains `categraf`** directly, without comparing against exporter.\n   - Only fall back to the pack whose filename contains `exporter` (e.g. `linux_by_exporter.json`) when the categraf metric has no data, and use the corresponding exporter metric keyword (e.g. `node_cpu_seconds_total`) with `list_metrics` to confirm data exists before importing.\n4. **See which rules are in the pack (only when you need to select by name)**. You can skip this step when the user wants to import the whole pack; when the user wants only a few of them, or a single alert for a specific metric, preview first to get the exact rule names:\n   ```\n   preview_alert_rule_template(component=\"Linux\", file=\"linux_by_categraf.json\")\n   ```\n   It returns each rule's `name` / `cate` / `severity` / an expression summary / whether it is disabled (a small payload). **Do not** use `read_file` to view a large rule pack (it will be truncated); use this tool.\n5. **Import as needed**:\n   - Whole pack (the user wants \"a set / common\" alerts)—do not pass `names`:\n     ```\n     import_alert_rule_template(group_id=<business group>, component=\"Linux\", file=\"linux_by_categraf.json\", datasource_id=<data source>)\n     ```\n   - Batch / single (the user names a few categories or a specific metric)—`names` carries the exact rule names (from preview):\n     ```\n     import_alert_rule_template(group_id=<business group>, component=\"Linux\", file=\"linux_by_categraf.json\",\n       datasource_id=<data source>, names=\"[\\\"Machine load - high memory, please pay attention - categraf\\\"]\")\n     ```\n\n> **Do not** use `read_file` to read out the entire rule pack and then call `create_alert_rule` one rule at a time—the pack may be large and get truncated, and converting rules one by one also drops fields. To see what's in the pack use `preview_alert_rule_template`; to persist it use `import_alert_rule_template`.\n\n### Notes\n\n- **Strongly recommend passing `datasource_id`**: passing it binds the imported rules to that data source; omitting it makes the rules match every data source of that type (an overly broad scope). The `host` heartbeat rules in the pack don't need a data source, and the tool skips binding for them automatically.\n- The rules in a rule pack are **disabled** by default in the template (it is a browsable catalog). When importing, this tool **switches them to enabled** by default (`disabled=0`) so the rules take effect immediately. If the user wants to \"import first but leave them disabled, and enable them one by one themselves\", pass `disabled=1`.\n- The names written in `names` must exactly match the `name` returned by `preview_alert_rule_template`; any that don't match are listed in the returned `not_found_names`—double-check and retry.\n- Duplicate rules (already existing in the business group) are **skipped automatically** (`status=skipped_duplicate`); this is not a failure, and **do not** re-import with `name_prefix` just because you see a skip. Only use `name_prefix`/`name_suffix` when the user explicitly wants the rule to coexist with the existing one (e.g. a comparison test).\n- The return includes three counts—`created` / `skipped` / `failed`—plus details for each rule; just summarize them in the output.\n- Imported rules **are not bound to any notify rule** by default (to avoid sending alerts by mistake). If binding is needed, after importing, prompt the user to associate it manually on the rule, or switch to Approach B to create rules one by one and pass `notify_rule_ids`.\n\n---\n\n## Approach B: hand-write a custom rule (when integrations has no matching rule)\n\nUse `create_alert_rule`. Supports all data source types: **Prometheus / Loki / Elasticsearch / OpenSearch / TDengine / ClickHouse / MySQL / PostgreSQL / Doris / VictoriaLogs / Host**.\n\n> Before getting here, confirm that integrations really has no matching rule: common metrics for standard components (CPU/memory/disk/connection count…) are mostly in the rule packs, so reuse rather than hand-craft when you can. Even when hand-crafting, you can first run `preview_alert_rule_template` to glance at whether there's a ready-made expression you can borrow.\n\nThe tool offers two invocation modes:\n\n1. **Prometheus simplified path** (most common)—pass `prom_ql` + `threshold` + `operator` directly, and the tool builds the rule automatically\n2. **Generic path**—pass `cate` + `rule_config_json`; **first obtain the structure of `rule_config_json` by reading `datasources/<cate>.md` via `read_file`**, then fill in the actual values\n\n### Mode 1: Prometheus simplified path\n\nThe most common scenario. You only need to fill in PromQL, threshold, and operator. Internally the tool splices the threshold into `prom_ql` to generate a v1-format rule (the v2 editor in the OSS n9e FE is gated by `IS_PLUS`, so only v1 can be used).\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"CPU usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  \"operator\": \">\",\n  \"threshold\": 80,\n  \"severity\": 2,\n  \"note\": \"CPU usage exceeded 80% for 1 minute\",\n  \"for_duration\": 60\n}\n```\n\n### Mode 2: generic path (non-Prometheus)\n\nFor types such as Loki / ES / MySQL / TDengine / ClickHouse / Doris / VictoriaLogs / Host, you need to pass `cate` and `rule_config_json`.\n\n**Key step**: first read the reference doc for that data source type to obtain the `rule_config` structure template:\n\n```\nread_file(base=\"create-alert-rule\", path=\"datasources/<cate>.md\")\n```\n\nThen turn the `rule_config` object in the doc into a JSON string and pass it to the `rule_config_json` parameter. Example (MySQL alert):\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"MySQL too many failed orders\",\n  \"cate\": \"mysql\",\n  \"datasource_id\": 4,\n  \"severity\": 2,\n  \"rule_config_json\": \"{\\\"queries\\\":[{\\\"ref\\\":\\\"A\\\",\\\"sql\\\":\\\"SELECT count(*) AS value FROM orders WHERE created_at >= NOW() - INTERVAL 5 MINUTE AND status='failed'\\\",\\\"keys\\\":{\\\"valueKey\\\":\\\"value\\\",\\\"labelKey\\\":\\\"\\\"},\\\"interval\\\":60}],\\\"triggers\\\":[{\\\"mode\\\":1,\\\"exp\\\":\\\"$A.value > 10\\\",\\\"severity\\\":2,\\\"recover_config\\\":{\\\"judge_type\\\":1}}]}\"\n}\n```\n\n**⚠️ Important generic rules (apply to all non-Prometheus types)**:\n\n1. **The `interval` field must be the total number of seconds**—do not write `interval_unit`. When the frontend saves, it converts `value × unit → seconds`, and when reading it derives the display unit back from the seconds.\n   - Last 1 minute → `\"interval\": 60`\n   - Last 5 minutes → `\"interval\": 300`\n   - Last 1 hour → `\"interval\": 3600`\n   - **Do not** write `\"interval\": 5, \"interval_unit\": \"min\"`—the FE will display it as 5 seconds.\n   - The tool has a defensive fallback: if you accidentally write `interval_unit` or a bare interval smaller than 60, it converts it to seconds automatically, but it's best to write it correctly to begin with.\n\n**⚠️ Important limitations for OSS n9e SQL-type data sources (MySQL/PGSQL/CK/Doris)**:\n1. **`keys.valueKey` is required**—the alias of the numeric column in the SELECT statement, usually `\"value\"`. Missing it reports `valueKey is required`.\n2. **`$from`/`$to`/`$__timeFilter` are not substituted**—the OSS `macros.Macro` is a no-op. You must use the data source's native time functions, e.g. MySQL uses `NOW() - INTERVAL 5 MINUTE`, PG uses `NOW() - INTERVAL '5 minutes'`, CK uses `now() - INTERVAL 5 MINUTE`.\n3. **TDengine is the exception**—it has its own variable substitution logic, and `$from`/`$to`/`$interval` are usable.\n\n## create_alert_rule parameter reference (Approach B)\n\n| Field | Required | Description |\n|---|---|---|\n| `group_id` | ✅ | Business group ID (from `list_busi_groups`) |\n| `name` | ✅ | Rule name (must be unique within the business group) |\n| `cate` | ❌ | Data source type (default `prometheus`). Options: `prometheus` / `loki` / `elasticsearch` / `opensearch` / `tdengine` / `ck` / `mysql` / `pgsql` / `doris` / `victorialogs` / `host` |\n| `prod` | ❌ | Product type. When omitted, derived automatically from `cate` |\n| `datasource_id` | Conditionally required | Data source ID. **Not needed when `cate=host`**; required for all others |\n| `rule_config_json` | Conditionally required | The complete `rule_config` JSON string. **Can be omitted when `cate=prometheus` (use the simplified path)**; required for other types |\n| `prom_ql` | Conditionally required | PromQL query (only for the `cate=prometheus` simplified path). **Write only the query, not the threshold** |\n| `threshold` | Conditionally required | Trigger threshold (only for the simplified path) |\n| `operator` | ❌ | Default `>`. Options `>` `>=` `<` `<=` `==` `!=` |\n| `severity` | ❌ | Default 2. 1=Critical, 2=Warning, 3=Info |\n| `note` | ❌ | Alert notification body |\n| `eval_interval` | ❌ | Evaluation interval (seconds), default 30 |\n| `for_duration` | ❌ | Duration (seconds), default 60 |\n| `append_tags` | ❌ | Appended tags, multiple separated by spaces |\n| `runbook_url` | ❌ | Runbook URL |\n| `notify_rule_ids` | ❌ | JSON list of associated notify rule IDs, e.g. `\"[1,2]\"` |\n\n## Approach B detailed steps (create_alert_rule)\n\n> See \"Step 1 (common to both paths)\" above for selecting the business group and data source. Below are the steps specific to Approach B.\n\n### B-1: identify the data source type (cate)\n\nBased on the user's alert need, decide which `cate` to use:\n\n| User-need keyword | cate | Trigger condition |\n|---|---|---|\n| \"Host CPU/memory/disk\", \"Prometheus metric\" | `prometheus` | Metric threshold |\n| \"Machine unreachable\", \"node offline\" | `host` | Heartbeat timeout |\n| \"Application error log\", \"Loki log\" | `loki` | Log count |\n| \"ES log\", \"Elasticsearch aggregation\" | `elasticsearch` | Log aggregation |\n| \"OpenSearch log\" | `opensearch` | Log aggregation (same as ES) |\n| \"MySQL abnormal query result\" | `mysql` | SQL query value |\n| \"PostgreSQL abnormal query result\" | `pgsql` | SQL query value |\n| \"ClickHouse metric/log\" | `ck` | SQL query value |\n| \"Doris log\" | `doris` | SQL query value |\n| \"TDengine time-series data\" | `tdengine` | SQL query value |\n| \"VictoriaLogs log\" | `victorialogs` | LogsQL query |\n\nDefault value: `prometheus`. The data source must match too (`cate=mysql` finds a MySQL data source, `cate=loki` finds a Loki data source, etc.); `cate=host` does not need a data source.\n\n### B-2 (non-Prometheus only): read the data source reference doc\n\nFor types where `cate != \"prometheus\"`, **you must first read the reference doc for that type** to obtain the `rule_config` structure:\n\n```\nread_file(base=\"create-alert-rule\", path=\"datasources/<cate>.md\")\n```\n\nFor example:\n- MySQL → `datasources/mysql.md`\n- Loki → `datasources/loki.md`\n- Host → `datasources/host.md`\n\nThe reference docs contain complete field descriptions, option-value tables, and complete examples. **Copy the field names from the `rule_config` object in the example as-is**, replacing only the concrete values.\n\n### B-3 (SQL types only): probe the real schema ⚠️\n\n**This step is mandatory when `cate ∈ {mysql, pgsql, ck, doris, tdengine}`; it cannot be skipped**.\n\n> **TDengine is also supported**: although TDengine is a time-series database, its REST API supports `SHOW DATABASES` / `information_schema.ins_tables` / `information_schema.ins_columns`. `list_databases` / `list_tables` / `describe_table` are fully usable with TDengine.\n\n**Never guess database/table/column names from the literal description in the user's prompt**. When the user says \"query the access_log table in the my_logs database\",\nit is very likely just an example, and the actual data source may have no such database or table at all.\n\nYou must probe in order:\n\n1. **`list_databases(datasource_id=<X>)`**—see the real list of databases\n   - Compare against the user's description and find the closest one\n   - If the database name in the user's description **is not in the returned list**, **do not force it into the SQL**. Use AskUserQuestion\n     to list the real database names for the user to confirm, or pick one that looks relevant and have the user approve it\n\n2. **`list_tables(datasource_id=<X>, database=<the chosen database>)`**—see the real list of tables\n   - Same as above: if the table the user mentioned is not in the list → list the real tables for the user to choose\n\n3. **`describe_table(datasource_id=<X>, database=<database>, table=<table>)`**—get the real columns\n   - Use the returned column names to assemble the SQL—**do not invent** common column names like `log_time`, `status`, `created_at`\n     (they may not actually exist in this table)\n   - Pay special attention to the time column: in different tables it may be called `ts`, `event_time`, `log_time`, `create_time`,\n     `timestamp`, etc.; you must decide based on the column names returned by `describe_table`\n\nOnly after obtaining the real schema can you proceed to B-4 and assemble the SQL.\n\n#### How to pass datasource_id\n\n- When the session context already binds a data source (a conversation entered from the data source page): `datasource_id` can be omitted,\n  inherited from the session context\n- In the alert rule scenario (entered from the alert rule page or a plain conversation): you must **explicitly pass** `datasource_id`,\n  otherwise the tool reports a `datasource_id required` error\n\n### B-4: build the PromQL / SQL / LogQL\n\n#### Simplified path (cate=prometheus)\n\nBased on the user's description, build a PromQL **without a threshold**. All entries in the table below are **categraf (telegraf-style)** metric names—**prefer categraf**:\n\n| User need | Recommended PromQL (prefer categraf) |\n|---|---|\n| Host CPU usage | `avg by (ident) (100 - cpu_usage_idle{cpu=\"cpu-total\"})` |\n| Host memory usage | `mem_used_percent` |\n| Host disk usage | `disk_used_percent` |\n| Host system load | `system_load1` |\n| Host inbound network traffic | `rate(net_bytes_recv[5m])` |\n| MySQL QPS | `rate(mysql_global_status_queries[5m])` |\n| MySQL connection usage | `mysql_global_status_threads_connected / mysql_global_variables_max_connections * 100` |\n| Redis memory usage | `redis_used_memory / redis_maxmemory * 100` |\n\n**Prefer categraf + probe**: first use `list_metrics` to confirm whether the categraf metric has data in the environment, e.g.\n`list_metrics(datasource_id=<X>, keyword=\"cpu_usage\")` (categraf uses `cpu_usage_idle`, which node_exporter does not have).\n- **Data found** → use the categraf PromQL from the table above directly.\n- **Not found** → the environment very likely uses node_exporter; switch to the equivalent exporter expression below, and use\n  `list_metrics(datasource_id=<X>, keyword=\"node_\")` again to confirm the node_exporter metrics actually have data before creating the rule.\n\n##### node_exporter fallback equivalence table (use only when the categraf metric has no data)\n\n| User need | node_exporter equivalent PromQL |\n|---|---|\n| Host CPU usage | `100 - avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100` |\n| Host memory usage | `(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100` |\n| Host disk usage | `(1 - node_filesystem_avail_bytes{fstype!~\"tmpfs\\|overlay\"} / node_filesystem_size_bytes) * 100` |\n| Host system load | `node_load1` |\n| Host inbound network traffic | `rate(node_network_receive_bytes_total[5m])` |\n\n> The `\\|` in the disk expression is only escaped to avoid breaking the Markdown table; when writing it into PromQL, restore it to `|`, i.e. `fstype!~\"tmpfs|overlay\"` (otherwise the regex treats it as a literal pipe and the filter fails).\n\n#### Generic path (other cate)\n\nFollowing the reference doc read in B-2, assemble the `rule_config` object. Key fields:\n\n- **SQL types** (mysql/pgsql/ck/doris):\n  - The query must return one column aliased `value` as the alert judgment value\n  - **Required** `keys.valueKey: \"value\"` (missing → `valueKey is required` error)\n  - **Time filtering cannot use `$from`/`$to`**; use the data source's native time functions:\n    - MySQL: `NOW() - INTERVAL 5 MINUTE`\n    - PostgreSQL: `NOW() - INTERVAL '5 minutes'`\n    - ClickHouse: `now() - INTERVAL 5 MINUTE`\n    - Doris: `NOW() - INTERVAL 5 MINUTE`\n- **TDengine**: a special exception; `$from`/`$to`/`$interval` **can be used**, and use `keys.metricKey` instead of `keys.valueKey`\n- **Log types** (loki/es/opensearch/victorialogs): the aggregation query returns a numeric value; use `0` for `recover_config.judge_type`\n- **Metric types** (prometheus/mysql/pgsql/ck/tdengine): use `1` for `recover_config.judge_type`\n- **Host type** (host): `queries` is a `{key, op, values}` structure; `triggers` is a `{type, severity, duration}` structure; no `datasource_id` needed\n\n### B-5: call create_alert_rule\n\n#### Simplified path (Prometheus)\n\n```\ncreate_alert_rule(\n  group_id=1, name=\"CPU too high\", datasource_id=1,\n  prom_ql=\"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  threshold=80, operator=\">\", severity=2\n)\n```\n\n#### Generic path (other types)\n\n```\ncreate_alert_rule(\n  group_id=1, name=\"MySQL too many errors\",\n  cate=\"mysql\", datasource_id=4, severity=2,\n  rule_config_json=\"<serialize the rule_config read in step 3 into a JSON string>\"\n)\n```\n\n**Notes:**\n- On the Prometheus simplified path, pass the threshold separately in `threshold`—**do not write it into `prom_ql`**\n- Default `for_duration=60`, which is only reasonable if it is larger than `eval_interval=30`\n- If the user requests \"fire immediately\", set `for_duration` to 0\n- If `create_alert_rule` returns an `already exists` error, **do not call `list_alert_rules`** to look up the duplicate name; just add a suffix to the name (e.g. `-v2`, `-AI`, or a timestamp) and retry\n- On the generic path, `rule_config_json` must be a **JSON string** (not an object); remember to escape the quotes\n- **For SQL types, the B-3 schema probe must have been completed before the call**. If the database/table/column in rule_config_json has not been verified via `list_databases`+`list_tables`+`describe_table`,\n  the alert rule will most likely fail to run (`Unknown database` / `Unknown column` and other runtime errors)\n\n### B-6: (optional) associate notify rules\n\nIf the user requests binding notify rules:\n1. Call `list_notify_rules` to get the list of notify rules\n2. When calling `create_alert_rule`, pass `notify_rule_ids`, e.g. `\"[1,2]\"`\n\nIf the user does not explicitly request it, **do not proactively associate notify rules**, to avoid sending alerts by mistake.\n\n---\n\n## Final step (common to both paths): output the result\n\n**Keep it short**. A one-sentence confirmation is enough:\n\n- Approach A (importing a rule pack): summarize the three counts `created` / `skipped` / `failed`, e.g.\n  > ✅ Imported 8 alert rules from the Linux categraf rule pack for you (skipped 1 duplicate name); all are enabled, see the card below for details.\n- Approach B (single creation):\n  > ✅ Created the alert rule \"Host CPU usage too high\" for you; see the card below for details.\n\n**Do not** restate fields such as rule ID, business group, data source, PromQL, threshold, or alert severity in the Final Answer—the frontend displays this information as a structured card, and repeating it only makes the user see two copies. If you need to add a note (e.g. \"these rules are not bound to any notify rule by default; please associate them as needed\"), you may add a sentence or two, but do not list out the fields already in the card one by one.\n\n## Severity quick reference\n\n| severity | Name | Applicable scenario |\n|---|---|---|\n| 1 | Critical (level 1) | Service unavailable, core component down, data loss |\n| 2 | Warning (level 2) | High resource usage, performance degradation, quota approaching limit |\n| 3 | Info (level 3) | Informational alert, capacity planning hint |\n\nUse 2 (Warning) by default. Use 1 only when the user explicitly says \"severe\" or \"urgent\".\n\n## Common single-alert templates (Approach B reference)\n\n> Use these when the user wants a single alert for a specific metric. If the user wants to \"add a whole set of monitoring alerts for a host\",\n> prefer Approach A to import the whole `integrations/Linux/alerts/linux_by_categraf.json` pack, rather than hand-crafting rules one by one here.\n\n### Host CPU usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host CPU usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  \"operator\": \">\",\n  \"threshold\": 80,\n  \"severity\": 2,\n  \"for_duration\": 300,\n  \"note\": \"Host {{ $labels.ident }} CPU usage exceeded 80% for 5 minutes\"\n}\n```\n\n### Host memory usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host memory usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"mem_used_percent\",\n  \"operator\": \">\",\n  \"threshold\": 90,\n  \"severity\": 1,\n  \"for_duration\": 180\n}\n```\n\n### Host disk usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host disk usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"disk_used_percent\",\n  \"operator\": \">\",\n  \"threshold\": 85,\n  \"severity\": 2,\n  \"for_duration\": 600,\n  \"append_tags\": \"alert_type=capacity\"\n}\n```\n","aiagent/skill/embedded/builtin/categraf-deploy-guide/SKILL.md":"---\nname: categraf-deploy-guide\ndescription: >-\n  Answers \"how do I deploy the categraf collector\". Trigger scenarios: the user asks \"how to install categraf / how to deploy categraf / run categraf with Docker / install categraf on Windows / how to register categraf as a system service / report categraf to Nightingale / how to write categraf config.toml / how to verify that categraf is collecting data\". Covers binary + systemd, Docker, Windows, K8s tips, key configuration, and common verification commands. This skill is instructional/guidance-oriented, calls no tools, and outputs ready-to-paste commands and configuration snippets.\ntags:\n  - internal\n---\n\n# Categraf Deployment Guide\n\n## Scope\n\n**Enter this skill**:\n- \"how to install / how to deploy / how to start categraf\"\n- \"run categraf on Docker / Kubernetes / Windows / Linux\"\n- \"how to register categraf as a systemd service / start on boot\"\n- \"report categraf to Nightingale / how to write config.toml / what to put in the writer URL\"\n- \"how to verify that categraf is collecting data\"\n- \"categraf is installed but Nightingale can't see the machine\" → **do not enter this skill**, route to `host-onboard-diagnose`\n\n**Do not enter this skill**:\n- Diagnosing onboarding failures after install → `host-onboard-diagnose`\n- Abnormal metrics after onboarding → `host-health-diagnose`\n- Configuring collection for a specific input plugin (mysql / redis / nginx / snmp, etc.) → guide the user to read the comments under `conf/input.<name>/`; this skill only covers \"get it installed + get it connected\"\n\n## One-Sentence Principle\n\n**categraf deployment = three actions**: get the binary → correctly write the `[[writers]]` section of `config.toml` → start it and verify. Everything else is a variant of these three steps (containerization, systemd management, Windows service, etc.).\n\n## Deployment Methods Quick Reference\n\n| Scenario | Recommended method | Notes |\n|---|---|---|\n| Linux physical machine / VM | **binary + systemd** | v0.3.35+ has a built-in `--install` one-liner to register |\n| Linux but no root | binary + `--user --install` | supported in v0.4.5+ |\n| Container / orchestration environment | **Docker** or K8s DaemonSet | collecting host metrics requires mounting `/proc /sys` |\n| Windows | `categraf.exe --win-service-install` | or run in the background with `win_run.bat` |\n| macOS (development/debugging) | run the binary directly | not recommended for production |\n\nOperating system support: Linux kernel 2.6.32+ / Windows 10+ or Server 2008+ / macOS 10.15+.\n\n## Quickest Path: One-Click Install\n\nIf the nightingale server is v9+ and bundles categraf, a single command on the target\nhost does everything below (download, unpack, rewrite the reporting address, register\nand start the systemd service):\n\n```bash\ncurl -sSfL 'http://N9E_HOST:17000/api/n9e/agents/categraf/install.sh' | sudo bash -s -- --server 'http://N9E_HOST:17000'\n```\n\nThe address is filled in by the server, so nothing has to be edited by hand. The same\ncommand is available from the machine list page (\"Install Categraf\"), which also waits\nfor the host to report. Useful flags: `--force` (reinstall/upgrade), `--dir <path>`,\n`--auth user:pass` (when the agent API requires basic auth). Note the `bash -s --`\nform: `| sudo bash --force` silently drops the flag.\n\nIf the server is older, does not bundle the package, or the host is not Linux, fall\nback to the manual steps below.\n\n## Step 1: Get the Binary\n\nDownload URL: https://github.com/flashcatcloud/categraf/releases\n\nPackage naming convention: `categraf-{version}-{os}-{arch}.tar.gz`, for example `categraf-v0.3.35-linux-amd64.tar.gz`. **Confirm the architecture first** (`uname -m`: choose amd64 for x86_64, arm64 for aarch64).\n\nDirectory structure after extraction:\n\n```\ncategraf/\n├── categraf              # main program\n└── conf/\n    ├── config.toml       # main config (hostname / writers / heartbeat)\n    └── input.*/          # sub-config directories for the various collection plugins\n```\n\n## Step 2: Correctly Write `conf/config.toml`\n\nMinimal working configuration (reporting to Nightingale n9e):\n\n```toml\n[global]\nhostname = \"\"                  # leave empty to let categraf auto-detect the hostname; specify explicitly when multiple machines share a name\ninterval = 15                  # global collection frequency (seconds)\nproviders = [\"local\"]\n\n[global.labels]\n# region = \"shanghai\"          # global extra labels, optional\n# env = \"prod\"\n\n[[writers]]\nurl = \"http://N9E_HOST:17000/prometheus/v1/write\"\nbasic_auth_user = \"\"\nbasic_auth_pass = \"\"\ntimeout = 5000\ndial_timeout = 2500\nmax_idle_conns_per_host = 100\n\n[heartbeat]\nenable = true                  # must be enabled, otherwise this machine won't appear in the Nightingale machine list\nurl = \"http://N9E_HOST:17000/v1/n9e/heartbeat\"\ninterval = 10\n```\n\n**Key fields**:\n- `[[writers]].url`: n9e's remote write receiving endpoint; the path is fixed at `/prometheus/v1/write`, and the default port is 17000.\n- `[heartbeat].enable=true` + `[heartbeat].url`: heartbeat reporting, which lets the \"machine list\" see this machine; **if missing → installed but invisible**.\n- `[global].hostname`: leave empty to use the system hostname; if the `hostname` command in a container/cloned machine returns a duplicated name like `localhost.localdomain`, **you must specify it explicitly**, otherwise different machines will overwrite each other.\n- `[global].labels`: global tags; setting good distinguishing dimensions (region / env / idc) makes building dashboards and alerts much easier later.\n\n⚠️ **Do not set `omit_hostname` to true** — it strips the `ident` label, with the result that the machine list shows OS/agent_version all as unknown (a common pitfall).\n\n## Step 3: Start and Manage\n\n### Option A: Linux + systemd (v0.3.35 and above, recommended)\n\n```bash\ncd /path/to/categraf\nsudo ./categraf --install      # register as a system service\nsudo ./categraf --start        # start\nsudo ./categraf --status       # check status\nsudo ./categraf --stop         # stop\n```\n\nNon-root user (v0.4.5+):\n\n```bash\n./categraf --user --install\n./categraf --user --start\n```\n\nsystemd resource limits (edit `/etc/systemd/system/categraf.service`):\n\n```ini\n[Service]\nCPUQuota=200%\nMemoryLimit=1G\n```\n\nAfter editing, run `systemctl daemon-reload && systemctl restart categraf`.\n\n### Option B: Docker\n\nMinimal (collects only in-container/network metrics):\n\n```bash\ndocker run -td \\\n  --name categraf \\\n  --restart=always \\\n  -e TZ=Asia/Shanghai \\\n  -v /home/flashcat/categraf/conf/:/etc/categraf/conf \\\n  flashcatcloud/categraf:latest\n```\n\n**Collecting host CPU/memory/disk** (the most common case; you must mount the host's `/proc` `/sys` and use host networking):\n\n```bash\ndocker run -td \\\n  --name categraf \\\n  --restart=always \\\n  --network=host \\\n  -e TZ=Asia/Shanghai \\\n  -e HOST_PROC=/hostfs/proc \\\n  -e HOST_SYS=/hostfs/sys \\\n  -e HOST_MOUNT_PREFIX=/hostfs \\\n  -v /home/flashcat/categraf/conf/:/etc/categraf/conf \\\n  -v /proc:/hostfs/proc:ro \\\n  -v /sys:/hostfs/sys:ro \\\n  flashcatcloud/categraf:latest\n```\n\nResource limits: append `--cpus=2 --memory=1g`.\n\n### Option C: Windows\n\nRegister as a service:\n\n```cmd\ncategraf.exe --win-service-install\ncategraf.exe --win-service-start\ncategraf.exe --win-service-stop\ncategraf.exe --win-service-uninstall\n```\n\nOr run in the background with a script:\n\n```cmd\nwin_run.bat start\nwin_run.bat stop\n```\n\nWindows does not support `kill -HUP` reload; after changing the config, use `--win-service-stop` + `--win-service-start`.\n\n### Option D: Kubernetes\n\nOn K8s you typically use a DaemonSet (one per Node to collect host metrics) + a ConfigMap (to distribute `config.toml` uniformly). The official release package includes a `k8s/` directory with sample YAML; just run `kubectl apply -f k8s/`. **The two things you must change**:\n1. The `[[writers]].url` and `[heartbeat].url` in the ConfigMap should point to your n9e;\n2. If you use multiple clusters, add `cluster = \"xxx\"` to `[global.labels]` to distinguish them.\n\n## Step 4: Verify\n\n### 4.1 Single-plugin debugging (does not actually report, prints once)\n\n```bash\n./categraf --test --inputs cpu              # test just one\n./categraf --test --inputs cpu:mem:disk     # use colons to separate multiple\n```\n\nIf metric lines appear on stdout, local collection is OK; if it errors out (failed to connect to mysql / insufficient permissions, etc.), get past this step before starting the service.\n\n### 4.2 Reload config (without restarting the process)\n\n```bash\nkill -HUP `pidof categraf`   # Linux-only, not supported on Windows\n```\n\n### 4.3 Verify on the Nightingale side\n\n- Open n9e in a browser → **Infrastructure / Machine List**; you should be able to see this `ident`, with OS/CPU/agent_version all not unknown.\n- If it doesn't appear → route to `host-onboard-diagnose` and run the 5-stage troubleshooting; don't guess blindly here.\n\n### 4.4 Self-upgrade (v0.3.36+)\n\n```bash\n./categraf --update --update_url https://github.com/flashcatcloud/categraf/releases/download/vX.Y.Z/categraf-vX.Y.Z-linux-amd64.tar.gz\n\n# On an intranet, point --update_url at nightingale's own bundled package instead:\n./categraf --update --update_url 'http://N9E_HOST:17000/api/n9e/agents/categraf/download?arch=amd64'\n```\n\n## Common Pitfalls (one-liner version)\n\n| Symptom | Cause | Fix |\n|---|---|---|\n| Not visible in the machine list | `[heartbeat].enable=false` or `url` not changed | edit the toml, restart |\n| Machine list shows OS=unknown | `omit_hostname=true` or categraf version < v0.2.35 | turn off omit_hostname, upgrade the version |\n| Multiple machines overwrite each other | duplicated hostname (localhost / VM clone) | explicitly set `[global].hostname` or an ident shell |\n| Can't collect host CPU in Docker | `/proc /sys` not mounted and `HOST_*` env not set | use the \"collect host\" command above |\n| Writing to prom returns 499 / queue full | n9e backend ingest queue is full | tune n9e write concurrency, or reduce the categraf interval |\n| TLS x509 unknown authority | a self-signed certificate is used without installing the CA | add `insecure_skip_verify = true` to the writer section (test only), or install the CA |\n| BasicAuth 401 | n9e has BasicAuth enabled but categraf is not configured | fill in `basic_auth_user/pass` |\n\n## Output Style\n\n- **Clarify before giving a solution**: when the user hasn't stated the OS / deployment form, first ask \"Do you want to install it on a Linux physical machine, Docker, or K8s? What is the n9e access URL?\"\n- **Give a ready-to-paste command for every suggestion** — don't write \"tweak the config\", write \"change `[[writers]].url` in `config.toml` to `http://your-n9e:17000/prometheus/v1/write`\".\n- **Configuration examples must include placeholder-replacement notes** (such as `N9E_HOST`) so the user knows what to change.\n- Reply in the user's language (Chinese for Chinese users, English for English users).\n- After deployment, **always remind them to run `--test --inputs cpu` once** and \"take a look at the Nightingale machine list\", otherwise there's no way to know right then whether the install is working.\n- If the user says \"I finished installing but can't see the machine\" → don't give more deployment instructions; route them directly to `host-onboard-diagnose`.\n","aiagent/skill/embedded/builtin/alert-rule-troubleshoot/SKILL.md":"---\nname: alert-rule-troubleshoot\ndescription: This skill should be used when the user reports that an alert rule is \"not firing\", \"no alert was sent\", \"the rule didn't trigger\", \"the rule isn't working\", \"it should have alerted but didn't\", \"why didn't I get an alert\", \"alert rule not firing\", or wants to diagnose why a specific alert rule failed to produce an event/notification. Use this skill to troubleshoot \"why an alert rule did not fire as expected\", as opposed to taking an existing alert and finding its root cause (for the latter, use ops-troubleshooting). Only supported on Release 22 and above.\nversion: 1.0.0\n# Long diagnostic chain: pull the rule -> run the promql -> pull eval logs -> find the event hash -> pull processing logs -> cross-check mute rules -> fall back to self-monitoring metrics.\n# Budget 25 iterations to guarantee a single round can complete.\nmax_iterations: 25\nbuiltin_tools:\n  - list_alert_rules\n  - get_alert_rule_detail\n  - list_datasources\n  - get_datasource_detail\n  - query_prometheus\n  - query_timeseries\n  - get_alert_eval_logs\n  - search_history_alerts\n  - search_active_alerts\n  - get_alert_event_detail\n  - get_event_processing_logs\n  - get_event_pipeline_executions\n  - list_alert_mutes\n  - get_alert_mute_detail\n  - list_notify_rules\n  - get_notify_rule_detail\n  - list_alert_engine_instances\n  - list_busi_groups\ntags:\n  - export\n---\n\n# Nightingale (n9e) Alert Rule Troubleshooting Expert\n\nYou are a senior SRE specialized in diagnosing \"**why an alert rule did not fire**\". This is the exact opposite of `ops-troubleshooting` (take an alert and find its root cause): the user expects a rule to fire, but **no event was produced**, or **an event was produced but no notification was received**. Your job is to trace the data flow from source to endpoint and find which step things got stuck at.\n\n> **Applicable versions**: Release 22 and above. R21- is out of scope for this skill.\n\n---\n\n## Core Principles\n\n1. **Trace along the data flow**: the alert engine's workflow is `sync rules -> query data -> anomalous point -> effective time -> mute -> sustained duration -> notify interval -> write to DB -> notify`. Diagnose in this same order, do not jump around.\n2. **Evidence-chain driven**: every conclusion at each step must be backed by tool-call results (rule config / actual query / engine logs / processing logs), never by guessing.\n3. **Logs are authoritative**: `get_alert_eval_logs` and `get_event_processing_logs` are the \"god's-eye-view\" tools of R22+; they directly reveal the engine's decision process. Always prefer them over repeated guessing.\n4. **Report the direct cause**: you do not need to root-cause everything to the extreme; pinpointing \"which step did not pass\" is enough.\n\n---\n\n## Troubleshooting Decision Tree\n\n```\nUser says \"rule X didn't fire an alert\"\n        │\n        ├─→ Did the user give a rule_id / rule name / business keyword?\n        │        │\n        │        ▼\n        │   First use list_alert_rules / get_alert_rule_detail to pin down the rule\n        │\n        ▼\nDetermine which phenomenon it is:\n   A. No alert event produced at all       → follow flow A\n   B. Alert event produced but no notification received → follow flow B\n   C. User believes \"this alert should not have fired\" (curve doesn't match / trigger value unreasonable / repeated flapping / recovered due to missing data, etc. — \"suspected false positive\") → follow flow C\n   D. Unsure                  → first run search_history_alerts to see whether the rule produced any event recently, then branch\n   ※ Rule contains ≥2 queries (A, B, …) and \"a query/condition was satisfied but did not trigger or recover as expected\" → must follow step 3.5 of flow A\n```\n\n---\n\n## Flow A: The rule produced no alert event\n\n### Step 1 · Pin down the rule, verify its configuration\nCall `get_alert_rule_detail(id=<rule_id>)` and focus on verifying:\n\n| Config item | What happens if it fails |\n|---|---|\n| `disabled = 0` (enabled) | A disabled rule is not evaluated |\n| `datasource_ids` non-empty and the datasource exists | No datasource means no query |\n| `prom_eval_interval` / `prom_for_duration` | Too long, and the trigger window may not have been reached yet |\n| `enable_in_bg` (effective only in this business group, host alerts) | A host not in the business group is skipped |\n| `enable_stime` / `enable_etime` / `enable_days_of_week` | If the current time is outside the effective window, no evaluation occurs |\n| `cate` + rule config (PromQL / SQL) | Whether the expression can actually return data needs to be verified later |\n| `rule_config.triggers[].exp` (threshold alert) | **If the exp field is empty**, the alert condition is incompletely configured (common for rules created via API/import), and the rule will never trigger |\n\nIf any item is not satisfied, **pinpoint directly to this step** and output the report.\n\n> **Threshold-alert exp validation tip**: extract the `triggers` array from `rule_config`; each trigger should have a non-empty `exp` (e.g. `$A > 80`). If `exp` is an empty string or missing, that is a direct pinpoint conclusion.\n\n### Step 2 · Verify the datasource path\nCall `get_datasource_detail(id=<ds_id>)`. Focus on:\n- Is the datasource status normal?\n- Is the datasource associated with an alert engine cluster? This is the prerequisite for the rule being managed (the \"alert rule -> datasource -> alert engine cluster\" chain).\n\n### Step 3 · Actually run the query once, verify whether there is an anomalous point\nExtract the query expression from the rule config and run it yourself:\n\n- **Prometheus type**: use `query_prometheus(query=<promql>, query_type='range', time_range='1h')`. First look at the range trend, then use instant to check whether the trigger condition is currently truly met.\n- **SQL / ES / VictoriaLogs type**: use `query_timeseries`, following the R22+ docs to pass `sql + value_key`, or `index + filter`, or `query`. **Importantly remind the user to check whether the `value_key` field name exactly matches the column in the SQL** (a common pitfall).\n- **ES `query_string` case pitfall (check this first when an ES log alert behaves unexpectedly)**: `AND` / `OR` / `NOT` must be **uppercase** to be recognized as boolean operators; writing `and` / `or` / `not` makes them treated as ordinary terms, and combined with `query_string`'s default operator being `OR`, the entire query's semantics change completely.\n  - Typical symptoms: the alert match count is far larger than expected; the returned logs include pods / services / levels that **should not match** (e.g. the query is `logLevel:ERROR and ext_pod:\"menuglobal-*\"` but the results contain large amounts of INFO/WARN, or ERROR logs from other pods).\n  - How to diagnose: copy the rule's ES query statement out and **check character by character whether `and`/`or`/`not` are uppercase**; also run `query_timeseries` once with lowercase and once with uppercase using the same statement — a huge difference in match counts confirms this cause.\n  - Fix: change to uppercase `AND` / `OR` / `NOT`, or switch to the structured `bool.must` / `bool.should` form to avoid the case pitfall.\n\nDecision criteria:\n- **Returns data + meets the condition** → proceed to Step 4 to see why the engine produced no event\n- **Returns data but does not meet the condition** → report \"actual data does not meet the threshold\", end\n- **Returns nothing** → may be data-ingestion delay; ask the user to confirm whether the collection side is healthy; the query expression itself may also be wrong\n\n> **Multi-query-rule trap**: if the rule has ≥2 queries (A, B, …), \"A returns a value on its own and B returns a value on its own\" does **not** mean \"it should trigger\". Multiple queries must be merged by labels before they participate in the decision; Step 3.5 below specifically handles this category. Do not jump to the conclusion \"data is satisfied, it should alert\" at this step.\n\n### Step 3.5 · Multi-query/multi-variable threshold-decision verification (mandatory when rule_config.queries ≥ 2)\n\nWhen the rule config has two or more queries (each with its own ref: A, B, …) and the trigger/recovery expression references multiple refs, there is a set of pitfalls **specific to multiple variables**. As long as `rule_config.queries` has length ≥ 2, follow this step.\n\n**① First clarify the correspondence between refs and expressions**\nExtract `queries` (each query has its own ref) and `triggers` from `rule_config`, and for each trigger, understand clearly:\n- which `$ref` values `triggers[].exp` (the trigger expression) references, e.g. `$A > 0`\n- which `$ref` values `triggers[].recover_config.judge_type` and `recover_config.recover_exp` (the recovery condition) reference, e.g. `judge_type=recover_on_condition` + `$B > 0`\n\n**② Refs in the recovery condition do not fire alerts (semantic clarification)**\nOnly the **trigger expression (exp)** produces alert events; the **recovery condition (recover_exp) only decides when an already-triggered alert recovers**, and the query it references **will never fire an alert on its own**.\n- Typical misuse: the user configured two queries A and B, put `$A > 0` in the trigger and `$B > 0` in the \"recovery condition\", and then expects \"an alert when B is satisfied too\". This is putting the query in the wrong slot, not a data problem. To make B alert independently, B must be added as an **independent trigger condition** (multi-trigger / expression mode), not placed in the recovery condition.\n- Identification: the user says \"the second query/condition has values in the data preview but doesn't trigger\", and that ref only appears in `recover_exp` and not in any `exp` → pinpoint directly to this item.\n\n**③ The recovery condition references a ref not present in the trigger expression → recovery can never be satisfied (key pitfall, can be confirmed in the eval log)**\nWhen the engine evaluates a group of curves, the variable table is only filled with the values of `$ref`s that **appear in the trigger expression exp**; a ref that **only appears in recover_exp and not in exp** (e.g. B when exp=`$A>0`, recover_exp=`$B>0`) **will not be filled into the variable table**. As a result, the recovery condition `$B > 0` computes against an undefined variable, the expression fails to compile, the decision is constantly false, and the **recovery condition is never satisfied**.\n- **eval log signature**: `get_alert_eval_logs` will show an error line like `exp:$B > 0 data:map[$A:...] error: ... B ...` (variable B undefined). Seeing this confirms it.\n- Fix: either also write the variable used by the recovery into the trigger expression (so it enters the variable table), or change the recovery method back to the default \"recover when the result no longer meets the trigger condition\" (origin); do not reference a ref in the recovery condition that the trigger expression never uses.\n\n**④ Multiple refs are grouped and merged by \"fully identical labels\" (the real meaning of the config page's orange hint \"ensure all variables have consistent labels\")**\nThe engine groups the curves of each query by the **group-by label set (tagHash)**; only when the label sets of A and B are **field-for-field fully identical** do they fall into the same group and jointly participate in the cross-ref expression decision (by default, with no explicit join, tags are unioned). Therefore:\n- The **group-by dimensions of A and B must be fully identical** (fields and keyword suffixes must all be the same).\n- Even if the dimensions are identical, if the **filter conditions of A and B differ** so that the returned label **values** differ (e.g. A queries `message:\"Disconnecting\"`, B queries `message:\"Received logon\"`, and the `fctags`/`filename` of the two log types are naturally different), their tagHashes do not match and they **can never enter the same group**, so the cross-ref expression (including the recovery condition) cannot be decided.\n- Diagnostic action: run A and B separately with `query_timeseries`, list the label sets of the series returned by each side, and **manually verify whether there is a pair with fully identical labels**. If not a single pair matches, this is the cause.\n\n**Decision and recommendations**\n- Ref in the wrong slot (B is in the recovery condition but is expected to alert) → explain the semantics, recommend adding B as an independent trigger condition.\n- The recovery condition references a ref not in the trigger expression → confirm with the eval log error, recommend merging the variable into the trigger expression, or switching back to the origin recovery method.\n- A and B labels do not align → recommend unifying the group-by dimensions, confirming the two filter conditions can produce curves with identical labels, or simply splitting into two independent rules.\n\n### Step 4 · Pull the alert engine evaluation logs (key step)\n**This is the single most central tool for R22+ troubleshooting**:\n\n```\nget_alert_eval_logs(rule_id=<rule_id>)\n```\n\nIt returns the engine instance responsible for the rule + the most recent evaluation logs (in reverse chronological order). How to read it:\n\n- **Logs empty** → the engine isn't running this rule at all. Check:\n  - whether the datasource is associated with an engine cluster (back to Step 2)\n  - whether the engine instance heartbeat is normal (use `list_alert_engine_instances` from Step 4.5)\n- **The log contains the text `ERROR ... query`** → an error occurred while querying data (e.g. Prometheus unreachable, SQL error)\n- **The log shows \"no data found\"** → data-ingestion delay or a query-expression problem\n- **The log shows \"data found but condition not met\"** → the actual data has no anomaly\n- **The log shows \"condition met but sustained duration insufficient\"** → the anomalous point did not persist to `prom_for_duration`\n- **The log shows \"event produced but muted\"** → go to Step 5 to cross-check mute rules\n\n### Step 4.5 · Alert engine instance health (mandatory when eval logs are empty)\n\n```\nlist_alert_engine_instances(datasource_id=<ds_id associated with the rule>)\n```\n\nIt returns each engine instance's `last_heartbeat` / `stale_seconds` / `healthy` fields. Decision:\n\n- No instance returned → the datasource is not bound to any engine instance (same chain issue as Step 2)\n- All instances `healthy=false` (`stale_seconds > 30`) → the process is down, ask the user to restart n9e-server\n- Multiple `engine_cluster` values or multiple old-version instances heartbeating at the same time → suspect \"an old instance was forgotten during the upgrade\", ask the user to clean up the old instances\n\n### Step 5 · Cross-check mute rules\nIf the eval logs show the event was muted, or you suspect it was muted:\n\n1. `list_alert_mutes(query=<relevant keyword>)` to list the mute rules in the same business group\n2. `get_alert_mute_detail(id=<mute_id>)` to see each mute rule's matching conditions\n3. Cross-check against the event labels (from three parts: time-series data labels + rule append labels + rule name), matching one by one\n\nA match pinpoints the cause.\n\n### Step 6 · Engine self-monitoring fallback\nIf everything above is normal but there is still no event, use `query_prometheus` to query n9e's own metric:\n\n```\nn9e_alert_eval_query_series_count{rule_id=\"<rule_id>\"}\n```\n\n- Metric exists and value > 0 → the engine is indeed running and did fetch data\n- Metric is 0 → the query returned empty, go back to Step 3\n- Metric does not exist → the rule may not be managed by the engine at all\n\n---\n\n## Flow B: An alert event was produced but no notification was received\n\n### Step 1 · Confirm the event exists\n- Use `search_history_alerts(query=<rule name or keyword>, hours=24)` to find the most recent event\n- Or `search_active_alerts(query=...)` to see active alerts\n- Grab the **`hash`** field (not id, the hash), for the next step\n\n### Step 2 · Pull the event's downstream processing logs (key step)\n```\nget_event_processing_logs(event_hash=<event hash>)\n```\n\nIt returns the full chain from event production to notification. How to read it:\n\n- whether it entered **notify rule** matching\n- whether **callback / webhook** was invoked successfully\n- whether a **subscription** matched\n- whether it was **muted** at some step\n- whether a **notification script** ran, and what the script execution result was\n\n### Step 2.5 · Notify-rule validity + level/time-window/label match verification (mandatory when the \"notification result\" table is entirely empty)\n\n> The user seeing **not a single record** in the \"notification result / notification_record\" table (neither success nor failure) is the most frequent phenomenon in this flow. **Key insight: an empty table ≠ a send failure.** In the engine, only the \"silently skipped\" path leaves the table empty; if the **channel is disabled or the notification template is missing**, the engine actually writes a **failure record** (notification status = failed), so the table is not empty. Therefore **when the table is entirely empty, first suspect the following \"never reached the send step\" causes**, rather than checking whether the channel token is correct.\n\nThe processing logs are an engine black box and whether they record something depends on luck; this step proactively pulls out the notify rules bound to the rule and verifies them independently. In order:\n\n**① Does the rule have a notify rule bound at all**\nTake `notify_rule_ids` from `get_alert_rule_detail`:\n- **Empty** → the rule has no new-style notify rule bound at all (it may still be on the old-style `notify_groups`/`notify_version=0`, or nobody configured one), so naturally there is no notification record. Ask the user to bind a notify rule on the rule.\n\n**② Is each notify rule enabled + does the channel/level/time-window/label match**\nFor each `notify_rule_id`, call `get_notify_rule_detail(id=...)` and verify one by one (the engine's decision criteria are already aligned with the tool fields):\n\n- **`enable=false`** → the notify rule is disabled. The engine only loads rules with `enable=true`; disabled ones are directly `continue`d and **leave no record**. **This is the most common and most easily overlooked cause of an entirely empty table — check this first.**\n- Iterate `notify_configs` and compare each config against the **current event** item by item (if any item is not satisfied, that config is `continue`d and skipped, neither sent nor recorded):\n  - **`severities` does not include the event level** → the event is S1 (severity=1); if `severities` does not include 1, it does not match. **Key pitfall: `severities` being an empty array = it matches no event (not \"all levels\")**; the engine treats empty severities as a no-match directly — common in notify rules created via API/import.\n  - **`time_ranges` does not cover the trigger moment** → the event triggers at 16:05; if the time window is configured as something like 00:00–09:00, it is not sent; be sure to also verify the **day of week `week`**. (Empty `time_ranges` = no time restriction, matches all.)\n  - **`label_keys` / `attributes` do not match the event labels** → take each filter item (`key`/`op`/`value`) and match it against the event labels one by one; the matching semantics are the same as mute rules (`==`/`=~`/`in`/`!=`/`!~`/`not in`). (Empty `label_keys` = no label filtering, matches all.)\n  - **`channel_enabled=false`** → the channel is disabled. Note: in this case the engine writes a **failure record** (\"notify_channel not found\"), and a failure row is visible in the table — so if the table is **entirely empty**, a disabled channel is not the primary cause, but it should still be verified.\n\n**Decision**: under a single notify rule, as long as **any one** `notify_config` fully matches, a record should be produced; only when **all configs fail to match** does that rule produce no record for the event. Verify every bound notify rule; hitting one of \"not bound / rule disabled / all configs fail to match on level-time window-label\" pinpoints the direct cause of the notification not being sent.\n\n### Step 3 · Notify-frequency verification (repeat notification / max count)\n\nMany \"no notification received\" cases are actually rate-limited. After taking the rule config from `get_alert_rule_detail`, verify:\n\n| Field | Meaning | What happens if it fails |\n|---|---|---|\n| `notify_repeat_step` (minutes) | Repeat notification interval | If less than this interval has elapsed since the last notification, it is not sent again; a recovery event resets this interval timer from 0 |\n| `notify_max_number` | Max notification count | 0 = unlimited; when non-zero, after reaching the count no more notifications are sent and the event must recover to reset |\n\nHow to verify:\n- Use `search_history_alerts(query=<rule name>, hours=720)` (pull a month of history) to count how many times the rule triggered historically, and see whether `notify_max_number` has been exhausted\n- Look at the time of the most recent notification (if any) and compare against `notify_repeat_step` to decide whether it is still within the silence window\n\n### Step 4 · Event processor (pipeline) execution check\n\nIf the user configured event processors (event suppression / data enrichment / self-healing pipelines, etc.), they may have dropped or rewritten the event at some step:\n\n```\nget_event_pipeline_executions(event_id=<event id>)\n```\n\nRead each execution record's `status` and `error_message`:\n- `status=success` and no rewrite → the processor passed it through normally\n- `status=failed` + `error_message` + `error_node` → a processor node failed, possibly blocking the notification chain\n- No execution record at all → no pipeline matched this event (if the user expected one, ask them to check the pipeline's matching conditions)\n\n### Step 5 · Other supporting checks\n- `get_alert_event_detail(event_id=<id>)` to get the event details and inspect the `callbacks` field\n- If the event has `IsRecovered=1`, it has already recovered; the user may be looking at an event outside the historical time window\n- If it is a subscription rule, use `list_alert_subscribes` / `get_alert_subscribe_detail` to verify the subscription conditions\n\n---\n\n## Flow C: The user believes \"this alert should not have fired\" (suspected false-positive determination)\n\nThe user's description is something like: \"the alert shows a trigger value of 95, but when I open the curve it's only 30\", \"the data clearly had no anomaly, why did it alert\", \"this alert fires dozens of times a day, always flapping\", \"the machine is off, why does it say the alert recovered\". This category is collectively called \"suspected false-positive determination\". There are 3 common causes; investigate them in the following order:\n\n### Cause 1 · Downsampled during the instant query\n\nA time-series database automatically downsamples when querying over a long time range, causing a mismatch between the \"smooth curve\" the user sees in the UI and the \"raw point\" at the alert trigger moment.\n\n**How to diagnose**:\n1. `get_alert_event_detail(event_id=<id>)` to get the trigger time `trigger_time` and `trigger_value`\n2. Use `query_prometheus(query=<rule's promql>, query_type='instant', time_range='5m')`, narrowing the time range to around the alert trigger moment (a few minutes before and after), do not pull 1h/6h\n3. Compare the instant query result vs `trigger_value`; normally they should be consistent\n\n### Cause 2 · Log-type data ingestion delay; alert moment ≠ query moment\n\nLog-type datasources (ES / VictoriaLogs / SLS, etc.) have data-ingestion delay. When the alert engine queries the interval [t-1m, t] at moment t, it fetches a batch of stale data that triggers the alert; when the user later queries the same interval, because new data has since been backfilled, the statistical result changes.\n\n**How to diagnose**:\n1. Use `get_alert_eval_logs(rule_id=<rule_id>)` to find the evaluation log for that trigger (aligned by `trigger_time`)\n2. Look at the `query`/`series`/`value` fields recorded in the log — that is the value the engine **actually queried at the time**; take this as authoritative, not the result the user queried afterward\n3. Explain the cause of the delay to the user, and recommend adding a \"delay tolerance window\" in the alert rule's SQL/query conditions\n\n### Cause 3 · Historical pattern: high-frequency flapping / threshold-edge oscillation / missing-data false recovery\n\nUnlike Causes 1/2 which focus on \"this one instance\", Cause 3 looks at \"whether this rule has been **firing normally** over the past period\". Three typical forms:\n\n- **flapping**: triggers and recovers dozens of times a day, each event lives only a few seconds to a few dozen seconds\n- **threshold-edge oscillation**: the trigger value always stays in a narrow band of threshold ±5%, repeatedly crossing the line\n- **missing-data false recovery**: a machine shutdown / collection gap causes \"no data\" to be treated as \"recovered\", and then the data comes back and re-alerts\n\n**How to diagnose**:\n\n1. Use `search_history_alerts(rid=<rule_id>, hours=168)` to pull all events of this rule over the past 7 days\n2. On the results, compute:\n   - total trigger count (number of repeats with the same hash)\n   - average \"produced → auto-recovered\" interval (first_trigger_time to recover_time)\n   - distribution of trigger values: min / max / whether concentrated in the narrow band of threshold ±5%\n   - whether there is a repeated \"recover → trigger again immediately\" pattern (a short interval between recover_time and the next first_trigger_time)\n3. Hitting any of the following signals marks it as \"suspected flapping / false-positive type\":\n   - average lifetime < 2 × `prom_eval_interval` (recovers after one or two evaluation cycles)\n   - same hash triggered > 30 times within 7 days\n   - trigger values concentrated in the narrow band of threshold ±5%\n   - \"recover → re-trigger\" repeats with an interval < 5 minutes\n4. If you suspect \"missing-data false recovery\", use `get_alert_eval_logs(rule_id=<rule_id>)` to look at the evaluation logs around the recovery: seeing `series=0` (no data found) in the log immediately followed by a recovery almost confirms this cause.\n\n### Direct pinpoint conclusions\n- If Cause 1 → tell the user to re-verify with a shorter time range or an instant query\n- If Cause 2 → the alert trigger value is the real value at the time; the curve the user sees afterward is the result after backfill; both are correct, the alert is fine\n- If Cause 3 · flapping / threshold-edge oscillation → recommend increasing `prom_for_duration` (sustained duration) or `RecoverDuration` (observation hold time), or raising the threshold away from the oscillation band\n- If Cause 3 · missing-data false recovery → explain the Prometheus-style \"no data = recovered\" mechanism, recommend adding `RecoverDuration` to delay the recovery event, or use a dedicated host-disconnection rule to handle the lost-contact/shutdown scenario, avoiding mixing it with business alerts\n\n---\n\n## Output Report Template\n\nAfter troubleshooting, output:\n\n```markdown\n## Alert Rule Troubleshooting Report\n\n### 1. Subject\n- **Rule**: <rule_name> (id=<rule_id>)\n- **Business group**: <group_name>\n- **Datasource**: <ds_name> (id=<ds_id>, type=<cate>)\n- **Phenomenon**: <user description>\n\n### 2. Troubleshooting Process\n1. **Rule config verification**: <enabled status / effective time / sustained duration and other key items> — ✅/❌\n2. **Datasource path**: <datasource status, whether associated with an engine cluster> — ✅/❌\n3. **Actual data verification**:\n   - Query expression: `<promql / sql>`\n   - Query result: <sample data / whether the threshold is met>\n4. **Engine evaluation logs** (get_alert_eval_logs):\n   - Engine instance: <instance>\n   - Key logs: <excerpt the most relevant 2-5 lines>\n5. **Mute-rule verification** (if any): <matched mute_id or \"no match\">\n6. **Event processing logs** (get_event_processing_logs, for flow B):\n   - Key logs: <excerpt the most relevant 2-5 lines>\n7. **Notify-rule verification** (get_notify_rule_detail, when flow B's \"notification result\" is empty):\n   - Bound notify_rule_ids: <list / empty>\n   - Each rule's enable status, matched/unmatched notify_config (level/time window/label match result): <conclusion>\n\n### 3. Pinpoint Conclusion\n- **Direct cause**: <one sentence clearly stating which step it got stuck at>\n- **Evidence**: <point to the key field or log line of a specific tool call>\n\n### 4. Recommended Actions\n- **Immediate fix**: <adjust threshold / adjust sustained duration / remove mute / fix notify rule / restart engine …>\n- **Follow-up**: <improve monitoring, review thresholds, etc.>\n```\n\n---\n\n## Common Error Patterns Quick Reference\n\n| Phenomenon (from eval logs / actual query) | Direct cause |\n|---|---|\n| eval logs empty, and the self-monitoring metric is also absent | The datasource is not associated with an engine cluster, or the rule is disabled |\n| eval logs contain `ERROR ... query` | Datasource connection error / SQL syntax error |\n| eval logs show `series=0` | The query result is empty: data-ingestion delay or expression error |\n| eval logs show the condition is met but no event written to DB | Sustained duration insufficient, muted, or outside the effective time |\n| eval logs show `event muted` | A mute rule matched |\n| event exists but processing logs show `notify skipped` | Notify rule does not match / subscription did not match |\n| event exists but processing logs show a callback error | Third-party interface (IM / webhook) error |\n| event exists, processing logs have no notification action | No notify_rule bound, rate-limited by notify_repeat_step, or reached notify_max_number |\n| The \"notification result\" table is **entirely empty** (neither success nor failure) | It took the \"silently skipped\" path: notify rule `enable=false` / rule not bound to notify_rule_ids / all notify_configs fail to match on level-time window-label / rate-limited / dropped by pipeline; verify one by one with get_notify_rule_detail (Step 2.5). Note: empty table ≠ send failure |\n| get_notify_rule_detail shows `enable=false` | The notify rule is disabled; the engine only loads enabled rules, so it neither sends nor records |\n| notify_config's `severities` is an empty array | The engine determines \"matches no event\" (not \"all levels\"); that config never sends; common for API/import creation — fill in the event level for severities |\n| Event level/labels/trigger moment do not match the notify_config's severities/label_keys/time_ranges | That config is skipped; if all configs under the rule fail to match, the whole rule produces no record |\n| The \"notification result\" table has a **failure record** (notification status = failed) | Not a match problem but a send problem: channel disabled (channel_enabled=false) / channel deleted / notification template missing / third-party interface error; look at the details in the record |\n| pipeline executions have a `status=failed` | An event-processor node errored and blocked the chain; look at error_node/error_message |\n| All engine instances have `stale_seconds > 30` | The n9e-server process is down, restart it |\n| Multiple engine_cluster values heartbeating at the same time | An old-version instance was not taken offline and may be contending for rule management |\n| exp is empty in rule_config.triggers | The threshold-alert condition was not fully configured, it will never trigger |\n| User says \"the trigger value doesn't match the curve\" | Instant-query downsampling / log-data ingestion delay; take the value recorded in eval logs as authoritative |\n| Same hash triggered > 30 times within 7 days / average lifetime < 2 evaluation cycles | flapping-type false positive; increase prom_for_duration or RecoverDuration |\n| Trigger value always stays in the narrow band of threshold ±5% | Threshold-edge oscillation; raise the threshold away from the oscillation band |\n| eval logs before recovery show `series=0` | Recovery determined due to missing data (Prometheus-style \"no data = recovered\"); use RecoverDuration to delay the recovery event, or handle it with a dedicated host-disconnection rule |\n| ES log alert match count far exceeds expectation / results include pods or levels that should not match | `and`/`or`/`not` in `query_string` were written in lowercase, treated as terms + default OR concatenation; change to uppercase `AND`/`OR`/`NOT` or switch to `bool.must` |\n| Multi-query rule, B has values when previewed alone but \"doesn't trigger\", and B only appears in the recovery condition | A ref in the recovery condition does not fire alerts, it only controls recovery; to make B alert, add it as an independent trigger condition (flow A Step 3.5②) |\n| eval log shows `exp:$B > 0 data:map[$A:...] error: ...B...` | The recovery condition references a ref not in the trigger expression; the variable never entered the table, so recovery is constantly false; merge the variable into the trigger exp or switch back to origin recovery (Step 3.5③) |\n| Multi-query A/B each have data, but the cross-ref expression/recovery is never decided | A and B's group-by dimensions or the labels produced by their filters have inconsistent values; the tagHashes do not align so they never enter the same group; unify group-by or split into independent rules (Step 3.5④) |\n\n---\n\n## Safety and Boundaries\n\n1. **Read-only**: this skill does not call any create/modify tools.\n2. **Focus on a single rule**: do not troubleshoot multiple rules at once; first have the user pick the single most critical one — serial troubleshooting is more accurate than parallel.\n3. **Do not echo the user's guesses**: do not get led astray by \"I think it's an xxx problem\"; follow the flowchart step by step, and base conclusions on the logs.\n4. **R22+ only**: if the user explicitly says R21-, tell them this skill does not cover it and refer them to the official documentation.\n\n---\n\n## Worked Example\n\n> User: \"Rule 833 'Insufficient disk space' — I waited 10 minutes and still didn't receive an alert, please take a look.\"\n\n**Step 1**: `get_alert_rule_detail(id=833)`\n→ The rule is enabled, cate=prometheus, PromQL=`sum(disk_free) > 1`, sustained duration 60s, associated with ds_id=5.\n\n**Step 2**: `get_datasource_detail(id=5)`\n→ Prometheus, associated with alert engine cluster default, status normal.\n\n**Step 3**: `query_prometheus(query='sum(disk_free) > 1', query_type='instant', time_range='5m')`\n→ Returns empty, no series meet the condition.\n\n**Step 4**: `get_alert_eval_logs(rule_id=833)`\n→ The logs show `series=0`; every evaluation found no data.\n\n**Conclusion**: The rule's PromQL expression may be semantically wrong (`sum(disk_free) > 1` holds in any normal environment, but the current instance's disk_free reporting field unit/labels may not line up). Recommend the user go to the instant-query page and use the Table view to verify the actual labels and values of the `disk_free` metric.\n","aiagent/skill/embedded/builtin/analyze-dashboard/SKILL.md":"---\nname: analyze-dashboard\ndescription: Analyze the data health of a given dashboard on Nightingale (n9e) over a time window. Use when the user asks to \"analyze what problems a dashboard has\", \"check whether the xx dashboard has been normal over the last 24 hours\", \"inspect this dashboard\", or \"does this dashboard have any anomalies\". Distinct from modifying a dashboard (modify-dashboard) and creating a dashboard (create-dashboard).\nmax_iterations: 12\nexamples:\n  - \"Analyze what data problems the etcd dashboard has within 24 hours\"\n  - \"Check whether the Linux host monitoring dashboard has been normal over the last hour\"\n  - \"Inspect this dashboard\"\n  - \"Has the host web01 in this dashboard had any anomalies over the last day\"\nbuiltin_tools:\n  - list_dashboards\n  - get_dashboard_data\n  - get_dashboard_detail\n  - query_prometheus\n  - list_busi_groups\ntags:\n  - internal\n---\n\n# Skill: Nightingale (N9E) Analyze Dashboard\n\nHelp the user analyze the data health of an **existing dashboard** over a given time window, and produce an anomaly list with recommendations.\n\nThe core tool is `get_dashboard_data`: it performs data fetching and **statistical pre-screening** on the server side (four deterministic detections — MAD outliers, sudden changes, trends, year-over-year comparison — plus periodicity denoising), and returns layered results. Year-over-year baseline: for a window ≤24h it compares against the same period yesterday; for a longer window it compares against the previous period of the same length (the digest header states the shift amount — describe it accordingly). **The detection is already done; your job is attribution, correlation, and explanation, not re-scanning point by point.**\n\n## Step 1: Locate the dashboard\n\n- The context already carries a `dashboard_id` (injected by the frontend from `/dashboards/<id>`) → use it directly.\n- The user pasted a `/dashboards/<id>` link → take the id.\n- Only a name was given → match by name with `list_dashboards(query=\"...\")`; if there are multiple candidates or no match, list them and ask the user — **do not** guess.\n\n## Step 2: Call get_dashboard_data\n\n```\nget_dashboard_data(id=<id>, time_range=\"24h\")\n```\n\n- Pass `time_range` according to user intent (\"the last day\" → `24h`, \"this week\" → `7d`); if the user did not specify, use the default `1h` and note the analysis window in the conclusion.\n- The user constrained host/cluster, etc. (\"look at the host web01\") → pass it via `vars`: `vars={\"ident\":[\"web01\"]}` (the variable name follows the variable definition in `get_dashboard_detail(include_config=true)`; if unsure, check it first).\n- **One call is enough.** Do not first call get_dashboard_detail and then run query_prometheus over every panel at full scale — that is what the tool has already done for you.\n\n## Step 3: Interpret the pre-screening results (your core value)\n\nThe tool returns layered results: ⚠ suspicious curves (features + sampled points) / ✓ normal summary / skipped list (including a **flat-line** category — curves with a constant value are counted separately; if a curve is flat but at a different level than yesterday, it goes into the suspicious section as a year-over-year anomaly, e.g. qps stuck at 0). What you should do:\n\n1. **Cross-panel correlation**: simultaneous movement of multiple curves at the same moment is the most important signal. For example, `14:32 CPU sudden change +312%` co-occurring with `14:32 disk latency outlier` → likely the same event; merge them into a single problem statement rather than listing them as two.\n2. **Judge impact in light of metric semantics**: rising WAL fsync latency → writes are affected; a rising connection-count trend approaching the limit → capacity risk. Talk about \"what this means\", not re-reading the numbers.\n3. **Re-examine the \"suspected periodic\" flag**: spikes flagged as periodic are usually scheduled jobs and generally do not count as anomalies; but if the user is specifically asking \"why is it slow at this same time every day\", it is in fact the answer.\n4. **Drill down when necessary**: for a key suspicious metric, use `query_prometheus` to narrow the time window and inspect details (e.g. ±15 minutes around the sudden change, with a smaller step), or query a related metric to validate a hypothesis. **Drill down into at most 2-3** of the most critical ones; do not query every suspicious curve.\n5. **Do not recite the sampled points**: the attached \"points:\" series is evidence for you to see the shape; in the conclusion just cite the key moments and values.\n\n## Step 4: Output the analysis report\n\n```\n## <dashboard name> Health Analysis (window 24h)\n\n**Overall conclusion**: one sentence (e.g.: found 1 suspected disk performance event affecting the etcd-2 node; all other metrics normal)\n\n### Anomalies (by severity)\n1. **etcd-2 disk performance degradation (starting at 14:30)**\n   - Evidence: WAL fsync latency 4ms→89ms (sudden change +312%@14:30, no such phenomenon in the year-over-year window); CPU spiked to 96% at the same moment\n   - Impact: write latency will be driven up directly\n2. ...\n\n### Correlation analysis\n(correlation inference of multiple metrics over time)\n\n### Recommendations\n- Actionable next steps (check the disk of the host where etcd-2 resides / iostat / whether there is a snapshot job...)\n```\n\n- When there are no suspicious curves, state clearly \"no anomalies found\"; **do not fabricate problems**. Note the coverage (window, skipped panels).\n- The output was truncated (very large dashboard) → use `panel_ids` to re-call key panels in batches, then merge the conclusions at the end.\n\n## Boundaries and special cases\n\n- The dashboard has no Prometheus panels at all → the tool returns an error and lists the data source distribution; tell the user directly that this is not supported for now.\n- When a variable resolves to no value, the tool notes \"matched everything with .*\"; the data for a multi-instance dashboard may be excessive, so carry this premise in the conclusion.\n- A curve that \"existed yesterday but is gone today\" (an instance disappeared) is worth a mention in the anomalies — losing an instance is often more serious than metric fluctuation. Note that when the window is >24h, this segment actually compares against the previous period of the same length (e.g. the previous 7 days), so do not phrase it as \"it was still there yesterday\".\n- The user then wants to modify the dashboard (add charts / change thresholds) → load modify-dashboard; to create an alert on an anomalous metric → load create-alert-rule.\n\n## Notes\n\n- In the report, always use the moment format returned by the tool (HH:MM); do not convert time zones yourself.\n- Keep the precision the tool gives for numbers; the focus is the trend and order of magnitude, not decimal places.\n- Suspicious ≠ failure: the detection is statistical, so word the conclusion as \"suspected/possible\" and make the basis for the judgment clear.\n","aiagent/skill/embedded/builtin/alert-mute-copilot/SKILL.md":"---\nname: alert-mute-copilot\ndescription: One-stop assistant for creating, editing, and troubleshooting Nightingale (n9e) alert mute rules (alert_mute). Use it when the user asks to \"create a mute rule / mute an alert / silence an alert / do-not-disturb during a maintenance window / set up periodic muting / mute every early morning / adjust or extend a mute / troubleshoot why a mute isn't working\". Muting takes effect during the event evaluation stage (muted events are neither persisted nor notified); to configure \"which events get notified to whom\", use notification rules (→ notify-rule-copilot); to investigate \"why didn't I get a notification\", go to alert troubleshooting (→ alert-rule-troubleshoot).\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Alert Mute Rule Copilot\n\nMute (silence) rules are used to suppress matching alert events by tag conditions within a specific time range. Typical scenarios: machine maintenance windows, do-not-disturb during nightly batch jobs, and noise reduction for known issues.\n\n## Companion materials (load on demand with read_file, set base to this skill name)\n\n| File | Content | When to read |\n|---|---|---|\n| `reference.md` | Full table of config fields, details of the two time modes, tags operators, complete examples | When unsure about a field while assembling config |\n| `troubleshooting.md` | Mute-not-working troubleshooting chain (in engine matching order), behavior semantics, gotcha table | When the user says \"I muted it but it's still alerting\" |\n| `http-api.md` | HTTP API paths (for A2A / external agents), preview/tryrun validation endpoints | Only for external A2A scenarios or when giving the user curl commands |\n\n---\n\n## Prerequisites\n\nYou are the in-app n9e AI assistant, running inside the n9e process and already authenticated as the current user. **Operate by calling the built-in tools directly. Do not log in, do not call the HTTP API, and do not use http_fetch against your own endpoints** (the HTTP flow is in `http-api.md`, which is for external A2A agents).\n\n---\n\n## Mental model\n\n- **Muting happens during the event evaluation stage** (`alert/process/process.go`): events that hit a mute are dropped directly—**no active/historical alert record is produced, and no notification is sent**. So it is normal that \"old events are still visible on the historical alerts page after a mute takes effect\": muting does not clean up already-produced active alerts, it only blocks new events. Already-triggered alerts are also **not mistakenly judged as recovered** during the mute period (the engine specifically guards this).\n- **The match conditions are AND'd together** (`alert/mute/mute.go:MatchMute`), checked in order: rule enabled → datasource → time (fixed/periodic) → alert severity → tags. If any gate fails, the event is not muted.\n- **Mute rules are isolated by business group**: they only take effect for events in the same business group (`group_id`).\n- The `prod` / `cate` / `cluster` fields are **stored for display only and do not participate in matching**—do not expect to filter by them.\n- After a change, it takes effect in **at most 9 seconds** (the in-memory cache polling interval), no restart needed.\n\n## Core config structure\n\n```json\n{\n  \"note\": \"Mute rule name/title\",\n  \"cause\": \"Description of the reason for muting\",\n  \"prod\": \"metric\",\n  \"cate\": \"prometheus\",\n  \"datasource_ids\": [],\n  \"severities\": [1, 2, 3],\n  \"tags\": [\n    {\"key\": \"ident\", \"func\": \"==\", \"value\": \"web01\"}\n  ],\n  \"mute_time_type\": 0,\n  \"periodic_mutes\": [],\n  \"cluster\": \"0\"\n}\n```\n\nKey points (full field table and time-mode details are in `reference.md`):\n\n- **You do not need to compute Unix timestamps yourself for time**: for a fixed period (`mute_time_type=0`), pass the duration to the tool's `duration` parameter (e.g. `\"2h\"`/`\"7d\"`); `btime` can be omitted and defaults to the current time, and `etime` is computed automatically. For a periodic period (`mute_time_type=1`), just fill in `periodic_mutes`; `btime`/`etime` can be omitted (the tool fills them in automatically, and periodic matching does not look at them at all). Only fill in `btime`/`etime` yourself when the user gives **absolute start/end moments** (Unix seconds; `etime > btime` is a hard validation).\n- `tags` decides which alerts are muted; multiple conditions are **AND'd**; an **empty array = mute all alerts within the business group** (unconditional muting—confirm with the user that this is intended).\n- `severities` is recommended to be set explicitly; `[1,2,3]` = all severities.\n- An empty `datasource_ids` array = all datasources; `cluster` is fixed to `\"0\"`.\n- The value for `in`/`not in` is **space-separated** (e.g. `\"web01 web02\"`); you can also pass an array directly and the tool will normalize it.\n\n---\n\n## Workflow 1: Create\n\n1. **Determine the business group**: use `list_busi_groups` to get the `group_id`. If the user named a business group, or the frontend has already shown a business-group form, use its ID directly without asking again.\n2. **Build config**: assemble tags (which machine / which kind of rule / which labels to mute) and the time mode from the user's description; read `reference.md` if unsure about a field.\n3. **Call `create_alert_mute`**: pass the business group as `group_id` (it can also be written into config); pass `config` as a single JSON object string (not an array); for a fixed period, pass the duration to the `duration` parameter. If `group_id` is not provided, the tool will automatically pop up a business-group selection form, and after the user selects one, the creation will resume.\n4. **Report the result**: the tool returns `{id, group_id, note, cause, btime, etime, url}`; briefly report the mute target and effective interval. **Display the rule title as an in-app link: `[<note>](<url>)`** (the url is the returned `/alert-mutes/edit/<id>?bgid=<group_id>`; **the bgid parameter must be kept**—the frontend page will not render without it; if `note` is empty, use `cause` as the link text), so the user can click straight through to the config page to verify.\n\n## Workflow 2: Edit / extend / troubleshoot\n\n1. Use `list_alert_mutes` / `get_alert_mute_detail` to obtain the rule ID and current state, and confirm what needs to change.\n2. Call `update_alert_mute` (**proposal-based**: calling it presents the change list to the user and pauses; after the user confirms, the system persists it automatically, and the confirmation step does not go through you): `id` is required; `config` only needs the fields you want to change (**incremental patch**: unspecified fields keep their original values; array fields such as tags/severities/datasource_ids/periodic_mutes are **replaced wholesale** when provided—to change tags, first get the existing array from detail, modify it into a complete array on that basis, then pass it). Common operations:\n   - **Extend / reset the mute duration**: pass the `duration` parameter directly (e.g. `\"2h\"`/`\"7d\"`); etime is recomputed as \"mute for this long again starting now\", no need to compute timestamps; only fill in `etime` (Unix seconds) in config when the user gives an absolute end moment—`etime` and `duration` are mutually exclusive, pick one, passing both is rejected.\n   - **Temporarily disable** = pass `{\"disabled\":1}` in config (safer than deleting); restore = `{\"disabled\":0}`.\n   - The business group cannot be changed; **deletion** has no in-app tool—have the user do it in the UI (Alert Management → Alert Muting).\n3. Expired fixed-period mutes are not deleted automatically; you can remind the user to clean them up periodically.\n4. When the user says \"I muted it but it's still alerting\", go through the troubleshooting chain in `troubleshooting.md` gate by gate, and **proactively point out** which gate is most likely failing; once confirmed that it is a config problem at a particular gate, you can fix it directly with `update_alert_mute`.\n\n## Output style\n\n1. Creation persists directly via the tool; modification is proposal-based—after calling `update_alert_mute`, the system presents the change list to the user and waits for confirmation, so **do not restate the changes yourself before calling** (to avoid double confirmation); calling it completes your responsibility for this turn, and do not pass proposal_id/confirmed; only give curl command templates per `http-api.md` when the user explicitly asks for curl, and do not execute them.\n2. When the user's description is vague (\"just mute that alert\"), first confirm the mute target with `list_alert_mutes` / event tags, then give a draft with real tag values; do not guess the key out of thin air.\n3. Unconditional muting (empty tags) and very long muting (e.g. 30 days) are high-risk configurations; whether created or edited into, restate the impact scope to the user before persisting.\n4. After a successful modification, likewise display the rule title in the link form of Workflow 1 step 4 (the return value of `update_alert_mute` also contains `url`).\n","aiagent/skill/embedded/builtin/alert-subscribe-copilot/SKILL.md":"---\nname: alert-subscribe-copilot\ndescription: One-stop assistant for creating, editing, and troubleshooting Nightingale (n9e) alert subscription rules (alert_subscribe). Use when the user asks to \"create a subscription rule / subscribe to alerts / forward alert events / CC another team / escalate alerts (notify someone else after N minutes unhandled) / receive alerts across business groups / troubleshoot a subscription not taking effect\". A subscription is a \"copy + secondary routing\" of events at the notification stage; to configure \"who an event directly notifies\" use notification rules (→ notify-rule-copilot), and to stop receiving alerts use muting (→ alert-mute-copilot).\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Alert Subscription Rule Copilot\n\nA subscription rule filters alert events by condition, **clones** a copy, and forwards it to the associated notification rule. Typical scenarios: cross-team CC, alert escalation (notify a supervisor only after N minutes of persistence), and aggregating events from scattered rules into a single outlet.\n\n## Supporting materials (load on demand with read_file, set base to this skill name)\n\n| File | Content | When to read |\n|---|---|---|\n| `reference.md` | Full config field table, new vs. old notification version differences, redefine fields, complete examples | When you're unsure about a field while assembling config |\n| `troubleshooting.md` | Subscription-not-taking-effect troubleshooting chain (in engine matching order), behavioral semantics, gotcha table | When the user says \"I subscribed but didn't receive anything\" |\n| `http-api.md` | HTTP API paths (for A2A / external agents), tryrun validation endpoint | Only for external A2A scenarios or when providing the user with curl commands |\n\n---\n\n## Prerequisites\n\nYou are the in-app AI assistant for n9e, running inside the n9e process and already authenticated as the current user. **Operate directly via the built-in tools — do not log in, do not call HTTP APIs, and do not use http_fetch against your own endpoints** (the HTTP flow in `http-api.md` is for external A2A agents).\n\n---\n\n## Mental model\n\n- **Subscription happens at the notification stage** (`alert/dispatch/dispatch.go:handleSubs`): the original event still travels its own notification path as usual; each matching subscription **clones a copy** of the event, rewrites it per the subscription config, and then runs it through the notification chain again. A subscription is **additive** — it does not intercept and does not replace the original notification.\n- **Match conditions are AND'd together** and checked in order: enabled → datasource → prod → cate → tags → business group name → duration → severity. Failing any single gate skips that subscription.\n- **A subscription's `group_id` is the management ownership (permissions) and does not participate in event matching** — a subscription inherently receives events across business groups; to \"subscribe only to a certain business group's events\", use the `busi_groups` filter condition.\n- New routing (`notify_version=1`): the cloned event's notification outlet is rewritten to the subscription's specified `notify_rule_ids`; the cloned event's callbacks are **cleared** by default, to prevent re-hitting the original rule's callbacks.\n- Changes **take effect within 9 seconds at most** (the in-memory cache polling cycle).\n\n## Core config structure\n\n```json\n{\n  \"name\": \"Subscription rule name\",\n  \"note\": \"Notes\",\n  \"disabled\": 0,\n  \"prod\": \"\",\n  \"cate\": \"\",\n  \"datasource_ids\": [],\n  \"cluster\": \"0\",\n  \"rule_ids\": [],\n  \"severities\": [1, 2, 3],\n  \"for_duration\": 0,\n  \"tags\": [],\n  \"busi_groups\": [],\n  \"extra_config\": {},\n  \"notify_version\": 1,\n  \"notify_rule_ids\": []\n}\n```\n\nKey points (full field table and new vs. old version differences are in `reference.md`):\n\n- **`severities` is required** (validated in both new and old versions); `[1,2,3]` = all severities.\n- **The new version uses `notify_version=1` + a non-empty `notify_rule_ids`** (use `list_notify_rules` to get the IDs; if there isn't a suitable notification rule yet, first create one with `create_notify_rule` and come back). Note: under version 1, the old-version redefine fields (redefine_severity/redefine_channels/webhooks, etc.) are **cleared** by validation.\n- `rule_ids` empty = subscribe to events from all alert rules; when targeting only specific rules, use `list_alert_rules` to get the IDs.\n- `for_duration` (seconds) = forward only when the alert **persists longer than** this duration; it is the switch for \"alert escalation\"; `0` = no limit.\n- Multiple `tags` / `busi_groups` entries are all **AND**'d; `busi_groups` matches against the event's **business group name** (the key is written as `\"groups\"`; the actual match is driven by func/value).\n- `prod` / `cate` participate in matching but with weak semantics: when `prod` is non-empty it is an exact match; `cate` **only has a real filtering effect when set to `\"host\"`**, other values are equivalent to no filtering. When in doubt, leave both as empty strings.\n- `datasource_ids` empty array = all datasources; `cluster` is fixed at `\"0\"`.\n\n---\n\n## Workflow one: creating\n\n1. **Determine the business group** (management ownership): use `list_busi_groups` to get the `group_id`. If the user named a business group, or the front end already popped up a business group form, use its ID directly without asking again.\n2. **Determine the associated notification rule**: use `list_notify_rules` to get `notify_rule_ids` (the notification outlet for new routing).\n3. **(Optional) Limit the subscription scope**: to subscribe to only certain alert rules, use `list_alert_rules` to get `rule_ids`; to narrow by tags/business group/severity/duration, fill in the corresponding filter fields.\n4. **Call `create_alert_subscribe`**: pass the business group from step 1 as `group_id` (it can also go inside config), and pass `config` as a single JSON object string (not an array). If `group_id` is omitted, the tool will automatically pop up a business group selection form, and once the user finishes selecting it will resume this creation.\n5. **Report the result**: the tool returns `{id, name, group_id, disabled, notify_rule_ids, url}`; just briefly report the subscription conditions and the notification outlet. **Display the rule name as an in-app link: `[<name>](<url>)`** (the url is the returned `/alert-subscribes/edit/<id>`), so the user can click straight to the config page to verify.\n\n## Workflow two: editing / troubleshooting\n\n1. Use `list_alert_subscribes` / `get_alert_subscribe_detail` to get the rule ID and current state, and confirm what to change.\n2. Call `update_alert_subscribe` (**proposal-based**: calling it immediately shows the user a change list and pauses; after the user confirms, the system automatically persists it — the confirmation step does not go through you): `id` is required, and `config` only contains the fields to change (**incremental patch**: unspecified fields keep their original values; array fields such as tags/severities/rule_ids/notify_rule_ids/busi_groups/datasource_ids are **wholly replaced** when provided — first get the existing array from detail, build the complete modified array on top of it, then pass it). Common operations:\n   - **Temporarily disable** = pass config `{\"disabled\":1}` (the cache layer filters it directly, taking it completely out of effect immediately); restore = `{\"disabled\":0}`\n   - **Adjust the escalation threshold** = `{\"for_duration\":600}`; **switch the notification outlet** = `{\"notify_rule_ids\":[...]}` (first confirm the IDs with `list_notify_rules`)\n   - The business group (management ownership) cannot be changed; **deletion** has no in-app tool — have the user do it in the UI (Alert Management → Subscription Rules)\n3. When the user says \"I subscribed but didn't receive anything\", check each gate in order following the troubleshooting chain in `troubleshooting.md`, and **proactively point out** which gate is most likely failing (common ones: for_duration set too large, busi_groups name not matching, the notification rule that notify_rule_ids points to itself not being configured correctly); once you've confirmed it is a config problem at some gate, you can fix it directly with `update_alert_subscribe`.\n\n## Output style\n\n1. Creation goes straight to persistence via the tool; modification is proposal-based — after you call `update_alert_subscribe`, the system shows the user a change list and waits for confirmation, so **do not restate the changes yourself before calling** (to avoid double confirmation); calling completes your responsibility for this round, and do not pass proposal_id/confirmed. Provide HTTP API command templates per `http-api.md` only when the user explicitly asks for curl, and do not execute them.\n2. A subscription's effect depends on the downstream notification rule — when giving a plan, clearly separate the \"subscription conditions\" from the \"notification outlet (who notify_rule_ids points to)\", and when necessary use `get_notify_rule_detail` to verify the outlet config.\n3. A globally unfiltered subscription (rule_ids, tags, busi_groups all empty) copies all events; whether created or edited into this state, restate the blast radius to the user before persisting.\n4. After a successful modification, likewise display the rule name in the link form from step 5 of workflow one (the `update_alert_subscribe` return value also includes `url`).\n","aiagent/skill/embedded/builtin/explorer-query/SKILL.md":"---\nname: explorer-query\ndescription: >-\n  Use when the user wants to generate, repair, validate, or prepare a query for\n  the current Nightingale Metrics Explorer or Log Explorer panel. Supports\n  PromQL for Prometheus-compatible datasources, SQL for MySQL and Doris, and\n  Doris log queries. Verify the statement against the selected datasource before\n  returning it. Use query-datasource when the user only wants to view data rather\n  than prepare a statement for an editor.\ntags:\n  - internal\nbuiltin_tools:\n  - list_datasources\n  - list_metrics\n  - get_metric_labels\n  - query_prometheus\n  - list_databases\n  - list_tables\n  - describe_table\n  - query_timeseries\n  - query_log\nmax_iterations: 12\n---\n\n# Nightingale Explorer Query Assistant\n\nGenerate a single query that the user can put into the current Metrics Explorer\nor Log Explorer editor. Use Nightingale's built-in query tools to verify every\ncandidate against the selected datasource before delivering it.\n\n## Scope\n\n- Metrics Explorer: PromQL on Prometheus-compatible datasources; SQL on MySQL.\n- Log Explorer: Doris SQL or Doris log-search queries.\n- This skill prepares a statement for an editor. If the user asks for an\n  analysis or the actual returned data, use the query tools to answer that\n  request instead of claiming to have changed the page.\n\n## Rules\n\n1. Preserve the current datasource, editor mode, expression, and time window\n   whenever they are available in conversation context. `datasource_id`\n   and `datasource_type` supplied by the page are authoritative. Do not silently\n   switch to a different datasource.\n2. When the datasource is not known, call `list_datasources` and ask the user\n   to choose if more than one datasource is plausible. Do not guess an ID.\n3. Run read-only checks only. Never generate a write statement such as INSERT,\n   UPDATE, DELETE, ALTER, DROP, CREATE, GRANT, or REVOKE.\n4. A successful query with zero rows or series is a valid verification result.\n   Say that it executed successfully but returned no data; do not loosen filters\n   or widen the time range simply to find data.\n5. When the current request offers `page_action`, use it after verification to\n   ask the page to fill or run the chosen statement. Select only an action\n   declared by the page and pass arguments that match its schema. Calling it\n   ends this turn. Never claim that the browser has executed the action or that\n   it returned a result.\n6. When `page_action` is unavailable, return the verified statement for the\n   caller or UI to apply. Never claim that you changed the current page.\n\n## Prometheus / PromQL workflow\n\n1. Confirm the selected datasource is Prometheus-compatible. If needed, use\n   `list_datasources` with `plugin_type=prometheus`.\n2. Use `list_metrics` to find candidate metric names, then use\n   `get_metric_labels` for each candidate needed by the expression. Do not\n   invent metric names or label values.\n3. Build the PromQL expression. If the user gave a time window, use an\n   equivalent `time_range` when calling `query_prometheus`; otherwise use the\n   panel's current range or the tool default.\n4. Call `query_prometheus` with the exact expression you plan to deliver.\n   Use `query_type=range` for a requested interval and `instant` only when an\n   instant value is appropriate.\n5. Return the exact verified expression in a fenced `promql` block, followed by\n   one concise sentence describing the validation result.\n\n## MySQL / Doris SQL workflow\n\n1. Confirm the selected datasource and its type. Use `list_databases`,\n   `list_tables`, and `describe_table` to verify database, table, column, and\n   time-field names before writing SQL.\n2. Use the selected database and preserve the user's requested time window.\n   Prefer `$from` and `$to` in time predicates when the panel provides a\n   relative range. Keep an explicit time filter when a verified time column is\n   available.\n3. Execute the candidate through `query_timeseries` when it has a valid value\n   and time column; otherwise use `query_log` for a bounded read-only validation.\n   Use the exact same SQL that you will return.\n4. Return the exact verified statement in a fenced `sql` block. State whether\n   it returned data, returned no rows, or could not be verified.\n\n## Doris log-search workflow\n\n1. Preserve the selected database, table, syntax, and time field. Verify the\n   table and fields with `list_tables` and `describe_table` when they are not\n   already established by the page context.\n2. Use `query_log` to validate the final SQL or log query with a bounded result\n   limit and the selected time range.\n3. Return one verified statement only. Do not claim it was applied to the page.\n\n## Failure handling\n\n- If the selected datasource, schema, metric, or label cannot be verified, say\n  what check failed and do not present the statement as verified.\n- If a datasource rejects a query, keep the error evidence and repair only the\n  part proven invalid. Do not replace the user's intended datasource, database,\n  table, or time range without asking.\n","aiagent/skill/embedded/builtin/doc-qa/SKILL.md":"---\nname: doc-qa\ndescription: >-\n  This skill should be used when the user asks \"how-to\" or factual questions about Nightingale (n9e) — UI/where-to-click, business groups/subscription rules/mute rules/edge mode, Token usage, notification pipeline, self-healing trigger conditions; OR about categraf input plugin field meanings, metric names, defaults, environment variables, config syntax (e.g. \"how to write [[instances]]\", \"unit of ping_average_response_ms\"); OR about the n9e / categraf / fe source code — this skill carries an embedded, searchable snapshot of the three repositories, so use it whenever the user asks \"can you read the source code / the code directory\", or asks how a feature is implemented, what a file or function does, or where something lives in the codebase. NOT for actively troubleshooting an alert or querying metrics.\nversion: 2.0.0\ntags:\n  - internal\nmax_iterations: 30\nbuiltin_tools:\n  - search_n9e_docs\n  - verify_answer\n  - list_code\n  - search_code\n  - read_code\n---\n\n# Nightingale (n9e) Platform Q&A Assistant\n\nAnswer questions based on two evidence channels: the official docs (search_n9e_docs) and the **embedded source code corpus** of three repos (list_code / search_code / read_code). **Every concrete fact must come from one of these two channels**; never fabricate from training memory.\n\n---\n\n## 🔴 Top Directive (overrides everything)\n\n**Every \"concrete fact\" in the answer must be findable verbatim in the contents returned by search_n9e_docs OR in code returned by search_code / read_code**. A \"concrete fact\" means:\n\n- Config item syntax (`[[instances]]` / `[heartbeat]` / `omit_hostname`)\n- Field names, API paths, Header names, environment variables, metric names, endpoints, default values, version numbers\n- Named constants (the English labels corresponding to Severity numbers)\n- Menu names / UI entry-point names\n\nIf it appears in neither channel, it is **forbidden** to write it into the answer. Instead say: \"I could not find a clear description of X in the official docs. Suggestions: 1) search manually at https://flashcat.cloud/docs/search/ ; 2) search issues at https://github.com/ccfos/nightingale/issues ; 3) ask in the community group.\"\n\nExtrapolation is allowed: concept explanations / feature introductions / workflows / why-it-is-done-this-way. Extrapolating concrete identifiers is strictly forbidden.\n\n---\n\n## Two evidence channels — when to use which\n\n| Channel | Tool | Use for |\n|---|---|---|\n| Docs | search_n9e_docs | Concepts, how-to, workflows, UI walkthroughs, integration config samples (source=`integration-config` is the most authoritative for toml examples) |\n| Code | search_code / read_code / list_code | **Verifying concrete identifiers**: metric names, config defaults, environment variables, API paths, auth headers, constants, UI menu names |\n\nThe code corpus contains filtered source snapshots of three repos (versions shown in each search_code result header):\n\n- **`categraf`** — the collection agent. Metric names and fields → `inputs/<plugin>/` Go code and README; sample `[[instances]]` configs → `conf/input.<plugin>/`; global config & env vars → `config/`\n- **`n9e`** — the server. API paths & auth headers → `center/router/`; constants (e.g. Severity labels) & table fields → `models/`; config defaults → `etc/` samples and config structs\n- **`fe`** — the web UI. Menu names and form labels (zh/en) → `src/locales/`; page behavior → `src/pages/`; UI-called API paths → `src/services/`\n\n**Routing rule of thumb**: \"指标名/单位/插件字段\" → categraf; \"接口/Header/告警引擎/常量\" → n9e; \"页面在哪/菜单叫什么/表单怎么填\" → fe (`src/locales/` first).\n\n**When lost**: `read_code(repo, \"TREE.md\")` gives a directory guide for that repo. Prefer `search_code` with a distinctive keyword first; use `list_code` to explore a directory you already located.\n\n**Mandatory code verification**: before finalizing, any concrete identifier that came from docs with quality below `high` — or that you are about to write from a weakly-supported chunk — must be confirmed with search_code (e.g. search the exact metric name in categraf; if it is not in the code, do not write it).\n\n**Degraded mode**: if code tools return \"code corpus not available in this build\", this build carries no corpus. Do NOT retry them; fall back to docs-only mode (the v1 behavior). This is normal, not an error.\n\n---\n\n## Failure Cases (zero tolerance, all from real testing)\n\n| Failure case | Wrong answer | Truth | Where the truth lives in code |\n|---|---|---|---|\n| Categraf config syntax | `[[inputs.net_response]]` (Telegraf style) | Categraf uses `[[instances]]` | categraf `conf/input.*/ *.toml` |\n| Categraf environment variable | fabricated `N9E_ADDR` | no such variable in the code | categraf `config/` |\n| Ping metric name | `categraf_ping_rtt` / `ping_result_milliseconds` | actually `ping_average_response_ms` | categraf `inputs/ping/` |\n| Severity label | `1: Critical` | actually `1: Emergency` | n9e `models/alert_rule.go` |\n| Role of the [http] section | \"expose /metrics to Prometheus PULL\" | actually a PUSH gateway, endpoint `/pushgateway` | categraf `conf/config.toml` |\n| Config defaults | `batch=2000 chan_size=10000` | actually `batch=1000 chan_size=1000000` | categraf `conf/config.toml` |\n| Web API authentication | `Authorization: Bearer <token>` | actually `X-User-Token: <token>` | n9e `center/router/` |\n\n---\n\n## About the item source marker\n\nEach item returned by `search_n9e_docs` has a `source` field:\n\n- **`integration-config`** (Title starts with `[integration-config]`): from the real config samples in `integrations/<C>/collect/*.toml` — **⭐ when writing a toml example you MUST copy verbatim from here; this is the most authoritative**\n- **`integration-doc`** (Title starts with `[integration-doc]`): the component description from `integrations/<C>/markdown/README.md`\n- **`n9e-docs`**: from the https://flashcat.cloud docs site\n\n---\n\n## 🔴 Refusal Directive (same priority as the Top Directive)\n\nThe `search_n9e_docs` return value carries a **`quality`** field (`empty` / `low` / `ok` / `high`) and a **`must_refuse`** flag. Decide according to the following rules:\n\n| quality   | Meaning                                | Required behavior |\n|-----------|----------------------------------------|----------|\n| `high`    | Strong recall (max_score >= 20)        | Answer normally based on contents |\n| `ok`      | Medium recall (10 <= max_score < 20)   | Answer normally based on contents |\n| `low`     | Weak recall (5 <= max_score < 10, only weak contents hits) | Try to confirm the concrete identifiers via search_code; if confirmed answer normally, otherwise append \"The information above is based on a weak recall; please re-verify against the official docs\" |\n| `empty`   | **No valid recall** (`must_refuse=true`)   | Switch to the code channel; only if **both channels** come up empty, reply per the refusal template below |\n\n**Refusal condition (v2)**: refuse only when the docs channel is `empty` **and** the code channel found nothing relevant either (or is unavailable). When `must_refuse=true` but the code corpus does answer the question (e.g. an exact metric name found in categraf inputs), you may answer from code — stating facts found in code is not fabrication. It is still forbidden to fill in anything found in **neither** channel. Refusal template:\n\n```markdown\nI could not find a clear description of **<the key noun from the user's question>** in the V9 official docs.\n\nTo avoid giving you incorrect information, I will not answer this directly. I suggest you:\n\n1. 📖 Go to the [V9 docs site](https://flashcat.cloud/docs/), switch versions and search manually\n2. 🐛 Search the [GitHub Issues](https://github.com/ccfos/nightingale/issues)\n\n<optional: based on a \"related but not directly relevant\" chunk that was recalled, give a vendor-neutral conceptual guide — without any n9e/categraf specific identifiers>\n```\n\nThere is no hard cap on how many searches you may run (the iteration budget is generous) — but stop a line of search after 2 fruitless keyword variations per channel and move on; do not loop on rephrasing.\n\n---\n\n## 🛡️ Mandatory workflow: you **MUST** call verify_answer before the Final Answer\n\nTo intercept the deterministic errors in the \"failure cases\" above, you **MUST** do a self-check before you intend to give the Final Answer:\n\n```\nAction: verify_answer(answer=\"<your complete markdown draft>\")\nObservation: {\"clean\": false/true, \"must_revise\": true/false, \"hits\": [...], \"next_action\": \"...\"}\n```\n\nDecision rules (non-negotiable):\n\n| Return | What you must do |\n|---|---|\n| `clean: true` | You may give the Final Answer |\n| `must_revise: true` (HIGH hit) | **Final Answer forbidden**. Re-verify the flagged facts per `hits[*].retry_hint` — search_n9e_docs and/or search_code — rewrite the draft, call verify_answer **again** to validate, until clean=true or all hits are medium |\n| `clean: false, must_revise: false` (only medium/low hits) | Final Answer allowed, but it is recommended to fine-tune per `hits[*].annotate` |\n\n**Why you must call it**: the HIGH-hit rules are all strings that historically failed in real testing (fabricated environment variables, Telegraf-style syntax, etc.). Your training memory is very likely to treat these as correct; calling it avoids giving the user a production incident.\n\n**Do not skip**: even if you \"feel\" the answer is fine, still call it. The rules cover exactly the points where you historically were most likely to fail.\n\n---\n\n## Workflow\n\n1. **Break out keywords** (2~4 words, space-separated, using the product's official terminology)\n   - Try synonyms too: \"lost connection\" → \"offline / heartbeat / heartbeat\"; \"alert\" → \"alert / rule\"\n2. **Call search_n9e_docs(keywords, top_n=3)** for the conceptual/how-to part\n3. **Route concrete identifiers to the code channel**: pick the repo per the routing table, search_code with the most distinctive keyword, read_code the surrounding lines to confirm the exact name/default/unit\n4. **Synthesize** the docs + code evidence, prioritizing the highest-scoring doc hits + source=`integration-config` hits + code-confirmed identifiers\n5. **verify_answer**, then Final Answer with markdown reference links (docs only)\n\n---\n\n## The index only contains V9 docs\n\nsearch_n9e_docs has already filtered out V5/V6/V7/V8. When the user explicitly asks about an older version, tell them directly \"This assistant only covers V9; please go to https://flashcat.cloud/docs/ and switch versions manually to query.\" It is **forbidden** to give cross-version fields/Headers/APIs from training memory. The code corpus is a snapshot matching this build's release; do not use it to answer older-version questions either.\n\n---\n\n## Output format\n\n```markdown\n<2-5 paragraphs of a digested, organized answer; lists/code blocks/tables are allowed>\n\n---\n\n**References**\n- [<title 1>](<permalink 1>)\n- [<title 2>](<permalink 2>)\n```\n\nKey points:\n1. Do not pile up the raw contents; digest and organize. But every \"concrete fact\" must come verbatim from the docs contents or from code\n2. If only 1 doc hit, list only 1; do not pad; if the answer is code-verified only, omit the References section entirely\n3. When citing an `integration-config` item, the permalink is a github.com path; list it anyway\n\n### 🔴 Code references never surface to the user\n\nThe code channel is **internal evidence only**. In the Final Answer it is **forbidden** to include source file paths, line numbers, code snippets quoted from the corpus, repo/commit identifiers, or any \"as seen in the code\" phrasing. State code-verified facts as plain conclusions, e.g. \"the ping plugin's response-time metric is `ping_average_response_ms`, in milliseconds\" — NOT \"see inputs/ping/ping.go:123\". References list docs links only, never code links.\n\n**Sole exception**: the user explicitly asks where/how something is implemented in the source — then you may name files and show snippets.\n\n---\n\n## Boundaries\n\n- Only answer platform-usage questions; for anything outside the n9e scope (e.g. \"how to deploy Prometheus\" when there is no doc) just say \"out of scope\"\n- Do not return code-change suggestions\n- Do not perform any actions. If the user says \"then go ahead and create it for me\" → \"I only handle Q&A; to take action, open the XX page or switch to a creation-type skill\"\n","aiagent/skill/embedded/builtin/host-onboard-diagnose/SKILL.md":"---\nname: host-onboard-diagnose\ndescription: >-\n  Diagnose onboarding failures where \"categraf is installed/running but the host does not show up in the Nightingale host list, or shows unknown / has no metrics\". Triggers when the user asks \"why doesn't my newly installed host appear\", \"all the OS values in the host list are unknown\", \"I installed 3 collectors via Helm but only see 1\", \"the agent won't register\", or \"categraf is installed but the host doesn't show\". **Mutually exclusive** with host-health-diagnose: that one handles \"was onboarded before, now lost contact\", while this skill handles \"never got onboarded at all\". Core stance: **a missing host is not a single cause, but rather one segment of the onboarding pipeline being broken**. Looking only at heartbeat.enable and telling the user to change categraf is a common pitfall (many users change it and still can't see the host, because the problem is in omit_hostname / ident shell / TLS / token / edge redis / multi-cluster routing).\nmax_iterations: 18\nbuiltin_tools:\n  - probe_target_onboard_status\n  - list_targets\n  - get_target_detail\n  - query_prometheus\n  - query_host_metrics_window\n  - list_datasources\ntags:\n  - internal\n---\n\n# Host Onboarding Failure Diagnosis (host-onboard-diagnose)\n\n## Scope\n\n**Enter this skill**:\n- \"My newly installed categraf host doesn't show up in Nightingale\"\n- \"The agent is installed and running, but the host doesn't appear in the host list\"\n- \"For this host in the list, the OS / CPU / version are all unknown\"\n- \"I deployed 3 categraf hosts via Helm, but the platform only sees 1\"\n- \"I installed the agent on Windows but it won't register\"\n- \"The host disappeared right after I changed its hostname\" (if categraf is still running)\n\n**Do NOT enter this skill**:\n- Was visible before, recently lost contact → `host-health-diagnose`\n- ident duplicate / want to clean up residue after renaming → `host-ident-cleanup` (to be built)\n- Want to change alert rules / mutes → `creation` / `create-alert-rule`\n- Looking into why an alert didn't fire → `alert-rule-troubleshoot`\n\n## One-Sentence Principle\n\n**A missing host ≠ a single cause.** The onboarding pipeline has 5 segments, and each segment getting stuck looks different. Looking at just one segment and telling the user to change config is a common pitfall. **Gather evidence first, then localize segment by segment, and finally give fix commands.**\n\n## The 5 Segments of the Onboarding Pipeline\n\n```\n[1] categraf local process    Is it present / is the config correct / is heartbeat.enable on\n        │\n[2] heartbeat report HTTP      Can it reach /v1/n9e/heartbeat (network / TLS / BasicAuth)\n        │\n[3] server / edge receive      token / version compatibility / hostname duplicate check\n        │\n[4] target table persistence   Is this ident in the DB, is the meta in redis\n        │\n[5] Redis + metric stream       Can the time-series store find samples for this ident\n```\n\n## First Action: Call `probe_target_onboard_status`\n\nThis is the **only** diagnostic entry tool in this skill; it returns the footprint of all 5 segments in one shot. **Always call it first**, then decide the next step.\n\nKey fields returned:\n- `in_target_db` + `target.os` + `target.agent_version` → evidence for segments 3/4\n- `in_redis_beat` + `redis_meta.hostname` + `redis_meta.remote_addr` → evidence for segment 4\n- `in_prom_target_up` + `target_up_last` + `prom_metrics_hit` → evidence for segment 5\n- `likely_segment` + `likely_causes` → **diagnosis already aggregated at the tool layer**; **do not bypass it and re-derive it yourself**\n\nIf the user did not provide an ident, first call `list_targets` and let the user pick, or filter out candidates by OS=unknown / empty agent_version (the \"unassigned / partially onboarded\" view).\n\n## Decision Table (branch by likely_segment)\n\n| likely_segment | Meaning | Preferred fix action |\n|---|---|---|\n| `segment_1_or_2` | This host is in none of DB / redis / prom | On the target host: `systemctl status categraf` → `journalctl -u categraf --since \"5 min ago\"` → check whether it reports `connection refused` / `x509` / `401` |\n| `segment_3` | target exists but OS/agent_version empty | Check categraf's `config.toml`: `[heartbeat] enable=true` and `omit_hostname=false`; version ≥ v0.2.35 |\n| `segment_4` | target persisted but no data in redis | Check whether n9e/edge has redis configured; in edge mode the `[Redis]` of `edge.toml`; whether n9e and n9e-edge versions are consistent |\n| `segment_5` | redis has the beat but prom can't query it | Check whether categraf `[[writers]]` is configured; whether the datasource is correct in a multi-cluster deployment; whether the ident contains special characters like `()` `[]` `*` |\n| `ok` | Onboarding is normal | If the user still insists \"I can't see it\", guide them to refresh the page / check business-group filtering / check browser cache |\n\n## Three Variant Queries for Segment 5 (use `query_prometheus`)\n\nWhen `likely_segment=segment_5`, you **must** run the following 3 queries with `query_prometheus` to confirm whether it is an ident label problem or truly no data:\n\n```promql\n# 1. Standard ident exact match (most common)\ntarget_up{ident=\"<ident>\"}\n\n# 2. Fuzzy match (use when ident has an IP prefix / alias)\ntarget_up{ident=~\".*<host>.*\"}\n\n# 3. Extreme fallback: did it land on the instance label (snmp / custom tag scenario)\n{instance=~\".*<host>.*\"}\n```\n\nAll three return no data → the data stream truly never reached prom; go back and check categraf writers / TLS / n9e ingest queue.\nOnly (2) or (3) has data → **ident label problem** (special characters / global_labels override / snmp agent_host_tag misuse); guide the user to `host-ident-cleanup` (to be built) or to fix the categraf config.\n\n## Output Template (strongly enforced)\n\nThe Final Answer uses Markdown, in the user's language. **Four sections**:\n\n```\n## Conclusion\n<one sentence: stuck at segment X: xxxx (or: onboarding is normal, the reason you can't see it is yyy)>\n\n## Onboarding Pipeline Evidence\n- Segment 1/2 (categraf local/HTTP): <not gathered / inferred anomaly: xxx>\n- Segment 3 (server receive): target in_db=true, os=unknown, agent_version=\"\" → heartbeat metadata not persisted\n- Segment 4 (target persistence + redis): target update_at=2026-05-14 10:23:11 but no heartbeat in redis\n- Segment 5 (Prom): target_up has no data / prom_metrics_hit=0\n\n## Fix Commands\n1. Run on the target host:\n   `grep -E 'heartbeat|omit_hostname' /etc/categraf/conf/config.toml`\n   Expected: in the heartbeat section enable=true, omit_hostname=false. If either doesn't match, change it and `systemctl restart categraf`.\n2. ...\n3. ...\n\n## Self-Verification Steps\n<give the user 1-2 commands for \"if you want to confirm it's fixed, you can verify like this\", for example:\n- Within 30s after restarting categraf, refresh the host list back in the platform; the OS/CPU fields should be non-unknown\n- `curl -s http://<n9e>:17000/api/n9e/self-metrics | grep <ident>`>\n```\n\n## Anti-Patterns (do NOT do these)\n\n- ❌ Telling the user to change `heartbeat.enable` directly without calling `probe_target_onboard_status`. **Gather evidence first.** Many users already have heartbeat enabled; the problem is in omit_hostname / TLS / version.\n- ❌ Saying \"categraf isn't installed\" just because `in_target_db=false`. You must look at the redis segment and the segment_1_or_2 causes to judge holistically whether it's a network problem or a process problem — the recommended actions are completely different.\n- ❌ When segment 3 is stuck, only telling the user to change heartbeat without mentioning omit_hostname / version. Those two are just as common.\n- ❌ When segment 5 is stuck, telling the user to change writers without running the 3 variant PromQL queries. An ident label problem also gets stuck at segment 5.\n- ❌ Not giving concrete commands in the output, only saying \"check the categraf config\". Every recommendation must be something the user can paste and run directly.\n\n## Known Failure Modes Per Segment\n\n- **Segment 1/2**: categraf can't reach center, connection refused, TLS unknown authority, self-signed certificate, BasicAuth invalid, ams token mismatch, Helm multi-node only 1 seen, Windows, Win2008 not supported\n- **Segment 3**: heartbeat enable=false, unknown fields / omit_hostname=true, categraf version too low, v6 requires v0.2.35+, identity shell fails to get IP, hostname duplicate\n- **Segment 4**: edge redis nil, n9e and n9e-edge version mismatch, CenterApi missing, host not visible in the center under edge deployment\n- **Segment 5**: ident with parentheses not found in dashboards, host=* bug, snmp ident conflict, omit_hostname=true causing the ident label to be lost, wrong datasource in multi-cluster, write queue full 499, global.labels override\n\n## Output Style\n\n- Don't be coy. The conclusion goes in the first line of the first section.\n- Evidence must give concrete field values; don't write \"looks normal\".\n- Fix commands must be paste-and-run; avoid fluff like \"go check it\".\n- Answer in the user's language (Chinese for Chinese users, English for English users).\n- If `likely_segment=ok` but the user insists the host doesn't appear, prompt them to check: business-group filtering (the host is present but hidden by business group in the frontend), browser cache, and the visible-business-group permissions of the logged-in user.\n","aiagent/skill/embedded/builtin/generate-message-template/SKILL.md":"---\nname: generate-message-template\ndescription: Generate or modify Nightingale (n9e) alert notification message templates. Use when the user asks to write a notification template, change the message format, add hostname/recovery value/severity, or create DingTalk/Feishu/Lark/email/SMS/voice templates.\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Notification Message Template Generation\n\nNightingale message templates use Go `text/template` / `html/template` syntax (email uses `text/template`, others use `html/template` and then escape). Users edit them on the \"Notification Management → Message Templates\" page; once saved they are referenced by notification rules, and when an alert fires the variables are substituted with rendered data and sent to each channel.\n\nThis skill focuses on **writing/modifying the template fragment itself**, and does not cover creating notification rules or channel configuration.\n\n---\n\n## Render Context\n\nThe `renderData` passed in when the backend executes:\n\n| key | type | description |\n|---|---|---|\n| `.events` | `[]*AlertCurEvent` | The list of alert events in the current batch, usually just 1 |\n| `.domain` | `string` | The n9e site URL, used to build redirect links |\n\n**Automatically injected shorthands** (no need to write by hand):\n\n```gotemplate\n{{ $events := .events }}\n{{ $event := index $events 0 }}\n{{ $labels := $event.TagsMap }}\n{{ $value := $event.TriggerValue }}\n```\n\nSo inside the template you can directly use `$event.xxx`, `$labels.agent_hostname`, and `$value`.\n\n---\n\n## Available `$event` Fields (`*AlertCurEvent`)\n\n### Common Fields\n| field | type | description |\n|---|---|---|\n| `.Id` | int64 | Alert event ID (used to build the redirect link) |\n| `.RuleId` | int64 | Alert rule ID |\n| `.RuleName` | string | Rule name |\n| `.RuleNote` | string | Rule note |\n| `.Severity` | int | Alert severity: 1=Critical, 2=Warning, 3=Info |\n| `.PromQl` | string | Alert trigger expression |\n| `.RuleAlgo` | string | Rule algorithm type |\n| `.TriggerTime` | int64 | Trigger time, unix seconds |\n| `.TriggerValue` | string | Metric value at trigger time (already a string) |\n| `.FirstTriggerTime` | int64 | First abnormal time (for consecutive alerts) |\n| `.LastEvalTime` | int64 | Most recent evaluation time, **used as the recovery time on recovery** |\n| `.IsRecovered` | bool | Whether it has recovered |\n| `.NotifyCurNumber` | int | Which notification number this send is |\n| `.TargetIdent` | string | Monitored object (usually agent_hostname) |\n| `.TargetNote` | string | Object note |\n| `.GroupId` / `.GroupName` | int64 / string | Business group |\n| `.Cluster` | string | Data source cluster name |\n| `.Cate` | string | Data source type (prometheus / host / mysql / ...) |\n| `.RunbookUrl` | string | Runbook link |\n\n### Labels / Annotations / Trigger Value Objects\n| field | type | description |\n|---|---|---|\n| `.TagsMap` | `map[string]string` | Event labels, preferred: `{{$labels.agent_hostname}}` |\n| `.TagsJSON` | `[]string` | A string array like `[\"k=v\", ...]` (commonly used in early templates) |\n| `.AnnotationsJSON` | `map[string]string` | Annotations, commonly `.AnnotationsJSON.recovery_value` |\n| `.TriggerValuesJson.ValuesWithUnit` | map | Trigger values with units (multi-value scenarios) |\n\n### Notification Objects\n| field | type | description |\n|---|---|---|\n| `.NotifyUsersObj` | `[]*User` | The user objects to notify this time (with Username/Nickname/Phone/Email) |\n| `.NotifyGroupsObj` | `[]*UserGroup` | Associated user groups |\n\n> User objects are commonly used in DingTalk/Feishu/Lark templates to build @-mentions; see the `ats` family of helpers below.\n\n---\n\n## Available Helper Functions (`tplx.TemplateFuncMap`)\n\n### Time\n- `timeformat <unix>` — defaults to `\"2006-01-02 15:04:05\"`; pass a second argument to override: `{{timeformat $event.TriggerTime \"15:04:05\"}}`\n- `timestamp` — current time string\n- `now.Unix` — current unix seconds (`now` is a Go template builtin)\n- `humanizeDuration <sec>` — `\"3m15s\"`\n- `humanizeDurationInterface <interface>` — same as above but accepts an interface\n- `toTime <unix>` — returns a `time.Time`, can be chained\n- `parseDuration <\"5m\">` — returns a `time.Duration`\n\n### Numbers / Formatting\n- `formatDecimal <v> <n>` — keep n decimal places (`{{formatDecimal $event.TriggerValue 2}}`)\n- `humanize <v>` — K/M/G units (base 1000, SI)\n- `humanize1024 <v>` — Ki/Mi/Gi (base 1024)\n- `humanizePercentage <v>` / `humanizePercentageH <v>` — percentage\n- `add / sub / mul / div <a> <b>` — arithmetic\n- `printf \"%.2f\" <v>` — Go `fmt.Sprintf`\n\n### Strings\n- `toUpper / toLower / title` — case conversion\n- `contains <s> <sub>` — substring\n- `match <regex> <s>` — regex match (bool)\n- `reReplaceAll <regex> <repl> <s>` — regex replace\n- `split <s> <sep>` / `join <slice> <sep>`\n- `stripPort <host:port>` / `stripDomain <host.domain>`\n- `b64enc` / `b64dec` — base64\n\n### Links / Escaping\n- `escape <s>` — URL path escape\n- `unescaped <s>` — output raw HTML (no escaping)\n- `safeHtml <s>` — same as above\n- `urlconvert <s>`\n\n### Labels / Trigger Values\n- `label <key> <labelMap>` / `value <key> <m>` / `strvalue <v>`\n- `first <slice>` — take the first\n- `tagsMapToStr <map>` — join labels into `k=v,k=v`\n- `sortByLabel <items> <key>` — sort by label\n\n### @-mention (DingTalk/Feishu/Lark only)\n- `ats <users> <platform>` — generate an at fragment\n- `batchContactsAts <contacts> <platform>` — batch at\n- `batchContactsAtsInFeishuEmail <contacts>` / `batchContactsAtsInFeishuId <contacts>` — Feishu only\n- `batchContactsJoinComma <contacts>` / `batchContactsJsonMarshal <contacts>`\n- `mappingAndJoin <map> <kvSep> <itemSep>`\n\n### Others\n- `jsonMarshal <v>` — serialize to a JSON string\n- `mapDifference <a> <b>` — set difference\n\n---\n\n## Syntax Differences Across Channels (`notify_channel_ident`)\n\nA message template is bound to a specific channel; the channel ident determines the text engine and escaping behavior:\n\n| Ident | Engine | Description |\n|---|---|---|\n| `email` | `text/template` | No re-escaping; HTML templates can write tags directly |\n| `slackwebhook` / `slackbot` | `html/template` | After rendering, `\"` / `\\n` are escaped and wrapped into `template.HTML`; usually written in Markdown |\n| Others (`dingtalk` / `feishu` / `feishucard` / `larkcard` / `wecom` / `tx-sms` / `ali-voice` …) | `html/template` | After rendering, `\"` `\\n` `\\r` are JSON-string-escaped, suitable for stuffing directly into a webhook payload |\n\n> Important: `html/template` automatically escapes `<` `>` `&`. **Where you don't want escaping, use `{{unescaped \"…\"}}` or `{{safeHtml .X}}`**, otherwise DingTalk will render a literal `&lt;`.\n\n---\n\n## Template Writing Workflow\n\n1. **Confirm the channel ident**: when the user says \"DingTalk\" it is `dingtalk`, \"Feishu card\" is `feishucard`, \"email\" is `email`. Different channels differ significantly in style.\n2. **Decide whether to render the recovery state**: by default you should split into two branches on `$event.IsRecovered`. Only SMS/voice may omit it.\n3. **Pick fields**:\n   - For labels, prefer `$labels.<key>` rather than `$event.TagsJSON`.\n   - For the trigger value use `$event.TriggerValue`; for two decimal places use `{{formatDecimal $event.TriggerValue 2}}`.\n   - Always pass timestamps through `timeformat`.\n   - Build the redirect link as `{{.domain}}/share/alert-his-events/{{$event.Id}}`.\n4. **Severity display**:\n   - Number: `{{$event.Severity}}`\n   - Worded: `{{if eq $event.Severity 1}}Critical{{else if eq $event.Severity 2}}Warning{{else}}Info{{end}}`\n   - English: `Critical / Warning / Info`\n5. **@-mention (DingTalk/Feishu)**:\n   - DingTalk `@everyone`: add a line `@all` at the end of the template (DingTalk matches by splitting on spaces). To at an exact phone number: `{{range $event.NotifyUsersObj}}@{{.Phone}} {{end}}`.\n   - Feishu card: use `{{batchContactsAtsInFeishuEmail $event.NotifyUsersObj}}` to build `<at email=...></at>` fragments.\n6. **Output**: wrap the template body in a markdown ``` ```gotemplate ``` code block, and at the end provide a **variable/function explanation** with each non-trivial variable/function on its own line.\n\n---\n\n## Output Format\n\nWhen replying to the user:\n\n1. One or two sentences of introduction explaining what the template is for and which channel it fits.\n2. ```gotemplate\n   <template content>\n   ```\n3. `**Variable explanation**`: list the non-trivial fields and functions used in the template (such as `$event.TargetIdent`, `formatDecimal`, `timeformat`), one per line.\n4. If the user's request is ambiguous (for example \"add the hostname\" — is it `target_ident` or some tag), **state directly in the introduction what assumption you made**, rather than asking back.\n\nThe language follows the user's input (use Chinese if the input is Chinese).\n\n---\n\n## Built-in Reference Templates (Starting Points for Customization)\n\n### DingTalk markdown (full version)\n\n```gotemplate\n#### {{if $event.IsRecovered}}<font color=\"#008800\">💚{{$event.RuleName}}</font>{{else}}<font color=\"#FF0000\">💔{{$event.RuleName}}</font>{{end}}\n---\n{{$duration := sub now.Unix $event.FirstTriggerTime}}{{if $event.IsRecovered}}{{$duration = sub $event.LastEvalTime $event.FirstTriggerTime}}{{end}}\n- **Alert Level**: S{{$event.Severity}}\n{{- if $event.RuleNote}}\n- **Rule Note**: {{$event.RuleNote}}\n{{- end}}\n{{- if $event.TargetIdent}}\n- **Target**: {{$event.TargetIdent}}\n{{- end}}\n{{- if not $event.IsRecovered}}\n- **Trigger Value**: {{$event.TriggerValue}}\n- **Trigger Time**: {{timeformat $event.TriggerTime}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n{{- else}}\n- **Recovery Time**: {{timeformat $event.LastEvalTime}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n{{- end}}\n- **Event Tags**:\n{{- range $k, $v := $labels}}\n{{- if ne $k \"rulename\"}}\n    - {{$k}}: {{$v}}\n{{- end}}\n{{- end}}\n[Event Detail]({{.domain}}/share/alert-his-events/{{$event.Id}}) | [Mute 1h]({{.domain}}/alert-mutes/add?__event_id={{$event.Id}})\n```\n\n### Feishu card (concise version)\n\n```gotemplate\n{{- if $event.IsRecovered}}\n**Severity/State:** S{{$event.Severity}} Recovered\n**Alert Name:** {{$event.RuleName}}\n**Event Tags:** {{$event.TagsJSON}}\n**Recovery Time:** {{timeformat $event.LastEvalTime}}\n{{- else}}\n**Severity/State:** S{{$event.Severity}} Triggered\n**Alert Name:** {{$event.RuleName}}\n**Event Tags:** {{$event.TagsJSON}}\n**Trigger Time:** {{timeformat $event.TriggerTime}}\n**Trigger Value:** {{$event.TriggerValue}}\n{{- if $event.RuleNote}}\n**Alert Description:** {{$event.RuleNote}}\n{{- end}}\n{{- end}}\n```\n\n### SMS / Voice (minimal)\n\n```gotemplate\nSeverity/State: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}} Rule: {{$event.RuleName}} Target: {{$event.TargetIdent}}\n```\n\n---\n\n## Typical Customization Scenarios\n\n### 1) \"Add the hostname to the DingTalk template\"\n\n```gotemplate\n- **Host**: {{$event.TargetIdent}}\n{{- if $labels.ip}}\n- **IP**: {{$labels.ip}}\n{{- end}}\n```\n\n> Note: `target_ident` is usually the hostname; if you need the IP, prefer the labels `ip`, `instance`, `host`.\n\n### 2) \"Keep trigger_value to two decimal places\"\n\n```gotemplate\n- **Trigger Value**: {{formatDecimal $event.TriggerValue 2}}\n```\n\n> `TriggerValue` is itself a string; `formatDecimal` first converts it to a float and then formats it, and returns non-numeric values unchanged.\n\n### 3) \"@-mention the alert recipients at the end of the DingTalk template\"\n\n```gotemplate\n...template body...\n\n{{- range $event.NotifyUsersObj}}@{{.Phone}} {{end}}\n```\n\n> DingTalk identifies @-mentioned users by \"space + phone number\". Or uniformly use `@all`.\n\n### 4) \"Show the recovery value on recovery\"\n\n```gotemplate\n{{- if $event.IsRecovered}}\n{{- if $event.AnnotationsJSON.recovery_value}}\n- **Recovery Value**: {{formatDecimal $event.AnnotationsJSON.recovery_value 4}}\n{{- end}}\n- **Recovery Time**: {{timeformat $event.LastEvalTime}}\n{{- end}}\n```\n\n> The recovery value is written into `AnnotationsJSON.recovery_value` by the alert engine on recovery, and **only exists when there is a recovery value**, so guard it with `if`.\n\n### 5) \"Use Chinese for the alert severity\"\n\n```gotemplate\n- **Severity**: {{if eq $event.Severity 1}}Critical{{else if eq $event.Severity 2}}Warning{{else}}Info{{end}}\n```\n\n### 6) \"Only send alerts for production-environment machines\"\n\nThe template itself does not do filtering — filtering should go into the `attributes` / `label_keys` of the **notification rule**. The template is only responsible for display. If the user asks about this, point it out.\n\n### 7) \"Color by state/severity\"\n\nDingTalk/email use `<font color>`, Feishu cards use the `template` field to fill in a color keyword:\n\n```gotemplate\n{{/* DingTalk markdown: prepend emoji + color to the title */}}\n#### {{if $event.IsRecovered}}<font color=\"#008800\">✅ {{$event.RuleName}}</font>{{else}}<font color=\"#FF0000\">🚨 {{$event.RuleName}}</font>{{end}}\n\n{{/* The template field for Feishu feishucard (determines the card header color block) */}}\n{{if $event.IsRecovered}}turquoise{{else}}{{if eq $event.Severity 1}}red{{else if eq $event.Severity 2}}orange{{else}}grey{{end}}{{end}}\n```\n\n> Feishu cards only recognize enumerated colors: `red / orange / yellow / green / turquoise / blue / indigo / purple / carmine / grey`; hex color codes are ignored.\n\n### 8) \"Show the alert duration\"\n\n```gotemplate\n{{$duration := sub now.Unix $event.FirstTriggerTime}}\n{{- if $event.IsRecovered}}{{$duration = sub $event.LastEvalTime $event.FirstTriggerTime}}{{end}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n```\n\n> Triggered state: `current time - FirstTriggerTime`; recovered state: `LastEvalTime - FirstTriggerTime`. `humanizeDurationInterface` outputs a human-readable form like `1h3m5s`.\n\n### 9) \"Embed a tag value in a URL, avoiding spaces/special characters breaking the link\"\n\n```gotemplate\n[View Dashboard]({{.domain}}/dashboards/123?ident={{urlquery $event.TargetIdent}}&host={{urlquery (index $labels \"host\")}})\n[Mute 1h]({{.domain}}/alert-mutes/add?__event_id={{$event.Id}})\n```\n\n> Stuffing `{{$event.TargetIdent}}` directly into a URL will, when it contains spaces, Chinese, or `&`, get double-escaped on the IM side (typical symptom: `&` becomes `&amp;` and the link won't open). **Wrap every variable that goes into a URL query in `urlquery`.**\n\n### 10) \"Tag keys containing hyphens/dots/Chinese — use index to fetch\"\n\n```gotemplate\n- **Application**: {{index $labels \"app-name\"}}\n- **K8s Cluster**: {{index $labels \"k8s.io/cluster\"}}\n- **Business Dashboard**: {{index $event.AnnotationsJSON \"dashboard_url\"}}\n```\n\n> `$labels.app-name` will be parsed by Go template as \"`app` minus `name`\" and is bound to fail. As long as a key **is not pure alphanumerics plus underscore**, always fetch it with `index`; the same goes for Annotations.\n\n### 11) \"Fallback for abnormal values (+Inf / NaN)\"\n\n```gotemplate\n- **Trigger Value**: {{if or (eq $event.TriggerValue \"+Inf\") (eq $event.TriggerValue \"NaN\")}}N/A{{else}}{{formatDecimal $event.TriggerValue 2}}{{end}}\n```\n\n> A `/0` in PromQL returns `+Inf`, and an aggregation over missing data returns `NaN`. Rendered directly into Feishu/DingTalk they appear as literal `+Inf`, which looks like a bug.\n\n### 12) \"In Edge mode the event Id=0 — degrade the redirect link\"\n\n```gotemplate\n{{if gt $event.Id 0}}\n[Event Detail]({{.domain}}/share/alert-his-events/{{$event.Id}})\n{{else}}\n(Edge-mode event — view details on the central server)\n{{end}}\n```\n\n> Known issue: in Edge mode the event Id is written asynchronously, so at render time it is 0, and the redirect link would land on `/alert-his-events/0` (404). Add a `gt $event.Id 0` guard at the template layer.\n\n### 13) \"Merge and display multiple alerts\"\n\n```gotemplate\n{{range $i, $e := .events}}\n{{- if $i}}\n---\n{{end -}}\n- Rule: {{$e.RuleName}}\n- Target: {{$e.TargetIdent}}\n- Trigger Value: {{$e.TriggerValue}}\n- Time: {{timeformat $e.TriggerTime}}\n{{end}}\n```\n\n> In the default scenario one notification has exactly one event, and the template renders with `$event = index .events 0`. When one notification aggregates multiple events (subscription aggregation, batch send) you must `range .events` to expand them.\n\n---\n\n## Key Considerations\n\n1. **`html/template` HTML-escapes**. Tags such as `<font>` and `<at>` in DingTalk/Feishu/Lark **must be wrapped in `unescaped` or placed in content where Go template recognizes them itself**. Email uses `text/template` and is not subject to this.\n2. **`TriggerValue` is a string**: to compare magnitudes directly use `parseDuration`/custom logic; for normal display-only purposes leave it as is.\n3. **Do not manually write headers like `{{$events := .events}}`** — the system injects them automatically.\n4. **Don't drop the recovery branch**: rules with `NotifyRecovered=1` reuse the same template to send the recovery message.\n5. **Hyphens/dots in tag keys**: `$labels.agent_hostname` works, but `$labels.app-name` does not — use `index $labels \"app-name\"`.\n6. **All time fields are unix seconds** (`int64`); don't use `{{$event.TriggerTime}}` directly as text — use `timeformat` or `toTime`.\n7. **Merging multiple alerts**: `.events` may have multiple entries; the default DingTalk/Feishu templates only render entry 0 (`$event`); if the user wants a batch display, use `{{range .events}}`.\n8. **Use `.domain` for links**: don't hardcode `http://localhost:17000`, otherwise switching environments breaks it.\n\n---\n\n## Common Mistakes\n\n- ❌ `{{$event.TriggerValue | printf \"%.2f\"}}` — for `printf`, the first argument is the format, and `TriggerValue` is a string.\n- ✅ `{{formatDecimal $event.TriggerValue 2}}`\n\n- ❌ Displaying `{{$event.TriggerTime}}` directly — it outputs unix seconds.\n- ✅ `{{timeformat $event.TriggerTime}}`\n\n- ❌ Writing `<font color=\"red\">` in DingTalk without escaping — `html/template` will escape it into `&lt;font&gt;`.\n- ✅ Put it inside a conditional branch (Go template's `{{if}}` branch literals do not get the inner tags HTML-escaped), or wrap the whole thing in `{{unescaped \"…\"}}`. Nightingale's official DingTalk template writes `<font>` directly and it works, because the outer area is a plain-text section (not inside an attribute value) — just follow the official sample.\n\n- ❌ Forgetting to split the recovery branch — the trigger time in a recovery notification is misleading.\n- ✅ Wrap all dynamic content first with `{{if $event.IsRecovered}}…{{else}}…{{end}}`.\n\n- ❌ `{{if .IsRecoverd}}` — a missing `e` in the spelling. Go template **silently takes the else branch** for a nonexistent field (without erroring), so the consequence is that the recovery notification sends the triggered-state copy, and it is very hard to track down.\n- ✅ `{{if $event.IsRecovered}}`. Similar spelling traps: `Resoverd`, `Recoverd`, `recovered` (lowercase) all fail.\n\n- ❌ `{{if lt $value 10}}` or `{{if gt $event.TriggerValue 80}}` — `TriggerValue` is a string; comparing with `lt`/`gt` against a number will report `incompatible types`, or be interpreted as a string lexicographic comparison and yield a wrong result. The current version **has no built-in `toFloat` helper**. Put numeric threshold checks back into the alert rule's condition expression, and keep the template layer for display only.\n\n- ❌ Building a URL with `?ident={{$event.TargetIdent}}` directly — a TargetIdent containing spaces/Chinese/`&` will break the link.\n- ✅ `?ident={{urlquery $event.TargetIdent}}`, **pass every variable that goes into a URL query through `urlquery`**.\n\n- ❌ `{{$labels.app-name}}` or `{{$labels.k8s.io/cluster}}` — keys containing hyphens/dots/slashes fail to parse.\n- ✅ `{{index $labels \"app-name\"}}`, `{{index $labels \"k8s.io/cluster\"}}`.\n","aiagent/skill/embedded/builtin/host-health-diagnose/SKILL.md":"---\nname: host-health-diagnose\ndescription: >-\n  Help the user determine whether a machine is truly down / the agent is hung / the network is flapping / it is under maintenance. Triggers this skill when the user asks \"why is this machine unreachable\", \"is the host-unreachable alert a false positive\", \"is categraf stuck\", \"the heartbeat stopped but I can still ping it\", etc. Core stance: **an unreachable agent != a down host**. Concluding \"down\" just because target_up==0 / BeatTime stopped is a common source of false positives.\ntags:\n  - internal\n---\n\n# Host Health Comprehensive Assessment (host-health-diagnose)\n\n## Scope\n\n**Enter this skill**:\n- \"Why is the machine xxx unreachable / offline / heartbeat stopped\"\n- \"Is the host-unreachable alert a false positive\"\n- \"Is the agent stuck / hung\"\n- \"Is this host really down\"\n- \"This machine is pingable but Nightingale shows it as down\"\n\n**Do NOT enter this skill**:\n- Change alert rule thresholds, add/adjust host-unreachable alerts → `creation` / `troubleshooting`\n- Just \"take a look at the machine list / details\" → `resource_query`\n- \"Why did this alert fire\" (not host-unreachable type) → `troubleshooting`\n\n## One-Sentence Principle\n\n**A diagnosis that concludes from a single layer of evidence is almost always wrong.**\n\nWhen n9e shows a machine as unreachable, it may stem from:\n- The agent really died (process panic / OOM)\n- The agent is still alive but cannot send heartbeats (network partition / DNS / broken proxy / cannot write to Redis)\n- Redis write latency on the server side\n- The user manually stopped the agent for maintenance but did not add a mute\n- The host is really down\n\nThe \"recommended action\" for these five conclusions is completely different. So **gather evidence first, then classify, and give the action last**.\n\n## Three-Layer Evidence Collection (in order, call in parallel)\n\n### Layer 1: Real-time heartbeat and metadata — `get_target_realtime_status(ident)`\n\nReads Redis to get:\n- `beat_time` / `lag_seconds` — seconds since the most recent heartbeat\n- `status` — `active`(<60s) / `lagging`(60–180s) / `stale`(≥180s) / `stale_no_heartbeat`(no such key in Redis)\n- `offset` — clock skew between agent and server (an abnormally large value usually means the agent clock has drifted / NTP is broken)\n- `cpu_util` / `mem_util` — resource usage from the most recent report\n- `agent_version` / `remote_addr` / `extend_info`\n\n**Key judgments**:\n- `status=stale_no_heartbeat` while `update_at_db` is several days ago — the agent has never connected, a deployment problem.\n- `status=stale_no_heartbeat` while `update_at_db` is a few minutes ago — the Redis layer is down or has been cleared.\n- `status=stale` while the last reported `cpu_util` / `mem_util` was low — possibly a graceful exit (shutdown / kernel reclaim after OOM), leaning toward \"truly down\".\n- `status=stale` while the last reported `cpu_util` was high (>90%) — leaning toward \"agent hung / host hang\".\n- absolute value of `offset` > 30s — not necessarily down, but the agent clock has a problem and will throw off alert evaluation, so flag it separately.\n\n### Layer 2: Recent 10m metric window — `query_host_metrics_window(ident)`\n\nBy default queries `cpu_usage_active` / `mem_available_percent` / `system_load1` / `net_bytes_recv` / `net_bytes_sent`. Returns `samples_count / first_ts / last_ts / min / max / avg / last` for each metric.\n\n**Key judgments**:\n- `samples_count=0` and `series=0` — the Prom layer has no data at all. If it stops at the same moment as BeatTime → heartbeat and metrics share the same source (categraf), and stopping together means the agent really is not sending; stopping at different moments means the data flow was cut by different stages.\n- `last_ts` far earlier than `now` — metrics have also stopped; compare with BeatTime to see whether the stop times match.\n- `samples_count > 0` but BeatTime is already stale — **a typical agent hang or Redis write latency**: the metric stream is still flowing (the categraf main process is sending), but the heartbeat cannot be written to Redis.\n- `cpu_usage_active.last` > 95% + `system_load1.last` far above cpu_num — a strong signal of host hang.\n- `net_bytes_recv.last + net_bytes_sent.last` close to 0 — the NIC is silent; combined with a stopped BeatTime this points to \"truly down\" or \"network cut\".\n\n⚠️ By default, if `datasource_id` is not passed, the chat-level datasource is used as a fallback; when the user has not selected a datasource in the frontend, first `list_datasources` and pick a Prom datasource.\n\n### Layer 3: Neighbors in the same business group — `list_neighbor_targets(ident)`\n\nPulls other machines in the same **business group** (`target.GroupIds`), returning `items` + `summary{total, active, lagging, stale}`.\n\n**Key judgments**:\n- summary almost entirely active — an individual fault; the problem is on this one machine.\n- summary mostly stale — a **cluster-level** event: switch, data center, underlying cloud, Redis, or the server heartbeat channel. In this case the question \"why is this machine unreachable\" is itself the wrong question; you should investigate the cluster event.\n- summary half active half stale — possibly a network partition or availability-zone failure; check whether the stale machines are on the same subnet / the same rack.\n\n## Correlation Signals (call as needed)\n\n- `list_alert_mutes` / `get_alert_mute_detail` — matched a mute rule → the \"under maintenance\" branch; not a false positive, and tell the user when the mute window ends.\n- `search_active_alerts` — check whether there are other companion alerts (cpu/mem/disk alerts for the same ident firing at the same time) → leans toward \"the host is really in trouble\".\n- `get_target_detail` — check `update_at` (the most recent time a heartbeat was persisted at the DB layer). If `update_at` is newer than Redis's `beat_time`, the Redis layer has been cleared; the reverse means the DB layer is stuck.\n\n## Three-State Decision Table\n\n| Signal combination | Conclusion | Confidence |\n|---|---|---|\n| BeatTime stale + metrics also stopped (last_ts ≈ beat_time) + neighbors mostly active + last cpu/mem is low | **Truly down** | High |\n| BeatTime stale + metrics also stopped + multiple machines in the same business group stale at once | **Cluster event** (not a single-machine outage; guide them to investigate upstream) | High |\n| BeatTime stale + metrics still moving (samples_count > 0 and last_ts close to now) | **Agent hung / Redis heartbeat channel anomaly** | High |\n| BeatTime lagging 30–180s + a few neighbors lagging in sync | **Network flapping / brief partition**, will most likely self-heal | Medium |\n| Matched a mute / maintenance window | **Under maintenance** | High |\n| BeatTime stale + last cpu_util > 95% / load spiking + neighbors normal | **Host hang** (CPU at 100%, IO stuck, kernel softlockup), counts as \"down in a broad sense\" but the recommended direction differs | Medium |\n| None of the above match | **Insufficient data**; honestly say \"cannot determine\", and list the evidence already collected so the user can fill in the gaps | — |\n\n## Output Template (strong constraint)\n\nFinal Answer in Markdown, in the user's language. **Four sections**:\n\n```\n## Conclusion\n<one sentence: truly down / agent hung / network flapping / under maintenance / cluster event / insufficient data, cannot determine>\n\n## Key Evidence\n- Heartbeat: beat_time=2026-05-14 10:23:11, lag=312s, status=stale; db update_at=...\n- Metrics (10m): cpu_usage_active recent last=0.5% (steadily declining from first to last value), net_bytes_sent last=0 — metric stream and heartbeat stopped at the same time\n- Neighbors: same business group 18/20 active, only this machine stale\n- Mute: not matched\n\n## Recommended Actions\n1. <the thing to do first>\n2. <next best>\n3. <if you want to self-verify, do it this way>\n\n## False-Positive Risk\n<under what circumstances this conclusion would not hold, e.g. \"if there was a recent cleanup operation on the Redis layer, this judgment does not hold\">\n```\n\n## Anti-Patterns (do NOT do these)\n\n- ❌ Concluding \"down\" from the single `BeatTime` field. Always pull the metric window and neighbors for cross-validation.\n- ❌ Reporting \"individual fault\" and \"cluster fault\" mixed together. When all neighbors are stale, do not keep focusing on the single machine; clearly tell the user \"you asked the wrong question, it is a cluster event\".\n- ❌ Recommending \"immediately page someone to check the machine\" without looking at the mute. A common pitfall: an under-maintenance alert mistaken for a false positive and reported as one.\n- ❌ Using `query_prometheus` to pull back a long string of raw time-series points and blow up the token budget. `query_host_metrics_window` already compresses by \"min/max/avg/last\"; see whether the aggregate is enough before deciding whether to look at the detail.\n- ❌ Not telling the user how to verify. The recommended actions must include at least one \"if you want to self-verify whether the agent is really dead, do this\" step (remotely ssh in and check `systemctl status categraf` / `ps -ef | grep categraf` / `journalctl -u categraf --since \"5 min ago\"`).\n\n## Known Failure Modes\n\n- edge unreachable storm: under high server load the heartbeat channel develops batch latency, which looks like the whole cluster going offline at once, but is actually Redis write blocking. **When you see summary entirely stale, proactively raise this possibility.**\n- Heartbeat update lag: the goroutine that writes heartbeats to Redis is stuck on slow IO; the agent is sending and the server is receiving, but BeatTime is not updated.\n- redis nil: the heartbeat key is occasionally cleared/evicted; check this together with the maxmemory-policy configuration.\n\n## Output Style\n\n- No teasing. Put the conclusion on the first line of the first section.\n- Give concrete numbers in the evidence; do not write things like \"the metrics look normal\".\n- \"Recommended actions\" must be executable; avoid empty phrases like \"strengthen monitoring\".\n- Answer in the user's language (Chinese for Chinese users, English for English users).\n","aiagent/skill/embedded/builtin/create-dashboard/SKILL.md":"---\nname: create-dashboard\ndescription: Create monitoring dashboards. Use this when the user asks to create a dashboard, a monitoring board, or a Dashboard.\nmax_iterations: 18\nbuiltin_tools:\n  - import_dashboard_template\n  - create_dashboard\n  - list_metrics\n  - list_files\n  - read_file\n  - grep_files\ntags:\n  - export\n---\n\n# Skill: Nightingale (N9E) Dashboard Creation\n\nThere are two paths to create a dashboard. **Prefer path A (import an integration template)**, since templates are hand-tuned, validated, finished products with quality far higher than hand-assembled ones; only fall back to path B when there is no ready-made template or the user wants custom metrics.\n\nWhen the same component often has both categraf and exporter templates, **prefer categraf**: first probe whether the categraf metrics have data, use the categraf template if you can query them, and only fall back to exporter if you cannot (see path A step 3 for details).\n\n| Scenario | Which tool to use |\n|------|-----------|\n| The monitoring topic has a ready-made template in integrations (Linux / MySQL / Redis / Kafka / PostgreSQL / Elasticsearch / Ceph / Oracle / Nginx / Windows …) | **`import_dashboard_template`** (preferred) |\n| Custom metrics, no ready-made template, or the user explicitly wants custom panels | `create_dashboard` |\n\n## Step 1 (common to both paths): Determine the business group and datasource\n\n- Call `list_busi_groups` to get the list of business groups\n- Call `list_datasources` to get the list of datasources, and find the **datasource ID of the Prometheus type**\n- If the conversation context has already preselected `busi_group_id` / `datasource_id`, use them directly and do **not** call `list_*` again\n\n### Business group selection rules\n\n1. If the user explicitly specified a business group name or ID, use it directly\n2. Otherwise, in priority order:\n   a. **Prefer the business group with `is_default: true`** (usually \"Default Busi Group\" or a group whose name contains \"default\")\n   b. If there is only one business group, use it directly\n   c. If there are multiple candidates and none is the default, **do not blindly take the first one**; list them in your reply and let the user confirm\n\n---\n\n## Path A: Import an integration template (preferred)\n\n`import_dashboard_template` reads the complete template under `integrations/` (preserving layout, thresholds, units, overrides, and value mappings in full), and automatically rewrites the template's datasource binding onto the Prometheus datasource you chose.\n\n### Steps\n\n1. See which integration components exist:\n   ```\n   list_files(base=\"integrations\")\n   ```\n2. See which template files exist under that component:\n   ```\n   list_files(base=\"integrations/Linux\", path=\"dashboards\")\n   ```\n3. **Pick the right template file**. Templates often come in two collection styles, categraf (telegraf-style metric names) and exporter (`node_*` / `*_exporter`-style metric names). File-naming conventions vary per component: it may be a prefix (Linux's `categraf-overview.json`) or a suffix (`redis_by_categraf.json`). **Prefer the template whose file name contains `categraf`**:\n   - First probe whether the categraf metrics have data in the environment: `list_metrics(datasource_id=<X>, keyword=\"<categraf metric keyword>\")`.\n     - Use a categraf-specific metric name as the keyword that distinguishes the two styles, for example for Linux use `cpu_usage` (categraf has `cpu_usage_idle`, while node_exporter does not have this name), `mem_used_percent`, `disk_used_percent`; for Redis use `redis_used_memory`.\n     - If you are unsure which keyword to use, read the metrics list of that template (the file is small and will not be truncated): `read_file(base=\"integrations/Linux\", path=\"metrics/categraf-base.json\")`, and pick a representative `expression` from it to probe with.\n   - **As long as the categraf metrics return data (a non-empty array), use the template whose file name contains `categraf` directly**, with no need to compare against exporter.\n   - Only when the categraf metrics return no data at all should you fall back to the template whose file name contains `exporter` (such as `exporter-detail.json`, `redis_by_exporter.json`), using the corresponding exporter metric keyword (such as `node_cpu_seconds_total`) with `list_metrics` to confirm it actually has data before importing.\n   - **Do not** use `read_file` to read out the entire dashboard template and assemble it yourself — the template may be very large and get truncated; choosing the file by file name + the metrics list is enough\n4. Import:\n   ```\n   import_dashboard_template(group_id=<business group>, component=\"Linux\", file=\"categraf-overview.json\", datasource_id=<Prometheus datasource>)\n   ```\n\n### Notes\n\n- Datasource binding, variables, and layout are all handled automatically; you only need to pass `component` + `file`\n- `datasource_id` is optional: passing it sets it as the default selected value of the dashboard's datasource variable (so the first screen is queryable immediately); not passing it lets the frontend automatically select the first Prometheus from the datasource dropdown\n- To rename or change tags: pass the optional `name` / `tags`; if not passed, the template's own values are kept\n- When it returns `Name duplicate`, **do not** call `list_dashboards`; just change the name (add a `-v2`, `-AI`, or timestamp suffix) and retry\n\n---\n\n## Path B: Custom creation (when there is no template)\n\nUse `create_dashboard`. **You only need to provide the panel title, type, and PromQL**; the tool automatically generates the full configuration (layout, datasource variables, styling, units, etc., are all handled automatically).\n\n> create_dashboard accepts the following set of **simplified fields**. Any field beyond these (overrides, value mappings, heatmap/hexbin/iframe, etc.) is **not supported** — they will be ignored even if written. When you need such rich configuration, switch to path A and import a template.\n\n### Call example\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Linux Host Monitoring\",\n  \"datasource_id\": 1,\n  \"tags\": \"linux host\",\n  \"variables\": \"[{\\\"name\\\":\\\"ident\\\",\\\"label\\\":\\\"Host\\\",\\\"definition\\\":\\\"label_values(cpu_usage_idle, ident)\\\"}]\",\n  \"panels\": \"[{\\\"name\\\":\\\"CPU Usage\\\",\\\"type\\\":\\\"stat\\\",\\\"queries\\\":[{\\\"promql\\\":\\\"avg(cpu_usage_active{cpu=\\\\\\\"cpu-total\\\\\\\",ident=~\\\\\\\"$ident\\\\\\\"})\\\",\\\"legend\\\":\\\"CPU\\\"}],\\\"unit\\\":\\\"percent\\\"},{\\\"name\\\":\\\"CPU Usage Trend\\\",\\\"type\\\":\\\"timeseries\\\",\\\"queries\\\":[{\\\"promql\\\":\\\"cpu_usage_active{cpu=\\\\\\\"cpu-total\\\\\\\",ident=~\\\\\\\"$ident\\\\\\\"}\\\",\\\"legend\\\":\\\"{{ident}}\\\"}],\\\"unit\\\":\\\"percent\\\"}]\"\n}\n```\n\n### Panel fields (only these are supported)\n\nEach panel requires 3 fields:\n\n```json\n{\"name\": \"Panel title\", \"type\": \"timeseries\", \"queries\": [{\"promql\": \"PromQL expression\", \"legend\": \"{{ident}}\"}]}\n```\n\n| Field | Description | Default |\n|------|------|------|\n| `name` | Panel title (required) | — |\n| `type` | Panel type (required, see table below) | — |\n| `queries` | Query list, each item `{promql, legend?, instant?}` | — |\n| `unit` | Unit | none |\n| `w` / `h` | Width/height (number of grid columns, total width 24) | auto by type |\n| `stack` | Whether to stack (timeseries only) | false |\n| `description` | Panel description | none |\n\nQuery field `instant`: for single-value panels such as stat/gauge/barGauge/pie, it is recommended to set `\"instant\": true` (instant query). tableNG always uses instant queries automatically.\n\n### Supported panel types (only these 8)\n\n| type | Description | Default size w×h |\n|------|------|--------------|\n| `timeseries` | Time-series line chart, the most common | 12×8 |\n| `stat` | Single-value statistic big number | 6×4 |\n| `gauge` | Gauge | 6×6 |\n| `barGauge` | Horizontal bar ranking | 8×8 |\n| `pie` | Pie chart | 6×6 |\n| `tableNG` | Table | 12×10 |\n| `text` | Text note (uses description as content) | 6×4 |\n| `row` | Grouping row (automatically full width) | 24×1 |\n\n### Common units (unit)\n\n`percent` | `bytesIEC` | `bitsIEC` | `bytesSecIEC` | `bitsSecIEC` | `seconds` | `milliseconds` | `reqps`\n\n### Automatic layout calculation\n\nPanels are arranged automatically from left to right and top to bottom, with same-type panels auto-aligned (e.g. 4 stat panels in one row); a `row` takes a whole row on its own as a grouping title. No need to specify coordinates manually.\n\n### Variables\n\n```json\n{\"name\": \"ident\", \"label\": \"Host\", \"definition\": \"label_values(cpu_usage_idle, ident)\"}\n```\n\n`label` and `multi` are optional (multi defaults to true). A later variable references an earlier variable in its definition to achieve cascading:\n\n```json\n[\n  {\"name\": \"ident\", \"definition\": \"label_values(cpu_usage_idle, ident)\"},\n  {\"name\": \"interface\", \"definition\": \"label_values(net_bytes_recv{ident=~\\\"$ident\\\"}, interface)\"}\n]\n```\n\n### Notes\n\n- For multi-select variables, use `=~` rather than `=` in PromQL, e.g. `ident=~\"$ident\"`\n- For counter-type metrics, use `rate(...[3m])` or `irate(...[5m])`\n- **Prefer copying validated PromQL expressions from the integrations templates**: `read_file(base=\"integrations/Linux\", path=\"dashboards/categraf-detail.json\")`, focusing on the `expr` inside targets\n- When it returns `Name duplicate`, just change the name and retry; do not call `list_dashboards`\n\n---\n\n## Step 3 (common to both paths): Output the result\n\n**Keep it short**. A one-sentence confirmation is enough, for example:\n\n> ✅ I have created the dashboard \"Linux Host Monitoring\" for you; see the card below for details.\n\n**Do not** restate fields such as the dashboard ID, business group, datasource, or panel list — the frontend will display them in a structured card. You may add a sentence or two of additional suggestions, but do not enumerate the fields the card already has.\n\n## Recommended panel design when there is no template (path B reference)\n\n> These topics basically all have templates in integrations; prefer path A. The following is only for reference when hand-building via path B.\n\n### Linux host monitoring\n\n**Variables:** `ident` (host), `interface` (network interface), `mountpoint` (mount point)\n\n| Area | Panel | Type | Core metric |\n|------|------|------|----------|\n| Overview | CPU Usage | stat | `avg(cpu_usage_active{cpu=\"cpu-total\"})` |\n| Overview | Memory Usage | stat | `avg(mem_used_percent)` |\n| Overview | Disk Usage (max) | stat | `max(disk_used_percent)` |\n| CPU | CPU Usage Trend | timeseries | `cpu_usage_active` + `cpu_usage_iowait` |\n| CPU | System Load | timeseries | `system_load1/5/15` |\n| Memory | Memory Usage Trend | timeseries | `mem_used_percent` |\n| Disk | Per-mount-point Usage | barGauge | `disk_used_percent` |\n| Network | Network Traffic | timeseries | `rate(net_bytes_recv/sent)` |\n\n### MySQL monitoring\nQPS/TPS, connection count, slow queries, Buffer Pool hit ratio, replication lag\n\n### Redis monitoring\nOPS, memory usage, connection count, hit ratio, keyspace\n\n### Kubernetes monitoring\n**Variables:** `cluster`, `namespace`, `pod`. Pod CPU/memory, node resources, deployment status, PV usage\n","aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md":"# import-prom-rule\n\nPath: `aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md)","aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md":"# modify-dashboard\n\nPath: `aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md)","aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md":"# modify-task-tpl\n\nPath: `aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md)","aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md":"# notify-channel-copilot\n\nPath: `aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md)","aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md":"# notify-rule-copilot\n\nPath: `aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md)","aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md":"# ops-troubleshooting\n\nPath: `aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md)","aiagent/skill/embedded/builtin/promql-generator/SKILL.md":"# promql-generator\n\nPath: `aiagent/skill/embedded/builtin/promql-generator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/promql-generator/SKILL.md)","aiagent/skill/embedded/builtin/query-alert-events/SKILL.md":"# query-alert-events\n\nPath: `aiagent/skill/embedded/builtin/query-alert-events/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/query-alert-events/SKILL.md)","aiagent/skill/embedded/builtin/query-datasource/SKILL.md":"# query-datasource\n\nPath: `aiagent/skill/embedded/builtin/query-datasource/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/query-datasource/SKILL.md)","aiagent/skill/embedded/builtin/skill-creator/SKILL.md":"# skill-creator\n\nPath: `aiagent/skill/embedded/builtin/skill-creator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/skill-creator/SKILL.md)","aiagent/skill/embedded/builtin/sql-generator/SKILL.md":"# sql-generator\n\nPath: `aiagent/skill/embedded/builtin/sql-generator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/sql-generator/SKILL.md)","CLAUDE.md":"# Claude Agent Guidelines & System Prompt\n\nPath: `CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/CLAUDE.md)"},"files":{"aiagent/skill/embedded/builtin/create-alert-rule/SKILL.md":"---\nname: create-alert-rule\ndescription: |\n  **Create alert rules**. Prefer reusing the validated rules in integrations (standard components like Linux/MySQL/Redis/Kafka/PostgreSQL/Elasticsearch all ship ready-made rule packs); import as many rules as the user needs—one rule, a batch, or a whole pack. Only hand-write a custom rule when integrations has nothing that fits. Supports all data sources: Prometheus / Loki / ES / OpenSearch / MySQL / PG / TDengine / ClickHouse / Doris / VictoriaLogs / Host.\n  ⚠️ **Do NOT use this skill for bulk YAML imports**—when the user provides a URL or a YAML file, awesome-prometheus-alerts, node-exporter.yml, and the like, use import-prom-rule instead.\n  Triggers: create an alert / add an alert / help me set up a CPU alert / add a set of alert rules for MySQL / configure common alerts for a host / I want to monitor a metric.\nexamples:\n  - \"Configure a set of common alert rules for the host\"\n  - \"Add a set of alert rules for MySQL\"\n  - \"Help me create an alert for CPU usage exceeding 80%\"\n  - \"Add a MySQL slow query alert rule\"\n  - \"Add a memory alert for the host, fire when it exceeds 90%\"\nmax_iterations: 20\nbuiltin_tools:\n  - preview_alert_rule_template\n  - import_alert_rule_template\n  - create_alert_rule\n  - list_busi_groups\n  - list_datasources\n  - list_metrics\n  - list_notify_rules\n  - list_files\n  - read_file\n  - list_databases\n  - list_tables\n  - describe_table\ntags:\n  - export\n---\n\n# Skill: Nightingale (N9E) Alert Rule Creation\n\nTwo tools: `import_alert_rule_template` (reuse the validated rules in integrations) and `create_alert_rule` (hand-write rules).\n\n**Prefer reusing the rules in integrations**: the rules under `integrations/<component>/alerts/` are finished products that have been hand-tuned and validated (severity, duration, evaluation interval, appended tags, annotations, and effective time windows are all configured), and their quality far exceeds hand-crafted rules. **But import as many as the user needs—do not blindly import the whole pack**:\n\n- The user wants \"a set / common\" alerts for some component → import the whole pack (do not pass `names`)\n- The user names only a few categories (e.g. \"disk and memory alerts\") → import only those (pass them in `names`)\n- The user wants a **single** alert for a specific metric → if the pack happens to contain a matching rule, import just that one (pass one entry in `names`) or borrow its expression; if the pack has nothing that fits, hand-write with `create_alert_rule`\n\nOnly go to Approach B (`create_alert_rule`) when integrations has no matching rule, or when the user provides a fully custom query condition.\n\nWhen a component ships both a categraf and an exporter rule pack, **prefer categraf**: first probe whether the categraf metrics have data; if found, use the categraf pack, otherwise fall back to the exporter pack (see Approach A step 3 for details).\n\n| User need | What to do |\n|------|-----------|\n| Configure a set / a few alerts for a standard component (Linux / MySQL / Redis / Kafka / PostgreSQL / Elasticsearch / Ceph / Oracle / Windows / MongoDB …) | Approach A: `preview_alert_rule_template` to see what's in the pack → `import_alert_rule_template` to import as needed |\n| A single alert for a specific metric, and the integration pack has a matching rule | Approach A: `import_alert_rule_template` + `names` to import just that one |\n| Fully custom rules that integrations doesn't cover (including niche conditions like Loki / ES / SQL query values / log counts) | Approach B: `create_alert_rule` |\n\n> ⚠️ **Do NOT use this skill for bulk YAML imports**—when the user provides a URL or a YAML file, awesome-prometheus-alerts, node-exporter.yml, and the like, use import-prom-rule instead.\n\n## Step 1 (common to both paths): determine the business group and data source\n\n- Call `list_busi_groups` to get the list of business groups\n- Call `list_datasources` to get the list of data sources, and find the data source ID of the matching type (rule packs are mostly the Prometheus type)\n- If the session context already has a preselected `busi_group_id` / `datasource_id`, use it directly—**do not** call `list_*` again\n- `cate=host` (host-unreachable class) does not need a data source\n\n### Business group selection rules\n\n1. If the user explicitly specified a business group name or ID, use it directly\n2. Otherwise, by priority:\n   a. **Prefer the business group with `is_default: true`** (usually \"Default Busi Group\" or a group whose name contains \"Default\")\n   b. If there is only one business group, use it directly\n   c. When there are multiple candidates and none is the default, **do not blindly take the first one**; list them in your reply and ask the user to confirm\n\n---\n\n## Approach A: reuse validated rules in integrations (as needed: single / batch / whole pack)\n\n`import_alert_rule_template` imports rules from the rule pack under `integrations/<component>/alerts/` (each rule's severity, duration, evaluation interval, appended tags, annotations, and effective time window are all preserved), binds the rules to the data source you select, and uniformly switches them to the enabled state. **How many rules are imported is controlled by the `names` parameter**, driven by the user's need.\n\n### Steps\n\n1. See which integration components are available:\n   ```\n   list_files(base=\"integrations\")\n   ```\n2. See which rule pack files exist under that component:\n   ```\n   list_files(base=\"integrations/Linux\", path=\"alerts\")\n   ```\n3. **Pick the right rule pack file (prefer categraf)**. Rule packs often come in two collection styles, categraf and exporter, and the filename convention varies per component (for Linux it's `linux_by_categraf.json` / `linux_by_exporter.json`, but it may use other suffixes). **Prefer the pack whose filename contains `categraf`**:\n   - First probe whether the categraf metrics have data in the environment: `list_metrics(datasource_id=<X>, keyword=\"<categraf metric keyword>\")`.\n     - Use a categraf-specific metric name that distinguishes the two styles as the keyword, e.g. for Linux use `cpu_usage` (categraf uses `cpu_usage_idle`, which node_exporter does not have), `mem_used_percent`, `disk_used_percent`; for Redis use `redis_used_memory`; for MySQL use `mysql_global_status_queries`.\n   - **As long as the categraf metric returns data (a non-empty array), use the pack whose filename contains `categraf`** directly, without comparing against exporter.\n   - Only fall back to the pack whose filename contains `exporter` (e.g. `linux_by_exporter.json`) when the categraf metric has no data, and use the corresponding exporter metric keyword (e.g. `node_cpu_seconds_total`) with `list_metrics` to confirm data exists before importing.\n4. **See which rules are in the pack (only when you need to select by name)**. You can skip this step when the user wants to import the whole pack; when the user wants only a few of them, or a single alert for a specific metric, preview first to get the exact rule names:\n   ```\n   preview_alert_rule_template(component=\"Linux\", file=\"linux_by_categraf.json\")\n   ```\n   It returns each rule's `name` / `cate` / `severity` / an expression summary / whether it is disabled (a small payload). **Do not** use `read_file` to view a large rule pack (it will be truncated); use this tool.\n5. **Import as needed**:\n   - Whole pack (the user wants \"a set / common\" alerts)—do not pass `names`:\n     ```\n     import_alert_rule_template(group_id=<business group>, component=\"Linux\", file=\"linux_by_categraf.json\", datasource_id=<data source>)\n     ```\n   - Batch / single (the user names a few categories or a specific metric)—`names` carries the exact rule names (from preview):\n     ```\n     import_alert_rule_template(group_id=<business group>, component=\"Linux\", file=\"linux_by_categraf.json\",\n       datasource_id=<data source>, names=\"[\\\"Machine load - high memory, please pay attention - categraf\\\"]\")\n     ```\n\n> **Do not** use `read_file` to read out the entire rule pack and then call `create_alert_rule` one rule at a time—the pack may be large and get truncated, and converting rules one by one also drops fields. To see what's in the pack use `preview_alert_rule_template`; to persist it use `import_alert_rule_template`.\n\n### Notes\n\n- **Strongly recommend passing `datasource_id`**: passing it binds the imported rules to that data source; omitting it makes the rules match every data source of that type (an overly broad scope). The `host` heartbeat rules in the pack don't need a data source, and the tool skips binding for them automatically.\n- The rules in a rule pack are **disabled** by default in the template (it is a browsable catalog). When importing, this tool **switches them to enabled** by default (`disabled=0`) so the rules take effect immediately. If the user wants to \"import first but leave them disabled, and enable them one by one themselves\", pass `disabled=1`.\n- The names written in `names` must exactly match the `name` returned by `preview_alert_rule_template`; any that don't match are listed in the returned `not_found_names`—double-check and retry.\n- Duplicate rules (already existing in the business group) are **skipped automatically** (`status=skipped_duplicate`); this is not a failure, and **do not** re-import with `name_prefix` just because you see a skip. Only use `name_prefix`/`name_suffix` when the user explicitly wants the rule to coexist with the existing one (e.g. a comparison test).\n- The return includes three counts—`created` / `skipped` / `failed`—plus details for each rule; just summarize them in the output.\n- Imported rules **are not bound to any notify rule** by default (to avoid sending alerts by mistake). If binding is needed, after importing, prompt the user to associate it manually on the rule, or switch to Approach B to create rules one by one and pass `notify_rule_ids`.\n\n---\n\n## Approach B: hand-write a custom rule (when integrations has no matching rule)\n\nUse `create_alert_rule`. Supports all data source types: **Prometheus / Loki / Elasticsearch / OpenSearch / TDengine / ClickHouse / MySQL / PostgreSQL / Doris / VictoriaLogs / Host**.\n\n> Before getting here, confirm that integrations really has no matching rule: common metrics for standard components (CPU/memory/disk/connection count…) are mostly in the rule packs, so reuse rather than hand-craft when you can. Even when hand-crafting, you can first run `preview_alert_rule_template` to glance at whether there's a ready-made expression you can borrow.\n\nThe tool offers two invocation modes:\n\n1. **Prometheus simplified path** (most common)—pass `prom_ql` + `threshold` + `operator` directly, and the tool builds the rule automatically\n2. **Generic path**—pass `cate` + `rule_config_json`; **first obtain the structure of `rule_config_json` by reading `datasources/<cate>.md` via `read_file`**, then fill in the actual values\n\n### Mode 1: Prometheus simplified path\n\nThe most common scenario. You only need to fill in PromQL, threshold, and operator. Internally the tool splices the threshold into `prom_ql` to generate a v1-format rule (the v2 editor in the OSS n9e FE is gated by `IS_PLUS`, so only v1 can be used).\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"CPU usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  \"operator\": \">\",\n  \"threshold\": 80,\n  \"severity\": 2,\n  \"note\": \"CPU usage exceeded 80% for 1 minute\",\n  \"for_duration\": 60\n}\n```\n\n### Mode 2: generic path (non-Prometheus)\n\nFor types such as Loki / ES / MySQL / TDengine / ClickHouse / Doris / VictoriaLogs / Host, you need to pass `cate` and `rule_config_json`.\n\n**Key step**: first read the reference doc for that data source type to obtain the `rule_config` structure template:\n\n```\nread_file(base=\"create-alert-rule\", path=\"datasources/<cate>.md\")\n```\n\nThen turn the `rule_config` object in the doc into a JSON string and pass it to the `rule_config_json` parameter. Example (MySQL alert):\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"MySQL too many failed orders\",\n  \"cate\": \"mysql\",\n  \"datasource_id\": 4,\n  \"severity\": 2,\n  \"rule_config_json\": \"{\\\"queries\\\":[{\\\"ref\\\":\\\"A\\\",\\\"sql\\\":\\\"SELECT count(*) AS value FROM orders WHERE created_at >= NOW() - INTERVAL 5 MINUTE AND status='failed'\\\",\\\"keys\\\":{\\\"valueKey\\\":\\\"value\\\",\\\"labelKey\\\":\\\"\\\"},\\\"interval\\\":60}],\\\"triggers\\\":[{\\\"mode\\\":1,\\\"exp\\\":\\\"$A.value > 10\\\",\\\"severity\\\":2,\\\"recover_config\\\":{\\\"judge_type\\\":1}}]}\"\n}\n```\n\n**⚠️ Important generic rules (apply to all non-Prometheus types)**:\n\n1. **The `interval` field must be the total number of seconds**—do not write `interval_unit`. When the frontend saves, it converts `value × unit → seconds`, and when reading it derives the display unit back from the seconds.\n   - Last 1 minute → `\"interval\": 60`\n   - Last 5 minutes → `\"interval\": 300`\n   - Last 1 hour → `\"interval\": 3600`\n   - **Do not** write `\"interval\": 5, \"interval_unit\": \"min\"`—the FE will display it as 5 seconds.\n   - The tool has a defensive fallback: if you accidentally write `interval_unit` or a bare interval smaller than 60, it converts it to seconds automatically, but it's best to write it correctly to begin with.\n\n**⚠️ Important limitations for OSS n9e SQL-type data sources (MySQL/PGSQL/CK/Doris)**:\n1. **`keys.valueKey` is required**—the alias of the numeric column in the SELECT statement, usually `\"value\"`. Missing it reports `valueKey is required`.\n2. **`$from`/`$to`/`$__timeFilter` are not substituted**—the OSS `macros.Macro` is a no-op. You must use the data source's native time functions, e.g. MySQL uses `NOW() - INTERVAL 5 MINUTE`, PG uses `NOW() - INTERVAL '5 minutes'`, CK uses `now() - INTERVAL 5 MINUTE`.\n3. **TDengine is the exception**—it has its own variable substitution logic, and `$from`/`$to`/`$interval` are usable.\n\n## create_alert_rule parameter reference (Approach B)\n\n| Field | Required | Description |\n|---|---|---|\n| `group_id` | ✅ | Business group ID (from `list_busi_groups`) |\n| `name` | ✅ | Rule name (must be unique within the business group) |\n| `cate` | ❌ | Data source type (default `prometheus`). Options: `prometheus` / `loki` / `elasticsearch` / `opensearch` / `tdengine` / `ck` / `mysql` / `pgsql` / `doris` / `victorialogs` / `host` |\n| `prod` | ❌ | Product type. When omitted, derived automatically from `cate` |\n| `datasource_id` | Conditionally required | Data source ID. **Not needed when `cate=host`**; required for all others |\n| `rule_config_json` | Conditionally required | The complete `rule_config` JSON string. **Can be omitted when `cate=prometheus` (use the simplified path)**; required for other types |\n| `prom_ql` | Conditionally required | PromQL query (only for the `cate=prometheus` simplified path). **Write only the query, not the threshold** |\n| `threshold` | Conditionally required | Trigger threshold (only for the simplified path) |\n| `operator` | ❌ | Default `>`. Options `>` `>=` `<` `<=` `==` `!=` |\n| `severity` | ❌ | Default 2. 1=Critical, 2=Warning, 3=Info |\n| `note` | ❌ | Alert notification body |\n| `eval_interval` | ❌ | Evaluation interval (seconds), default 30 |\n| `for_duration` | ❌ | Duration (seconds), default 60 |\n| `append_tags` | ❌ | Appended tags, multiple separated by spaces |\n| `runbook_url` | ❌ | Runbook URL |\n| `notify_rule_ids` | ❌ | JSON list of associated notify rule IDs, e.g. `\"[1,2]\"` |\n\n## Approach B detailed steps (create_alert_rule)\n\n> See \"Step 1 (common to both paths)\" above for selecting the business group and data source. Below are the steps specific to Approach B.\n\n### B-1: identify the data source type (cate)\n\nBased on the user's alert need, decide which `cate` to use:\n\n| User-need keyword | cate | Trigger condition |\n|---|---|---|\n| \"Host CPU/memory/disk\", \"Prometheus metric\" | `prometheus` | Metric threshold |\n| \"Machine unreachable\", \"node offline\" | `host` | Heartbeat timeout |\n| \"Application error log\", \"Loki log\" | `loki` | Log count |\n| \"ES log\", \"Elasticsearch aggregation\" | `elasticsearch` | Log aggregation |\n| \"OpenSearch log\" | `opensearch` | Log aggregation (same as ES) |\n| \"MySQL abnormal query result\" | `mysql` | SQL query value |\n| \"PostgreSQL abnormal query result\" | `pgsql` | SQL query value |\n| \"ClickHouse metric/log\" | `ck` | SQL query value |\n| \"Doris log\" | `doris` | SQL query value |\n| \"TDengine time-series data\" | `tdengine` | SQL query value |\n| \"VictoriaLogs log\" | `victorialogs` | LogsQL query |\n\nDefault value: `prometheus`. The data source must match too (`cate=mysql` finds a MySQL data source, `cate=loki` finds a Loki data source, etc.); `cate=host` does not need a data source.\n\n### B-2 (non-Prometheus only): read the data source reference doc\n\nFor types where `cate != \"prometheus\"`, **you must first read the reference doc for that type** to obtain the `rule_config` structure:\n\n```\nread_file(base=\"create-alert-rule\", path=\"datasources/<cate>.md\")\n```\n\nFor example:\n- MySQL → `datasources/mysql.md`\n- Loki → `datasources/loki.md`\n- Host → `datasources/host.md`\n\nThe reference docs contain complete field descriptions, option-value tables, and complete examples. **Copy the field names from the `rule_config` object in the example as-is**, replacing only the concrete values.\n\n### B-3 (SQL types only): probe the real schema ⚠️\n\n**This step is mandatory when `cate ∈ {mysql, pgsql, ck, doris, tdengine}`; it cannot be skipped**.\n\n> **TDengine is also supported**: although TDengine is a time-series database, its REST API supports `SHOW DATABASES` / `information_schema.ins_tables` / `information_schema.ins_columns`. `list_databases` / `list_tables` / `describe_table` are fully usable with TDengine.\n\n**Never guess database/table/column names from the literal description in the user's prompt**. When the user says \"query the access_log table in the my_logs database\",\nit is very likely just an example, and the actual data source may have no such database or table at all.\n\nYou must probe in order:\n\n1. **`list_databases(datasource_id=<X>)`**—see the real list of databases\n   - Compare against the user's description and find the closest one\n   - If the database name in the user's description **is not in the returned list**, **do not force it into the SQL**. Use AskUserQuestion\n     to list the real database names for the user to confirm, or pick one that looks relevant and have the user approve it\n\n2. **`list_tables(datasource_id=<X>, database=<the chosen database>)`**—see the real list of tables\n   - Same as above: if the table the user mentioned is not in the list → list the real tables for the user to choose\n\n3. **`describe_table(datasource_id=<X>, database=<database>, table=<table>)`**—get the real columns\n   - Use the returned column names to assemble the SQL—**do not invent** common column names like `log_time`, `status`, `created_at`\n     (they may not actually exist in this table)\n   - Pay special attention to the time column: in different tables it may be called `ts`, `event_time`, `log_time`, `create_time`,\n     `timestamp`, etc.; you must decide based on the column names returned by `describe_table`\n\nOnly after obtaining the real schema can you proceed to B-4 and assemble the SQL.\n\n#### How to pass datasource_id\n\n- When the session context already binds a data source (a conversation entered from the data source page): `datasource_id` can be omitted,\n  inherited from the session context\n- In the alert rule scenario (entered from the alert rule page or a plain conversation): you must **explicitly pass** `datasource_id`,\n  otherwise the tool reports a `datasource_id required` error\n\n### B-4: build the PromQL / SQL / LogQL\n\n#### Simplified path (cate=prometheus)\n\nBased on the user's description, build a PromQL **without a threshold**. All entries in the table below are **categraf (telegraf-style)** metric names—**prefer categraf**:\n\n| User need | Recommended PromQL (prefer categraf) |\n|---|---|\n| Host CPU usage | `avg by (ident) (100 - cpu_usage_idle{cpu=\"cpu-total\"})` |\n| Host memory usage | `mem_used_percent` |\n| Host disk usage | `disk_used_percent` |\n| Host system load | `system_load1` |\n| Host inbound network traffic | `rate(net_bytes_recv[5m])` |\n| MySQL QPS | `rate(mysql_global_status_queries[5m])` |\n| MySQL connection usage | `mysql_global_status_threads_connected / mysql_global_variables_max_connections * 100` |\n| Redis memory usage | `redis_used_memory / redis_maxmemory * 100` |\n\n**Prefer categraf + probe**: first use `list_metrics` to confirm whether the categraf metric has data in the environment, e.g.\n`list_metrics(datasource_id=<X>, keyword=\"cpu_usage\")` (categraf uses `cpu_usage_idle`, which node_exporter does not have).\n- **Data found** → use the categraf PromQL from the table above directly.\n- **Not found** → the environment very likely uses node_exporter; switch to the equivalent exporter expression below, and use\n  `list_metrics(datasource_id=<X>, keyword=\"node_\")` again to confirm the node_exporter metrics actually have data before creating the rule.\n\n##### node_exporter fallback equivalence table (use only when the categraf metric has no data)\n\n| User need | node_exporter equivalent PromQL |\n|---|---|\n| Host CPU usage | `100 - avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100` |\n| Host memory usage | `(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100` |\n| Host disk usage | `(1 - node_filesystem_avail_bytes{fstype!~\"tmpfs\\|overlay\"} / node_filesystem_size_bytes) * 100` |\n| Host system load | `node_load1` |\n| Host inbound network traffic | `rate(node_network_receive_bytes_total[5m])` |\n\n> The `\\|` in the disk expression is only escaped to avoid breaking the Markdown table; when writing it into PromQL, restore it to `|`, i.e. `fstype!~\"tmpfs|overlay\"` (otherwise the regex treats it as a literal pipe and the filter fails).\n\n#### Generic path (other cate)\n\nFollowing the reference doc read in B-2, assemble the `rule_config` object. Key fields:\n\n- **SQL types** (mysql/pgsql/ck/doris):\n  - The query must return one column aliased `value` as the alert judgment value\n  - **Required** `keys.valueKey: \"value\"` (missing → `valueKey is required` error)\n  - **Time filtering cannot use `$from`/`$to`**; use the data source's native time functions:\n    - MySQL: `NOW() - INTERVAL 5 MINUTE`\n    - PostgreSQL: `NOW() - INTERVAL '5 minutes'`\n    - ClickHouse: `now() - INTERVAL 5 MINUTE`\n    - Doris: `NOW() - INTERVAL 5 MINUTE`\n- **TDengine**: a special exception; `$from`/`$to`/`$interval` **can be used**, and use `keys.metricKey` instead of `keys.valueKey`\n- **Log types** (loki/es/opensearch/victorialogs): the aggregation query returns a numeric value; use `0` for `recover_config.judge_type`\n- **Metric types** (prometheus/mysql/pgsql/ck/tdengine): use `1` for `recover_config.judge_type`\n- **Host type** (host): `queries` is a `{key, op, values}` structure; `triggers` is a `{type, severity, duration}` structure; no `datasource_id` needed\n\n### B-5: call create_alert_rule\n\n#### Simplified path (Prometheus)\n\n```\ncreate_alert_rule(\n  group_id=1, name=\"CPU too high\", datasource_id=1,\n  prom_ql=\"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  threshold=80, operator=\">\", severity=2\n)\n```\n\n#### Generic path (other types)\n\n```\ncreate_alert_rule(\n  group_id=1, name=\"MySQL too many errors\",\n  cate=\"mysql\", datasource_id=4, severity=2,\n  rule_config_json=\"<serialize the rule_config read in step 3 into a JSON string>\"\n)\n```\n\n**Notes:**\n- On the Prometheus simplified path, pass the threshold separately in `threshold`—**do not write it into `prom_ql`**\n- Default `for_duration=60`, which is only reasonable if it is larger than `eval_interval=30`\n- If the user requests \"fire immediately\", set `for_duration` to 0\n- If `create_alert_rule` returns an `already exists` error, **do not call `list_alert_rules`** to look up the duplicate name; just add a suffix to the name (e.g. `-v2`, `-AI`, or a timestamp) and retry\n- On the generic path, `rule_config_json` must be a **JSON string** (not an object); remember to escape the quotes\n- **For SQL types, the B-3 schema probe must have been completed before the call**. If the database/table/column in rule_config_json has not been verified via `list_databases`+`list_tables`+`describe_table`,\n  the alert rule will most likely fail to run (`Unknown database` / `Unknown column` and other runtime errors)\n\n### B-6: (optional) associate notify rules\n\nIf the user requests binding notify rules:\n1. Call `list_notify_rules` to get the list of notify rules\n2. When calling `create_alert_rule`, pass `notify_rule_ids`, e.g. `\"[1,2]\"`\n\nIf the user does not explicitly request it, **do not proactively associate notify rules**, to avoid sending alerts by mistake.\n\n---\n\n## Final step (common to both paths): output the result\n\n**Keep it short**. A one-sentence confirmation is enough:\n\n- Approach A (importing a rule pack): summarize the three counts `created` / `skipped` / `failed`, e.g.\n  > ✅ Imported 8 alert rules from the Linux categraf rule pack for you (skipped 1 duplicate name); all are enabled, see the card below for details.\n- Approach B (single creation):\n  > ✅ Created the alert rule \"Host CPU usage too high\" for you; see the card below for details.\n\n**Do not** restate fields such as rule ID, business group, data source, PromQL, threshold, or alert severity in the Final Answer—the frontend displays this information as a structured card, and repeating it only makes the user see two copies. If you need to add a note (e.g. \"these rules are not bound to any notify rule by default; please associate them as needed\"), you may add a sentence or two, but do not list out the fields already in the card one by one.\n\n## Severity quick reference\n\n| severity | Name | Applicable scenario |\n|---|---|---|\n| 1 | Critical (level 1) | Service unavailable, core component down, data loss |\n| 2 | Warning (level 2) | High resource usage, performance degradation, quota approaching limit |\n| 3 | Info (level 3) | Informational alert, capacity planning hint |\n\nUse 2 (Warning) by default. Use 1 only when the user explicitly says \"severe\" or \"urgent\".\n\n## Common single-alert templates (Approach B reference)\n\n> Use these when the user wants a single alert for a specific metric. If the user wants to \"add a whole set of monitoring alerts for a host\",\n> prefer Approach A to import the whole `integrations/Linux/alerts/linux_by_categraf.json` pack, rather than hand-crafting rules one by one here.\n\n### Host CPU usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host CPU usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  \"operator\": \">\",\n  \"threshold\": 80,\n  \"severity\": 2,\n  \"for_duration\": 300,\n  \"note\": \"Host {{ $labels.ident }} CPU usage exceeded 80% for 5 minutes\"\n}\n```\n\n### Host memory usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host memory usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"mem_used_percent\",\n  \"operator\": \">\",\n  \"threshold\": 90,\n  \"severity\": 1,\n  \"for_duration\": 180\n}\n```\n\n### Host disk usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host disk usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"disk_used_percent\",\n  \"operator\": \">\",\n  \"threshold\": 85,\n  \"severity\": 2,\n  \"for_duration\": 600,\n  \"append_tags\": \"alert_type=capacity\"\n}\n```\n","aiagent/skill/embedded/builtin/categraf-deploy-guide/SKILL.md":"---\nname: categraf-deploy-guide\ndescription: >-\n  Answers \"how do I deploy the categraf collector\". Trigger scenarios: the user asks \"how to install categraf / how to deploy categraf / run categraf with Docker / install categraf on Windows / how to register categraf as a system service / report categraf to Nightingale / how to write categraf config.toml / how to verify that categraf is collecting data\". Covers binary + systemd, Docker, Windows, K8s tips, key configuration, and common verification commands. This skill is instructional/guidance-oriented, calls no tools, and outputs ready-to-paste commands and configuration snippets.\ntags:\n  - internal\n---\n\n# Categraf Deployment Guide\n\n## Scope\n\n**Enter this skill**:\n- \"how to install / how to deploy / how to start categraf\"\n- \"run categraf on Docker / Kubernetes / Windows / Linux\"\n- \"how to register categraf as a systemd service / start on boot\"\n- \"report categraf to Nightingale / how to write config.toml / what to put in the writer URL\"\n- \"how to verify that categraf is collecting data\"\n- \"categraf is installed but Nightingale can't see the machine\" → **do not enter this skill**, route to `host-onboard-diagnose`\n\n**Do not enter this skill**:\n- Diagnosing onboarding failures after install → `host-onboard-diagnose`\n- Abnormal metrics after onboarding → `host-health-diagnose`\n- Configuring collection for a specific input plugin (mysql / redis / nginx / snmp, etc.) → guide the user to read the comments under `conf/input.<name>/`; this skill only covers \"get it installed + get it connected\"\n\n## One-Sentence Principle\n\n**categraf deployment = three actions**: get the binary → correctly write the `[[writers]]` section of `config.toml` → start it and verify. Everything else is a variant of these three steps (containerization, systemd management, Windows service, etc.).\n\n## Deployment Methods Quick Reference\n\n| Scenario | Recommended method | Notes |\n|---|---|---|\n| Linux physical machine / VM | **binary + systemd** | v0.3.35+ has a built-in `--install` one-liner to register |\n| Linux but no root | binary + `--user --install` | supported in v0.4.5+ |\n| Container / orchestration environment | **Docker** or K8s DaemonSet | collecting host metrics requires mounting `/proc /sys` |\n| Windows | `categraf.exe --win-service-install` | or run in the background with `win_run.bat` |\n| macOS (development/debugging) | run the binary directly | not recommended for production |\n\nOperating system support: Linux kernel 2.6.32+ / Windows 10+ or Server 2008+ / macOS 10.15+.\n\n## Quickest Path: One-Click Install\n\nIf the nightingale server is v9+ and bundles categraf, a single command on the target\nhost does everything below (download, unpack, rewrite the reporting address, register\nand start the systemd service):\n\n```bash\ncurl -sSfL 'http://N9E_HOST:17000/api/n9e/agents/categraf/install.sh' | sudo bash -s -- --server 'http://N9E_HOST:17000'\n```\n\nThe address is filled in by the server, so nothing has to be edited by hand. The same\ncommand is available from the machine list page (\"Install Categraf\"), which also waits\nfor the host to report. Useful flags: `--force` (reinstall/upgrade), `--dir <path>`,\n`--auth user:pass` (when the agent API requires basic auth). Note the `bash -s --`\nform: `| sudo bash --force` silently drops the flag.\n\nIf the server is older, does not bundle the package, or the host is not Linux, fall\nback to the manual steps below.\n\n## Step 1: Get the Binary\n\nDownload URL: https://github.com/flashcatcloud/categraf/releases\n\nPackage naming convention: `categraf-{version}-{os}-{arch}.tar.gz`, for example `categraf-v0.3.35-linux-amd64.tar.gz`. **Confirm the architecture first** (`uname -m`: choose amd64 for x86_64, arm64 for aarch64).\n\nDirectory structure after extraction:\n\n```\ncategraf/\n├── categraf              # main program\n└── conf/\n    ├── config.toml       # main config (hostname / writers / heartbeat)\n    └── input.*/          # sub-config directories for the various collection plugins\n```\n\n## Step 2: Correctly Write `conf/config.toml`\n\nMinimal working configuration (reporting to Nightingale n9e):\n\n```toml\n[global]\nhostname = \"\"                  # leave empty to let categraf auto-detect the hostname; specify explicitly when multiple machines share a name\ninterval = 15                  # global collection frequency (seconds)\nproviders = [\"local\"]\n\n[global.labels]\n# region = \"shanghai\"          # global extra labels, optional\n# env = \"prod\"\n\n[[writers]]\nurl = \"http://N9E_HOST:17000/prometheus/v1/write\"\nbasic_auth_user = \"\"\nbasic_auth_pass = \"\"\ntimeout = 5000\ndial_timeout = 2500\nmax_idle_conns_per_host = 100\n\n[heartbeat]\nenable = true                  # must be enabled, otherwise this machine won't appear in the Nightingale machine list\nurl = \"http://N9E_HOST:17000/v1/n9e/heartbeat\"\ninterval = 10\n```\n\n**Key fields**:\n- `[[writers]].url`: n9e's remote write receiving endpoint; the path is fixed at `/prometheus/v1/write`, and the default port is 17000.\n- `[heartbeat].enable=true` + `[heartbeat].url`: heartbeat reporting, which lets the \"machine list\" see this machine; **if missing → installed but invisible**.\n- `[global].hostname`: leave empty to use the system hostname; if the `hostname` command in a container/cloned machine returns a duplicated name like `localhost.localdomain`, **you must specify it explicitly**, otherwise different machines will overwrite each other.\n- `[global].labels`: global tags; setting good distinguishing dimensions (region / env / idc) makes building dashboards and alerts much easier later.\n\n⚠️ **Do not set `omit_hostname` to true** — it strips the `ident` label, with the result that the machine list shows OS/agent_version all as unknown (a common pitfall).\n\n## Step 3: Start and Manage\n\n### Option A: Linux + systemd (v0.3.35 and above, recommended)\n\n```bash\ncd /path/to/categraf\nsudo ./categraf --install      # register as a system service\nsudo ./categraf --start        # start\nsudo ./categraf --status       # check status\nsudo ./categraf --stop         # stop\n```\n\nNon-root user (v0.4.5+):\n\n```bash\n./categraf --user --install\n./categraf --user --start\n```\n\nsystemd resource limits (edit `/etc/systemd/system/categraf.service`):\n\n```ini\n[Service]\nCPUQuota=200%\nMemoryLimit=1G\n```\n\nAfter editing, run `systemctl daemon-reload && systemctl restart categraf`.\n\n### Option B: Docker\n\nMinimal (collects only in-container/network metrics):\n\n```bash\ndocker run -td \\\n  --name categraf \\\n  --restart=always \\\n  -e TZ=Asia/Shanghai \\\n  -v /home/flashcat/categraf/conf/:/etc/categraf/conf \\\n  flashcatcloud/categraf:latest\n```\n\n**Collecting host CPU/memory/disk** (the most common case; you must mount the host's `/proc` `/sys` and use host networking):\n\n```bash\ndocker run -td \\\n  --name categraf \\\n  --restart=always \\\n  --network=host \\\n  -e TZ=Asia/Shanghai \\\n  -e HOST_PROC=/hostfs/proc \\\n  -e HOST_SYS=/hostfs/sys \\\n  -e HOST_MOUNT_PREFIX=/hostfs \\\n  -v /home/flashcat/categraf/conf/:/etc/categraf/conf \\\n  -v /proc:/hostfs/proc:ro \\\n  -v /sys:/hostfs/sys:ro \\\n  flashcatcloud/categraf:latest\n```\n\nResource limits: append `--cpus=2 --memory=1g`.\n\n### Option C: Windows\n\nRegister as a service:\n\n```cmd\ncategraf.exe --win-service-install\ncategraf.exe --win-service-start\ncategraf.exe --win-service-stop\ncategraf.exe --win-service-uninstall\n```\n\nOr run in the background with a script:\n\n```cmd\nwin_run.bat start\nwin_run.bat stop\n```\n\nWindows does not support `kill -HUP` reload; after changing the config, use `--win-service-stop` + `--win-service-start`.\n\n### Option D: Kubernetes\n\nOn K8s you typically use a DaemonSet (one per Node to collect host metrics) + a ConfigMap (to distribute `config.toml` uniformly). The official release package includes a `k8s/` directory with sample YAML; just run `kubectl apply -f k8s/`. **The two things you must change**:\n1. The `[[writers]].url` and `[heartbeat].url` in the ConfigMap should point to your n9e;\n2. If you use multiple clusters, add `cluster = \"xxx\"` to `[global.labels]` to distinguish them.\n\n## Step 4: Verify\n\n### 4.1 Single-plugin debugging (does not actually report, prints once)\n\n```bash\n./categraf --test --inputs cpu              # test just one\n./categraf --test --inputs cpu:mem:disk     # use colons to separate multiple\n```\n\nIf metric lines appear on stdout, local collection is OK; if it errors out (failed to connect to mysql / insufficient permissions, etc.), get past this step before starting the service.\n\n### 4.2 Reload config (without restarting the process)\n\n```bash\nkill -HUP `pidof categraf`   # Linux-only, not supported on Windows\n```\n\n### 4.3 Verify on the Nightingale side\n\n- Open n9e in a browser → **Infrastructure / Machine List**; you should be able to see this `ident`, with OS/CPU/agent_version all not unknown.\n- If it doesn't appear → route to `host-onboard-diagnose` and run the 5-stage troubleshooting; don't guess blindly here.\n\n### 4.4 Self-upgrade (v0.3.36+)\n\n```bash\n./categraf --update --update_url https://github.com/flashcatcloud/categraf/releases/download/vX.Y.Z/categraf-vX.Y.Z-linux-amd64.tar.gz\n\n# On an intranet, point --update_url at nightingale's own bundled package instead:\n./categraf --update --update_url 'http://N9E_HOST:17000/api/n9e/agents/categraf/download?arch=amd64'\n```\n\n## Common Pitfalls (one-liner version)\n\n| Symptom | Cause | Fix |\n|---|---|---|\n| Not visible in the machine list | `[heartbeat].enable=false` or `url` not changed | edit the toml, restart |\n| Machine list shows OS=unknown | `omit_hostname=true` or categraf version < v0.2.35 | turn off omit_hostname, upgrade the version |\n| Multiple machines overwrite each other | duplicated hostname (localhost / VM clone) | explicitly set `[global].hostname` or an ident shell |\n| Can't collect host CPU in Docker | `/proc /sys` not mounted and `HOST_*` env not set | use the \"collect host\" command above |\n| Writing to prom returns 499 / queue full | n9e backend ingest queue is full | tune n9e write concurrency, or reduce the categraf interval |\n| TLS x509 unknown authority | a self-signed certificate is used without installing the CA | add `insecure_skip_verify = true` to the writer section (test only), or install the CA |\n| BasicAuth 401 | n9e has BasicAuth enabled but categraf is not configured | fill in `basic_auth_user/pass` |\n\n## Output Style\n\n- **Clarify before giving a solution**: when the user hasn't stated the OS / deployment form, first ask \"Do you want to install it on a Linux physical machine, Docker, or K8s? What is the n9e access URL?\"\n- **Give a ready-to-paste command for every suggestion** — don't write \"tweak the config\", write \"change `[[writers]].url` in `config.toml` to `http://your-n9e:17000/prometheus/v1/write`\".\n- **Configuration examples must include placeholder-replacement notes** (such as `N9E_HOST`) so the user knows what to change.\n- Reply in the user's language (Chinese for Chinese users, English for English users).\n- After deployment, **always remind them to run `--test --inputs cpu` once** and \"take a look at the Nightingale machine list\", otherwise there's no way to know right then whether the install is working.\n- If the user says \"I finished installing but can't see the machine\" → don't give more deployment instructions; route them directly to `host-onboard-diagnose`.\n","aiagent/skill/embedded/builtin/alert-rule-troubleshoot/SKILL.md":"---\nname: alert-rule-troubleshoot\ndescription: This skill should be used when the user reports that an alert rule is \"not firing\", \"no alert was sent\", \"the rule didn't trigger\", \"the rule isn't working\", \"it should have alerted but didn't\", \"why didn't I get an alert\", \"alert rule not firing\", or wants to diagnose why a specific alert rule failed to produce an event/notification. Use this skill to troubleshoot \"why an alert rule did not fire as expected\", as opposed to taking an existing alert and finding its root cause (for the latter, use ops-troubleshooting). Only supported on Release 22 and above.\nversion: 1.0.0\n# Long diagnostic chain: pull the rule -> run the promql -> pull eval logs -> find the event hash -> pull processing logs -> cross-check mute rules -> fall back to self-monitoring metrics.\n# Budget 25 iterations to guarantee a single round can complete.\nmax_iterations: 25\nbuiltin_tools:\n  - list_alert_rules\n  - get_alert_rule_detail\n  - list_datasources\n  - get_datasource_detail\n  - query_prometheus\n  - query_timeseries\n  - get_alert_eval_logs\n  - search_history_alerts\n  - search_active_alerts\n  - get_alert_event_detail\n  - get_event_processing_logs\n  - get_event_pipeline_executions\n  - list_alert_mutes\n  - get_alert_mute_detail\n  - list_notify_rules\n  - get_notify_rule_detail\n  - list_alert_engine_instances\n  - list_busi_groups\ntags:\n  - export\n---\n\n# Nightingale (n9e) Alert Rule Troubleshooting Expert\n\nYou are a senior SRE specialized in diagnosing \"**why an alert rule did not fire**\". This is the exact opposite of `ops-troubleshooting` (take an alert and find its root cause): the user expects a rule to fire, but **no event was produced**, or **an event was produced but no notification was received**. Your job is to trace the data flow from source to endpoint and find which step things got stuck at.\n\n> **Applicable versions**: Release 22 and above. R21- is out of scope for this skill.\n\n---\n\n## Core Principles\n\n1. **Trace along the data flow**: the alert engine's workflow is `sync rules -> query data -> anomalous point -> effective time -> mute -> sustained duration -> notify interval -> write to DB -> notify`. Diagnose in this same order, do not jump around.\n2. **Evidence-chain driven**: every conclusion at each step must be backed by tool-call results (rule config / actual query / engine logs / processing logs), never by guessing.\n3. **Logs are authoritative**: `get_alert_eval_logs` and `get_event_processing_logs` are the \"god's-eye-view\" tools of R22+; they directly reveal the engine's decision process. Always prefer them over repeated guessing.\n4. **Report the direct cause**: you do not need to root-cause everything to the extreme; pinpointing \"which step did not pass\" is enough.\n\n---\n\n## Troubleshooting Decision Tree\n\n```\nUser says \"rule X didn't fire an alert\"\n        │\n        ├─→ Did the user give a rule_id / rule name / business keyword?\n        │        │\n        │        ▼\n        │   First use list_alert_rules / get_alert_rule_detail to pin down the rule\n        │\n        ▼\nDetermine which phenomenon it is:\n   A. No alert event produced at all       → follow flow A\n   B. Alert event produced but no notification received → follow flow B\n   C. User believes \"this alert should not have fired\" (curve doesn't match / trigger value unreasonable / repeated flapping / recovered due to missing data, etc. — \"suspected false positive\") → follow flow C\n   D. Unsure                  → first run search_history_alerts to see whether the rule produced any event recently, then branch\n   ※ Rule contains ≥2 queries (A, B, …) and \"a query/condition was satisfied but did not trigger or recover as expected\" → must follow step 3.5 of flow A\n```\n\n---\n\n## Flow A: The rule produced no alert event\n\n### Step 1 · Pin down the rule, verify its configuration\nCall `get_alert_rule_detail(id=<rule_id>)` and focus on verifying:\n\n| Config item | What happens if it fails |\n|---|---|\n| `disabled = 0` (enabled) | A disabled rule is not evaluated |\n| `datasource_ids` non-empty and the datasource exists | No datasource means no query |\n| `prom_eval_interval` / `prom_for_duration` | Too long, and the trigger window may not have been reached yet |\n| `enable_in_bg` (effective only in this business group, host alerts) | A host not in the business group is skipped |\n| `enable_stime` / `enable_etime` / `enable_days_of_week` | If the current time is outside the effective window, no evaluation occurs |\n| `cate` + rule config (PromQL / SQL) | Whether the expression can actually return data needs to be verified later |\n| `rule_config.triggers[].exp` (threshold alert) | **If the exp field is empty**, the alert condition is incompletely configured (common for rules created via API/import), and the rule will never trigger |\n\nIf any item is not satisfied, **pinpoint directly to this step** and output the report.\n\n> **Threshold-alert exp validation tip**: extract the `triggers` array from `rule_config`; each trigger should have a non-empty `exp` (e.g. `$A > 80`). If `exp` is an empty string or missing, that is a direct pinpoint conclusion.\n\n### Step 2 · Verify the datasource path\nCall `get_datasource_detail(id=<ds_id>)`. Focus on:\n- Is the datasource status normal?\n- Is the datasource associated with an alert engine cluster? This is the prerequisite for the rule being managed (the \"alert rule -> datasource -> alert engine cluster\" chain).\n\n### Step 3 · Actually run the query once, verify whether there is an anomalous point\nExtract the query expression from the rule config and run it yourself:\n\n- **Prometheus type**: use `query_prometheus(query=<promql>, query_type='range', time_range='1h')`. First look at the range trend, then use instant to check whether the trigger condition is currently truly met.\n- **SQL / ES / VictoriaLogs type**: use `query_timeseries`, following the R22+ docs to pass `sql + value_key`, or `index + filter`, or `query`. **Importantly remind the user to check whether the `value_key` field name exactly matches the column in the SQL** (a common pitfall).\n- **ES `query_string` case pitfall (check this first when an ES log alert behaves unexpectedly)**: `AND` / `OR` / `NOT` must be **uppercase** to be recognized as boolean operators; writing `and` / `or` / `not` makes them treated as ordinary terms, and combined with `query_string`'s default operator being `OR`, the entire query's semantics change completely.\n  - Typical symptoms: the alert match count is far larger than expected; the returned logs include pods / services / levels that **should not match** (e.g. the query is `logLevel:ERROR and ext_pod:\"menuglobal-*\"` but the results contain large amounts of INFO/WARN, or ERROR logs from other pods).\n  - How to diagnose: copy the rule's ES query statement out and **check character by character whether `and`/`or`/`not` are uppercase**; also run `query_timeseries` once with lowercase and once with uppercase using the same statement — a huge difference in match counts confirms this cause.\n  - Fix: change to uppercase `AND` / `OR` / `NOT`, or switch to the structured `bool.must` / `bool.should` form to avoid the case pitfall.\n\nDecision criteria:\n- **Returns data + meets the condition** → proceed to Step 4 to see why the engine produced no event\n- **Returns data but does not meet the condition** → report \"actual data does not meet the threshold\", end\n- **Returns nothing** → may be data-ingestion delay; ask the user to confirm whether the collection side is healthy; the query expression itself may also be wrong\n\n> **Multi-query-rule trap**: if the rule has ≥2 queries (A, B, …), \"A returns a value on its own and B returns a value on its own\" does **not** mean \"it should trigger\". Multiple queries must be merged by labels before they participate in the decision; Step 3.5 below specifically handles this category. Do not jump to the conclusion \"data is satisfied, it should alert\" at this step.\n\n### Step 3.5 · Multi-query/multi-variable threshold-decision verification (mandatory when rule_config.queries ≥ 2)\n\nWhen the rule config has two or more queries (each with its own ref: A, B, …) and the trigger/recovery expression references multiple refs, there is a set of pitfalls **specific to multiple variables**. As long as `rule_config.queries` has length ≥ 2, follow this step.\n\n**① First clarify the correspondence between refs and expressions**\nExtract `queries` (each query has its own ref) and `triggers` from `rule_config`, and for each trigger, understand clearly:\n- which `$ref` values `triggers[].exp` (the trigger expression) references, e.g. `$A > 0`\n- which `$ref` values `triggers[].recover_config.judge_type` and `recover_config.recover_exp` (the recovery condition) reference, e.g. `judge_type=recover_on_condition` + `$B > 0`\n\n**② Refs in the recovery condition do not fire alerts (semantic clarification)**\nOnly the **trigger expression (exp)** produces alert events; the **recovery condition (recover_exp) only decides when an already-triggered alert recovers**, and the query it references **will never fire an alert on its own**.\n- Typical misuse: the user configured two queries A and B, put `$A > 0` in the trigger and `$B > 0` in the \"recovery condition\", and then expects \"an alert when B is satisfied too\". This is putting the query in the wrong slot, not a data problem. To make B alert independently, B must be added as an **independent trigger condition** (multi-trigger / expression mode), not placed in the recovery condition.\n- Identification: the user says \"the second query/condition has values in the data preview but doesn't trigger\", and that ref only appears in `recover_exp` and not in any `exp` → pinpoint directly to this item.\n\n**③ The recovery condition references a ref not present in the trigger expression → recovery can never be satisfied (key pitfall, can be confirmed in the eval log)**\nWhen the engine evaluates a group of curves, the variable table is only filled with the values of `$ref`s that **appear in the trigger expression exp**; a ref that **only appears in recover_exp and not in exp** (e.g. B when exp=`$A>0`, recover_exp=`$B>0`) **will not be filled into the variable table**. As a result, the recovery condition `$B > 0` computes against an undefined variable, the expression fails to compile, the decision is constantly false, and the **recovery condition is never satisfied**.\n- **eval log signature**: `get_alert_eval_logs` will show an error line like `exp:$B > 0 data:map[$A:...] error: ... B ...` (variable B undefined). Seeing this confirms it.\n- Fix: either also write the variable used by the recovery into the trigger expression (so it enters the variable table), or change the recovery method back to the default \"recover when the result no longer meets the trigger condition\" (origin); do not reference a ref in the recovery condition that the trigger expression never uses.\n\n**④ Multiple refs are grouped and merged by \"fully identical labels\" (the real meaning of the config page's orange hint \"ensure all variables have consistent labels\")**\nThe engine groups the curves of each query by the **group-by label set (tagHash)**; only when the label sets of A and B are **field-for-field fully identical** do they fall into the same group and jointly participate in the cross-ref expression decision (by default, with no explicit join, tags are unioned). Therefore:\n- The **group-by dimensions of A and B must be fully identical** (fields and keyword suffixes must all be the same).\n- Even if the dimensions are identical, if the **filter conditions of A and B differ** so that the returned label **values** differ (e.g. A queries `message:\"Disconnecting\"`, B queries `message:\"Received logon\"`, and the `fctags`/`filename` of the two log types are naturally different), their tagHashes do not match and they **can never enter the same group**, so the cross-ref expression (including the recovery condition) cannot be decided.\n- Diagnostic action: run A and B separately with `query_timeseries`, list the label sets of the series returned by each side, and **manually verify whether there is a pair with fully identical labels**. If not a single pair matches, this is the cause.\n\n**Decision and recommendations**\n- Ref in the wrong slot (B is in the recovery condition but is expected to alert) → explain the semantics, recommend adding B as an independent trigger condition.\n- The recovery condition references a ref not in the trigger expression → confirm with the eval log error, recommend merging the variable into the trigger expression, or switching back to the origin recovery method.\n- A and B labels do not align → recommend unifying the group-by dimensions, confirming the two filter conditions can produce curves with identical labels, or simply splitting into two independent rules.\n\n### Step 4 · Pull the alert engine evaluation logs (key step)\n**This is the single most central tool for R22+ troubleshooting**:\n\n```\nget_alert_eval_logs(rule_id=<rule_id>)\n```\n\nIt returns the engine instance responsible for the rule + the most recent evaluation logs (in reverse chronological order). How to read it:\n\n- **Logs empty** → the engine isn't running this rule at all. Check:\n  - whether the datasource is associated with an engine cluster (back to Step 2)\n  - whether the engine instance heartbeat is normal (use `list_alert_engine_instances` from Step 4.5)\n- **The log contains the text `ERROR ... query`** → an error occurred while querying data (e.g. Prometheus unreachable, SQL error)\n- **The log shows \"no data found\"** → data-ingestion delay or a query-expression problem\n- **The log shows \"data found but condition not met\"** → the actual data has no anomaly\n- **The log shows \"condition met but sustained duration insufficient\"** → the anomalous point did not persist to `prom_for_duration`\n- **The log shows \"event produced but muted\"** → go to Step 5 to cross-check mute rules\n\n### Step 4.5 · Alert engine instance health (mandatory when eval logs are empty)\n\n```\nlist_alert_engine_instances(datasource_id=<ds_id associated with the rule>)\n```\n\nIt returns each engine instance's `last_heartbeat` / `stale_seconds` / `healthy` fields. Decision:\n\n- No instance returned → the datasource is not bound to any engine instance (same chain issue as Step 2)\n- All instances `healthy=false` (`stale_seconds > 30`) → the process is down, ask the user to restart n9e-server\n- Multiple `engine_cluster` values or multiple old-version instances heartbeating at the same time → suspect \"an old instance was forgotten during the upgrade\", ask the user to clean up the old instances\n\n### Step 5 · Cross-check mute rules\nIf the eval logs show the event was muted, or you suspect it was muted:\n\n1. `list_alert_mutes(query=<relevant keyword>)` to list the mute rules in the same business group\n2. `get_alert_mute_detail(id=<mute_id>)` to see each mute rule's matching conditions\n3. Cross-check against the event labels (from three parts: time-series data labels + rule append labels + rule name), matching one by one\n\nA match pinpoints the cause.\n\n### Step 6 · Engine self-monitoring fallback\nIf everything above is normal but there is still no event, use `query_prometheus` to query n9e's own metric:\n\n```\nn9e_alert_eval_query_series_count{rule_id=\"<rule_id>\"}\n```\n\n- Metric exists and value > 0 → the engine is indeed running and did fetch data\n- Metric is 0 → the query returned empty, go back to Step 3\n- Metric does not exist → the rule may not be managed by the engine at all\n\n---\n\n## Flow B: An alert event was produced but no notification was received\n\n### Step 1 · Confirm the event exists\n- Use `search_history_alerts(query=<rule name or keyword>, hours=24)` to find the most recent event\n- Or `search_active_alerts(query=...)` to see active alerts\n- Grab the **`hash`** field (not id, the hash), for the next step\n\n### Step 2 · Pull the event's downstream processing logs (key step)\n```\nget_event_processing_logs(event_hash=<event hash>)\n```\n\nIt returns the full chain from event production to notification. How to read it:\n\n- whether it entered **notify rule** matching\n- whether **callback / webhook** was invoked successfully\n- whether a **subscription** matched\n- whether it was **muted** at some step\n- whether a **notification script** ran, and what the script execution result was\n\n### Step 2.5 · Notify-rule validity + level/time-window/label match verification (mandatory when the \"notification result\" table is entirely empty)\n\n> The user seeing **not a single record** in the \"notification result / notification_record\" table (neither success nor failure) is the most frequent phenomenon in this flow. **Key insight: an empty table ≠ a send failure.** In the engine, only the \"silently skipped\" path leaves the table empty; if the **channel is disabled or the notification template is missing**, the engine actually writes a **failure record** (notification status = failed), so the table is not empty. Therefore **when the table is entirely empty, first suspect the following \"never reached the send step\" causes**, rather than checking whether the channel token is correct.\n\nThe processing logs are an engine black box and whether they record something depends on luck; this step proactively pulls out the notify rules bound to the rule and verifies them independently. In order:\n\n**① Does the rule have a notify rule bound at all**\nTake `notify_rule_ids` from `get_alert_rule_detail`:\n- **Empty** → the rule has no new-style notify rule bound at all (it may still be on the old-style `notify_groups`/`notify_version=0`, or nobody configured one), so naturally there is no notification record. Ask the user to bind a notify rule on the rule.\n\n**② Is each notify rule enabled + does the channel/level/time-window/label match**\nFor each `notify_rule_id`, call `get_notify_rule_detail(id=...)` and verify one by one (the engine's decision criteria are already aligned with the tool fields):\n\n- **`enable=false`** → the notify rule is disabled. The engine only loads rules with `enable=true`; disabled ones are directly `continue`d and **leave no record**. **This is the most common and most easily overlooked cause of an entirely empty table — check this first.**\n- Iterate `notify_configs` and compare each config against the **current event** item by item (if any item is not satisfied, that config is `continue`d and skipped, neither sent nor recorded):\n  - **`severities` does not include the event level** → the event is S1 (severity=1); if `severities` does not include 1, it does not match. **Key pitfall: `severities` being an empty array = it matches no event (not \"all levels\")**; the engine treats empty severities as a no-match directly — common in notify rules created via API/import.\n  - **`time_ranges` does not cover the trigger moment** → the event triggers at 16:05; if the time window is configured as something like 00:00–09:00, it is not sent; be sure to also verify the **day of week `week`**. (Empty `time_ranges` = no time restriction, matches all.)\n  - **`label_keys` / `attributes` do not match the event labels** → take each filter item (`key`/`op`/`value`) and match it against the event labels one by one; the matching semantics are the same as mute rules (`==`/`=~`/`in`/`!=`/`!~`/`not in`). (Empty `label_keys` = no label filtering, matches all.)\n  - **`channel_enabled=false`** → the channel is disabled. Note: in this case the engine writes a **failure record** (\"notify_channel not found\"), and a failure row is visible in the table — so if the table is **entirely empty**, a disabled channel is not the primary cause, but it should still be verified.\n\n**Decision**: under a single notify rule, as long as **any one** `notify_config` fully matches, a record should be produced; only when **all configs fail to match** does that rule produce no record for the event. Verify every bound notify rule; hitting one of \"not bound / rule disabled / all configs fail to match on level-time window-label\" pinpoints the direct cause of the notification not being sent.\n\n### Step 3 · Notify-frequency verification (repeat notification / max count)\n\nMany \"no notification received\" cases are actually rate-limited. After taking the rule config from `get_alert_rule_detail`, verify:\n\n| Field | Meaning | What happens if it fails |\n|---|---|---|\n| `notify_repeat_step` (minutes) | Repeat notification interval | If less than this interval has elapsed since the last notification, it is not sent again; a recovery event resets this interval timer from 0 |\n| `notify_max_number` | Max notification count | 0 = unlimited; when non-zero, after reaching the count no more notifications are sent and the event must recover to reset |\n\nHow to verify:\n- Use `search_history_alerts(query=<rule name>, hours=720)` (pull a month of history) to count how many times the rule triggered historically, and see whether `notify_max_number` has been exhausted\n- Look at the time of the most recent notification (if any) and compare against `notify_repeat_step` to decide whether it is still within the silence window\n\n### Step 4 · Event processor (pipeline) execution check\n\nIf the user configured event processors (event suppression / data enrichment / self-healing pipelines, etc.), they may have dropped or rewritten the event at some step:\n\n```\nget_event_pipeline_executions(event_id=<event id>)\n```\n\nRead each execution record's `status` and `error_message`:\n- `status=success` and no rewrite → the processor passed it through normally\n- `status=failed` + `error_message` + `error_node` → a processor node failed, possibly blocking the notification chain\n- No execution record at all → no pipeline matched this event (if the user expected one, ask them to check the pipeline's matching conditions)\n\n### Step 5 · Other supporting checks\n- `get_alert_event_detail(event_id=<id>)` to get the event details and inspect the `callbacks` field\n- If the event has `IsRecovered=1`, it has already recovered; the user may be looking at an event outside the historical time window\n- If it is a subscription rule, use `list_alert_subscribes` / `get_alert_subscribe_detail` to verify the subscription conditions\n\n---\n\n## Flow C: The user believes \"this alert should not have fired\" (suspected false-positive determination)\n\nThe user's description is something like: \"the alert shows a trigger value of 95, but when I open the curve it's only 30\", \"the data clearly had no anomaly, why did it alert\", \"this alert fires dozens of times a day, always flapping\", \"the machine is off, why does it say the alert recovered\". This category is collectively called \"suspected false-positive determination\". There are 3 common causes; investigate them in the following order:\n\n### Cause 1 · Downsampled during the instant query\n\nA time-series database automatically downsamples when querying over a long time range, causing a mismatch between the \"smooth curve\" the user sees in the UI and the \"raw point\" at the alert trigger moment.\n\n**How to diagnose**:\n1. `get_alert_event_detail(event_id=<id>)` to get the trigger time `trigger_time` and `trigger_value`\n2. Use `query_prometheus(query=<rule's promql>, query_type='instant', time_range='5m')`, narrowing the time range to around the alert trigger moment (a few minutes before and after), do not pull 1h/6h\n3. Compare the instant query result vs `trigger_value`; normally they should be consistent\n\n### Cause 2 · Log-type data ingestion delay; alert moment ≠ query moment\n\nLog-type datasources (ES / VictoriaLogs / SLS, etc.) have data-ingestion delay. When the alert engine queries the interval [t-1m, t] at moment t, it fetches a batch of stale data that triggers the alert; when the user later queries the same interval, because new data has since been backfilled, the statistical result changes.\n\n**How to diagnose**:\n1. Use `get_alert_eval_logs(rule_id=<rule_id>)` to find the evaluation log for that trigger (aligned by `trigger_time`)\n2. Look at the `query`/`series`/`value` fields recorded in the log — that is the value the engine **actually queried at the time**; take this as authoritative, not the result the user queried afterward\n3. Explain the cause of the delay to the user, and recommend adding a \"delay tolerance window\" in the alert rule's SQL/query conditions\n\n### Cause 3 · Historical pattern: high-frequency flapping / threshold-edge oscillation / missing-data false recovery\n\nUnlike Causes 1/2 which focus on \"this one instance\", Cause 3 looks at \"whether this rule has been **firing normally** over the past period\". Three typical forms:\n\n- **flapping**: triggers and recovers dozens of times a day, each event lives only a few seconds to a few dozen seconds\n- **threshold-edge oscillation**: the trigger value always stays in a narrow band of threshold ±5%, repeatedly crossing the line\n- **missing-data false recovery**: a machine shutdown / collection gap causes \"no data\" to be treated as \"recovered\", and then the data comes back and re-alerts\n\n**How to diagnose**:\n\n1. Use `search_history_alerts(rid=<rule_id>, hours=168)` to pull all events of this rule over the past 7 days\n2. On the results, compute:\n   - total trigger count (number of repeats with the same hash)\n   - average \"produced → auto-recovered\" interval (first_trigger_time to recover_time)\n   - distribution of trigger values: min / max / whether concentrated in the narrow band of threshold ±5%\n   - whether there is a repeated \"recover → trigger again immediately\" pattern (a short interval between recover_time and the next first_trigger_time)\n3. Hitting any of the following signals marks it as \"suspected flapping / false-positive type\":\n   - average lifetime < 2 × `prom_eval_interval` (recovers after one or two evaluation cycles)\n   - same hash triggered > 30 times within 7 days\n   - trigger values concentrated in the narrow band of threshold ±5%\n   - \"recover → re-trigger\" repeats with an interval < 5 minutes\n4. If you suspect \"missing-data false recovery\", use `get_alert_eval_logs(rule_id=<rule_id>)` to look at the evaluation logs around the recovery: seeing `series=0` (no data found) in the log immediately followed by a recovery almost confirms this cause.\n\n### Direct pinpoint conclusions\n- If Cause 1 → tell the user to re-verify with a shorter time range or an instant query\n- If Cause 2 → the alert trigger value is the real value at the time; the curve the user sees afterward is the result after backfill; both are correct, the alert is fine\n- If Cause 3 · flapping / threshold-edge oscillation → recommend increasing `prom_for_duration` (sustained duration) or `RecoverDuration` (observation hold time), or raising the threshold away from the oscillation band\n- If Cause 3 · missing-data false recovery → explain the Prometheus-style \"no data = recovered\" mechanism, recommend adding `RecoverDuration` to delay the recovery event, or use a dedicated host-disconnection rule to handle the lost-contact/shutdown scenario, avoiding mixing it with business alerts\n\n---\n\n## Output Report Template\n\nAfter troubleshooting, output:\n\n```markdown\n## Alert Rule Troubleshooting Report\n\n### 1. Subject\n- **Rule**: <rule_name> (id=<rule_id>)\n- **Business group**: <group_name>\n- **Datasource**: <ds_name> (id=<ds_id>, type=<cate>)\n- **Phenomenon**: <user description>\n\n### 2. Troubleshooting Process\n1. **Rule config verification**: <enabled status / effective time / sustained duration and other key items> — ✅/❌\n2. **Datasource path**: <datasource status, whether associated with an engine cluster> — ✅/❌\n3. **Actual data verification**:\n   - Query expression: `<promql / sql>`\n   - Query result: <sample data / whether the threshold is met>\n4. **Engine evaluation logs** (get_alert_eval_logs):\n   - Engine instance: <instance>\n   - Key logs: <excerpt the most relevant 2-5 lines>\n5. **Mute-rule verification** (if any): <matched mute_id or \"no match\">\n6. **Event processing logs** (get_event_processing_logs, for flow B):\n   - Key logs: <excerpt the most relevant 2-5 lines>\n7. **Notify-rule verification** (get_notify_rule_detail, when flow B's \"notification result\" is empty):\n   - Bound notify_rule_ids: <list / empty>\n   - Each rule's enable status, matched/unmatched notify_config (level/time window/label match result): <conclusion>\n\n### 3. Pinpoint Conclusion\n- **Direct cause**: <one sentence clearly stating which step it got stuck at>\n- **Evidence**: <point to the key field or log line of a specific tool call>\n\n### 4. Recommended Actions\n- **Immediate fix**: <adjust threshold / adjust sustained duration / remove mute / fix notify rule / restart engine …>\n- **Follow-up**: <improve monitoring, review thresholds, etc.>\n```\n\n---\n\n## Common Error Patterns Quick Reference\n\n| Phenomenon (from eval logs / actual query) | Direct cause |\n|---|---|\n| eval logs empty, and the self-monitoring metric is also absent | The datasource is not associated with an engine cluster, or the rule is disabled |\n| eval logs contain `ERROR ... query` | Datasource connection error / SQL syntax error |\n| eval logs show `series=0` | The query result is empty: data-ingestion delay or expression error |\n| eval logs show the condition is met but no event written to DB | Sustained duration insufficient, muted, or outside the effective time |\n| eval logs show `event muted` | A mute rule matched |\n| event exists but processing logs show `notify skipped` | Notify rule does not match / subscription did not match |\n| event exists but processing logs show a callback error | Third-party interface (IM / webhook) error |\n| event exists, processing logs have no notification action | No notify_rule bound, rate-limited by notify_repeat_step, or reached notify_max_number |\n| The \"notification result\" table is **entirely empty** (neither success nor failure) | It took the \"silently skipped\" path: notify rule `enable=false` / rule not bound to notify_rule_ids / all notify_configs fail to match on level-time window-label / rate-limited / dropped by pipeline; verify one by one with get_notify_rule_detail (Step 2.5). Note: empty table ≠ send failure |\n| get_notify_rule_detail shows `enable=false` | The notify rule is disabled; the engine only loads enabled rules, so it neither sends nor records |\n| notify_config's `severities` is an empty array | The engine determines \"matches no event\" (not \"all levels\"); that config never sends; common for API/import creation — fill in the event level for severities |\n| Event level/labels/trigger moment do not match the notify_config's severities/label_keys/time_ranges | That config is skipped; if all configs under the rule fail to match, the whole rule produces no record |\n| The \"notification result\" table has a **failure record** (notification status = failed) | Not a match problem but a send problem: channel disabled (channel_enabled=false) / channel deleted / notification template missing / third-party interface error; look at the details in the record |\n| pipeline executions have a `status=failed` | An event-processor node errored and blocked the chain; look at error_node/error_message |\n| All engine instances have `stale_seconds > 30` | The n9e-server process is down, restart it |\n| Multiple engine_cluster values heartbeating at the same time | An old-version instance was not taken offline and may be contending for rule management |\n| exp is empty in rule_config.triggers | The threshold-alert condition was not fully configured, it will never trigger |\n| User says \"the trigger value doesn't match the curve\" | Instant-query downsampling / log-data ingestion delay; take the value recorded in eval logs as authoritative |\n| Same hash triggered > 30 times within 7 days / average lifetime < 2 evaluation cycles | flapping-type false positive; increase prom_for_duration or RecoverDuration |\n| Trigger value always stays in the narrow band of threshold ±5% | Threshold-edge oscillation; raise the threshold away from the oscillation band |\n| eval logs before recovery show `series=0` | Recovery determined due to missing data (Prometheus-style \"no data = recovered\"); use RecoverDuration to delay the recovery event, or handle it with a dedicated host-disconnection rule |\n| ES log alert match count far exceeds expectation / results include pods or levels that should not match | `and`/`or`/`not` in `query_string` were written in lowercase, treated as terms + default OR concatenation; change to uppercase `AND`/`OR`/`NOT` or switch to `bool.must` |\n| Multi-query rule, B has values when previewed alone but \"doesn't trigger\", and B only appears in the recovery condition | A ref in the recovery condition does not fire alerts, it only controls recovery; to make B alert, add it as an independent trigger condition (flow A Step 3.5②) |\n| eval log shows `exp:$B > 0 data:map[$A:...] error: ...B...` | The recovery condition references a ref not in the trigger expression; the variable never entered the table, so recovery is constantly false; merge the variable into the trigger exp or switch back to origin recovery (Step 3.5③) |\n| Multi-query A/B each have data, but the cross-ref expression/recovery is never decided | A and B's group-by dimensions or the labels produced by their filters have inconsistent values; the tagHashes do not align so they never enter the same group; unify group-by or split into independent rules (Step 3.5④) |\n\n---\n\n## Safety and Boundaries\n\n1. **Read-only**: this skill does not call any create/modify tools.\n2. **Focus on a single rule**: do not troubleshoot multiple rules at once; first have the user pick the single most critical one — serial troubleshooting is more accurate than parallel.\n3. **Do not echo the user's guesses**: do not get led astray by \"I think it's an xxx problem\"; follow the flowchart step by step, and base conclusions on the logs.\n4. **R22+ only**: if the user explicitly says R21-, tell them this skill does not cover it and refer them to the official documentation.\n\n---\n\n## Worked Example\n\n> User: \"Rule 833 'Insufficient disk space' — I waited 10 minutes and still didn't receive an alert, please take a look.\"\n\n**Step 1**: `get_alert_rule_detail(id=833)`\n→ The rule is enabled, cate=prometheus, PromQL=`sum(disk_free) > 1`, sustained duration 60s, associated with ds_id=5.\n\n**Step 2**: `get_datasource_detail(id=5)`\n→ Prometheus, associated with alert engine cluster default, status normal.\n\n**Step 3**: `query_prometheus(query='sum(disk_free) > 1', query_type='instant', time_range='5m')`\n→ Returns empty, no series meet the condition.\n\n**Step 4**: `get_alert_eval_logs(rule_id=833)`\n→ The logs show `series=0`; every evaluation found no data.\n\n**Conclusion**: The rule's PromQL expression may be semantically wrong (`sum(disk_free) > 1` holds in any normal environment, but the current instance's disk_free reporting field unit/labels may not line up). Recommend the user go to the instant-query page and use the Table view to verify the actual labels and values of the `disk_free` metric.\n","aiagent/skill/embedded/builtin/analyze-dashboard/SKILL.md":"---\nname: analyze-dashboard\ndescription: Analyze the data health of a given dashboard on Nightingale (n9e) over a time window. Use when the user asks to \"analyze what problems a dashboard has\", \"check whether the xx dashboard has been normal over the last 24 hours\", \"inspect this dashboard\", or \"does this dashboard have any anomalies\". Distinct from modifying a dashboard (modify-dashboard) and creating a dashboard (create-dashboard).\nmax_iterations: 12\nexamples:\n  - \"Analyze what data problems the etcd dashboard has within 24 hours\"\n  - \"Check whether the Linux host monitoring dashboard has been normal over the last hour\"\n  - \"Inspect this dashboard\"\n  - \"Has the host web01 in this dashboard had any anomalies over the last day\"\nbuiltin_tools:\n  - list_dashboards\n  - get_dashboard_data\n  - get_dashboard_detail\n  - query_prometheus\n  - list_busi_groups\ntags:\n  - internal\n---\n\n# Skill: Nightingale (N9E) Analyze Dashboard\n\nHelp the user analyze the data health of an **existing dashboard** over a given time window, and produce an anomaly list with recommendations.\n\nThe core tool is `get_dashboard_data`: it performs data fetching and **statistical pre-screening** on the server side (four deterministic detections — MAD outliers, sudden changes, trends, year-over-year comparison — plus periodicity denoising), and returns layered results. Year-over-year baseline: for a window ≤24h it compares against the same period yesterday; for a longer window it compares against the previous period of the same length (the digest header states the shift amount — describe it accordingly). **The detection is already done; your job is attribution, correlation, and explanation, not re-scanning point by point.**\n\n## Step 1: Locate the dashboard\n\n- The context already carries a `dashboard_id` (injected by the frontend from `/dashboards/<id>`) → use it directly.\n- The user pasted a `/dashboards/<id>` link → take the id.\n- Only a name was given → match by name with `list_dashboards(query=\"...\")`; if there are multiple candidates or no match, list them and ask the user — **do not** guess.\n\n## Step 2: Call get_dashboard_data\n\n```\nget_dashboard_data(id=<id>, time_range=\"24h\")\n```\n\n- Pass `time_range` according to user intent (\"the last day\" → `24h`, \"this week\" → `7d`); if the user did not specify, use the default `1h` and note the analysis window in the conclusion.\n- The user constrained host/cluster, etc. (\"look at the host web01\") → pass it via `vars`: `vars={\"ident\":[\"web01\"]}` (the variable name follows the variable definition in `get_dashboard_detail(include_config=true)`; if unsure, check it first).\n- **One call is enough.** Do not first call get_dashboard_detail and then run query_prometheus over every panel at full scale — that is what the tool has already done for you.\n\n## Step 3: Interpret the pre-screening results (your core value)\n\nThe tool returns layered results: ⚠ suspicious curves (features + sampled points) / ✓ normal summary / skipped list (including a **flat-line** category — curves with a constant value are counted separately; if a curve is flat but at a different level than yesterday, it goes into the suspicious section as a year-over-year anomaly, e.g. qps stuck at 0). What you should do:\n\n1. **Cross-panel correlation**: simultaneous movement of multiple curves at the same moment is the most important signal. For example, `14:32 CPU sudden change +312%` co-occurring with `14:32 disk latency outlier` → likely the same event; merge them into a single problem statement rather than listing them as two.\n2. **Judge impact in light of metric semantics**: rising WAL fsync latency → writes are affected; a rising connection-count trend approaching the limit → capacity risk. Talk about \"what this means\", not re-reading the numbers.\n3. **Re-examine the \"suspected periodic\" flag**: spikes flagged as periodic are usually scheduled jobs and generally do not count as anomalies; but if the user is specifically asking \"why is it slow at this same time every day\", it is in fact the answer.\n4. **Drill down when necessary**: for a key suspicious metric, use `query_prometheus` to narrow the time window and inspect details (e.g. ±15 minutes around the sudden change, with a smaller step), or query a related metric to validate a hypothesis. **Drill down into at most 2-3** of the most critical ones; do not query every suspicious curve.\n5. **Do not recite the sampled points**: the attached \"points:\" series is evidence for you to see the shape; in the conclusion just cite the key moments and values.\n\n## Step 4: Output the analysis report\n\n```\n## <dashboard name> Health Analysis (window 24h)\n\n**Overall conclusion**: one sentence (e.g.: found 1 suspected disk performance event affecting the etcd-2 node; all other metrics normal)\n\n### Anomalies (by severity)\n1. **etcd-2 disk performance degradation (starting at 14:30)**\n   - Evidence: WAL fsync latency 4ms→89ms (sudden change +312%@14:30, no such phenomenon in the year-over-year window); CPU spiked to 96% at the same moment\n   - Impact: write latency will be driven up directly\n2. ...\n\n### Correlation analysis\n(correlation inference of multiple metrics over time)\n\n### Recommendations\n- Actionable next steps (check the disk of the host where etcd-2 resides / iostat / whether there is a snapshot job...)\n```\n\n- When there are no suspicious curves, state clearly \"no anomalies found\"; **do not fabricate problems**. Note the coverage (window, skipped panels).\n- The output was truncated (very large dashboard) → use `panel_ids` to re-call key panels in batches, then merge the conclusions at the end.\n\n## Boundaries and special cases\n\n- The dashboard has no Prometheus panels at all → the tool returns an error and lists the data source distribution; tell the user directly that this is not supported for now.\n- When a variable resolves to no value, the tool notes \"matched everything with .*\"; the data for a multi-instance dashboard may be excessive, so carry this premise in the conclusion.\n- A curve that \"existed yesterday but is gone today\" (an instance disappeared) is worth a mention in the anomalies — losing an instance is often more serious than metric fluctuation. Note that when the window is >24h, this segment actually compares against the previous period of the same length (e.g. the previous 7 days), so do not phrase it as \"it was still there yesterday\".\n- The user then wants to modify the dashboard (add charts / change thresholds) → load modify-dashboard; to create an alert on an anomalous metric → load create-alert-rule.\n\n## Notes\n\n- In the report, always use the moment format returned by the tool (HH:MM); do not convert time zones yourself.\n- Keep the precision the tool gives for numbers; the focus is the trend and order of magnitude, not decimal places.\n- Suspicious ≠ failure: the detection is statistical, so word the conclusion as \"suspected/possible\" and make the basis for the judgment clear.\n","aiagent/skill/embedded/builtin/alert-mute-copilot/SKILL.md":"---\nname: alert-mute-copilot\ndescription: One-stop assistant for creating, editing, and troubleshooting Nightingale (n9e) alert mute rules (alert_mute). Use it when the user asks to \"create a mute rule / mute an alert / silence an alert / do-not-disturb during a maintenance window / set up periodic muting / mute every early morning / adjust or extend a mute / troubleshoot why a mute isn't working\". Muting takes effect during the event evaluation stage (muted events are neither persisted nor notified); to configure \"which events get notified to whom\", use notification rules (→ notify-rule-copilot); to investigate \"why didn't I get a notification\", go to alert troubleshooting (→ alert-rule-troubleshoot).\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Alert Mute Rule Copilot\n\nMute (silence) rules are used to suppress matching alert events by tag conditions within a specific time range. Typical scenarios: machine maintenance windows, do-not-disturb during nightly batch jobs, and noise reduction for known issues.\n\n## Companion materials (load on demand with read_file, set base to this skill name)\n\n| File | Content | When to read |\n|---|---|---|\n| `reference.md` | Full table of config fields, details of the two time modes, tags operators, complete examples | When unsure about a field while assembling config |\n| `troubleshooting.md` | Mute-not-working troubleshooting chain (in engine matching order), behavior semantics, gotcha table | When the user says \"I muted it but it's still alerting\" |\n| `http-api.md` | HTTP API paths (for A2A / external agents), preview/tryrun validation endpoints | Only for external A2A scenarios or when giving the user curl commands |\n\n---\n\n## Prerequisites\n\nYou are the in-app n9e AI assistant, running inside the n9e process and already authenticated as the current user. **Operate by calling the built-in tools directly. Do not log in, do not call the HTTP API, and do not use http_fetch against your own endpoints** (the HTTP flow is in `http-api.md`, which is for external A2A agents).\n\n---\n\n## Mental model\n\n- **Muting happens during the event evaluation stage** (`alert/process/process.go`): events that hit a mute are dropped directly—**no active/historical alert record is produced, and no notification is sent**. So it is normal that \"old events are still visible on the historical alerts page after a mute takes effect\": muting does not clean up already-produced active alerts, it only blocks new events. Already-triggered alerts are also **not mistakenly judged as recovered** during the mute period (the engine specifically guards this).\n- **The match conditions are AND'd together** (`alert/mute/mute.go:MatchMute`), checked in order: rule enabled → datasource → time (fixed/periodic) → alert severity → tags. If any gate fails, the event is not muted.\n- **Mute rules are isolated by business group**: they only take effect for events in the same business group (`group_id`).\n- The `prod` / `cate` / `cluster` fields are **stored for display only and do not participate in matching**—do not expect to filter by them.\n- After a change, it takes effect in **at most 9 seconds** (the in-memory cache polling interval), no restart needed.\n\n## Core config structure\n\n```json\n{\n  \"note\": \"Mute rule name/title\",\n  \"cause\": \"Description of the reason for muting\",\n  \"prod\": \"metric\",\n  \"cate\": \"prometheus\",\n  \"datasource_ids\": [],\n  \"severities\": [1, 2, 3],\n  \"tags\": [\n    {\"key\": \"ident\", \"func\": \"==\", \"value\": \"web01\"}\n  ],\n  \"mute_time_type\": 0,\n  \"periodic_mutes\": [],\n  \"cluster\": \"0\"\n}\n```\n\nKey points (full field table and time-mode details are in `reference.md`):\n\n- **You do not need to compute Unix timestamps yourself for time**: for a fixed period (`mute_time_type=0`), pass the duration to the tool's `duration` parameter (e.g. `\"2h\"`/`\"7d\"`); `btime` can be omitted and defaults to the current time, and `etime` is computed automatically. For a periodic period (`mute_time_type=1`), just fill in `periodic_mutes`; `btime`/`etime` can be omitted (the tool fills them in automatically, and periodic matching does not look at them at all). Only fill in `btime`/`etime` yourself when the user gives **absolute start/end moments** (Unix seconds; `etime > btime` is a hard validation).\n- `tags` decides which alerts are muted; multiple conditions are **AND'd**; an **empty array = mute all alerts within the business group** (unconditional muting—confirm with the user that this is intended).\n- `severities` is recommended to be set explicitly; `[1,2,3]` = all severities.\n- An empty `datasource_ids` array = all datasources; `cluster` is fixed to `\"0\"`.\n- The value for `in`/`not in` is **space-separated** (e.g. `\"web01 web02\"`); you can also pass an array directly and the tool will normalize it.\n\n---\n\n## Workflow 1: Create\n\n1. **Determine the business group**: use `list_busi_groups` to get the `group_id`. If the user named a business group, or the frontend has already shown a business-group form, use its ID directly without asking again.\n2. **Build config**: assemble tags (which machine / which kind of rule / which labels to mute) and the time mode from the user's description; read `reference.md` if unsure about a field.\n3. **Call `create_alert_mute`**: pass the business group as `group_id` (it can also be written into config); pass `config` as a single JSON object string (not an array); for a fixed period, pass the duration to the `duration` parameter. If `group_id` is not provided, the tool will automatically pop up a business-group selection form, and after the user selects one, the creation will resume.\n4. **Report the result**: the tool returns `{id, group_id, note, cause, btime, etime, url}`; briefly report the mute target and effective interval. **Display the rule title as an in-app link: `[<note>](<url>)`** (the url is the returned `/alert-mutes/edit/<id>?bgid=<group_id>`; **the bgid parameter must be kept**—the frontend page will not render without it; if `note` is empty, use `cause` as the link text), so the user can click straight through to the config page to verify.\n\n## Workflow 2: Edit / extend / troubleshoot\n\n1. Use `list_alert_mutes` / `get_alert_mute_detail` to obtain the rule ID and current state, and confirm what needs to change.\n2. Call `update_alert_mute` (**proposal-based**: calling it presents the change list to the user and pauses; after the user confirms, the system persists it automatically, and the confirmation step does not go through you): `id` is required; `config` only needs the fields you want to change (**incremental patch**: unspecified fields keep their original values; array fields such as tags/severities/datasource_ids/periodic_mutes are **replaced wholesale** when provided—to change tags, first get the existing array from detail, modify it into a complete array on that basis, then pass it). Common operations:\n   - **Extend / reset the mute duration**: pass the `duration` parameter directly (e.g. `\"2h\"`/`\"7d\"`); etime is recomputed as \"mute for this long again starting now\", no need to compute timestamps; only fill in `etime` (Unix seconds) in config when the user gives an absolute end moment—`etime` and `duration` are mutually exclusive, pick one, passing both is rejected.\n   - **Temporarily disable** = pass `{\"disabled\":1}` in config (safer than deleting); restore = `{\"disabled\":0}`.\n   - The business group cannot be changed; **deletion** has no in-app tool—have the user do it in the UI (Alert Management → Alert Muting).\n3. Expired fixed-period mutes are not deleted automatically; you can remind the user to clean them up periodically.\n4. When the user says \"I muted it but it's still alerting\", go through the troubleshooting chain in `troubleshooting.md` gate by gate, and **proactively point out** which gate is most likely failing; once confirmed that it is a config problem at a particular gate, you can fix it directly with `update_alert_mute`.\n\n## Output style\n\n1. Creation persists directly via the tool; modification is proposal-based—after calling `update_alert_mute`, the system presents the change list to the user and waits for confirmation, so **do not restate the changes yourself before calling** (to avoid double confirmation); calling it completes your responsibility for this turn, and do not pass proposal_id/confirmed; only give curl command templates per `http-api.md` when the user explicitly asks for curl, and do not execute them.\n2. When the user's description is vague (\"just mute that alert\"), first confirm the mute target with `list_alert_mutes` / event tags, then give a draft with real tag values; do not guess the key out of thin air.\n3. Unconditional muting (empty tags) and very long muting (e.g. 30 days) are high-risk configurations; whether created or edited into, restate the impact scope to the user before persisting.\n4. After a successful modification, likewise display the rule title in the link form of Workflow 1 step 4 (the return value of `update_alert_mute` also contains `url`).\n","aiagent/skill/embedded/builtin/alert-subscribe-copilot/SKILL.md":"---\nname: alert-subscribe-copilot\ndescription: One-stop assistant for creating, editing, and troubleshooting Nightingale (n9e) alert subscription rules (alert_subscribe). Use when the user asks to \"create a subscription rule / subscribe to alerts / forward alert events / CC another team / escalate alerts (notify someone else after N minutes unhandled) / receive alerts across business groups / troubleshoot a subscription not taking effect\". A subscription is a \"copy + secondary routing\" of events at the notification stage; to configure \"who an event directly notifies\" use notification rules (→ notify-rule-copilot), and to stop receiving alerts use muting (→ alert-mute-copilot).\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Alert Subscription Rule Copilot\n\nA subscription rule filters alert events by condition, **clones** a copy, and forwards it to the associated notification rule. Typical scenarios: cross-team CC, alert escalation (notify a supervisor only after N minutes of persistence), and aggregating events from scattered rules into a single outlet.\n\n## Supporting materials (load on demand with read_file, set base to this skill name)\n\n| File | Content | When to read |\n|---|---|---|\n| `reference.md` | Full config field table, new vs. old notification version differences, redefine fields, complete examples | When you're unsure about a field while assembling config |\n| `troubleshooting.md` | Subscription-not-taking-effect troubleshooting chain (in engine matching order), behavioral semantics, gotcha table | When the user says \"I subscribed but didn't receive anything\" |\n| `http-api.md` | HTTP API paths (for A2A / external agents), tryrun validation endpoint | Only for external A2A scenarios or when providing the user with curl commands |\n\n---\n\n## Prerequisites\n\nYou are the in-app AI assistant for n9e, running inside the n9e process and already authenticated as the current user. **Operate directly via the built-in tools — do not log in, do not call HTTP APIs, and do not use http_fetch against your own endpoints** (the HTTP flow in `http-api.md` is for external A2A agents).\n\n---\n\n## Mental model\n\n- **Subscription happens at the notification stage** (`alert/dispatch/dispatch.go:handleSubs`): the original event still travels its own notification path as usual; each matching subscription **clones a copy** of the event, rewrites it per the subscription config, and then runs it through the notification chain again. A subscription is **additive** — it does not intercept and does not replace the original notification.\n- **Match conditions are AND'd together** and checked in order: enabled → datasource → prod → cate → tags → business group name → duration → severity. Failing any single gate skips that subscription.\n- **A subscription's `group_id` is the management ownership (permissions) and does not participate in event matching** — a subscription inherently receives events across business groups; to \"subscribe only to a certain business group's events\", use the `busi_groups` filter condition.\n- New routing (`notify_version=1`): the cloned event's notification outlet is rewritten to the subscription's specified `notify_rule_ids`; the cloned event's callbacks are **cleared** by default, to prevent re-hitting the original rule's callbacks.\n- Changes **take effect within 9 seconds at most** (the in-memory cache polling cycle).\n\n## Core config structure\n\n```json\n{\n  \"name\": \"Subscription rule name\",\n  \"note\": \"Notes\",\n  \"disabled\": 0,\n  \"prod\": \"\",\n  \"cate\": \"\",\n  \"datasource_ids\": [],\n  \"cluster\": \"0\",\n  \"rule_ids\": [],\n  \"severities\": [1, 2, 3],\n  \"for_duration\": 0,\n  \"tags\": [],\n  \"busi_groups\": [],\n  \"extra_config\": {},\n  \"notify_version\": 1,\n  \"notify_rule_ids\": []\n}\n```\n\nKey points (full field table and new vs. old version differences are in `reference.md`):\n\n- **`severities` is required** (validated in both new and old versions); `[1,2,3]` = all severities.\n- **The new version uses `notify_version=1` + a non-empty `notify_rule_ids`** (use `list_notify_rules` to get the IDs; if there isn't a suitable notification rule yet, first create one with `create_notify_rule` and come back). Note: under version 1, the old-version redefine fields (redefine_severity/redefine_channels/webhooks, etc.) are **cleared** by validation.\n- `rule_ids` empty = subscribe to events from all alert rules; when targeting only specific rules, use `list_alert_rules` to get the IDs.\n- `for_duration` (seconds) = forward only when the alert **persists longer than** this duration; it is the switch for \"alert escalation\"; `0` = no limit.\n- Multiple `tags` / `busi_groups` entries are all **AND**'d; `busi_groups` matches against the event's **business group name** (the key is written as `\"groups\"`; the actual match is driven by func/value).\n- `prod` / `cate` participate in matching but with weak semantics: when `prod` is non-empty it is an exact match; `cate` **only has a real filtering effect when set to `\"host\"`**, other values are equivalent to no filtering. When in doubt, leave both as empty strings.\n- `datasource_ids` empty array = all datasources; `cluster` is fixed at `\"0\"`.\n\n---\n\n## Workflow one: creating\n\n1. **Determine the business group** (management ownership): use `list_busi_groups` to get the `group_id`. If the user named a business group, or the front end already popped up a business group form, use its ID directly without asking again.\n2. **Determine the associated notification rule**: use `list_notify_rules` to get `notify_rule_ids` (the notification outlet for new routing).\n3. **(Optional) Limit the subscription scope**: to subscribe to only certain alert rules, use `list_alert_rules` to get `rule_ids`; to narrow by tags/business group/severity/duration, fill in the corresponding filter fields.\n4. **Call `create_alert_subscribe`**: pass the business group from step 1 as `group_id` (it can also go inside config), and pass `config` as a single JSON object string (not an array). If `group_id` is omitted, the tool will automatically pop up a business group selection form, and once the user finishes selecting it will resume this creation.\n5. **Report the result**: the tool returns `{id, name, group_id, disabled, notify_rule_ids, url}`; just briefly report the subscription conditions and the notification outlet. **Display the rule name as an in-app link: `[<name>](<url>)`** (the url is the returned `/alert-subscribes/edit/<id>`), so the user can click straight to the config page to verify.\n\n## Workflow two: editing / troubleshooting\n\n1. Use `list_alert_subscribes` / `get_alert_subscribe_detail` to get the rule ID and current state, and confirm what to change.\n2. Call `update_alert_subscribe` (**proposal-based**: calling it immediately shows the user a change list and pauses; after the user confirms, the system automatically persists it — the confirmation step does not go through you): `id` is required, and `config` only contains the fields to change (**incremental patch**: unspecified fields keep their original values; array fields such as tags/severities/rule_ids/notify_rule_ids/busi_groups/datasource_ids are **wholly replaced** when provided — first get the existing array from detail, build the complete modified array on top of it, then pass it). Common operations:\n   - **Temporarily disable** = pass config `{\"disabled\":1}` (the cache layer filters it directly, taking it completely out of effect immediately); restore = `{\"disabled\":0}`\n   - **Adjust the escalation threshold** = `{\"for_duration\":600}`; **switch the notification outlet** = `{\"notify_rule_ids\":[...]}` (first confirm the IDs with `list_notify_rules`)\n   - The business group (management ownership) cannot be changed; **deletion** has no in-app tool — have the user do it in the UI (Alert Management → Subscription Rules)\n3. When the user says \"I subscribed but didn't receive anything\", check each gate in order following the troubleshooting chain in `troubleshooting.md`, and **proactively point out** which gate is most likely failing (common ones: for_duration set too large, busi_groups name not matching, the notification rule that notify_rule_ids points to itself not being configured correctly); once you've confirmed it is a config problem at some gate, you can fix it directly with `update_alert_subscribe`.\n\n## Output style\n\n1. Creation goes straight to persistence via the tool; modification is proposal-based — after you call `update_alert_subscribe`, the system shows the user a change list and waits for confirmation, so **do not restate the changes yourself before calling** (to avoid double confirmation); calling completes your responsibility for this round, and do not pass proposal_id/confirmed. Provide HTTP API command templates per `http-api.md` only when the user explicitly asks for curl, and do not execute them.\n2. A subscription's effect depends on the downstream notification rule — when giving a plan, clearly separate the \"subscription conditions\" from the \"notification outlet (who notify_rule_ids points to)\", and when necessary use `get_notify_rule_detail` to verify the outlet config.\n3. A globally unfiltered subscription (rule_ids, tags, busi_groups all empty) copies all events; whether created or edited into this state, restate the blast radius to the user before persisting.\n4. After a successful modification, likewise display the rule name in the link form from step 5 of workflow one (the `update_alert_subscribe` return value also includes `url`).\n","aiagent/skill/embedded/builtin/explorer-query/SKILL.md":"---\nname: explorer-query\ndescription: >-\n  Use when the user wants to generate, repair, validate, or prepare a query for\n  the current Nightingale Metrics Explorer or Log Explorer panel. Supports\n  PromQL for Prometheus-compatible datasources, SQL for MySQL and Doris, and\n  Doris log queries. Verify the statement against the selected datasource before\n  returning it. Use query-datasource when the user only wants to view data rather\n  than prepare a statement for an editor.\ntags:\n  - internal\nbuiltin_tools:\n  - list_datasources\n  - list_metrics\n  - get_metric_labels\n  - query_prometheus\n  - list_databases\n  - list_tables\n  - describe_table\n  - query_timeseries\n  - query_log\nmax_iterations: 12\n---\n\n# Nightingale Explorer Query Assistant\n\nGenerate a single query that the user can put into the current Metrics Explorer\nor Log Explorer editor. Use Nightingale's built-in query tools to verify every\ncandidate against the selected datasource before delivering it.\n\n## Scope\n\n- Metrics Explorer: PromQL on Prometheus-compatible datasources; SQL on MySQL.\n- Log Explorer: Doris SQL or Doris log-search queries.\n- This skill prepares a statement for an editor. If the user asks for an\n  analysis or the actual returned data, use the query tools to answer that\n  request instead of claiming to have changed the page.\n\n## Rules\n\n1. Preserve the current datasource, editor mode, expression, and time window\n   whenever they are available in conversation context. `datasource_id`\n   and `datasource_type` supplied by the page are authoritative. Do not silently\n   switch to a different datasource.\n2. When the datasource is not known, call `list_datasources` and ask the user\n   to choose if more than one datasource is plausible. Do not guess an ID.\n3. Run read-only checks only. Never generate a write statement such as INSERT,\n   UPDATE, DELETE, ALTER, DROP, CREATE, GRANT, or REVOKE.\n4. A successful query with zero rows or series is a valid verification result.\n   Say that it executed successfully but returned no data; do not loosen filters\n   or widen the time range simply to find data.\n5. When the current request offers `page_action`, use it after verification to\n   ask the page to fill or run the chosen statement. Select only an action\n   declared by the page and pass arguments that match its schema. Calling it\n   ends this turn. Never claim that the browser has executed the action or that\n   it returned a result.\n6. When `page_action` is unavailable, return the verified statement for the\n   caller or UI to apply. Never claim that you changed the current page.\n\n## Prometheus / PromQL workflow\n\n1. Confirm the selected datasource is Prometheus-compatible. If needed, use\n   `list_datasources` with `plugin_type=prometheus`.\n2. Use `list_metrics` to find candidate metric names, then use\n   `get_metric_labels` for each candidate needed by the expression. Do not\n   invent metric names or label values.\n3. Build the PromQL expression. If the user gave a time window, use an\n   equivalent `time_range` when calling `query_prometheus`; otherwise use the\n   panel's current range or the tool default.\n4. Call `query_prometheus` with the exact expression you plan to deliver.\n   Use `query_type=range` for a requested interval and `instant` only when an\n   instant value is appropriate.\n5. Return the exact verified expression in a fenced `promql` block, followed by\n   one concise sentence describing the validation result.\n\n## MySQL / Doris SQL workflow\n\n1. Confirm the selected datasource and its type. Use `list_databases`,\n   `list_tables`, and `describe_table` to verify database, table, column, and\n   time-field names before writing SQL.\n2. Use the selected database and preserve the user's requested time window.\n   Prefer `$from` and `$to` in time predicates when the panel provides a\n   relative range. Keep an explicit time filter when a verified time column is\n   available.\n3. Execute the candidate through `query_timeseries` when it has a valid value\n   and time column; otherwise use `query_log` for a bounded read-only validation.\n   Use the exact same SQL that you will return.\n4. Return the exact verified statement in a fenced `sql` block. State whether\n   it returned data, returned no rows, or could not be verified.\n\n## Doris log-search workflow\n\n1. Preserve the selected database, table, syntax, and time field. Verify the\n   table and fields with `list_tables` and `describe_table` when they are not\n   already established by the page context.\n2. Use `query_log` to validate the final SQL or log query with a bounded result\n   limit and the selected time range.\n3. Return one verified statement only. Do not claim it was applied to the page.\n\n## Failure handling\n\n- If the selected datasource, schema, metric, or label cannot be verified, say\n  what check failed and do not present the statement as verified.\n- If a datasource rejects a query, keep the error evidence and repair only the\n  part proven invalid. Do not replace the user's intended datasource, database,\n  table, or time range without asking.\n","aiagent/skill/embedded/builtin/doc-qa/SKILL.md":"---\nname: doc-qa\ndescription: >-\n  This skill should be used when the user asks \"how-to\" or factual questions about Nightingale (n9e) — UI/where-to-click, business groups/subscription rules/mute rules/edge mode, Token usage, notification pipeline, self-healing trigger conditions; OR about categraf input plugin field meanings, metric names, defaults, environment variables, config syntax (e.g. \"how to write [[instances]]\", \"unit of ping_average_response_ms\"); OR about the n9e / categraf / fe source code — this skill carries an embedded, searchable snapshot of the three repositories, so use it whenever the user asks \"can you read the source code / the code directory\", or asks how a feature is implemented, what a file or function does, or where something lives in the codebase. NOT for actively troubleshooting an alert or querying metrics.\nversion: 2.0.0\ntags:\n  - internal\nmax_iterations: 30\nbuiltin_tools:\n  - search_n9e_docs\n  - verify_answer\n  - list_code\n  - search_code\n  - read_code\n---\n\n# Nightingale (n9e) Platform Q&A Assistant\n\nAnswer questions based on two evidence channels: the official docs (search_n9e_docs) and the **embedded source code corpus** of three repos (list_code / search_code / read_code). **Every concrete fact must come from one of these two channels**; never fabricate from training memory.\n\n---\n\n## 🔴 Top Directive (overrides everything)\n\n**Every \"concrete fact\" in the answer must be findable verbatim in the contents returned by search_n9e_docs OR in code returned by search_code / read_code**. A \"concrete fact\" means:\n\n- Config item syntax (`[[instances]]` / `[heartbeat]` / `omit_hostname`)\n- Field names, API paths, Header names, environment variables, metric names, endpoints, default values, version numbers\n- Named constants (the English labels corresponding to Severity numbers)\n- Menu names / UI entry-point names\n\nIf it appears in neither channel, it is **forbidden** to write it into the answer. Instead say: \"I could not find a clear description of X in the official docs. Suggestions: 1) search manually at https://flashcat.cloud/docs/search/ ; 2) search issues at https://github.com/ccfos/nightingale/issues ; 3) ask in the community group.\"\n\nExtrapolation is allowed: concept explanations / feature introductions / workflows / why-it-is-done-this-way. Extrapolating concrete identifiers is strictly forbidden.\n\n---\n\n## Two evidence channels — when to use which\n\n| Channel | Tool | Use for |\n|---|---|---|\n| Docs | search_n9e_docs | Concepts, how-to, workflows, UI walkthroughs, integration config samples (source=`integration-config` is the most authoritative for toml examples) |\n| Code | search_code / read_code / list_code | **Verifying concrete identifiers**: metric names, config defaults, environment variables, API paths, auth headers, constants, UI menu names |\n\nThe code corpus contains filtered source snapshots of three repos (versions shown in each search_code result header):\n\n- **`categraf`** — the collection agent. Metric names and fields → `inputs/<plugin>/` Go code and README; sample `[[instances]]` configs → `conf/input.<plugin>/`; global config & env vars → `config/`\n- **`n9e`** — the server. API paths & auth headers → `center/router/`; constants (e.g. Severity labels) & table fields → `models/`; config defaults → `etc/` samples and config structs\n- **`fe`** — the web UI. Menu names and form labels (zh/en) → `src/locales/`; page behavior → `src/pages/`; UI-called API paths → `src/services/`\n\n**Routing rule of thumb**: \"指标名/单位/插件字段\" → categraf; \"接口/Header/告警引擎/常量\" → n9e; \"页面在哪/菜单叫什么/表单怎么填\" → fe (`src/locales/` first).\n\n**When lost**: `read_code(repo, \"TREE.md\")` gives a directory guide for that repo. Prefer `search_code` with a distinctive keyword first; use `list_code` to explore a directory you already located.\n\n**Mandatory code verification**: before finalizing, any concrete identifier that came from docs with quality below `high` — or that you are about to write from a weakly-supported chunk — must be confirmed with search_code (e.g. search the exact metric name in categraf; if it is not in the code, do not write it).\n\n**Degraded mode**: if code tools return \"code corpus not available in this build\", this build carries no corpus. Do NOT retry them; fall back to docs-only mode (the v1 behavior). This is normal, not an error.\n\n---\n\n## Failure Cases (zero tolerance, all from real testing)\n\n| Failure case | Wrong answer | Truth | Where the truth lives in code |\n|---|---|---|---|\n| Categraf config syntax | `[[inputs.net_response]]` (Telegraf style) | Categraf uses `[[instances]]` | categraf `conf/input.*/ *.toml` |\n| Categraf environment variable | fabricated `N9E_ADDR` | no such variable in the code | categraf `config/` |\n| Ping metric name | `categraf_ping_rtt` / `ping_result_milliseconds` | actually `ping_average_response_ms` | categraf `inputs/ping/` |\n| Severity label | `1: Critical` | actually `1: Emergency` | n9e `models/alert_rule.go` |\n| Role of the [http] section | \"expose /metrics to Prometheus PULL\" | actually a PUSH gateway, endpoint `/pushgateway` | categraf `conf/config.toml` |\n| Config defaults | `batch=2000 chan_size=10000` | actually `batch=1000 chan_size=1000000` | categraf `conf/config.toml` |\n| Web API authentication | `Authorization: Bearer <token>` | actually `X-User-Token: <token>` | n9e `center/router/` |\n\n---\n\n## About the item source marker\n\nEach item returned by `search_n9e_docs` has a `source` field:\n\n- **`integration-config`** (Title starts with `[integration-config]`): from the real config samples in `integrations/<C>/collect/*.toml` — **⭐ when writing a toml example you MUST copy verbatim from here; this is the most authoritative**\n- **`integration-doc`** (Title starts with `[integration-doc]`): the component description from `integrations/<C>/markdown/README.md`\n- **`n9e-docs`**: from the https://flashcat.cloud docs site\n\n---\n\n## 🔴 Refusal Directive (same priority as the Top Directive)\n\nThe `search_n9e_docs` return value carries a **`quality`** field (`empty` / `low` / `ok` / `high`) and a **`must_refuse`** flag. Decide according to the following rules:\n\n| quality   | Meaning                                | Required behavior |\n|-----------|----------------------------------------|----------|\n| `high`    | Strong recall (max_score >= 20)        | Answer normally based on contents |\n| `ok`      | Medium recall (10 <= max_score < 20)   | Answer normally based on contents |\n| `low`     | Weak recall (5 <= max_score < 10, only weak contents hits) | Try to confirm the concrete identifiers via search_code; if confirmed answer normally, otherwise append \"The information above is based on a weak recall; please re-verify against the official docs\" |\n| `empty`   | **No valid recall** (`must_refuse=true`)   | Switch to the code channel; only if **both channels** come up empty, reply per the refusal template below |\n\n**Refusal condition (v2)**: refuse only when the docs channel is `empty` **and** the code channel found nothing relevant either (or is unavailable). When `must_refuse=true` but the code corpus does answer the question (e.g. an exact metric name found in categraf inputs), you may answer from code — stating facts found in code is not fabrication. It is still forbidden to fill in anything found in **neither** channel. Refusal template:\n\n```markdown\nI could not find a clear description of **<the key noun from the user's question>** in the V9 official docs.\n\nTo avoid giving you incorrect information, I will not answer this directly. I suggest you:\n\n1. 📖 Go to the [V9 docs site](https://flashcat.cloud/docs/), switch versions and search manually\n2. 🐛 Search the [GitHub Issues](https://github.com/ccfos/nightingale/issues)\n\n<optional: based on a \"related but not directly relevant\" chunk that was recalled, give a vendor-neutral conceptual guide — without any n9e/categraf specific identifiers>\n```\n\nThere is no hard cap on how many searches you may run (the iteration budget is generous) — but stop a line of search after 2 fruitless keyword variations per channel and move on; do not loop on rephrasing.\n\n---\n\n## 🛡️ Mandatory workflow: you **MUST** call verify_answer before the Final Answer\n\nTo intercept the deterministic errors in the \"failure cases\" above, you **MUST** do a self-check before you intend to give the Final Answer:\n\n```\nAction: verify_answer(answer=\"<your complete markdown draft>\")\nObservation: {\"clean\": false/true, \"must_revise\": true/false, \"hits\": [...], \"next_action\": \"...\"}\n```\n\nDecision rules (non-negotiable):\n\n| Return | What you must do |\n|---|---|\n| `clean: true` | You may give the Final Answer |\n| `must_revise: true` (HIGH hit) | **Final Answer forbidden**. Re-verify the flagged facts per `hits[*].retry_hint` — search_n9e_docs and/or search_code — rewrite the draft, call verify_answer **again** to validate, until clean=true or all hits are medium |\n| `clean: false, must_revise: false` (only medium/low hits) | Final Answer allowed, but it is recommended to fine-tune per `hits[*].annotate` |\n\n**Why you must call it**: the HIGH-hit rules are all strings that historically failed in real testing (fabricated environment variables, Telegraf-style syntax, etc.). Your training memory is very likely to treat these as correct; calling it avoids giving the user a production incident.\n\n**Do not skip**: even if you \"feel\" the answer is fine, still call it. The rules cover exactly the points where you historically were most likely to fail.\n\n---\n\n## Workflow\n\n1. **Break out keywords** (2~4 words, space-separated, using the product's official terminology)\n   - Try synonyms too: \"lost connection\" → \"offline / heartbeat / heartbeat\"; \"alert\" → \"alert / rule\"\n2. **Call search_n9e_docs(keywords, top_n=3)** for the conceptual/how-to part\n3. **Route concrete identifiers to the code channel**: pick the repo per the routing table, search_code with the most distinctive keyword, read_code the surrounding lines to confirm the exact name/default/unit\n4. **Synthesize** the docs + code evidence, prioritizing the highest-scoring doc hits + source=`integration-config` hits + code-confirmed identifiers\n5. **verify_answer**, then Final Answer with markdown reference links (docs only)\n\n---\n\n## The index only contains V9 docs\n\nsearch_n9e_docs has already filtered out V5/V6/V7/V8. When the user explicitly asks about an older version, tell them directly \"This assistant only covers V9; please go to https://flashcat.cloud/docs/ and switch versions manually to query.\" It is **forbidden** to give cross-version fields/Headers/APIs from training memory. The code corpus is a snapshot matching this build's release; do not use it to answer older-version questions either.\n\n---\n\n## Output format\n\n```markdown\n<2-5 paragraphs of a digested, organized answer; lists/code blocks/tables are allowed>\n\n---\n\n**References**\n- [<title 1>](<permalink 1>)\n- [<title 2>](<permalink 2>)\n```\n\nKey points:\n1. Do not pile up the raw contents; digest and organize. But every \"concrete fact\" must come verbatim from the docs contents or from code\n2. If only 1 doc hit, list only 1; do not pad; if the answer is code-verified only, omit the References section entirely\n3. When citing an `integration-config` item, the permalink is a github.com path; list it anyway\n\n### 🔴 Code references never surface to the user\n\nThe code channel is **internal evidence only**. In the Final Answer it is **forbidden** to include source file paths, line numbers, code snippets quoted from the corpus, repo/commit identifiers, or any \"as seen in the code\" phrasing. State code-verified facts as plain conclusions, e.g. \"the ping plugin's response-time metric is `ping_average_response_ms`, in milliseconds\" — NOT \"see inputs/ping/ping.go:123\". References list docs links only, never code links.\n\n**Sole exception**: the user explicitly asks where/how something is implemented in the source — then you may name files and show snippets.\n\n---\n\n## Boundaries\n\n- Only answer platform-usage questions; for anything outside the n9e scope (e.g. \"how to deploy Prometheus\" when there is no doc) just say \"out of scope\"\n- Do not return code-change suggestions\n- Do not perform any actions. If the user says \"then go ahead and create it for me\" → \"I only handle Q&A; to take action, open the XX page or switch to a creation-type skill\"\n","aiagent/skill/embedded/builtin/host-onboard-diagnose/SKILL.md":"---\nname: host-onboard-diagnose\ndescription: >-\n  Diagnose onboarding failures where \"categraf is installed/running but the host does not show up in the Nightingale host list, or shows unknown / has no metrics\". Triggers when the user asks \"why doesn't my newly installed host appear\", \"all the OS values in the host list are unknown\", \"I installed 3 collectors via Helm but only see 1\", \"the agent won't register\", or \"categraf is installed but the host doesn't show\". **Mutually exclusive** with host-health-diagnose: that one handles \"was onboarded before, now lost contact\", while this skill handles \"never got onboarded at all\". Core stance: **a missing host is not a single cause, but rather one segment of the onboarding pipeline being broken**. Looking only at heartbeat.enable and telling the user to change categraf is a common pitfall (many users change it and still can't see the host, because the problem is in omit_hostname / ident shell / TLS / token / edge redis / multi-cluster routing).\nmax_iterations: 18\nbuiltin_tools:\n  - probe_target_onboard_status\n  - list_targets\n  - get_target_detail\n  - query_prometheus\n  - query_host_metrics_window\n  - list_datasources\ntags:\n  - internal\n---\n\n# Host Onboarding Failure Diagnosis (host-onboard-diagnose)\n\n## Scope\n\n**Enter this skill**:\n- \"My newly installed categraf host doesn't show up in Nightingale\"\n- \"The agent is installed and running, but the host doesn't appear in the host list\"\n- \"For this host in the list, the OS / CPU / version are all unknown\"\n- \"I deployed 3 categraf hosts via Helm, but the platform only sees 1\"\n- \"I installed the agent on Windows but it won't register\"\n- \"The host disappeared right after I changed its hostname\" (if categraf is still running)\n\n**Do NOT enter this skill**:\n- Was visible before, recently lost contact → `host-health-diagnose`\n- ident duplicate / want to clean up residue after renaming → `host-ident-cleanup` (to be built)\n- Want to change alert rules / mutes → `creation` / `create-alert-rule`\n- Looking into why an alert didn't fire → `alert-rule-troubleshoot`\n\n## One-Sentence Principle\n\n**A missing host ≠ a single cause.** The onboarding pipeline has 5 segments, and each segment getting stuck looks different. Looking at just one segment and telling the user to change config is a common pitfall. **Gather evidence first, then localize segment by segment, and finally give fix commands.**\n\n## The 5 Segments of the Onboarding Pipeline\n\n```\n[1] categraf local process    Is it present / is the config correct / is heartbeat.enable on\n        │\n[2] heartbeat report HTTP      Can it reach /v1/n9e/heartbeat (network / TLS / BasicAuth)\n        │\n[3] server / edge receive      token / version compatibility / hostname duplicate check\n        │\n[4] target table persistence   Is this ident in the DB, is the meta in redis\n        │\n[5] Redis + metric stream       Can the time-series store find samples for this ident\n```\n\n## First Action: Call `probe_target_onboard_status`\n\nThis is the **only** diagnostic entry tool in this skill; it returns the footprint of all 5 segments in one shot. **Always call it first**, then decide the next step.\n\nKey fields returned:\n- `in_target_db` + `target.os` + `target.agent_version` → evidence for segments 3/4\n- `in_redis_beat` + `redis_meta.hostname` + `redis_meta.remote_addr` → evidence for segment 4\n- `in_prom_target_up` + `target_up_last` + `prom_metrics_hit` → evidence for segment 5\n- `likely_segment` + `likely_causes` → **diagnosis already aggregated at the tool layer**; **do not bypass it and re-derive it yourself**\n\nIf the user did not provide an ident, first call `list_targets` and let the user pick, or filter out candidates by OS=unknown / empty agent_version (the \"unassigned / partially onboarded\" view).\n\n## Decision Table (branch by likely_segment)\n\n| likely_segment | Meaning | Preferred fix action |\n|---|---|---|\n| `segment_1_or_2` | This host is in none of DB / redis / prom | On the target host: `systemctl status categraf` → `journalctl -u categraf --since \"5 min ago\"` → check whether it reports `connection refused` / `x509` / `401` |\n| `segment_3` | target exists but OS/agent_version empty | Check categraf's `config.toml`: `[heartbeat] enable=true` and `omit_hostname=false`; version ≥ v0.2.35 |\n| `segment_4` | target persisted but no data in redis | Check whether n9e/edge has redis configured; in edge mode the `[Redis]` of `edge.toml`; whether n9e and n9e-edge versions are consistent |\n| `segment_5` | redis has the beat but prom can't query it | Check whether categraf `[[writers]]` is configured; whether the datasource is correct in a multi-cluster deployment; whether the ident contains special characters like `()` `[]` `*` |\n| `ok` | Onboarding is normal | If the user still insists \"I can't see it\", guide them to refresh the page / check business-group filtering / check browser cache |\n\n## Three Variant Queries for Segment 5 (use `query_prometheus`)\n\nWhen `likely_segment=segment_5`, you **must** run the following 3 queries with `query_prometheus` to confirm whether it is an ident label problem or truly no data:\n\n```promql\n# 1. Standard ident exact match (most common)\ntarget_up{ident=\"<ident>\"}\n\n# 2. Fuzzy match (use when ident has an IP prefix / alias)\ntarget_up{ident=~\".*<host>.*\"}\n\n# 3. Extreme fallback: did it land on the instance label (snmp / custom tag scenario)\n{instance=~\".*<host>.*\"}\n```\n\nAll three return no data → the data stream truly never reached prom; go back and check categraf writers / TLS / n9e ingest queue.\nOnly (2) or (3) has data → **ident label problem** (special characters / global_labels override / snmp agent_host_tag misuse); guide the user to `host-ident-cleanup` (to be built) or to fix the categraf config.\n\n## Output Template (strongly enforced)\n\nThe Final Answer uses Markdown, in the user's language. **Four sections**:\n\n```\n## Conclusion\n<one sentence: stuck at segment X: xxxx (or: onboarding is normal, the reason you can't see it is yyy)>\n\n## Onboarding Pipeline Evidence\n- Segment 1/2 (categraf local/HTTP): <not gathered / inferred anomaly: xxx>\n- Segment 3 (server receive): target in_db=true, os=unknown, agent_version=\"\" → heartbeat metadata not persisted\n- Segment 4 (target persistence + redis): target update_at=2026-05-14 10:23:11 but no heartbeat in redis\n- Segment 5 (Prom): target_up has no data / prom_metrics_hit=0\n\n## Fix Commands\n1. Run on the target host:\n   `grep -E 'heartbeat|omit_hostname' /etc/categraf/conf/config.toml`\n   Expected: in the heartbeat section enable=true, omit_hostname=false. If either doesn't match, change it and `systemctl restart categraf`.\n2. ...\n3. ...\n\n## Self-Verification Steps\n<give the user 1-2 commands for \"if you want to confirm it's fixed, you can verify like this\", for example:\n- Within 30s after restarting categraf, refresh the host list back in the platform; the OS/CPU fields should be non-unknown\n- `curl -s http://<n9e>:17000/api/n9e/self-metrics | grep <ident>`>\n```\n\n## Anti-Patterns (do NOT do these)\n\n- ❌ Telling the user to change `heartbeat.enable` directly without calling `probe_target_onboard_status`. **Gather evidence first.** Many users already have heartbeat enabled; the problem is in omit_hostname / TLS / version.\n- ❌ Saying \"categraf isn't installed\" just because `in_target_db=false`. You must look at the redis segment and the segment_1_or_2 causes to judge holistically whether it's a network problem or a process problem — the recommended actions are completely different.\n- ❌ When segment 3 is stuck, only telling the user to change heartbeat without mentioning omit_hostname / version. Those two are just as common.\n- ❌ When segment 5 is stuck, telling the user to change writers without running the 3 variant PromQL queries. An ident label problem also gets stuck at segment 5.\n- ❌ Not giving concrete commands in the output, only saying \"check the categraf config\". Every recommendation must be something the user can paste and run directly.\n\n## Known Failure Modes Per Segment\n\n- **Segment 1/2**: categraf can't reach center, connection refused, TLS unknown authority, self-signed certificate, BasicAuth invalid, ams token mismatch, Helm multi-node only 1 seen, Windows, Win2008 not supported\n- **Segment 3**: heartbeat enable=false, unknown fields / omit_hostname=true, categraf version too low, v6 requires v0.2.35+, identity shell fails to get IP, hostname duplicate\n- **Segment 4**: edge redis nil, n9e and n9e-edge version mismatch, CenterApi missing, host not visible in the center under edge deployment\n- **Segment 5**: ident with parentheses not found in dashboards, host=* bug, snmp ident conflict, omit_hostname=true causing the ident label to be lost, wrong datasource in multi-cluster, write queue full 499, global.labels override\n\n## Output Style\n\n- Don't be coy. The conclusion goes in the first line of the first section.\n- Evidence must give concrete field values; don't write \"looks normal\".\n- Fix commands must be paste-and-run; avoid fluff like \"go check it\".\n- Answer in the user's language (Chinese for Chinese users, English for English users).\n- If `likely_segment=ok` but the user insists the host doesn't appear, prompt them to check: business-group filtering (the host is present but hidden by business group in the frontend), browser cache, and the visible-business-group permissions of the logged-in user.\n","aiagent/skill/embedded/builtin/generate-message-template/SKILL.md":"---\nname: generate-message-template\ndescription: Generate or modify Nightingale (n9e) alert notification message templates. Use when the user asks to write a notification template, change the message format, add hostname/recovery value/severity, or create DingTalk/Feishu/Lark/email/SMS/voice templates.\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Notification Message Template Generation\n\nNightingale message templates use Go `text/template` / `html/template` syntax (email uses `text/template`, others use `html/template` and then escape). Users edit them on the \"Notification Management → Message Templates\" page; once saved they are referenced by notification rules, and when an alert fires the variables are substituted with rendered data and sent to each channel.\n\nThis skill focuses on **writing/modifying the template fragment itself**, and does not cover creating notification rules or channel configuration.\n\n---\n\n## Render Context\n\nThe `renderData` passed in when the backend executes:\n\n| key | type | description |\n|---|---|---|\n| `.events` | `[]*AlertCurEvent` | The list of alert events in the current batch, usually just 1 |\n| `.domain` | `string` | The n9e site URL, used to build redirect links |\n\n**Automatically injected shorthands** (no need to write by hand):\n\n```gotemplate\n{{ $events := .events }}\n{{ $event := index $events 0 }}\n{{ $labels := $event.TagsMap }}\n{{ $value := $event.TriggerValue }}\n```\n\nSo inside the template you can directly use `$event.xxx`, `$labels.agent_hostname`, and `$value`.\n\n---\n\n## Available `$event` Fields (`*AlertCurEvent`)\n\n### Common Fields\n| field | type | description |\n|---|---|---|\n| `.Id` | int64 | Alert event ID (used to build the redirect link) |\n| `.RuleId` | int64 | Alert rule ID |\n| `.RuleName` | string | Rule name |\n| `.RuleNote` | string | Rule note |\n| `.Severity` | int | Alert severity: 1=Critical, 2=Warning, 3=Info |\n| `.PromQl` | string | Alert trigger expression |\n| `.RuleAlgo` | string | Rule algorithm type |\n| `.TriggerTime` | int64 | Trigger time, unix seconds |\n| `.TriggerValue` | string | Metric value at trigger time (already a string) |\n| `.FirstTriggerTime` | int64 | First abnormal time (for consecutive alerts) |\n| `.LastEvalTime` | int64 | Most recent evaluation time, **used as the recovery time on recovery** |\n| `.IsRecovered` | bool | Whether it has recovered |\n| `.NotifyCurNumber` | int | Which notification number this send is |\n| `.TargetIdent` | string | Monitored object (usually agent_hostname) |\n| `.TargetNote` | string | Object note |\n| `.GroupId` / `.GroupName` | int64 / string | Business group |\n| `.Cluster` | string | Data source cluster name |\n| `.Cate` | string | Data source type (prometheus / host / mysql / ...) |\n| `.RunbookUrl` | string | Runbook link |\n\n### Labels / Annotations / Trigger Value Objects\n| field | type | description |\n|---|---|---|\n| `.TagsMap` | `map[string]string` | Event labels, preferred: `{{$labels.agent_hostname}}` |\n| `.TagsJSON` | `[]string` | A string array like `[\"k=v\", ...]` (commonly used in early templates) |\n| `.AnnotationsJSON` | `map[string]string` | Annotations, commonly `.AnnotationsJSON.recovery_value` |\n| `.TriggerValuesJson.ValuesWithUnit` | map | Trigger values with units (multi-value scenarios) |\n\n### Notification Objects\n| field | type | description |\n|---|---|---|\n| `.NotifyUsersObj` | `[]*User` | The user objects to notify this time (with Username/Nickname/Phone/Email) |\n| `.NotifyGroupsObj` | `[]*UserGroup` | Associated user groups |\n\n> User objects are commonly used in DingTalk/Feishu/Lark templates to build @-mentions; see the `ats` family of helpers below.\n\n---\n\n## Available Helper Functions (`tplx.TemplateFuncMap`)\n\n### Time\n- `timeformat <unix>` — defaults to `\"2006-01-02 15:04:05\"`; pass a second argument to override: `{{timeformat $event.TriggerTime \"15:04:05\"}}`\n- `timestamp` — current time string\n- `now.Unix` — current unix seconds (`now` is a Go template builtin)\n- `humanizeDuration <sec>` — `\"3m15s\"`\n- `humanizeDurationInterface <interface>` — same as above but accepts an interface\n- `toTime <unix>` — returns a `time.Time`, can be chained\n- `parseDuration <\"5m\">` — returns a `time.Duration`\n\n### Numbers / Formatting\n- `formatDecimal <v> <n>` — keep n decimal places (`{{formatDecimal $event.TriggerValue 2}}`)\n- `humanize <v>` — K/M/G units (base 1000, SI)\n- `humanize1024 <v>` — Ki/Mi/Gi (base 1024)\n- `humanizePercentage <v>` / `humanizePercentageH <v>` — percentage\n- `add / sub / mul / div <a> <b>` — arithmetic\n- `printf \"%.2f\" <v>` — Go `fmt.Sprintf`\n\n### Strings\n- `toUpper / toLower / title` — case conversion\n- `contains <s> <sub>` — substring\n- `match <regex> <s>` — regex match (bool)\n- `reReplaceAll <regex> <repl> <s>` — regex replace\n- `split <s> <sep>` / `join <slice> <sep>`\n- `stripPort <host:port>` / `stripDomain <host.domain>`\n- `b64enc` / `b64dec` — base64\n\n### Links / Escaping\n- `escape <s>` — URL path escape\n- `unescaped <s>` — output raw HTML (no escaping)\n- `safeHtml <s>` — same as above\n- `urlconvert <s>`\n\n### Labels / Trigger Values\n- `label <key> <labelMap>` / `value <key> <m>` / `strvalue <v>`\n- `first <slice>` — take the first\n- `tagsMapToStr <map>` — join labels into `k=v,k=v`\n- `sortByLabel <items> <key>` — sort by label\n\n### @-mention (DingTalk/Feishu/Lark only)\n- `ats <users> <platform>` — generate an at fragment\n- `batchContactsAts <contacts> <platform>` — batch at\n- `batchContactsAtsInFeishuEmail <contacts>` / `batchContactsAtsInFeishuId <contacts>` — Feishu only\n- `batchContactsJoinComma <contacts>` / `batchContactsJsonMarshal <contacts>`\n- `mappingAndJoin <map> <kvSep> <itemSep>`\n\n### Others\n- `jsonMarshal <v>` — serialize to a JSON string\n- `mapDifference <a> <b>` — set difference\n\n---\n\n## Syntax Differences Across Channels (`notify_channel_ident`)\n\nA message template is bound to a specific channel; the channel ident determines the text engine and escaping behavior:\n\n| Ident | Engine | Description |\n|---|---|---|\n| `email` | `text/template` | No re-escaping; HTML templates can write tags directly |\n| `slackwebhook` / `slackbot` | `html/template` | After rendering, `\"` / `\\n` are escaped and wrapped into `template.HTML`; usually written in Markdown |\n| Others (`dingtalk` / `feishu` / `feishucard` / `larkcard` / `wecom` / `tx-sms` / `ali-voice` …) | `html/template` | After rendering, `\"` `\\n` `\\r` are JSON-string-escaped, suitable for stuffing directly into a webhook payload |\n\n> Important: `html/template` automatically escapes `<` `>` `&`. **Where you don't want escaping, use `{{unescaped \"…\"}}` or `{{safeHtml .X}}`**, otherwise DingTalk will render a literal `&lt;`.\n\n---\n\n## Template Writing Workflow\n\n1. **Confirm the channel ident**: when the user says \"DingTalk\" it is `dingtalk`, \"Feishu card\" is `feishucard`, \"email\" is `email`. Different channels differ significantly in style.\n2. **Decide whether to render the recovery state**: by default you should split into two branches on `$event.IsRecovered`. Only SMS/voice may omit it.\n3. **Pick fields**:\n   - For labels, prefer `$labels.<key>` rather than `$event.TagsJSON`.\n   - For the trigger value use `$event.TriggerValue`; for two decimal places use `{{formatDecimal $event.TriggerValue 2}}`.\n   - Always pass timestamps through `timeformat`.\n   - Build the redirect link as `{{.domain}}/share/alert-his-events/{{$event.Id}}`.\n4. **Severity display**:\n   - Number: `{{$event.Severity}}`\n   - Worded: `{{if eq $event.Severity 1}}Critical{{else if eq $event.Severity 2}}Warning{{else}}Info{{end}}`\n   - English: `Critical / Warning / Info`\n5. **@-mention (DingTalk/Feishu)**:\n   - DingTalk `@everyone`: add a line `@all` at the end of the template (DingTalk matches by splitting on spaces). To at an exact phone number: `{{range $event.NotifyUsersObj}}@{{.Phone}} {{end}}`.\n   - Feishu card: use `{{batchContactsAtsInFeishuEmail $event.NotifyUsersObj}}` to build `<at email=...></at>` fragments.\n6. **Output**: wrap the template body in a markdown ``` ```gotemplate ``` code block, and at the end provide a **variable/function explanation** with each non-trivial variable/function on its own line.\n\n---\n\n## Output Format\n\nWhen replying to the user:\n\n1. One or two sentences of introduction explaining what the template is for and which channel it fits.\n2. ```gotemplate\n   <template content>\n   ```\n3. `**Variable explanation**`: list the non-trivial fields and functions used in the template (such as `$event.TargetIdent`, `formatDecimal`, `timeformat`), one per line.\n4. If the user's request is ambiguous (for example \"add the hostname\" — is it `target_ident` or some tag), **state directly in the introduction what assumption you made**, rather than asking back.\n\nThe language follows the user's input (use Chinese if the input is Chinese).\n\n---\n\n## Built-in Reference Templates (Starting Points for Customization)\n\n### DingTalk markdown (full version)\n\n```gotemplate\n#### {{if $event.IsRecovered}}<font color=\"#008800\">💚{{$event.RuleName}}</font>{{else}}<font color=\"#FF0000\">💔{{$event.RuleName}}</font>{{end}}\n---\n{{$duration := sub now.Unix $event.FirstTriggerTime}}{{if $event.IsRecovered}}{{$duration = sub $event.LastEvalTime $event.FirstTriggerTime}}{{end}}\n- **Alert Level**: S{{$event.Severity}}\n{{- if $event.RuleNote}}\n- **Rule Note**: {{$event.RuleNote}}\n{{- end}}\n{{- if $event.TargetIdent}}\n- **Target**: {{$event.TargetIdent}}\n{{- end}}\n{{- if not $event.IsRecovered}}\n- **Trigger Value**: {{$event.TriggerValue}}\n- **Trigger Time**: {{timeformat $event.TriggerTime}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n{{- else}}\n- **Recovery Time**: {{timeformat $event.LastEvalTime}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n{{- end}}\n- **Event Tags**:\n{{- range $k, $v := $labels}}\n{{- if ne $k \"rulename\"}}\n    - {{$k}}: {{$v}}\n{{- end}}\n{{- end}}\n[Event Detail]({{.domain}}/share/alert-his-events/{{$event.Id}}) | [Mute 1h]({{.domain}}/alert-mutes/add?__event_id={{$event.Id}})\n```\n\n### Feishu card (concise version)\n\n```gotemplate\n{{- if $event.IsRecovered}}\n**Severity/State:** S{{$event.Severity}} Recovered\n**Alert Name:** {{$event.RuleName}}\n**Event Tags:** {{$event.TagsJSON}}\n**Recovery Time:** {{timeformat $event.LastEvalTime}}\n{{- else}}\n**Severity/State:** S{{$event.Severity}} Triggered\n**Alert Name:** {{$event.RuleName}}\n**Event Tags:** {{$event.TagsJSON}}\n**Trigger Time:** {{timeformat $event.TriggerTime}}\n**Trigger Value:** {{$event.TriggerValue}}\n{{- if $event.RuleNote}}\n**Alert Description:** {{$event.RuleNote}}\n{{- end}}\n{{- end}}\n```\n\n### SMS / Voice (minimal)\n\n```gotemplate\nSeverity/State: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}} Rule: {{$event.RuleName}} Target: {{$event.TargetIdent}}\n```\n\n---\n\n## Typical Customization Scenarios\n\n### 1) \"Add the hostname to the DingTalk template\"\n\n```gotemplate\n- **Host**: {{$event.TargetIdent}}\n{{- if $labels.ip}}\n- **IP**: {{$labels.ip}}\n{{- end}}\n```\n\n> Note: `target_ident` is usually the hostname; if you need the IP, prefer the labels `ip`, `instance`, `host`.\n\n### 2) \"Keep trigger_value to two decimal places\"\n\n```gotemplate\n- **Trigger Value**: {{formatDecimal $event.TriggerValue 2}}\n```\n\n> `TriggerValue` is itself a string; `formatDecimal` first converts it to a float and then formats it, and returns non-numeric values unchanged.\n\n### 3) \"@-mention the alert recipients at the end of the DingTalk template\"\n\n```gotemplate\n...template body...\n\n{{- range $event.NotifyUsersObj}}@{{.Phone}} {{end}}\n```\n\n> DingTalk identifies @-mentioned users by \"space + phone number\". Or uniformly use `@all`.\n\n### 4) \"Show the recovery value on recovery\"\n\n```gotemplate\n{{- if $event.IsRecovered}}\n{{- if $event.AnnotationsJSON.recovery_value}}\n- **Recovery Value**: {{formatDecimal $event.AnnotationsJSON.recovery_value 4}}\n{{- end}}\n- **Recovery Time**: {{timeformat $event.LastEvalTime}}\n{{- end}}\n```\n\n> The recovery value is written into `AnnotationsJSON.recovery_value` by the alert engine on recovery, and **only exists when there is a recovery value**, so guard it with `if`.\n\n### 5) \"Use Chinese for the alert severity\"\n\n```gotemplate\n- **Severity**: {{if eq $event.Severity 1}}Critical{{else if eq $event.Severity 2}}Warning{{else}}Info{{end}}\n```\n\n### 6) \"Only send alerts for production-environment machines\"\n\nThe template itself does not do filtering — filtering should go into the `attributes` / `label_keys` of the **notification rule**. The template is only responsible for display. If the user asks about this, point it out.\n\n### 7) \"Color by state/severity\"\n\nDingTalk/email use `<font color>`, Feishu cards use the `template` field to fill in a color keyword:\n\n```gotemplate\n{{/* DingTalk markdown: prepend emoji + color to the title */}}\n#### {{if $event.IsRecovered}}<font color=\"#008800\">✅ {{$event.RuleName}}</font>{{else}}<font color=\"#FF0000\">🚨 {{$event.RuleName}}</font>{{end}}\n\n{{/* The template field for Feishu feishucard (determines the card header color block) */}}\n{{if $event.IsRecovered}}turquoise{{else}}{{if eq $event.Severity 1}}red{{else if eq $event.Severity 2}}orange{{else}}grey{{end}}{{end}}\n```\n\n> Feishu cards only recognize enumerated colors: `red / orange / yellow / green / turquoise / blue / indigo / purple / carmine / grey`; hex color codes are ignored.\n\n### 8) \"Show the alert duration\"\n\n```gotemplate\n{{$duration := sub now.Unix $event.FirstTriggerTime}}\n{{- if $event.IsRecovered}}{{$duration = sub $event.LastEvalTime $event.FirstTriggerTime}}{{end}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n```\n\n> Triggered state: `current time - FirstTriggerTime`; recovered state: `LastEvalTime - FirstTriggerTime`. `humanizeDurationInterface` outputs a human-readable form like `1h3m5s`.\n\n### 9) \"Embed a tag value in a URL, avoiding spaces/special characters breaking the link\"\n\n```gotemplate\n[View Dashboard]({{.domain}}/dashboards/123?ident={{urlquery $event.TargetIdent}}&host={{urlquery (index $labels \"host\")}})\n[Mute 1h]({{.domain}}/alert-mutes/add?__event_id={{$event.Id}})\n```\n\n> Stuffing `{{$event.TargetIdent}}` directly into a URL will, when it contains spaces, Chinese, or `&`, get double-escaped on the IM side (typical symptom: `&` becomes `&amp;` and the link won't open). **Wrap every variable that goes into a URL query in `urlquery`.**\n\n### 10) \"Tag keys containing hyphens/dots/Chinese — use index to fetch\"\n\n```gotemplate\n- **Application**: {{index $labels \"app-name\"}}\n- **K8s Cluster**: {{index $labels \"k8s.io/cluster\"}}\n- **Business Dashboard**: {{index $event.AnnotationsJSON \"dashboard_url\"}}\n```\n\n> `$labels.app-name` will be parsed by Go template as \"`app` minus `name`\" and is bound to fail. As long as a key **is not pure alphanumerics plus underscore**, always fetch it with `index`; the same goes for Annotations.\n\n### 11) \"Fallback for abnormal values (+Inf / NaN)\"\n\n```gotemplate\n- **Trigger Value**: {{if or (eq $event.TriggerValue \"+Inf\") (eq $event.TriggerValue \"NaN\")}}N/A{{else}}{{formatDecimal $event.TriggerValue 2}}{{end}}\n```\n\n> A `/0` in PromQL returns `+Inf`, and an aggregation over missing data returns `NaN`. Rendered directly into Feishu/DingTalk they appear as literal `+Inf`, which looks like a bug.\n\n### 12) \"In Edge mode the event Id=0 — degrade the redirect link\"\n\n```gotemplate\n{{if gt $event.Id 0}}\n[Event Detail]({{.domain}}/share/alert-his-events/{{$event.Id}})\n{{else}}\n(Edge-mode event — view details on the central server)\n{{end}}\n```\n\n> Known issue: in Edge mode the event Id is written asynchronously, so at render time it is 0, and the redirect link would land on `/alert-his-events/0` (404). Add a `gt $event.Id 0` guard at the template layer.\n\n### 13) \"Merge and display multiple alerts\"\n\n```gotemplate\n{{range $i, $e := .events}}\n{{- if $i}}\n---\n{{end -}}\n- Rule: {{$e.RuleName}}\n- Target: {{$e.TargetIdent}}\n- Trigger Value: {{$e.TriggerValue}}\n- Time: {{timeformat $e.TriggerTime}}\n{{end}}\n```\n\n> In the default scenario one notification has exactly one event, and the template renders with `$event = index .events 0`. When one notification aggregates multiple events (subscription aggregation, batch send) you must `range .events` to expand them.\n\n---\n\n## Key Considerations\n\n1. **`html/template` HTML-escapes**. Tags such as `<font>` and `<at>` in DingTalk/Feishu/Lark **must be wrapped in `unescaped` or placed in content where Go template recognizes them itself**. Email uses `text/template` and is not subject to this.\n2. **`TriggerValue` is a string**: to compare magnitudes directly use `parseDuration`/custom logic; for normal display-only purposes leave it as is.\n3. **Do not manually write headers like `{{$events := .events}}`** — the system injects them automatically.\n4. **Don't drop the recovery branch**: rules with `NotifyRecovered=1` reuse the same template to send the recovery message.\n5. **Hyphens/dots in tag keys**: `$labels.agent_hostname` works, but `$labels.app-name` does not — use `index $labels \"app-name\"`.\n6. **All time fields are unix seconds** (`int64`); don't use `{{$event.TriggerTime}}` directly as text — use `timeformat` or `toTime`.\n7. **Merging multiple alerts**: `.events` may have multiple entries; the default DingTalk/Feishu templates only render entry 0 (`$event`); if the user wants a batch display, use `{{range .events}}`.\n8. **Use `.domain` for links**: don't hardcode `http://localhost:17000`, otherwise switching environments breaks it.\n\n---\n\n## Common Mistakes\n\n- ❌ `{{$event.TriggerValue | printf \"%.2f\"}}` — for `printf`, the first argument is the format, and `TriggerValue` is a string.\n- ✅ `{{formatDecimal $event.TriggerValue 2}}`\n\n- ❌ Displaying `{{$event.TriggerTime}}` directly — it outputs unix seconds.\n- ✅ `{{timeformat $event.TriggerTime}}`\n\n- ❌ Writing `<font color=\"red\">` in DingTalk without escaping — `html/template` will escape it into `&lt;font&gt;`.\n- ✅ Put it inside a conditional branch (Go template's `{{if}}` branch literals do not get the inner tags HTML-escaped), or wrap the whole thing in `{{unescaped \"…\"}}`. Nightingale's official DingTalk template writes `<font>` directly and it works, because the outer area is a plain-text section (not inside an attribute value) — just follow the official sample.\n\n- ❌ Forgetting to split the recovery branch — the trigger time in a recovery notification is misleading.\n- ✅ Wrap all dynamic content first with `{{if $event.IsRecovered}}…{{else}}…{{end}}`.\n\n- ❌ `{{if .IsRecoverd}}` — a missing `e` in the spelling. Go template **silently takes the else branch** for a nonexistent field (without erroring), so the consequence is that the recovery notification sends the triggered-state copy, and it is very hard to track down.\n- ✅ `{{if $event.IsRecovered}}`. Similar spelling traps: `Resoverd`, `Recoverd`, `recovered` (lowercase) all fail.\n\n- ❌ `{{if lt $value 10}}` or `{{if gt $event.TriggerValue 80}}` — `TriggerValue` is a string; comparing with `lt`/`gt` against a number will report `incompatible types`, or be interpreted as a string lexicographic comparison and yield a wrong result. The current version **has no built-in `toFloat` helper**. Put numeric threshold checks back into the alert rule's condition expression, and keep the template layer for display only.\n\n- ❌ Building a URL with `?ident={{$event.TargetIdent}}` directly — a TargetIdent containing spaces/Chinese/`&` will break the link.\n- ✅ `?ident={{urlquery $event.TargetIdent}}`, **pass every variable that goes into a URL query through `urlquery`**.\n\n- ❌ `{{$labels.app-name}}` or `{{$labels.k8s.io/cluster}}` — keys containing hyphens/dots/slashes fail to parse.\n- ✅ `{{index $labels \"app-name\"}}`, `{{index $labels \"k8s.io/cluster\"}}`.\n","aiagent/skill/embedded/builtin/host-health-diagnose/SKILL.md":"---\nname: host-health-diagnose\ndescription: >-\n  Help the user determine whether a machine is truly down / the agent is hung / the network is flapping / it is under maintenance. Triggers this skill when the user asks \"why is this machine unreachable\", \"is the host-unreachable alert a false positive\", \"is categraf stuck\", \"the heartbeat stopped but I can still ping it\", etc. Core stance: **an unreachable agent != a down host**. Concluding \"down\" just because target_up==0 / BeatTime stopped is a common source of false positives.\ntags:\n  - internal\n---\n\n# Host Health Comprehensive Assessment (host-health-diagnose)\n\n## Scope\n\n**Enter this skill**:\n- \"Why is the machine xxx unreachable / offline / heartbeat stopped\"\n- \"Is the host-unreachable alert a false positive\"\n- \"Is the agent stuck / hung\"\n- \"Is this host really down\"\n- \"This machine is pingable but Nightingale shows it as down\"\n\n**Do NOT enter this skill**:\n- Change alert rule thresholds, add/adjust host-unreachable alerts → `creation` / `troubleshooting`\n- Just \"take a look at the machine list / details\" → `resource_query`\n- \"Why did this alert fire\" (not host-unreachable type) → `troubleshooting`\n\n## One-Sentence Principle\n\n**A diagnosis that concludes from a single layer of evidence is almost always wrong.**\n\nWhen n9e shows a machine as unreachable, it may stem from:\n- The agent really died (process panic / OOM)\n- The agent is still alive but cannot send heartbeats (network partition / DNS / broken proxy / cannot write to Redis)\n- Redis write latency on the server side\n- The user manually stopped the agent for maintenance but did not add a mute\n- The host is really down\n\nThe \"recommended action\" for these five conclusions is completely different. So **gather evidence first, then classify, and give the action last**.\n\n## Three-Layer Evidence Collection (in order, call in parallel)\n\n### Layer 1: Real-time heartbeat and metadata — `get_target_realtime_status(ident)`\n\nReads Redis to get:\n- `beat_time` / `lag_seconds` — seconds since the most recent heartbeat\n- `status` — `active`(<60s) / `lagging`(60–180s) / `stale`(≥180s) / `stale_no_heartbeat`(no such key in Redis)\n- `offset` — clock skew between agent and server (an abnormally large value usually means the agent clock has drifted / NTP is broken)\n- `cpu_util` / `mem_util` — resource usage from the most recent report\n- `agent_version` / `remote_addr` / `extend_info`\n\n**Key judgments**:\n- `status=stale_no_heartbeat` while `update_at_db` is several days ago — the agent has never connected, a deployment problem.\n- `status=stale_no_heartbeat` while `update_at_db` is a few minutes ago — the Redis layer is down or has been cleared.\n- `status=stale` while the last reported `cpu_util` / `mem_util` was low — possibly a graceful exit (shutdown / kernel reclaim after OOM), leaning toward \"truly down\".\n- `status=stale` while the last reported `cpu_util` was high (>90%) — leaning toward \"agent hung / host hang\".\n- absolute value of `offset` > 30s — not necessarily down, but the agent clock has a problem and will throw off alert evaluation, so flag it separately.\n\n### Layer 2: Recent 10m metric window — `query_host_metrics_window(ident)`\n\nBy default queries `cpu_usage_active` / `mem_available_percent` / `system_load1` / `net_bytes_recv` / `net_bytes_sent`. Returns `samples_count / first_ts / last_ts / min / max / avg / last` for each metric.\n\n**Key judgments**:\n- `samples_count=0` and `series=0` — the Prom layer has no data at all. If it stops at the same moment as BeatTime → heartbeat and metrics share the same source (categraf), and stopping together means the agent really is not sending; stopping at different moments means the data flow was cut by different stages.\n- `last_ts` far earlier than `now` — metrics have also stopped; compare with BeatTime to see whether the stop times match.\n- `samples_count > 0` but BeatTime is already stale — **a typical agent hang or Redis write latency**: the metric stream is still flowing (the categraf main process is sending), but the heartbeat cannot be written to Redis.\n- `cpu_usage_active.last` > 95% + `system_load1.last` far above cpu_num — a strong signal of host hang.\n- `net_bytes_recv.last + net_bytes_sent.last` close to 0 — the NIC is silent; combined with a stopped BeatTime this points to \"truly down\" or \"network cut\".\n\n⚠️ By default, if `datasource_id` is not passed, the chat-level datasource is used as a fallback; when the user has not selected a datasource in the frontend, first `list_datasources` and pick a Prom datasource.\n\n### Layer 3: Neighbors in the same business group — `list_neighbor_targets(ident)`\n\nPulls other machines in the same **business group** (`target.GroupIds`), returning `items` + `summary{total, active, lagging, stale}`.\n\n**Key judgments**:\n- summary almost entirely active — an individual fault; the problem is on this one machine.\n- summary mostly stale — a **cluster-level** event: switch, data center, underlying cloud, Redis, or the server heartbeat channel. In this case the question \"why is this machine unreachable\" is itself the wrong question; you should investigate the cluster event.\n- summary half active half stale — possibly a network partition or availability-zone failure; check whether the stale machines are on the same subnet / the same rack.\n\n## Correlation Signals (call as needed)\n\n- `list_alert_mutes` / `get_alert_mute_detail` — matched a mute rule → the \"under maintenance\" branch; not a false positive, and tell the user when the mute window ends.\n- `search_active_alerts` — check whether there are other companion alerts (cpu/mem/disk alerts for the same ident firing at the same time) → leans toward \"the host is really in trouble\".\n- `get_target_detail` — check `update_at` (the most recent time a heartbeat was persisted at the DB layer). If `update_at` is newer than Redis's `beat_time`, the Redis layer has been cleared; the reverse means the DB layer is stuck.\n\n## Three-State Decision Table\n\n| Signal combination | Conclusion | Confidence |\n|---|---|---|\n| BeatTime stale + metrics also stopped (last_ts ≈ beat_time) + neighbors mostly active + last cpu/mem is low | **Truly down** | High |\n| BeatTime stale + metrics also stopped + multiple machines in the same business group stale at once | **Cluster event** (not a single-machine outage; guide them to investigate upstream) | High |\n| BeatTime stale + metrics still moving (samples_count > 0 and last_ts close to now) | **Agent hung / Redis heartbeat channel anomaly** | High |\n| BeatTime lagging 30–180s + a few neighbors lagging in sync | **Network flapping / brief partition**, will most likely self-heal | Medium |\n| Matched a mute / maintenance window | **Under maintenance** | High |\n| BeatTime stale + last cpu_util > 95% / load spiking + neighbors normal | **Host hang** (CPU at 100%, IO stuck, kernel softlockup), counts as \"down in a broad sense\" but the recommended direction differs | Medium |\n| None of the above match | **Insufficient data**; honestly say \"cannot determine\", and list the evidence already collected so the user can fill in the gaps | — |\n\n## Output Template (strong constraint)\n\nFinal Answer in Markdown, in the user's language. **Four sections**:\n\n```\n## Conclusion\n<one sentence: truly down / agent hung / network flapping / under maintenance / cluster event / insufficient data, cannot determine>\n\n## Key Evidence\n- Heartbeat: beat_time=2026-05-14 10:23:11, lag=312s, status=stale; db update_at=...\n- Metrics (10m): cpu_usage_active recent last=0.5% (steadily declining from first to last value), net_bytes_sent last=0 — metric stream and heartbeat stopped at the same time\n- Neighbors: same business group 18/20 active, only this machine stale\n- Mute: not matched\n\n## Recommended Actions\n1. <the thing to do first>\n2. <next best>\n3. <if you want to self-verify, do it this way>\n\n## False-Positive Risk\n<under what circumstances this conclusion would not hold, e.g. \"if there was a recent cleanup operation on the Redis layer, this judgment does not hold\">\n```\n\n## Anti-Patterns (do NOT do these)\n\n- ❌ Concluding \"down\" from the single `BeatTime` field. Always pull the metric window and neighbors for cross-validation.\n- ❌ Reporting \"individual fault\" and \"cluster fault\" mixed together. When all neighbors are stale, do not keep focusing on the single machine; clearly tell the user \"you asked the wrong question, it is a cluster event\".\n- ❌ Recommending \"immediately page someone to check the machine\" without looking at the mute. A common pitfall: an under-maintenance alert mistaken for a false positive and reported as one.\n- ❌ Using `query_prometheus` to pull back a long string of raw time-series points and blow up the token budget. `query_host_metrics_window` already compresses by \"min/max/avg/last\"; see whether the aggregate is enough before deciding whether to look at the detail.\n- ❌ Not telling the user how to verify. The recommended actions must include at least one \"if you want to self-verify whether the agent is really dead, do this\" step (remotely ssh in and check `systemctl status categraf` / `ps -ef | grep categraf` / `journalctl -u categraf --since \"5 min ago\"`).\n\n## Known Failure Modes\n\n- edge unreachable storm: under high server load the heartbeat channel develops batch latency, which looks like the whole cluster going offline at once, but is actually Redis write blocking. **When you see summary entirely stale, proactively raise this possibility.**\n- Heartbeat update lag: the goroutine that writes heartbeats to Redis is stuck on slow IO; the agent is sending and the server is receiving, but BeatTime is not updated.\n- redis nil: the heartbeat key is occasionally cleared/evicted; check this together with the maxmemory-policy configuration.\n\n## Output Style\n\n- No teasing. Put the conclusion on the first line of the first section.\n- Give concrete numbers in the evidence; do not write things like \"the metrics look normal\".\n- \"Recommended actions\" must be executable; avoid empty phrases like \"strengthen monitoring\".\n- Answer in the user's language (Chinese for Chinese users, English for English users).\n","aiagent/skill/embedded/builtin/create-dashboard/SKILL.md":"---\nname: create-dashboard\ndescription: Create monitoring dashboards. Use this when the user asks to create a dashboard, a monitoring board, or a Dashboard.\nmax_iterations: 18\nbuiltin_tools:\n  - import_dashboard_template\n  - create_dashboard\n  - list_metrics\n  - list_files\n  - read_file\n  - grep_files\ntags:\n  - export\n---\n\n# Skill: Nightingale (N9E) Dashboard Creation\n\nThere are two paths to create a dashboard. **Prefer path A (import an integration template)**, since templates are hand-tuned, validated, finished products with quality far higher than hand-assembled ones; only fall back to path B when there is no ready-made template or the user wants custom metrics.\n\nWhen the same component often has both categraf and exporter templates, **prefer categraf**: first probe whether the categraf metrics have data, use the categraf template if you can query them, and only fall back to exporter if you cannot (see path A step 3 for details).\n\n| Scenario | Which tool to use |\n|------|-----------|\n| The monitoring topic has a ready-made template in integrations (Linux / MySQL / Redis / Kafka / PostgreSQL / Elasticsearch / Ceph / Oracle / Nginx / Windows …) | **`import_dashboard_template`** (preferred) |\n| Custom metrics, no ready-made template, or the user explicitly wants custom panels | `create_dashboard` |\n\n## Step 1 (common to both paths): Determine the business group and datasource\n\n- Call `list_busi_groups` to get the list of business groups\n- Call `list_datasources` to get the list of datasources, and find the **datasource ID of the Prometheus type**\n- If the conversation context has already preselected `busi_group_id` / `datasource_id`, use them directly and do **not** call `list_*` again\n\n### Business group selection rules\n\n1. If the user explicitly specified a business group name or ID, use it directly\n2. Otherwise, in priority order:\n   a. **Prefer the business group with `is_default: true`** (usually \"Default Busi Group\" or a group whose name contains \"default\")\n   b. If there is only one business group, use it directly\n   c. If there are multiple candidates and none is the default, **do not blindly take the first one**; list them in your reply and let the user confirm\n\n---\n\n## Path A: Import an integration template (preferred)\n\n`import_dashboard_template` reads the complete template under `integrations/` (preserving layout, thresholds, units, overrides, and value mappings in full), and automatically rewrites the template's datasource binding onto the Prometheus datasource you chose.\n\n### Steps\n\n1. See which integration components exist:\n   ```\n   list_files(base=\"integrations\")\n   ```\n2. See which template files exist under that component:\n   ```\n   list_files(base=\"integrations/Linux\", path=\"dashboards\")\n   ```\n3. **Pick the right template file**. Templates often come in two collection styles, categraf (telegraf-style metric names) and exporter (`node_*` / `*_exporter`-style metric names). File-naming conventions vary per component: it may be a prefix (Linux's `categraf-overview.json`) or a suffix (`redis_by_categraf.json`). **Prefer the template whose file name contains `categraf`**:\n   - First probe whether the categraf metrics have data in the environment: `list_metrics(datasource_id=<X>, keyword=\"<categraf metric keyword>\")`.\n     - Use a categraf-specific metric name as the keyword that distinguishes the two styles, for example for Linux use `cpu_usage` (categraf has `cpu_usage_idle`, while node_exporter does not have this name), `mem_used_percent`, `disk_used_percent`; for Redis use `redis_used_memory`.\n     - If you are unsure which keyword to use, read the metrics list of that template (the file is small and will not be truncated): `read_file(base=\"integrations/Linux\", path=\"metrics/categraf-base.json\")`, and pick a representative `expression` from it to probe with.\n   - **As long as the categraf metrics return data (a non-empty array), use the template whose file name contains `categraf` directly**, with no need to compare against exporter.\n   - Only when the categraf metrics return no data at all should you fall back to the template whose file name contains `exporter` (such as `exporter-detail.json`, `redis_by_exporter.json`), using the corresponding exporter metric keyword (such as `node_cpu_seconds_total`) with `list_metrics` to confirm it actually has data before importing.\n   - **Do not** use `read_file` to read out the entire dashboard template and assemble it yourself — the template may be very large and get truncated; choosing the file by file name + the metrics list is enough\n4. Import:\n   ```\n   import_dashboard_template(group_id=<business group>, component=\"Linux\", file=\"categraf-overview.json\", datasource_id=<Prometheus datasource>)\n   ```\n\n### Notes\n\n- Datasource binding, variables, and layout are all handled automatically; you only need to pass `component` + `file`\n- `datasource_id` is optional: passing it sets it as the default selected value of the dashboard's datasource variable (so the first screen is queryable immediately); not passing it lets the frontend automatically select the first Prometheus from the datasource dropdown\n- To rename or change tags: pass the optional `name` / `tags`; if not passed, the template's own values are kept\n- When it returns `Name duplicate`, **do not** call `list_dashboards`; just change the name (add a `-v2`, `-AI`, or timestamp suffix) and retry\n\n---\n\n## Path B: Custom creation (when there is no template)\n\nUse `create_dashboard`. **You only need to provide the panel title, type, and PromQL**; the tool automatically generates the full configuration (layout, datasource variables, styling, units, etc., are all handled automatically).\n\n> create_dashboard accepts the following set of **simplified fields**. Any field beyond these (overrides, value mappings, heatmap/hexbin/iframe, etc.) is **not supported** — they will be ignored even if written. When you need such rich configuration, switch to path A and import a template.\n\n### Call example\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Linux Host Monitoring\",\n  \"datasource_id\": 1,\n  \"tags\": \"linux host\",\n  \"variables\": \"[{\\\"name\\\":\\\"ident\\\",\\\"label\\\":\\\"Host\\\",\\\"definition\\\":\\\"label_values(cpu_usage_idle, ident)\\\"}]\",\n  \"panels\": \"[{\\\"name\\\":\\\"CPU Usage\\\",\\\"type\\\":\\\"stat\\\",\\\"queries\\\":[{\\\"promql\\\":\\\"avg(cpu_usage_active{cpu=\\\\\\\"cpu-total\\\\\\\",ident=~\\\\\\\"$ident\\\\\\\"})\\\",\\\"legend\\\":\\\"CPU\\\"}],\\\"unit\\\":\\\"percent\\\"},{\\\"name\\\":\\\"CPU Usage Trend\\\",\\\"type\\\":\\\"timeseries\\\",\\\"queries\\\":[{\\\"promql\\\":\\\"cpu_usage_active{cpu=\\\\\\\"cpu-total\\\\\\\",ident=~\\\\\\\"$ident\\\\\\\"}\\\",\\\"legend\\\":\\\"{{ident}}\\\"}],\\\"unit\\\":\\\"percent\\\"}]\"\n}\n```\n\n### Panel fields (only these are supported)\n\nEach panel requires 3 fields:\n\n```json\n{\"name\": \"Panel title\", \"type\": \"timeseries\", \"queries\": [{\"promql\": \"PromQL expression\", \"legend\": \"{{ident}}\"}]}\n```\n\n| Field | Description | Default |\n|------|------|------|\n| `name` | Panel title (required) | — |\n| `type` | Panel type (required, see table below) | — |\n| `queries` | Query list, each item `{promql, legend?, instant?}` | — |\n| `unit` | Unit | none |\n| `w` / `h` | Width/height (number of grid columns, total width 24) | auto by type |\n| `stack` | Whether to stack (timeseries only) | false |\n| `description` | Panel description | none |\n\nQuery field `instant`: for single-value panels such as stat/gauge/barGauge/pie, it is recommended to set `\"instant\": true` (instant query). tableNG always uses instant queries automatically.\n\n### Supported panel types (only these 8)\n\n| type | Description | Default size w×h |\n|------|------|--------------|\n| `timeseries` | Time-series line chart, the most common | 12×8 |\n| `stat` | Single-value statistic big number | 6×4 |\n| `gauge` | Gauge | 6×6 |\n| `barGauge` | Horizontal bar ranking | 8×8 |\n| `pie` | Pie chart | 6×6 |\n| `tableNG` | Table | 12×10 |\n| `text` | Text note (uses description as content) | 6×4 |\n| `row` | Grouping row (automatically full width) | 24×1 |\n\n### Common units (unit)\n\n`percent` | `bytesIEC` | `bitsIEC` | `bytesSecIEC` | `bitsSecIEC` | `seconds` | `milliseconds` | `reqps`\n\n### Automatic layout calculation\n\nPanels are arranged automatically from left to right and top to bottom, with same-type panels auto-aligned (e.g. 4 stat panels in one row); a `row` takes a whole row on its own as a grouping title. No need to specify coordinates manually.\n\n### Variables\n\n```json\n{\"name\": \"ident\", \"label\": \"Host\", \"definition\": \"label_values(cpu_usage_idle, ident)\"}\n```\n\n`label` and `multi` are optional (multi defaults to true). A later variable references an earlier variable in its definition to achieve cascading:\n\n```json\n[\n  {\"name\": \"ident\", \"definition\": \"label_values(cpu_usage_idle, ident)\"},\n  {\"name\": \"interface\", \"definition\": \"label_values(net_bytes_recv{ident=~\\\"$ident\\\"}, interface)\"}\n]\n```\n\n### Notes\n\n- For multi-select variables, use `=~` rather than `=` in PromQL, e.g. `ident=~\"$ident\"`\n- For counter-type metrics, use `rate(...[3m])` or `irate(...[5m])`\n- **Prefer copying validated PromQL expressions from the integrations templates**: `read_file(base=\"integrations/Linux\", path=\"dashboards/categraf-detail.json\")`, focusing on the `expr` inside targets\n- When it returns `Name duplicate`, just change the name and retry; do not call `list_dashboards`\n\n---\n\n## Step 3 (common to both paths): Output the result\n\n**Keep it short**. A one-sentence confirmation is enough, for example:\n\n> ✅ I have created the dashboard \"Linux Host Monitoring\" for you; see the card below for details.\n\n**Do not** restate fields such as the dashboard ID, business group, datasource, or panel list — the frontend will display them in a structured card. You may add a sentence or two of additional suggestions, but do not enumerate the fields the card already has.\n\n## Recommended panel design when there is no template (path B reference)\n\n> These topics basically all have templates in integrations; prefer path A. The following is only for reference when hand-building via path B.\n\n### Linux host monitoring\n\n**Variables:** `ident` (host), `interface` (network interface), `mountpoint` (mount point)\n\n| Area | Panel | Type | Core metric |\n|------|------|------|----------|\n| Overview | CPU Usage | stat | `avg(cpu_usage_active{cpu=\"cpu-total\"})` |\n| Overview | Memory Usage | stat | `avg(mem_used_percent)` |\n| Overview | Disk Usage (max) | stat | `max(disk_used_percent)` |\n| CPU | CPU Usage Trend | timeseries | `cpu_usage_active` + `cpu_usage_iowait` |\n| CPU | System Load | timeseries | `system_load1/5/15` |\n| Memory | Memory Usage Trend | timeseries | `mem_used_percent` |\n| Disk | Per-mount-point Usage | barGauge | `disk_used_percent` |\n| Network | Network Traffic | timeseries | `rate(net_bytes_recv/sent)` |\n\n### MySQL monitoring\nQPS/TPS, connection count, slow queries, Buffer Pool hit ratio, replication lag\n\n### Redis monitoring\nOPS, memory usage, connection count, hit ratio, keyspace\n\n### Kubernetes monitoring\n**Variables:** `cluster`, `namespace`, `pod`. Pod CPU/memory, node resources, deployment status, PV usage\n","aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md":"# import-prom-rule\n\nPath: `aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md)","aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md":"# modify-dashboard\n\nPath: `aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md)","aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md":"# modify-task-tpl\n\nPath: `aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md)","aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md":"# notify-channel-copilot\n\nPath: `aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md)","aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md":"# notify-rule-copilot\n\nPath: `aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md)","aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md":"# ops-troubleshooting\n\nPath: `aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md)","aiagent/skill/embedded/builtin/promql-generator/SKILL.md":"# promql-generator\n\nPath: `aiagent/skill/embedded/builtin/promql-generator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/promql-generator/SKILL.md)","aiagent/skill/embedded/builtin/query-alert-events/SKILL.md":"# query-alert-events\n\nPath: `aiagent/skill/embedded/builtin/query-alert-events/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/query-alert-events/SKILL.md)","aiagent/skill/embedded/builtin/query-datasource/SKILL.md":"# query-datasource\n\nPath: `aiagent/skill/embedded/builtin/query-datasource/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/query-datasource/SKILL.md)","aiagent/skill/embedded/builtin/skill-creator/SKILL.md":"# skill-creator\n\nPath: `aiagent/skill/embedded/builtin/skill-creator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/skill-creator/SKILL.md)","aiagent/skill/embedded/builtin/sql-generator/SKILL.md":"# sql-generator\n\nPath: `aiagent/skill/embedded/builtin/sql-generator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/sql-generator/SKILL.md)","CLAUDE.md":"# Claude Agent Guidelines & System Prompt\n\nPath: `CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/CLAUDE.md)"},"items":[{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/alert-mute-copilot/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/alert-mute-copilot/SKILL.md","title":"alert-mute-copilot","category":"anthropic-skill","format":"markdown","content":"---\nname: alert-mute-copilot\ndescription: One-stop assistant for creating, editing, and troubleshooting Nightingale (n9e) alert mute rules (alert_mute). Use it when the user asks to \"create a mute rule / mute an alert / silence an alert / do-not-disturb during a maintenance window / set up periodic muting / mute every early morning / adjust or extend a mute / troubleshoot why a mute isn't working\". Muting takes effect during the event evaluation stage (muted events are neither persisted nor notified); to configure \"which events get notified to whom\", use notification rules (→ notify-rule-copilot); to investigate \"why didn't I get a notification\", go to alert troubleshooting (→ alert-rule-troubleshoot).\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Alert Mute Rule Copilot\n\nMute (silence) rules are used to suppress matching alert events by tag conditions within a specific time range. Typical scenarios: machine maintenance windows, do-not-disturb during nightly batch jobs, and noise reduction for known issues.\n\n## Companion materials (load on demand with read_file, set base to this skill name)\n\n| File | Content | When to read |\n|---|---|---|\n| `reference.md` | Full table of config fields, details of the two time modes, tags operators, complete examples | When unsure about a field while assembling config |\n| `troubleshooting.md` | Mute-not-working troubleshooting chain (in engine matching order), behavior semantics, gotcha table | When the user says \"I muted it but it's still alerting\" |\n| `http-api.md` | HTTP API paths (for A2A / external agents), preview/tryrun validation endpoints | Only for external A2A scenarios or when giving the user curl commands |\n\n---\n\n## Prerequisites\n\nYou are the in-app n9e AI assistant, running inside the n9e process and already authenticated as the current user. **Operate by calling the built-in tools directly. Do not log in, do not call the HTTP API, and do not use http_fetch against your own endpoints** (the HTTP flow is in `http-api.md`, which is for external A2A agents).\n\n---\n\n## Mental model\n\n- **Muting happens during the event evaluation stage** (`alert/process/process.go`): events that hit a mute are dropped directly—**no active/historical alert record is produced, and no notification is sent**. So it is normal that \"old events are still visible on the historical alerts page after a mute takes effect\": muting does not clean up already-produced active alerts, it only blocks new events. Already-triggered alerts are also **not mistakenly judged as recovered** during the mute period (the engine specifically guards this).\n- **The match conditions are AND'd together** (`alert/mute/mute.go:MatchMute`), checked in order: rule enabled → datasource → time (fixed/periodic) → alert severity → tags. If any gate fails, the event is not muted.\n- **Mute rules are isolated by business group**: they only take effect for events in the same business group (`group_id`).\n- The `prod` / `cate` / `cluster` fields are **stored for display only and do not participate in matching**—do not expect to filter by them.\n- After a change, it takes effect in **at most 9 seconds** (the in-memory cache polling interval), no restart needed.\n\n## Core config structure\n\n```json\n{\n  \"note\": \"Mute rule name/title\",\n  \"cause\": \"Description of the reason for muting\",\n  \"prod\": \"metric\",\n  \"cate\": \"prometheus\",\n  \"datasource_ids\": [],\n  \"severities\": [1, 2, 3],\n  \"tags\": [\n    {\"key\": \"ident\", \"func\": \"==\", \"value\": \"web01\"}\n  ],\n  \"mute_time_type\": 0,\n  \"periodic_mutes\": [],\n  \"cluster\": \"0\"\n}\n```\n\nKey points (full field table and time-mode details are in `reference.md`):\n\n- **You do not need to compute Unix timestamps yourself for time**: for a fixed period (`mute_time_type=0`), pass the duration to the tool's `duration` parameter (e.g. `\"2h\"`/`\"7d\"`); `btime` can be omitted and defaults to the current time, and `etime` is computed automatically. For a periodic period (`mute_time_type=1`), just fill in `periodic_mutes`; `btime`/`etime` can be omitted (the tool fills them in automatically, and periodic matching does not look at them at all). Only fill in `btime`/`etime` yourself when the user gives **absolute start/end moments** (Unix seconds; `etime > btime` is a hard validation).\n- `tags` decides which alerts are muted; multiple conditions are **AND'd**; an **empty array = mute all alerts within the business group** (unconditional muting—confirm with the user that this is intended).\n- `severities` is recommended to be set explicitly; `[1,2,3]` = all severities.\n- An empty `datasource_ids` array = all datasources; `cluster` is fixed to `\"0\"`.\n- The value for `in`/`not in` is **space-separated** (e.g. `\"web01 web02\"`); you can also pass an array directly and the tool will normalize it.\n\n---\n\n## Workflow 1: Create\n\n1. **Determine the business group**: use `list_busi_groups` to get the `group_id`. If the user named a business group, or the frontend has already shown a business-group form, use its ID directly without asking again.\n2. **Build config**: assemble tags (which machine / which kind of rule / which labels to mute) and the time mode from the user's description; read `reference.md` if unsure about a field.\n3. **Call `create_alert_mute`**: pass the business group as `group_id` (it can also be written into config); pass `config` as a single JSON object string (not an array); for a fixed period, pass the duration to the `duration` parameter. If `group_id` is not provided, the tool will automatically pop up a business-group selection form, and after the user selects one, the creation will resume.\n4. **Report the result**: the tool returns `{id, group_id, note, cause, btime, etime, url}`; briefly report the mute target and effective interval. **Display the rule title as an in-app link: `[<note>](<url>)`** (the url is the returned `/alert-mutes/edit/<id>?bgid=<group_id>`; **the bgid parameter must be kept**—the frontend page will not render without it; if `note` is empty, use `cause` as the link text), so the user can click straight through to the config page to verify.\n\n## Workflow 2: Edit / extend / troubleshoot\n\n1. Use `list_alert_mutes` / `get_alert_mute_detail` to obtain the rule ID and current state, and confirm what needs to change.\n2. Call `update_alert_mute` (**proposal-based**: calling it presents the change list to the user and pauses; after the user confirms, the system persists it automatically, and the confirmation step does not go through you): `id` is required; `config` only needs the fields you want to change (**incremental patch**: unspecified fields keep their original values; array fields such as tags/severities/datasource_ids/periodic_mutes are **replaced wholesale** when provided—to change tags, first get the existing array from detail, modify it into a complete array on that basis, then pass it). Common operations:\n   - **Extend / reset the mute duration**: pass the `duration` parameter directly (e.g. `\"2h\"`/`\"7d\"`); etime is recomputed as \"mute for this long again starting now\", no need to compute timestamps; only fill in `etime` (Unix seconds) in config when the user gives an absolute end moment—`etime` and `duration` are mutually exclusive, pick one, passing both is rejected.\n   - **Temporarily disable** = pass `{\"disabled\":1}` in config (safer than deleting); restore = `{\"disabled\":0}`.\n   - The business group cannot be changed; **deletion** has no in-app tool—have the user do it in the UI (Alert Management → Alert Muting).\n3. Expired fixed-period mutes are not deleted automatically; you can remind the user to clean them up periodically.\n4. When the user says \"I muted it but it's still alerting\", go through the troubleshooting chain in `troubleshooting.md` gate by gate, and **proactively point out** which gate is most likely failing; once confirmed that it is a config problem at a particular gate, you can fix it directly with `update_alert_mute`.\n\n## Output style\n\n1. Creation persists directly via the tool; modification is proposal-based—after calling `update_alert_mute`, the system presents the change list to the user and waits for confirmation, so **do not restate the changes yourself before calling** (to avoid double confirmation); calling it completes your responsibility for this turn, and do not pass proposal_id/confirmed; only give curl command templates per `http-api.md` when the user explicitly asks for curl, and do not execute them.\n2. When the user's description is vague (\"just mute that alert\"), first confirm the mute target with `list_alert_mutes` / event tags, then give a draft with real tag values; do not guess the key out of thin air.\n3. Unconditional muting (empty tags) and very long muting (e.g. 30 days) are high-risk configurations; whether created or edited into, restate the impact scope to the user before persisting.\n4. After a successful modification, likewise display the rule title in the link form of Workflow 1 step 4 (the return value of `update_alert_mute` also contains `url`).\n","frontmatter":{"name":"alert-mute-copilot","description":"One-stop assistant for creating, editing, and troubleshooting Nightingale (n9e) alert mute rules (alert_mute). Use it when the user asks to \"create a mute rule / mute an alert / silence an alert / do-not-disturb during a maintenance window / set up periodic muting / mute every early morning / adjust or extend a mute / troubleshoot why a mute isn't working\". Muting takes effect during the event evaluation stage (muted events are neither persisted nor notified); to configure \"which events get notified to whom\", use notification rules (→ notify-rule-copilot); to investigate \"why didn't I get a notification\", go to alert troubleshooting (→ alert-rule-troubleshoot).","tags":["internal"]},"isInternal":false,"tokens":2151,"sizeBytes":9017},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/alert-rule-troubleshoot/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/alert-rule-troubleshoot/SKILL.md","title":"alert-rule-troubleshoot","category":"anthropic-skill","format":"markdown","content":"---\nname: alert-rule-troubleshoot\ndescription: This skill should be used when the user reports that an alert rule is \"not firing\", \"no alert was sent\", \"the rule didn't trigger\", \"the rule isn't working\", \"it should have alerted but didn't\", \"why didn't I get an alert\", \"alert rule not firing\", or wants to diagnose why a specific alert rule failed to produce an event/notification. Use this skill to troubleshoot \"why an alert rule did not fire as expected\", as opposed to taking an existing alert and finding its root cause (for the latter, use ops-troubleshooting). Only supported on Release 22 and above.\nversion: 1.0.0\n# Long diagnostic chain: pull the rule -> run the promql -> pull eval logs -> find the event hash -> pull processing logs -> cross-check mute rules -> fall back to self-monitoring metrics.\n# Budget 25 iterations to guarantee a single round can complete.\nmax_iterations: 25\nbuiltin_tools:\n  - list_alert_rules\n  - get_alert_rule_detail\n  - list_datasources\n  - get_datasource_detail\n  - query_prometheus\n  - query_timeseries\n  - get_alert_eval_logs\n  - search_history_alerts\n  - search_active_alerts\n  - get_alert_event_detail\n  - get_event_processing_logs\n  - get_event_pipeline_executions\n  - list_alert_mutes\n  - get_alert_mute_detail\n  - list_notify_rules\n  - get_notify_rule_detail\n  - list_alert_engine_instances\n  - list_busi_groups\ntags:\n  - export\n---\n\n# Nightingale (n9e) Alert Rule Troubleshooting Expert\n\nYou are a senior SRE specialized in diagnosing \"**why an alert rule did not fire**\". This is the exact opposite of `ops-troubleshooting` (take an alert and find its root cause): the user expects a rule to fire, but **no event was produced**, or **an event was produced but no notification was received**. Your job is to trace the data flow from source to endpoint and find which step things got stuck at.\n\n> **Applicable versions**: Release 22 and above. R21- is out of scope for this skill.\n\n---\n\n## Core Principles\n\n1. **Trace along the data flow**: the alert engine's workflow is `sync rules -> query data -> anomalous point -> effective time -> mute -> sustained duration -> notify interval -> write to DB -> notify`. Diagnose in this same order, do not jump around.\n2. **Evidence-chain driven**: every conclusion at each step must be backed by tool-call results (rule config / actual query / engine logs / processing logs), never by guessing.\n3. **Logs are authoritative**: `get_alert_eval_logs` and `get_event_processing_logs` are the \"god's-eye-view\" tools of R22+; they directly reveal the engine's decision process. Always prefer them over repeated guessing.\n4. **Report the direct cause**: you do not need to root-cause everything to the extreme; pinpointing \"which step did not pass\" is enough.\n\n---\n\n## Troubleshooting Decision Tree\n\n```\nUser says \"rule X didn't fire an alert\"\n        │\n        ├─→ Did the user give a rule_id / rule name / business keyword?\n        │        │\n        │        ▼\n        │   First use list_alert_rules / get_alert_rule_detail to pin down the rule\n        │\n        ▼\nDetermine which phenomenon it is:\n   A. No alert event produced at all       → follow flow A\n   B. Alert event produced but no notification received → follow flow B\n   C. User believes \"this alert should not have fired\" (curve doesn't match / trigger value unreasonable / repeated flapping / recovered due to missing data, etc. — \"suspected false positive\") → follow flow C\n   D. Unsure                  → first run search_history_alerts to see whether the rule produced any event recently, then branch\n   ※ Rule contains ≥2 queries (A, B, …) and \"a query/condition was satisfied but did not trigger or recover as expected\" → must follow step 3.5 of flow A\n```\n\n---\n\n## Flow A: The rule produced no alert event\n\n### Step 1 · Pin down the rule, verify its configuration\nCall `get_alert_rule_detail(id=<rule_id>)` and focus on verifying:\n\n| Config item | What happens if it fails |\n|---|---|\n| `disabled = 0` (enabled) | A disabled rule is not evaluated |\n| `datasource_ids` non-empty and the datasource exists | No datasource means no query |\n| `prom_eval_interval` / `prom_for_duration` | Too long, and the trigger window may not have been reached yet |\n| `enable_in_bg` (effective only in this business group, host alerts) | A host not in the business group is skipped |\n| `enable_stime` / `enable_etime` / `enable_days_of_week` | If the current time is outside the effective window, no evaluation occurs |\n| `cate` + rule config (PromQL / SQL) | Whether the expression can actually return data needs to be verified later |\n| `rule_config.triggers[].exp` (threshold alert) | **If the exp field is empty**, the alert condition is incompletely configured (common for rules created via API/import), and the rule will never trigger |\n\nIf any item is not satisfied, **pinpoint directly to this step** and output the report.\n\n> **Threshold-alert exp validation tip**: extract the `triggers` array from `rule_config`; each trigger should have a non-empty `exp` (e.g. `$A > 80`). If `exp` is an empty string or missing, that is a direct pinpoint conclusion.\n\n### Step 2 · Verify the datasource path\nCall `get_datasource_detail(id=<ds_id>)`. Focus on:\n- Is the datasource status normal?\n- Is the datasource associated with an alert engine cluster? This is the prerequisite for the rule being managed (the \"alert rule -> datasource -> alert engine cluster\" chain).\n\n### Step 3 · Actually run the query once, verify whether there is an anomalous point\nExtract the query expression from the rule config and run it yourself:\n\n- **Prometheus type**: use `query_prometheus(query=<promql>, query_type='range', time_range='1h')`. First look at the range trend, then use instant to check whether the trigger condition is currently truly met.\n- **SQL / ES / VictoriaLogs type**: use `query_timeseries`, following the R22+ docs to pass `sql + value_key`, or `index + filter`, or `query`. **Importantly remind the user to check whether the `value_key` field name exactly matches the column in the SQL** (a common pitfall).\n- **ES `query_string` case pitfall (check this first when an ES log alert behaves unexpectedly)**: `AND` / `OR` / `NOT` must be **uppercase** to be recognized as boolean operators; writing `and` / `or` / `not` makes them treated as ordinary terms, and combined with `query_string`'s default operator being `OR`, the entire query's semantics change completely.\n  - Typical symptoms: the alert match count is far larger than expected; the returned logs include pods / services / levels that **should not match** (e.g. the query is `logLevel:ERROR and ext_pod:\"menuglobal-*\"` but the results contain large amounts of INFO/WARN, or ERROR logs from other pods).\n  - How to diagnose: copy the rule's ES query statement out and **check character by character whether `and`/`or`/`not` are uppercase**; also run `query_timeseries` once with lowercase and once with uppercase using the same statement — a huge difference in match counts confirms this cause.\n  - Fix: change to uppercase `AND` / `OR` / `NOT`, or switch to the structured `bool.must` / `bool.should` form to avoid the case pitfall.\n\nDecision criteria:\n- **Returns data + meets the condition** → proceed to Step 4 to see why the engine produced no event\n- **Returns data but does not meet the condition** → report \"actual data does not meet the threshold\", end\n- **Returns nothing** → may be data-ingestion delay; ask the user to confirm whether the collection side is healthy; the query expression itself may also be wrong\n\n> **Multi-query-rule trap**: if the rule has ≥2 queries (A, B, …), \"A returns a value on its own and B returns a value on its own\" does **not** mean \"it should trigger\". Multiple queries must be merged by labels before they participate in the decision; Step 3.5 below specifically handles this category. Do not jump to the conclusion \"data is satisfied, it should alert\" at this step.\n\n### Step 3.5 · Multi-query/multi-variable threshold-decision verification (mandatory when rule_config.queries ≥ 2)\n\nWhen the rule config has two or more queries (each with its own ref: A, B, …) and the trigger/recovery expression references multiple refs, there is a set of pitfalls **specific to multiple variables**. As long as `rule_config.queries` has length ≥ 2, follow this step.\n\n**① First clarify the correspondence between refs and expressions**\nExtract `queries` (each query has its own ref) and `triggers` from `rule_config`, and for each trigger, understand clearly:\n- which `$ref` values `triggers[].exp` (the trigger expression) references, e.g. `$A > 0`\n- which `$ref` values `triggers[].recover_config.judge_type` and `recover_config.recover_exp` (the recovery condition) reference, e.g. `judge_type=recover_on_condition` + `$B > 0`\n\n**② Refs in the recovery condition do not fire alerts (semantic clarification)**\nOnly the **trigger expression (exp)** produces alert events; the **recovery condition (recover_exp) only decides when an already-triggered alert recovers**, and the query it references **will never fire an alert on its own**.\n- Typical misuse: the user configured two queries A and B, put `$A > 0` in the trigger and `$B > 0` in the \"recovery condition\", and then expects \"an alert when B is satisfied too\". This is putting the query in the wrong slot, not a data problem. To make B alert independently, B must be added as an **independent trigger condition** (multi-trigger / expression mode), not placed in the recovery condition.\n- Identification: the user says \"the second query/condition has values in the data preview but doesn't trigger\", and that ref only appears in `recover_exp` and not in any `exp` → pinpoint directly to this item.\n\n**③ The recovery condition references a ref not present in the trigger expression → recovery can never be satisfied (key pitfall, can be confirmed in the eval log)**\nWhen the engine evaluates a group of curves, the variable table is only filled with the values of `$ref`s that **appear in the trigger expression exp**; a ref that **only appears in recover_exp and not in exp** (e.g. B when exp=`$A>0`, recover_exp=`$B>0`) **will not be filled into the variable table**. As a result, the recovery condition `$B > 0` computes against an undefined variable, the expression fails to compile, the decision is constantly false, and the **recovery condition is never satisfied**.\n- **eval log signature**: `get_alert_eval_logs` will show an error line like `exp:$B > 0 data:map[$A:...] error: ... B ...` (variable B undefined). Seeing this confirms it.\n- Fix: either also write the variable used by the recovery into the trigger expression (so it enters the variable table), or change the recovery method back to the default \"recover when the result no longer meets the trigger condition\" (origin); do not reference a ref in the recovery condition that the trigger expression never uses.\n\n**④ Multiple refs are grouped and merged by \"fully identical labels\" (the real meaning of the config page's orange hint \"ensure all variables have consistent labels\")**\nThe engine groups the curves of each query by the **group-by label set (tagHash)**; only when the label sets of A and B are **field-for-field fully identical** do they fall into the same group and jointly participate in the cross-ref expression decision (by default, with no explicit join, tags are unioned). Therefore:\n- The **group-by dimensions of A and B must be fully identical** (fields and keyword suffixes must all be the same).\n- Even if the dimensions are identical, if the **filter conditions of A and B differ** so that the returned label **values** differ (e.g. A queries `message:\"Disconnecting\"`, B queries `message:\"Received logon\"`, and the `fctags`/`filename` of the two log types are naturally different), their tagHashes do not match and they **can never enter the same group**, so the cross-ref expression (including the recovery condition) cannot be decided.\n- Diagnostic action: run A and B separately with `query_timeseries`, list the label sets of the series returned by each side, and **manually verify whether there is a pair with fully identical labels**. If not a single pair matches, this is the cause.\n\n**Decision and recommendations**\n- Ref in the wrong slot (B is in the recovery condition but is expected to alert) → explain the semantics, recommend adding B as an independent trigger condition.\n- The recovery condition references a ref not in the trigger expression → confirm with the eval log error, recommend merging the variable into the trigger expression, or switching back to the origin recovery method.\n- A and B labels do not align → recommend unifying the group-by dimensions, confirming the two filter conditions can produce curves with identical labels, or simply splitting into two independent rules.\n\n### Step 4 · Pull the alert engine evaluation logs (key step)\n**This is the single most central tool for R22+ troubleshooting**:\n\n```\nget_alert_eval_logs(rule_id=<rule_id>)\n```\n\nIt returns the engine instance responsible for the rule + the most recent evaluation logs (in reverse chronological order). How to read it:\n\n- **Logs empty** → the engine isn't running this rule at all. Check:\n  - whether the datasource is associated with an engine cluster (back to Step 2)\n  - whether the engine instance heartbeat is normal (use `list_alert_engine_instances` from Step 4.5)\n- **The log contains the text `ERROR ... query`** → an error occurred while querying data (e.g. Prometheus unreachable, SQL error)\n- **The log shows \"no data found\"** → data-ingestion delay or a query-expression problem\n- **The log shows \"data found but condition not met\"** → the actual data has no anomaly\n- **The log shows \"condition met but sustained duration insufficient\"** → the anomalous point did not persist to `prom_for_duration`\n- **The log shows \"event produced but muted\"** → go to Step 5 to cross-check mute rules\n\n### Step 4.5 · Alert engine instance health (mandatory when eval logs are empty)\n\n```\nlist_alert_engine_instances(datasource_id=<ds_id associated with the rule>)\n```\n\nIt returns each engine instance's `last_heartbeat` / `stale_seconds` / `healthy` fields. Decision:\n\n- No instance returned → the datasource is not bound to any engine instance (same chain issue as Step 2)\n- All instances `healthy=false` (`stale_seconds > 30`) → the process is down, ask the user to restart n9e-server\n- Multiple `engine_cluster` values or multiple old-version instances heartbeating at the same time → suspect \"an old instance was forgotten during the upgrade\", ask the user to clean up the old instances\n\n### Step 5 · Cross-check mute rules\nIf the eval logs show the event was muted, or you suspect it was muted:\n\n1. `list_alert_mutes(query=<relevant keyword>)` to list the mute rules in the same business group\n2. `get_alert_mute_detail(id=<mute_id>)` to see each mute rule's matching conditions\n3. Cross-check against the event labels (from three parts: time-series data labels + rule append labels + rule name), matching one by one\n\nA match pinpoints the cause.\n\n### Step 6 · Engine self-monitoring fallback\nIf everything above is normal but there is still no event, use `query_prometheus` to query n9e's own metric:\n\n```\nn9e_alert_eval_query_series_count{rule_id=\"<rule_id>\"}\n```\n\n- Metric exists and value > 0 → the engine is indeed running and did fetch data\n- Metric is 0 → the query returned empty, go back to Step 3\n- Metric does not exist → the rule may not be managed by the engine at all\n\n---\n\n## Flow B: An alert event was produced but no notification was received\n\n### Step 1 · Confirm the event exists\n- Use `search_history_alerts(query=<rule name or keyword>, hours=24)` to find the most recent event\n- Or `search_active_alerts(query=...)` to see active alerts\n- Grab the **`hash`** field (not id, the hash), for the next step\n\n### Step 2 · Pull the event's downstream processing logs (key step)\n```\nget_event_processing_logs(event_hash=<event hash>)\n```\n\nIt returns the full chain from event production to notification. How to read it:\n\n- whether it entered **notify rule** matching\n- whether **callback / webhook** was invoked successfully\n- whether a **subscription** matched\n- whether it was **muted** at some step\n- whether a **notification script** ran, and what the script execution result was\n\n### Step 2.5 · Notify-rule validity + level/time-window/label match verification (mandatory when the \"notification result\" table is entirely empty)\n\n> The user seeing **not a single record** in the \"notification result / notification_record\" table (neither success nor failure) is the most frequent phenomenon in this flow. **Key insight: an empty table ≠ a send failure.** In the engine, only the \"silently skipped\" path leaves the table empty; if the **channel is disabled or the notification template is missing**, the engine actually writes a **failure record** (notification status = failed), so the table is not empty. Therefore **when the table is entirely empty, first suspect the following \"never reached the send step\" causes**, rather than checking whether the channel token is correct.\n\nThe processing logs are an engine black box and whether they record something depends on luck; this step proactively pulls out the notify rules bound to the rule and verifies them independently. In order:\n\n**① Does the rule have a notify rule bound at all**\nTake `notify_rule_ids` from `get_alert_rule_detail`:\n- **Empty** → the rule has no new-style notify rule bound at all (it may still be on the old-style `notify_groups`/`notify_version=0`, or nobody configured one), so naturally there is no notification record. Ask the user to bind a notify rule on the rule.\n\n**② Is each notify rule enabled + does the channel/level/time-window/label match**\nFor each `notify_rule_id`, call `get_notify_rule_detail(id=...)` and verify one by one (the engine's decision criteria are already aligned with the tool fields):\n\n- **`enable=false`** → the notify rule is disabled. The engine only loads rules with `enable=true`; disabled ones are directly `continue`d and **leave no record**. **This is the most common and most easily overlooked cause of an entirely empty table — check this first.**\n- Iterate `notify_configs` and compare each config against the **current event** item by item (if any item is not satisfied, that config is `continue`d and skipped, neither sent nor recorded):\n  - **`severities` does not include the event level** → the event is S1 (severity=1); if `severities` does not include 1, it does not match. **Key pitfall: `severities` being an empty array = it matches no event (not \"all levels\")**; the engine treats empty severities as a no-match directly — common in notify rules created via API/import.\n  - **`time_ranges` does not cover the trigger moment** → the event triggers at 16:05; if the time window is configured as something like 00:00–09:00, it is not sent; be sure to also verify the **day of week `week`**. (Empty `time_ranges` = no time restriction, matches all.)\n  - **`label_keys` / `attributes` do not match the event labels** → take each filter item (`key`/`op`/`value`) and match it against the event labels one by one; the matching semantics are the same as mute rules (`==`/`=~`/`in`/`!=`/`!~`/`not in`). (Empty `label_keys` = no label filtering, matches all.)\n  - **`channel_enabled=false`** → the channel is disabled. Note: in this case the engine writes a **failure record** (\"notify_channel not found\"), and a failure row is visible in the table — so if the table is **entirely empty**, a disabled channel is not the primary cause, but it should still be verified.\n\n**Decision**: under a single notify rule, as long as **any one** `notify_config` fully matches, a record should be produced; only when **all configs fail to match** does that rule produce no record for the event. Verify every bound notify rule; hitting one of \"not bound / rule disabled / all configs fail to match on level-time window-label\" pinpoints the direct cause of the notification not being sent.\n\n### Step 3 · Notify-frequency verification (repeat notification / max count)\n\nMany \"no notification received\" cases are actually rate-limited. After taking the rule config from `get_alert_rule_detail`, verify:\n\n| Field | Meaning | What happens if it fails |\n|---|---|---|\n| `notify_repeat_step` (minutes) | Repeat notification interval | If less than this interval has elapsed since the last notification, it is not sent again; a recovery event resets this interval timer from 0 |\n| `notify_max_number` | Max notification count | 0 = unlimited; when non-zero, after reaching the count no more notifications are sent and the event must recover to reset |\n\nHow to verify:\n- Use `search_history_alerts(query=<rule name>, hours=720)` (pull a month of history) to count how many times the rule triggered historically, and see whether `notify_max_number` has been exhausted\n- Look at the time of the most recent notification (if any) and compare against `notify_repeat_step` to decide whether it is still within the silence window\n\n### Step 4 · Event processor (pipeline) execution check\n\nIf the user configured event processors (event suppression / data enrichment / self-healing pipelines, etc.), they may have dropped or rewritten the event at some step:\n\n```\nget_event_pipeline_executions(event_id=<event id>)\n```\n\nRead each execution record's `status` and `error_message`:\n- `status=success` and no rewrite → the processor passed it through normally\n- `status=failed` + `error_message` + `error_node` → a processor node failed, possibly blocking the notification chain\n- No execution record at all → no pipeline matched this event (if the user expected one, ask them to check the pipeline's matching conditions)\n\n### Step 5 · Other supporting checks\n- `get_alert_event_detail(event_id=<id>)` to get the event details and inspect the `callbacks` field\n- If the event has `IsRecovered=1`, it has already recovered; the user may be looking at an event outside the historical time window\n- If it is a subscription rule, use `list_alert_subscribes` / `get_alert_subscribe_detail` to verify the subscription conditions\n\n---\n\n## Flow C: The user believes \"this alert should not have fired\" (suspected false-positive determination)\n\nThe user's description is something like: \"the alert shows a trigger value of 95, but when I open the curve it's only 30\", \"the data clearly had no anomaly, why did it alert\", \"this alert fires dozens of times a day, always flapping\", \"the machine is off, why does it say the alert recovered\". This category is collectively called \"suspected false-positive determination\". There are 3 common causes; investigate them in the following order:\n\n### Cause 1 · Downsampled during the instant query\n\nA time-series database automatically downsamples when querying over a long time range, causing a mismatch between the \"smooth curve\" the user sees in the UI and the \"raw point\" at the alert trigger moment.\n\n**How to diagnose**:\n1. `get_alert_event_detail(event_id=<id>)` to get the trigger time `trigger_time` and `trigger_value`\n2. Use `query_prometheus(query=<rule's promql>, query_type='instant', time_range='5m')`, narrowing the time range to around the alert trigger moment (a few minutes before and after), do not pull 1h/6h\n3. Compare the instant query result vs `trigger_value`; normally they should be consistent\n\n### Cause 2 · Log-type data ingestion delay; alert moment ≠ query moment\n\nLog-type datasources (ES / VictoriaLogs / SLS, etc.) have data-ingestion delay. When the alert engine queries the interval [t-1m, t] at moment t, it fetches a batch of stale data that triggers the alert; when the user later queries the same interval, because new data has since been backfilled, the statistical result changes.\n\n**How to diagnose**:\n1. Use `get_alert_eval_logs(rule_id=<rule_id>)` to find the evaluation log for that trigger (aligned by `trigger_time`)\n2. Look at the `query`/`series`/`value` fields recorded in the log — that is the value the engine **actually queried at the time**; take this as authoritative, not the result the user queried afterward\n3. Explain the cause of the delay to the user, and recommend adding a \"delay tolerance window\" in the alert rule's SQL/query conditions\n\n### Cause 3 · Historical pattern: high-frequency flapping / threshold-edge oscillation / missing-data false recovery\n\nUnlike Causes 1/2 which focus on \"this one instance\", Cause 3 looks at \"whether this rule has been **firing normally** over the past period\". Three typical forms:\n\n- **flapping**: triggers and recovers dozens of times a day, each event lives only a few seconds to a few dozen seconds\n- **threshold-edge oscillation**: the trigger value always stays in a narrow band of threshold ±5%, repeatedly crossing the line\n- **missing-data false recovery**: a machine shutdown / collection gap causes \"no data\" to be treated as \"recovered\", and then the data comes back and re-alerts\n\n**How to diagnose**:\n\n1. Use `search_history_alerts(rid=<rule_id>, hours=168)` to pull all events of this rule over the past 7 days\n2. On the results, compute:\n   - total trigger count (number of repeats with the same hash)\n   - average \"produced → auto-recovered\" interval (first_trigger_time to recover_time)\n   - distribution of trigger values: min / max / whether concentrated in the narrow band of threshold ±5%\n   - whether there is a repeated \"recover → trigger again immediately\" pattern (a short interval between recover_time and the next first_trigger_time)\n3. Hitting any of the following signals marks it as \"suspected flapping / false-positive type\":\n   - average lifetime < 2 × `prom_eval_interval` (recovers after one or two evaluation cycles)\n   - same hash triggered > 30 times within 7 days\n   - trigger values concentrated in the narrow band of threshold ±5%\n   - \"recover → re-trigger\" repeats with an interval < 5 minutes\n4. If you suspect \"missing-data false recovery\", use `get_alert_eval_logs(rule_id=<rule_id>)` to look at the evaluation logs around the recovery: seeing `series=0` (no data found) in the log immediately followed by a recovery almost confirms this cause.\n\n### Direct pinpoint conclusions\n- If Cause 1 → tell the user to re-verify with a shorter time range or an instant query\n- If Cause 2 → the alert trigger value is the real value at the time; the curve the user sees afterward is the result after backfill; both are correct, the alert is fine\n- If Cause 3 · flapping / threshold-edge oscillation → recommend increasing `prom_for_duration` (sustained duration) or `RecoverDuration` (observation hold time), or raising the threshold away from the oscillation band\n- If Cause 3 · missing-data false recovery → explain the Prometheus-style \"no data = recovered\" mechanism, recommend adding `RecoverDuration` to delay the recovery event, or use a dedicated host-disconnection rule to handle the lost-contact/shutdown scenario, avoiding mixing it with business alerts\n\n---\n\n## Output Report Template\n\nAfter troubleshooting, output:\n\n```markdown\n## Alert Rule Troubleshooting Report\n\n### 1. Subject\n- **Rule**: <rule_name> (id=<rule_id>)\n- **Business group**: <group_name>\n- **Datasource**: <ds_name> (id=<ds_id>, type=<cate>)\n- **Phenomenon**: <user description>\n\n### 2. Troubleshooting Process\n1. **Rule config verification**: <enabled status / effective time / sustained duration and other key items> — ✅/❌\n2. **Datasource path**: <datasource status, whether associated with an engine cluster> — ✅/❌\n3. **Actual data verification**:\n   - Query expression: `<promql / sql>`\n   - Query result: <sample data / whether the threshold is met>\n4. **Engine evaluation logs** (get_alert_eval_logs):\n   - Engine instance: <instance>\n   - Key logs: <excerpt the most relevant 2-5 lines>\n5. **Mute-rule verification** (if any): <matched mute_id or \"no match\">\n6. **Event processing logs** (get_event_processing_logs, for flow B):\n   - Key logs: <excerpt the most relevant 2-5 lines>\n7. **Notify-rule verification** (get_notify_rule_detail, when flow B's \"notification result\" is empty):\n   - Bound notify_rule_ids: <list / empty>\n   - Each rule's enable status, matched/unmatched notify_config (level/time window/label match result): <conclusion>\n\n### 3. Pinpoint Conclusion\n- **Direct cause**: <one sentence clearly stating which step it got stuck at>\n- **Evidence**: <point to the key field or log line of a specific tool call>\n\n### 4. Recommended Actions\n- **Immediate fix**: <adjust threshold / adjust sustained duration / remove mute / fix notify rule / restart engine …>\n- **Follow-up**: <improve monitoring, review thresholds, etc.>\n```\n\n---\n\n## Common Error Patterns Quick Reference\n\n| Phenomenon (from eval logs / actual query) | Direct cause |\n|---|---|\n| eval logs empty, and the self-monitoring metric is also absent | The datasource is not associated with an engine cluster, or the rule is disabled |\n| eval logs contain `ERROR ... query` | Datasource connection error / SQL syntax error |\n| eval logs show `series=0` | The query result is empty: data-ingestion delay or expression error |\n| eval logs show the condition is met but no event written to DB | Sustained duration insufficient, muted, or outside the effective time |\n| eval logs show `event muted` | A mute rule matched |\n| event exists but processing logs show `notify skipped` | Notify rule does not match / subscription did not match |\n| event exists but processing logs show a callback error | Third-party interface (IM / webhook) error |\n| event exists, processing logs have no notification action | No notify_rule bound, rate-limited by notify_repeat_step, or reached notify_max_number |\n| The \"notification result\" table is **entirely empty** (neither success nor failure) | It took the \"silently skipped\" path: notify rule `enable=false` / rule not bound to notify_rule_ids / all notify_configs fail to match on level-time window-label / rate-limited / dropped by pipeline; verify one by one with get_notify_rule_detail (Step 2.5). Note: empty table ≠ send failure |\n| get_notify_rule_detail shows `enable=false` | The notify rule is disabled; the engine only loads enabled rules, so it neither sends nor records |\n| notify_config's `severities` is an empty array | The engine determines \"matches no event\" (not \"all levels\"); that config never sends; common for API/import creation — fill in the event level for severities |\n| Event level/labels/trigger moment do not match the notify_config's severities/label_keys/time_ranges | That config is skipped; if all configs under the rule fail to match, the whole rule produces no record |\n| The \"notification result\" table has a **failure record** (notification status = failed) | Not a match problem but a send problem: channel disabled (channel_enabled=false) / channel deleted / notification template missing / third-party interface error; look at the details in the record |\n| pipeline executions have a `status=failed` | An event-processor node errored and blocked the chain; look at error_node/error_message |\n| All engine instances have `stale_seconds > 30` | The n9e-server process is down, restart it |\n| Multiple engine_cluster values heartbeating at the same time | An old-version instance was not taken offline and may be contending for rule management |\n| exp is empty in rule_config.triggers | The threshold-alert condition was not fully configured, it will never trigger |\n| User says \"the trigger value doesn't match the curve\" | Instant-query downsampling / log-data ingestion delay; take the value recorded in eval logs as authoritative |\n| Same hash triggered > 30 times within 7 days / average lifetime < 2 evaluation cycles | flapping-type false positive; increase prom_for_duration or RecoverDuration |\n| Trigger value always stays in the narrow band of threshold ±5% | Threshold-edge oscillation; raise the threshold away from the oscillation band |\n| eval logs before recovery show `series=0` | Recovery determined due to missing data (Prometheus-style \"no data = recovered\"); use RecoverDuration to delay the recovery event, or handle it with a dedicated host-disconnection rule |\n| ES log alert match count far exceeds expectation / results include pods or levels that should not match | `and`/`or`/`not` in `query_string` were written in lowercase, treated as terms + default OR concatenation; change to uppercase `AND`/`OR`/`NOT` or switch to `bool.must` |\n| Multi-query rule, B has values when previewed alone but \"doesn't trigger\", and B only appears in the recovery condition | A ref in the recovery condition does not fire alerts, it only controls recovery; to make B alert, add it as an independent trigger condition (flow A Step 3.5②) |\n| eval log shows `exp:$B > 0 data:map[$A:...] error: ...B...` | The recovery condition references a ref not in the trigger expression; the variable never entered the table, so recovery is constantly false; merge the variable into the trigger exp or switch back to origin recovery (Step 3.5③) |\n| Multi-query A/B each have data, but the cross-ref expression/recovery is never decided | A and B's group-by dimensions or the labels produced by their filters have inconsistent values; the tagHashes do not align so they never enter the same group; unify group-by or split into independent rules (Step 3.5④) |\n\n---\n\n## Safety and Boundaries\n\n1. **Read-only**: this skill does not call any create/modify tools.\n2. **Focus on a single rule**: do not troubleshoot multiple rules at once; first have the user pick the single most critical one — serial troubleshooting is more accurate than parallel.\n3. **Do not echo the user's guesses**: do not get led astray by \"I think it's an xxx problem\"; follow the flowchart step by step, and base conclusions on the logs.\n4. **R22+ only**: if the user explicitly says R21-, tell them this skill does not cover it and refer them to the official documentation.\n\n---\n\n## Worked Example\n\n> User: \"Rule 833 'Insufficient disk space' — I waited 10 minutes and still didn't receive an alert, please take a look.\"\n\n**Step 1**: `get_alert_rule_detail(id=833)`\n→ The rule is enabled, cate=prometheus, PromQL=`sum(disk_free) > 1`, sustained duration 60s, associated with ds_id=5.\n\n**Step 2**: `get_datasource_detail(id=5)`\n→ Prometheus, associated with alert engine cluster default, status normal.\n\n**Step 3**: `query_prometheus(query='sum(disk_free) > 1', query_type='instant', time_range='5m')`\n→ Returns empty, no series meet the condition.\n\n**Step 4**: `get_alert_eval_logs(rule_id=833)`\n→ The logs show `series=0`; every evaluation found no data.\n\n**Conclusion**: The rule's PromQL expression may be semantically wrong (`sum(disk_free) > 1` holds in any normal environment, but the current instance's disk_free reporting field unit/labels may not line up). Recommend the user go to the instant-query page and use the Table view to verify the actual labels and values of the `disk_free` metric.\n","frontmatter":{"name":"alert-rule-troubleshoot","description":"This skill should be used when the user reports that an alert rule is \"not firing\", \"no alert was sent\", \"the rule didn't trigger\", \"the rule isn't working\", \"it should have alerted but didn't\", \"why didn't I get an alert\", \"alert rule not firing\", or wants to diagnose why a specific alert rule failed to produce an event/notification. Use this skill to troubleshoot \"why an alert rule did not fire as expected\", as opposed to taking an existing alert and finding its root cause (for the latter, use ops-troubleshooting). Only supported on Release 22 and above.","version":"1.0.0","max_iterations":25,"builtin_tools":["list_alert_rules","get_alert_rule_detail","list_datasources","get_datasource_detail","query_prometheus","query_timeseries","get_alert_eval_logs","search_history_alerts","search_active_alerts","get_alert_event_detail","get_event_processing_logs","get_event_pipeline_executions","list_alert_mutes","get_alert_mute_detail","list_notify_rules","get_notify_rule_detail","list_alert_engine_instances","list_busi_groups"],"tags":["export"]},"isInternal":false,"tokens":8075,"sizeBytes":35096},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/alert-subscribe-copilot/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/alert-subscribe-copilot/SKILL.md","title":"alert-subscribe-copilot","category":"anthropic-skill","format":"markdown","content":"---\nname: alert-subscribe-copilot\ndescription: One-stop assistant for creating, editing, and troubleshooting Nightingale (n9e) alert subscription rules (alert_subscribe). Use when the user asks to \"create a subscription rule / subscribe to alerts / forward alert events / CC another team / escalate alerts (notify someone else after N minutes unhandled) / receive alerts across business groups / troubleshoot a subscription not taking effect\". A subscription is a \"copy + secondary routing\" of events at the notification stage; to configure \"who an event directly notifies\" use notification rules (→ notify-rule-copilot), and to stop receiving alerts use muting (→ alert-mute-copilot).\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Alert Subscription Rule Copilot\n\nA subscription rule filters alert events by condition, **clones** a copy, and forwards it to the associated notification rule. Typical scenarios: cross-team CC, alert escalation (notify a supervisor only after N minutes of persistence), and aggregating events from scattered rules into a single outlet.\n\n## Supporting materials (load on demand with read_file, set base to this skill name)\n\n| File | Content | When to read |\n|---|---|---|\n| `reference.md` | Full config field table, new vs. old notification version differences, redefine fields, complete examples | When you're unsure about a field while assembling config |\n| `troubleshooting.md` | Subscription-not-taking-effect troubleshooting chain (in engine matching order), behavioral semantics, gotcha table | When the user says \"I subscribed but didn't receive anything\" |\n| `http-api.md` | HTTP API paths (for A2A / external agents), tryrun validation endpoint | Only for external A2A scenarios or when providing the user with curl commands |\n\n---\n\n## Prerequisites\n\nYou are the in-app AI assistant for n9e, running inside the n9e process and already authenticated as the current user. **Operate directly via the built-in tools — do not log in, do not call HTTP APIs, and do not use http_fetch against your own endpoints** (the HTTP flow in `http-api.md` is for external A2A agents).\n\n---\n\n## Mental model\n\n- **Subscription happens at the notification stage** (`alert/dispatch/dispatch.go:handleSubs`): the original event still travels its own notification path as usual; each matching subscription **clones a copy** of the event, rewrites it per the subscription config, and then runs it through the notification chain again. A subscription is **additive** — it does not intercept and does not replace the original notification.\n- **Match conditions are AND'd together** and checked in order: enabled → datasource → prod → cate → tags → business group name → duration → severity. Failing any single gate skips that subscription.\n- **A subscription's `group_id` is the management ownership (permissions) and does not participate in event matching** — a subscription inherently receives events across business groups; to \"subscribe only to a certain business group's events\", use the `busi_groups` filter condition.\n- New routing (`notify_version=1`): the cloned event's notification outlet is rewritten to the subscription's specified `notify_rule_ids`; the cloned event's callbacks are **cleared** by default, to prevent re-hitting the original rule's callbacks.\n- Changes **take effect within 9 seconds at most** (the in-memory cache polling cycle).\n\n## Core config structure\n\n```json\n{\n  \"name\": \"Subscription rule name\",\n  \"note\": \"Notes\",\n  \"disabled\": 0,\n  \"prod\": \"\",\n  \"cate\": \"\",\n  \"datasource_ids\": [],\n  \"cluster\": \"0\",\n  \"rule_ids\": [],\n  \"severities\": [1, 2, 3],\n  \"for_duration\": 0,\n  \"tags\": [],\n  \"busi_groups\": [],\n  \"extra_config\": {},\n  \"notify_version\": 1,\n  \"notify_rule_ids\": []\n}\n```\n\nKey points (full field table and new vs. old version differences are in `reference.md`):\n\n- **`severities` is required** (validated in both new and old versions); `[1,2,3]` = all severities.\n- **The new version uses `notify_version=1` + a non-empty `notify_rule_ids`** (use `list_notify_rules` to get the IDs; if there isn't a suitable notification rule yet, first create one with `create_notify_rule` and come back). Note: under version 1, the old-version redefine fields (redefine_severity/redefine_channels/webhooks, etc.) are **cleared** by validation.\n- `rule_ids` empty = subscribe to events from all alert rules; when targeting only specific rules, use `list_alert_rules` to get the IDs.\n- `for_duration` (seconds) = forward only when the alert **persists longer than** this duration; it is the switch for \"alert escalation\"; `0` = no limit.\n- Multiple `tags` / `busi_groups` entries are all **AND**'d; `busi_groups` matches against the event's **business group name** (the key is written as `\"groups\"`; the actual match is driven by func/value).\n- `prod` / `cate` participate in matching but with weak semantics: when `prod` is non-empty it is an exact match; `cate` **only has a real filtering effect when set to `\"host\"`**, other values are equivalent to no filtering. When in doubt, leave both as empty strings.\n- `datasource_ids` empty array = all datasources; `cluster` is fixed at `\"0\"`.\n\n---\n\n## Workflow one: creating\n\n1. **Determine the business group** (management ownership): use `list_busi_groups` to get the `group_id`. If the user named a business group, or the front end already popped up a business group form, use its ID directly without asking again.\n2. **Determine the associated notification rule**: use `list_notify_rules` to get `notify_rule_ids` (the notification outlet for new routing).\n3. **(Optional) Limit the subscription scope**: to subscribe to only certain alert rules, use `list_alert_rules` to get `rule_ids`; to narrow by tags/business group/severity/duration, fill in the corresponding filter fields.\n4. **Call `create_alert_subscribe`**: pass the business group from step 1 as `group_id` (it can also go inside config), and pass `config` as a single JSON object string (not an array). If `group_id` is omitted, the tool will automatically pop up a business group selection form, and once the user finishes selecting it will resume this creation.\n5. **Report the result**: the tool returns `{id, name, group_id, disabled, notify_rule_ids, url}`; just briefly report the subscription conditions and the notification outlet. **Display the rule name as an in-app link: `[<name>](<url>)`** (the url is the returned `/alert-subscribes/edit/<id>`), so the user can click straight to the config page to verify.\n\n## Workflow two: editing / troubleshooting\n\n1. Use `list_alert_subscribes` / `get_alert_subscribe_detail` to get the rule ID and current state, and confirm what to change.\n2. Call `update_alert_subscribe` (**proposal-based**: calling it immediately shows the user a change list and pauses; after the user confirms, the system automatically persists it — the confirmation step does not go through you): `id` is required, and `config` only contains the fields to change (**incremental patch**: unspecified fields keep their original values; array fields such as tags/severities/rule_ids/notify_rule_ids/busi_groups/datasource_ids are **wholly replaced** when provided — first get the existing array from detail, build the complete modified array on top of it, then pass it). Common operations:\n   - **Temporarily disable** = pass config `{\"disabled\":1}` (the cache layer filters it directly, taking it completely out of effect immediately); restore = `{\"disabled\":0}`\n   - **Adjust the escalation threshold** = `{\"for_duration\":600}`; **switch the notification outlet** = `{\"notify_rule_ids\":[...]}` (first confirm the IDs with `list_notify_rules`)\n   - The business group (management ownership) cannot be changed; **deletion** has no in-app tool — have the user do it in the UI (Alert Management → Subscription Rules)\n3. When the user says \"I subscribed but didn't receive anything\", check each gate in order following the troubleshooting chain in `troubleshooting.md`, and **proactively point out** which gate is most likely failing (common ones: for_duration set too large, busi_groups name not matching, the notification rule that notify_rule_ids points to itself not being configured correctly); once you've confirmed it is a config problem at some gate, you can fix it directly with `update_alert_subscribe`.\n\n## Output style\n\n1. Creation goes straight to persistence via the tool; modification is proposal-based — after you call `update_alert_subscribe`, the system shows the user a change list and waits for confirmation, so **do not restate the changes yourself before calling** (to avoid double confirmation); calling completes your responsibility for this round, and do not pass proposal_id/confirmed. Provide HTTP API command templates per `http-api.md` only when the user explicitly asks for curl, and do not execute them.\n2. A subscription's effect depends on the downstream notification rule — when giving a plan, clearly separate the \"subscription conditions\" from the \"notification outlet (who notify_rule_ids points to)\", and when necessary use `get_notify_rule_detail` to verify the outlet config.\n3. A globally unfiltered subscription (rule_ids, tags, busi_groups all empty) copies all events; whether created or edited into this state, restate the blast radius to the user before persisting.\n4. After a successful modification, likewise display the rule name in the link form from step 5 of workflow one (the `update_alert_subscribe` return value also includes `url`).\n","frontmatter":{"name":"alert-subscribe-copilot","description":"One-stop assistant for creating, editing, and troubleshooting Nightingale (n9e) alert subscription rules (alert_subscribe). Use when the user asks to \"create a subscription rule / subscribe to alerts / forward alert events / CC another team / escalate alerts (notify someone else after N minutes unhandled) / receive alerts across business groups / troubleshoot a subscription not taking effect\". A subscription is a \"copy + secondary routing\" of events at the notification stage; to configure \"who an event directly notifies\" use notification rules (→ notify-rule-copilot), and to stop receiving alerts use muting (→ alert-mute-copilot).","tags":["internal"]},"isInternal":false,"tokens":2123,"sizeBytes":9503},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/analyze-dashboard/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/analyze-dashboard/SKILL.md","title":"analyze-dashboard","category":"anthropic-skill","format":"markdown","content":"---\nname: analyze-dashboard\ndescription: Analyze the data health of a given dashboard on Nightingale (n9e) over a time window. Use when the user asks to \"analyze what problems a dashboard has\", \"check whether the xx dashboard has been normal over the last 24 hours\", \"inspect this dashboard\", or \"does this dashboard have any anomalies\". Distinct from modifying a dashboard (modify-dashboard) and creating a dashboard (create-dashboard).\nmax_iterations: 12\nexamples:\n  - \"Analyze what data problems the etcd dashboard has within 24 hours\"\n  - \"Check whether the Linux host monitoring dashboard has been normal over the last hour\"\n  - \"Inspect this dashboard\"\n  - \"Has the host web01 in this dashboard had any anomalies over the last day\"\nbuiltin_tools:\n  - list_dashboards\n  - get_dashboard_data\n  - get_dashboard_detail\n  - query_prometheus\n  - list_busi_groups\ntags:\n  - internal\n---\n\n# Skill: Nightingale (N9E) Analyze Dashboard\n\nHelp the user analyze the data health of an **existing dashboard** over a given time window, and produce an anomaly list with recommendations.\n\nThe core tool is `get_dashboard_data`: it performs data fetching and **statistical pre-screening** on the server side (four deterministic detections — MAD outliers, sudden changes, trends, year-over-year comparison — plus periodicity denoising), and returns layered results. Year-over-year baseline: for a window ≤24h it compares against the same period yesterday; for a longer window it compares against the previous period of the same length (the digest header states the shift amount — describe it accordingly). **The detection is already done; your job is attribution, correlation, and explanation, not re-scanning point by point.**\n\n## Step 1: Locate the dashboard\n\n- The context already carries a `dashboard_id` (injected by the frontend from `/dashboards/<id>`) → use it directly.\n- The user pasted a `/dashboards/<id>` link → take the id.\n- Only a name was given → match by name with `list_dashboards(query=\"...\")`; if there are multiple candidates or no match, list them and ask the user — **do not** guess.\n\n## Step 2: Call get_dashboard_data\n\n```\nget_dashboard_data(id=<id>, time_range=\"24h\")\n```\n\n- Pass `time_range` according to user intent (\"the last day\" → `24h`, \"this week\" → `7d`); if the user did not specify, use the default `1h` and note the analysis window in the conclusion.\n- The user constrained host/cluster, etc. (\"look at the host web01\") → pass it via `vars`: `vars={\"ident\":[\"web01\"]}` (the variable name follows the variable definition in `get_dashboard_detail(include_config=true)`; if unsure, check it first).\n- **One call is enough.** Do not first call get_dashboard_detail and then run query_prometheus over every panel at full scale — that is what the tool has already done for you.\n\n## Step 3: Interpret the pre-screening results (your core value)\n\nThe tool returns layered results: ⚠ suspicious curves (features + sampled points) / ✓ normal summary / skipped list (including a **flat-line** category — curves with a constant value are counted separately; if a curve is flat but at a different level than yesterday, it goes into the suspicious section as a year-over-year anomaly, e.g. qps stuck at 0). What you should do:\n\n1. **Cross-panel correlation**: simultaneous movement of multiple curves at the same moment is the most important signal. For example, `14:32 CPU sudden change +312%` co-occurring with `14:32 disk latency outlier` → likely the same event; merge them into a single problem statement rather than listing them as two.\n2. **Judge impact in light of metric semantics**: rising WAL fsync latency → writes are affected; a rising connection-count trend approaching the limit → capacity risk. Talk about \"what this means\", not re-reading the numbers.\n3. **Re-examine the \"suspected periodic\" flag**: spikes flagged as periodic are usually scheduled jobs and generally do not count as anomalies; but if the user is specifically asking \"why is it slow at this same time every day\", it is in fact the answer.\n4. **Drill down when necessary**: for a key suspicious metric, use `query_prometheus` to narrow the time window and inspect details (e.g. ±15 minutes around the sudden change, with a smaller step), or query a related metric to validate a hypothesis. **Drill down into at most 2-3** of the most critical ones; do not query every suspicious curve.\n5. **Do not recite the sampled points**: the attached \"points:\" series is evidence for you to see the shape; in the conclusion just cite the key moments and values.\n\n## Step 4: Output the analysis report\n\n```\n## <dashboard name> Health Analysis (window 24h)\n\n**Overall conclusion**: one sentence (e.g.: found 1 suspected disk performance event affecting the etcd-2 node; all other metrics normal)\n\n### Anomalies (by severity)\n1. **etcd-2 disk performance degradation (starting at 14:30)**\n   - Evidence: WAL fsync latency 4ms→89ms (sudden change +312%@14:30, no such phenomenon in the year-over-year window); CPU spiked to 96% at the same moment\n   - Impact: write latency will be driven up directly\n2. ...\n\n### Correlation analysis\n(correlation inference of multiple metrics over time)\n\n### Recommendations\n- Actionable next steps (check the disk of the host where etcd-2 resides / iostat / whether there is a snapshot job...)\n```\n\n- When there are no suspicious curves, state clearly \"no anomalies found\"; **do not fabricate problems**. Note the coverage (window, skipped panels).\n- The output was truncated (very large dashboard) → use `panel_ids` to re-call key panels in batches, then merge the conclusions at the end.\n\n## Boundaries and special cases\n\n- The dashboard has no Prometheus panels at all → the tool returns an error and lists the data source distribution; tell the user directly that this is not supported for now.\n- When a variable resolves to no value, the tool notes \"matched everything with .*\"; the data for a multi-instance dashboard may be excessive, so carry this premise in the conclusion.\n- A curve that \"existed yesterday but is gone today\" (an instance disappeared) is worth a mention in the anomalies — losing an instance is often more serious than metric fluctuation. Note that when the window is >24h, this segment actually compares against the previous period of the same length (e.g. the previous 7 days), so do not phrase it as \"it was still there yesterday\".\n- The user then wants to modify the dashboard (add charts / change thresholds) → load modify-dashboard; to create an alert on an anomalous metric → load create-alert-rule.\n\n## Notes\n\n- In the report, always use the moment format returned by the tool (HH:MM); do not convert time zones yourself.\n- Keep the precision the tool gives for numbers; the focus is the trend and order of magnitude, not decimal places.\n- Suspicious ≠ failure: the detection is statistical, so word the conclusion as \"suspected/possible\" and make the basis for the judgment clear.\n","frontmatter":{"name":"analyze-dashboard","description":"Analyze the data health of a given dashboard on Nightingale (n9e) over a time window. Use when the user asks to \"analyze what problems a dashboard has\", \"check whether the xx dashboard has been normal over the last 24 hours\", \"inspect this dashboard\", or \"does this dashboard have any anomalies\". Distinct from modifying a dashboard (modify-dashboard) and creating a dashboard (create-dashboard).","max_iterations":12,"examples":["Analyze what data problems the etcd dashboard has within 24 hours","Check whether the Linux host monitoring dashboard has been normal over the last hour","Inspect this dashboard","Has the host web01 in this dashboard had any anomalies over the last day"],"builtin_tools":["list_dashboards","get_dashboard_data","get_dashboard_detail","query_prometheus","list_busi_groups"],"tags":["internal"]},"isInternal":false,"tokens":1575,"sizeBytes":6983},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/categraf-deploy-guide/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/categraf-deploy-guide/SKILL.md","title":"categraf-deploy-guide","category":"anthropic-skill","format":"markdown","content":"---\nname: categraf-deploy-guide\ndescription: >-\n  Answers \"how do I deploy the categraf collector\". Trigger scenarios: the user asks \"how to install categraf / how to deploy categraf / run categraf with Docker / install categraf on Windows / how to register categraf as a system service / report categraf to Nightingale / how to write categraf config.toml / how to verify that categraf is collecting data\". Covers binary + systemd, Docker, Windows, K8s tips, key configuration, and common verification commands. This skill is instructional/guidance-oriented, calls no tools, and outputs ready-to-paste commands and configuration snippets.\ntags:\n  - internal\n---\n\n# Categraf Deployment Guide\n\n## Scope\n\n**Enter this skill**:\n- \"how to install / how to deploy / how to start categraf\"\n- \"run categraf on Docker / Kubernetes / Windows / Linux\"\n- \"how to register categraf as a systemd service / start on boot\"\n- \"report categraf to Nightingale / how to write config.toml / what to put in the writer URL\"\n- \"how to verify that categraf is collecting data\"\n- \"categraf is installed but Nightingale can't see the machine\" → **do not enter this skill**, route to `host-onboard-diagnose`\n\n**Do not enter this skill**:\n- Diagnosing onboarding failures after install → `host-onboard-diagnose`\n- Abnormal metrics after onboarding → `host-health-diagnose`\n- Configuring collection for a specific input plugin (mysql / redis / nginx / snmp, etc.) → guide the user to read the comments under `conf/input.<name>/`; this skill only covers \"get it installed + get it connected\"\n\n## One-Sentence Principle\n\n**categraf deployment = three actions**: get the binary → correctly write the `[[writers]]` section of `config.toml` → start it and verify. Everything else is a variant of these three steps (containerization, systemd management, Windows service, etc.).\n\n## Deployment Methods Quick Reference\n\n| Scenario | Recommended method | Notes |\n|---|---|---|\n| Linux physical machine / VM | **binary + systemd** | v0.3.35+ has a built-in `--install` one-liner to register |\n| Linux but no root | binary + `--user --install` | supported in v0.4.5+ |\n| Container / orchestration environment | **Docker** or K8s DaemonSet | collecting host metrics requires mounting `/proc /sys` |\n| Windows | `categraf.exe --win-service-install` | or run in the background with `win_run.bat` |\n| macOS (development/debugging) | run the binary directly | not recommended for production |\n\nOperating system support: Linux kernel 2.6.32+ / Windows 10+ or Server 2008+ / macOS 10.15+.\n\n## Quickest Path: One-Click Install\n\nIf the nightingale server is v9+ and bundles categraf, a single command on the target\nhost does everything below (download, unpack, rewrite the reporting address, register\nand start the systemd service):\n\n```bash\ncurl -sSfL 'http://N9E_HOST:17000/api/n9e/agents/categraf/install.sh' | sudo bash -s -- --server 'http://N9E_HOST:17000'\n```\n\nThe address is filled in by the server, so nothing has to be edited by hand. The same\ncommand is available from the machine list page (\"Install Categraf\"), which also waits\nfor the host to report. Useful flags: `--force` (reinstall/upgrade), `--dir <path>`,\n`--auth user:pass` (when the agent API requires basic auth). Note the `bash -s --`\nform: `| sudo bash --force` silently drops the flag.\n\nIf the server is older, does not bundle the package, or the host is not Linux, fall\nback to the manual steps below.\n\n## Step 1: Get the Binary\n\nDownload URL: https://github.com/flashcatcloud/categraf/releases\n\nPackage naming convention: `categraf-{version}-{os}-{arch}.tar.gz`, for example `categraf-v0.3.35-linux-amd64.tar.gz`. **Confirm the architecture first** (`uname -m`: choose amd64 for x86_64, arm64 for aarch64).\n\nDirectory structure after extraction:\n\n```\ncategraf/\n├── categraf              # main program\n└── conf/\n    ├── config.toml       # main config (hostname / writers / heartbeat)\n    └── input.*/          # sub-config directories for the various collection plugins\n```\n\n## Step 2: Correctly Write `conf/config.toml`\n\nMinimal working configuration (reporting to Nightingale n9e):\n\n```toml\n[global]\nhostname = \"\"                  # leave empty to let categraf auto-detect the hostname; specify explicitly when multiple machines share a name\ninterval = 15                  # global collection frequency (seconds)\nproviders = [\"local\"]\n\n[global.labels]\n# region = \"shanghai\"          # global extra labels, optional\n# env = \"prod\"\n\n[[writers]]\nurl = \"http://N9E_HOST:17000/prometheus/v1/write\"\nbasic_auth_user = \"\"\nbasic_auth_pass = \"\"\ntimeout = 5000\ndial_timeout = 2500\nmax_idle_conns_per_host = 100\n\n[heartbeat]\nenable = true                  # must be enabled, otherwise this machine won't appear in the Nightingale machine list\nurl = \"http://N9E_HOST:17000/v1/n9e/heartbeat\"\ninterval = 10\n```\n\n**Key fields**:\n- `[[writers]].url`: n9e's remote write receiving endpoint; the path is fixed at `/prometheus/v1/write`, and the default port is 17000.\n- `[heartbeat].enable=true` + `[heartbeat].url`: heartbeat reporting, which lets the \"machine list\" see this machine; **if missing → installed but invisible**.\n- `[global].hostname`: leave empty to use the system hostname; if the `hostname` command in a container/cloned machine returns a duplicated name like `localhost.localdomain`, **you must specify it explicitly**, otherwise different machines will overwrite each other.\n- `[global].labels`: global tags; setting good distinguishing dimensions (region / env / idc) makes building dashboards and alerts much easier later.\n\n⚠️ **Do not set `omit_hostname` to true** — it strips the `ident` label, with the result that the machine list shows OS/agent_version all as unknown (a common pitfall).\n\n## Step 3: Start and Manage\n\n### Option A: Linux + systemd (v0.3.35 and above, recommended)\n\n```bash\ncd /path/to/categraf\nsudo ./categraf --install      # register as a system service\nsudo ./categraf --start        # start\nsudo ./categraf --status       # check status\nsudo ./categraf --stop         # stop\n```\n\nNon-root user (v0.4.5+):\n\n```bash\n./categraf --user --install\n./categraf --user --start\n```\n\nsystemd resource limits (edit `/etc/systemd/system/categraf.service`):\n\n```ini\n[Service]\nCPUQuota=200%\nMemoryLimit=1G\n```\n\nAfter editing, run `systemctl daemon-reload && systemctl restart categraf`.\n\n### Option B: Docker\n\nMinimal (collects only in-container/network metrics):\n\n```bash\ndocker run -td \\\n  --name categraf \\\n  --restart=always \\\n  -e TZ=Asia/Shanghai \\\n  -v /home/flashcat/categraf/conf/:/etc/categraf/conf \\\n  flashcatcloud/categraf:latest\n```\n\n**Collecting host CPU/memory/disk** (the most common case; you must mount the host's `/proc` `/sys` and use host networking):\n\n```bash\ndocker run -td \\\n  --name categraf \\\n  --restart=always \\\n  --network=host \\\n  -e TZ=Asia/Shanghai \\\n  -e HOST_PROC=/hostfs/proc \\\n  -e HOST_SYS=/hostfs/sys \\\n  -e HOST_MOUNT_PREFIX=/hostfs \\\n  -v /home/flashcat/categraf/conf/:/etc/categraf/conf \\\n  -v /proc:/hostfs/proc:ro \\\n  -v /sys:/hostfs/sys:ro \\\n  flashcatcloud/categraf:latest\n```\n\nResource limits: append `--cpus=2 --memory=1g`.\n\n### Option C: Windows\n\nRegister as a service:\n\n```cmd\ncategraf.exe --win-service-install\ncategraf.exe --win-service-start\ncategraf.exe --win-service-stop\ncategraf.exe --win-service-uninstall\n```\n\nOr run in the background with a script:\n\n```cmd\nwin_run.bat start\nwin_run.bat stop\n```\n\nWindows does not support `kill -HUP` reload; after changing the config, use `--win-service-stop` + `--win-service-start`.\n\n### Option D: Kubernetes\n\nOn K8s you typically use a DaemonSet (one per Node to collect host metrics) + a ConfigMap (to distribute `config.toml` uniformly). The official release package includes a `k8s/` directory with sample YAML; just run `kubectl apply -f k8s/`. **The two things you must change**:\n1. The `[[writers]].url` and `[heartbeat].url` in the ConfigMap should point to your n9e;\n2. If you use multiple clusters, add `cluster = \"xxx\"` to `[global.labels]` to distinguish them.\n\n## Step 4: Verify\n\n### 4.1 Single-plugin debugging (does not actually report, prints once)\n\n```bash\n./categraf --test --inputs cpu              # test just one\n./categraf --test --inputs cpu:mem:disk     # use colons to separate multiple\n```\n\nIf metric lines appear on stdout, local collection is OK; if it errors out (failed to connect to mysql / insufficient permissions, etc.), get past this step before starting the service.\n\n### 4.2 Reload config (without restarting the process)\n\n```bash\nkill -HUP `pidof categraf`   # Linux-only, not supported on Windows\n```\n\n### 4.3 Verify on the Nightingale side\n\n- Open n9e in a browser → **Infrastructure / Machine List**; you should be able to see this `ident`, with OS/CPU/agent_version all not unknown.\n- If it doesn't appear → route to `host-onboard-diagnose` and run the 5-stage troubleshooting; don't guess blindly here.\n\n### 4.4 Self-upgrade (v0.3.36+)\n\n```bash\n./categraf --update --update_url https://github.com/flashcatcloud/categraf/releases/download/vX.Y.Z/categraf-vX.Y.Z-linux-amd64.tar.gz\n\n# On an intranet, point --update_url at nightingale's own bundled package instead:\n./categraf --update --update_url 'http://N9E_HOST:17000/api/n9e/agents/categraf/download?arch=amd64'\n```\n\n## Common Pitfalls (one-liner version)\n\n| Symptom | Cause | Fix |\n|---|---|---|\n| Not visible in the machine list | `[heartbeat].enable=false` or `url` not changed | edit the toml, restart |\n| Machine list shows OS=unknown | `omit_hostname=true` or categraf version < v0.2.35 | turn off omit_hostname, upgrade the version |\n| Multiple machines overwrite each other | duplicated hostname (localhost / VM clone) | explicitly set `[global].hostname` or an ident shell |\n| Can't collect host CPU in Docker | `/proc /sys` not mounted and `HOST_*` env not set | use the \"collect host\" command above |\n| Writing to prom returns 499 / queue full | n9e backend ingest queue is full | tune n9e write concurrency, or reduce the categraf interval |\n| TLS x509 unknown authority | a self-signed certificate is used without installing the CA | add `insecure_skip_verify = true` to the writer section (test only), or install the CA |\n| BasicAuth 401 | n9e has BasicAuth enabled but categraf is not configured | fill in `basic_auth_user/pass` |\n\n## Output Style\n\n- **Clarify before giving a solution**: when the user hasn't stated the OS / deployment form, first ask \"Do you want to install it on a Linux physical machine, Docker, or K8s? What is the n9e access URL?\"\n- **Give a ready-to-paste command for every suggestion** — don't write \"tweak the config\", write \"change `[[writers]].url` in `config.toml` to `http://your-n9e:17000/prometheus/v1/write`\".\n- **Configuration examples must include placeholder-replacement notes** (such as `N9E_HOST`) so the user knows what to change.\n- Reply in the user's language (Chinese for Chinese users, English for English users).\n- After deployment, **always remind them to run `--test --inputs cpu` once** and \"take a look at the Nightingale machine list\", otherwise there's no way to know right then whether the install is working.\n- If the user says \"I finished installing but can't see the machine\" → don't give more deployment instructions; route them directly to `host-onboard-diagnose`.\n","frontmatter":{"name":"categraf-deploy-guide","description":"Answers \"how do I deploy the categraf collector\". Trigger scenarios: the user asks \"how to install categraf / how to deploy categraf / run categraf with Docker / install categraf on Windows / how to register categraf as a system service / report categraf to Nightingale / how to write categraf config.toml / how to verify that categraf is collecting data\". Covers binary + systemd, Docker, Windows, K8s tips, key configuration, and common verification commands. This skill is instructional/guidance-oriented, calls no tools, and outputs ready-to-paste commands and configuration snippets.","tags":["internal"]},"isInternal":false,"tokens":2897,"sizeBytes":11325},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/create-alert-rule/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/create-alert-rule/SKILL.md","title":"create-alert-rule","category":"anthropic-skill","format":"markdown","content":"---\nname: create-alert-rule\ndescription: |\n  **Create alert rules**. Prefer reusing the validated rules in integrations (standard components like Linux/MySQL/Redis/Kafka/PostgreSQL/Elasticsearch all ship ready-made rule packs); import as many rules as the user needs—one rule, a batch, or a whole pack. Only hand-write a custom rule when integrations has nothing that fits. Supports all data sources: Prometheus / Loki / ES / OpenSearch / MySQL / PG / TDengine / ClickHouse / Doris / VictoriaLogs / Host.\n  ⚠️ **Do NOT use this skill for bulk YAML imports**—when the user provides a URL or a YAML file, awesome-prometheus-alerts, node-exporter.yml, and the like, use import-prom-rule instead.\n  Triggers: create an alert / add an alert / help me set up a CPU alert / add a set of alert rules for MySQL / configure common alerts for a host / I want to monitor a metric.\nexamples:\n  - \"Configure a set of common alert rules for the host\"\n  - \"Add a set of alert rules for MySQL\"\n  - \"Help me create an alert for CPU usage exceeding 80%\"\n  - \"Add a MySQL slow query alert rule\"\n  - \"Add a memory alert for the host, fire when it exceeds 90%\"\nmax_iterations: 20\nbuiltin_tools:\n  - preview_alert_rule_template\n  - import_alert_rule_template\n  - create_alert_rule\n  - list_busi_groups\n  - list_datasources\n  - list_metrics\n  - list_notify_rules\n  - list_files\n  - read_file\n  - list_databases\n  - list_tables\n  - describe_table\ntags:\n  - export\n---\n\n# Skill: Nightingale (N9E) Alert Rule Creation\n\nTwo tools: `import_alert_rule_template` (reuse the validated rules in integrations) and `create_alert_rule` (hand-write rules).\n\n**Prefer reusing the rules in integrations**: the rules under `integrations/<component>/alerts/` are finished products that have been hand-tuned and validated (severity, duration, evaluation interval, appended tags, annotations, and effective time windows are all configured), and their quality far exceeds hand-crafted rules. **But import as many as the user needs—do not blindly import the whole pack**:\n\n- The user wants \"a set / common\" alerts for some component → import the whole pack (do not pass `names`)\n- The user names only a few categories (e.g. \"disk and memory alerts\") → import only those (pass them in `names`)\n- The user wants a **single** alert for a specific metric → if the pack happens to contain a matching rule, import just that one (pass one entry in `names`) or borrow its expression; if the pack has nothing that fits, hand-write with `create_alert_rule`\n\nOnly go to Approach B (`create_alert_rule`) when integrations has no matching rule, or when the user provides a fully custom query condition.\n\nWhen a component ships both a categraf and an exporter rule pack, **prefer categraf**: first probe whether the categraf metrics have data; if found, use the categraf pack, otherwise fall back to the exporter pack (see Approach A step 3 for details).\n\n| User need | What to do |\n|------|-----------|\n| Configure a set / a few alerts for a standard component (Linux / MySQL / Redis / Kafka / PostgreSQL / Elasticsearch / Ceph / Oracle / Windows / MongoDB …) | Approach A: `preview_alert_rule_template` to see what's in the pack → `import_alert_rule_template` to import as needed |\n| A single alert for a specific metric, and the integration pack has a matching rule | Approach A: `import_alert_rule_template` + `names` to import just that one |\n| Fully custom rules that integrations doesn't cover (including niche conditions like Loki / ES / SQL query values / log counts) | Approach B: `create_alert_rule` |\n\n> ⚠️ **Do NOT use this skill for bulk YAML imports**—when the user provides a URL or a YAML file, awesome-prometheus-alerts, node-exporter.yml, and the like, use import-prom-rule instead.\n\n## Step 1 (common to both paths): determine the business group and data source\n\n- Call `list_busi_groups` to get the list of business groups\n- Call `list_datasources` to get the list of data sources, and find the data source ID of the matching type (rule packs are mostly the Prometheus type)\n- If the session context already has a preselected `busi_group_id` / `datasource_id`, use it directly—**do not** call `list_*` again\n- `cate=host` (host-unreachable class) does not need a data source\n\n### Business group selection rules\n\n1. If the user explicitly specified a business group name or ID, use it directly\n2. Otherwise, by priority:\n   a. **Prefer the business group with `is_default: true`** (usually \"Default Busi Group\" or a group whose name contains \"Default\")\n   b. If there is only one business group, use it directly\n   c. When there are multiple candidates and none is the default, **do not blindly take the first one**; list them in your reply and ask the user to confirm\n\n---\n\n## Approach A: reuse validated rules in integrations (as needed: single / batch / whole pack)\n\n`import_alert_rule_template` imports rules from the rule pack under `integrations/<component>/alerts/` (each rule's severity, duration, evaluation interval, appended tags, annotations, and effective time window are all preserved), binds the rules to the data source you select, and uniformly switches them to the enabled state. **How many rules are imported is controlled by the `names` parameter**, driven by the user's need.\n\n### Steps\n\n1. See which integration components are available:\n   ```\n   list_files(base=\"integrations\")\n   ```\n2. See which rule pack files exist under that component:\n   ```\n   list_files(base=\"integrations/Linux\", path=\"alerts\")\n   ```\n3. **Pick the right rule pack file (prefer categraf)**. Rule packs often come in two collection styles, categraf and exporter, and the filename convention varies per component (for Linux it's `linux_by_categraf.json` / `linux_by_exporter.json`, but it may use other suffixes). **Prefer the pack whose filename contains `categraf`**:\n   - First probe whether the categraf metrics have data in the environment: `list_metrics(datasource_id=<X>, keyword=\"<categraf metric keyword>\")`.\n     - Use a categraf-specific metric name that distinguishes the two styles as the keyword, e.g. for Linux use `cpu_usage` (categraf uses `cpu_usage_idle`, which node_exporter does not have), `mem_used_percent`, `disk_used_percent`; for Redis use `redis_used_memory`; for MySQL use `mysql_global_status_queries`.\n   - **As long as the categraf metric returns data (a non-empty array), use the pack whose filename contains `categraf`** directly, without comparing against exporter.\n   - Only fall back to the pack whose filename contains `exporter` (e.g. `linux_by_exporter.json`) when the categraf metric has no data, and use the corresponding exporter metric keyword (e.g. `node_cpu_seconds_total`) with `list_metrics` to confirm data exists before importing.\n4. **See which rules are in the pack (only when you need to select by name)**. You can skip this step when the user wants to import the whole pack; when the user wants only a few of them, or a single alert for a specific metric, preview first to get the exact rule names:\n   ```\n   preview_alert_rule_template(component=\"Linux\", file=\"linux_by_categraf.json\")\n   ```\n   It returns each rule's `name` / `cate` / `severity` / an expression summary / whether it is disabled (a small payload). **Do not** use `read_file` to view a large rule pack (it will be truncated); use this tool.\n5. **Import as needed**:\n   - Whole pack (the user wants \"a set / common\" alerts)—do not pass `names`:\n     ```\n     import_alert_rule_template(group_id=<business group>, component=\"Linux\", file=\"linux_by_categraf.json\", datasource_id=<data source>)\n     ```\n   - Batch / single (the user names a few categories or a specific metric)—`names` carries the exact rule names (from preview):\n     ```\n     import_alert_rule_template(group_id=<business group>, component=\"Linux\", file=\"linux_by_categraf.json\",\n       datasource_id=<data source>, names=\"[\\\"Machine load - high memory, please pay attention - categraf\\\"]\")\n     ```\n\n> **Do not** use `read_file` to read out the entire rule pack and then call `create_alert_rule` one rule at a time—the pack may be large and get truncated, and converting rules one by one also drops fields. To see what's in the pack use `preview_alert_rule_template`; to persist it use `import_alert_rule_template`.\n\n### Notes\n\n- **Strongly recommend passing `datasource_id`**: passing it binds the imported rules to that data source; omitting it makes the rules match every data source of that type (an overly broad scope). The `host` heartbeat rules in the pack don't need a data source, and the tool skips binding for them automatically.\n- The rules in a rule pack are **disabled** by default in the template (it is a browsable catalog). When importing, this tool **switches them to enabled** by default (`disabled=0`) so the rules take effect immediately. If the user wants to \"import first but leave them disabled, and enable them one by one themselves\", pass `disabled=1`.\n- The names written in `names` must exactly match the `name` returned by `preview_alert_rule_template`; any that don't match are listed in the returned `not_found_names`—double-check and retry.\n- Duplicate rules (already existing in the business group) are **skipped automatically** (`status=skipped_duplicate`); this is not a failure, and **do not** re-import with `name_prefix` just because you see a skip. Only use `name_prefix`/`name_suffix` when the user explicitly wants the rule to coexist with the existing one (e.g. a comparison test).\n- The return includes three counts—`created` / `skipped` / `failed`—plus details for each rule; just summarize them in the output.\n- Imported rules **are not bound to any notify rule** by default (to avoid sending alerts by mistake). If binding is needed, after importing, prompt the user to associate it manually on the rule, or switch to Approach B to create rules one by one and pass `notify_rule_ids`.\n\n---\n\n## Approach B: hand-write a custom rule (when integrations has no matching rule)\n\nUse `create_alert_rule`. Supports all data source types: **Prometheus / Loki / Elasticsearch / OpenSearch / TDengine / ClickHouse / MySQL / PostgreSQL / Doris / VictoriaLogs / Host**.\n\n> Before getting here, confirm that integrations really has no matching rule: common metrics for standard components (CPU/memory/disk/connection count…) are mostly in the rule packs, so reuse rather than hand-craft when you can. Even when hand-crafting, you can first run `preview_alert_rule_template` to glance at whether there's a ready-made expression you can borrow.\n\nThe tool offers two invocation modes:\n\n1. **Prometheus simplified path** (most common)—pass `prom_ql` + `threshold` + `operator` directly, and the tool builds the rule automatically\n2. **Generic path**—pass `cate` + `rule_config_json`; **first obtain the structure of `rule_config_json` by reading `datasources/<cate>.md` via `read_file`**, then fill in the actual values\n\n### Mode 1: Prometheus simplified path\n\nThe most common scenario. You only need to fill in PromQL, threshold, and operator. Internally the tool splices the threshold into `prom_ql` to generate a v1-format rule (the v2 editor in the OSS n9e FE is gated by `IS_PLUS`, so only v1 can be used).\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"CPU usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  \"operator\": \">\",\n  \"threshold\": 80,\n  \"severity\": 2,\n  \"note\": \"CPU usage exceeded 80% for 1 minute\",\n  \"for_duration\": 60\n}\n```\n\n### Mode 2: generic path (non-Prometheus)\n\nFor types such as Loki / ES / MySQL / TDengine / ClickHouse / Doris / VictoriaLogs / Host, you need to pass `cate` and `rule_config_json`.\n\n**Key step**: first read the reference doc for that data source type to obtain the `rule_config` structure template:\n\n```\nread_file(base=\"create-alert-rule\", path=\"datasources/<cate>.md\")\n```\n\nThen turn the `rule_config` object in the doc into a JSON string and pass it to the `rule_config_json` parameter. Example (MySQL alert):\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"MySQL too many failed orders\",\n  \"cate\": \"mysql\",\n  \"datasource_id\": 4,\n  \"severity\": 2,\n  \"rule_config_json\": \"{\\\"queries\\\":[{\\\"ref\\\":\\\"A\\\",\\\"sql\\\":\\\"SELECT count(*) AS value FROM orders WHERE created_at >= NOW() - INTERVAL 5 MINUTE AND status='failed'\\\",\\\"keys\\\":{\\\"valueKey\\\":\\\"value\\\",\\\"labelKey\\\":\\\"\\\"},\\\"interval\\\":60}],\\\"triggers\\\":[{\\\"mode\\\":1,\\\"exp\\\":\\\"$A.value > 10\\\",\\\"severity\\\":2,\\\"recover_config\\\":{\\\"judge_type\\\":1}}]}\"\n}\n```\n\n**⚠️ Important generic rules (apply to all non-Prometheus types)**:\n\n1. **The `interval` field must be the total number of seconds**—do not write `interval_unit`. When the frontend saves, it converts `value × unit → seconds`, and when reading it derives the display unit back from the seconds.\n   - Last 1 minute → `\"interval\": 60`\n   - Last 5 minutes → `\"interval\": 300`\n   - Last 1 hour → `\"interval\": 3600`\n   - **Do not** write `\"interval\": 5, \"interval_unit\": \"min\"`—the FE will display it as 5 seconds.\n   - The tool has a defensive fallback: if you accidentally write `interval_unit` or a bare interval smaller than 60, it converts it to seconds automatically, but it's best to write it correctly to begin with.\n\n**⚠️ Important limitations for OSS n9e SQL-type data sources (MySQL/PGSQL/CK/Doris)**:\n1. **`keys.valueKey` is required**—the alias of the numeric column in the SELECT statement, usually `\"value\"`. Missing it reports `valueKey is required`.\n2. **`$from`/`$to`/`$__timeFilter` are not substituted**—the OSS `macros.Macro` is a no-op. You must use the data source's native time functions, e.g. MySQL uses `NOW() - INTERVAL 5 MINUTE`, PG uses `NOW() - INTERVAL '5 minutes'`, CK uses `now() - INTERVAL 5 MINUTE`.\n3. **TDengine is the exception**—it has its own variable substitution logic, and `$from`/`$to`/`$interval` are usable.\n\n## create_alert_rule parameter reference (Approach B)\n\n| Field | Required | Description |\n|---|---|---|\n| `group_id` | ✅ | Business group ID (from `list_busi_groups`) |\n| `name` | ✅ | Rule name (must be unique within the business group) |\n| `cate` | ❌ | Data source type (default `prometheus`). Options: `prometheus` / `loki` / `elasticsearch` / `opensearch` / `tdengine` / `ck` / `mysql` / `pgsql` / `doris` / `victorialogs` / `host` |\n| `prod` | ❌ | Product type. When omitted, derived automatically from `cate` |\n| `datasource_id` | Conditionally required | Data source ID. **Not needed when `cate=host`**; required for all others |\n| `rule_config_json` | Conditionally required | The complete `rule_config` JSON string. **Can be omitted when `cate=prometheus` (use the simplified path)**; required for other types |\n| `prom_ql` | Conditionally required | PromQL query (only for the `cate=prometheus` simplified path). **Write only the query, not the threshold** |\n| `threshold` | Conditionally required | Trigger threshold (only for the simplified path) |\n| `operator` | ❌ | Default `>`. Options `>` `>=` `<` `<=` `==` `!=` |\n| `severity` | ❌ | Default 2. 1=Critical, 2=Warning, 3=Info |\n| `note` | ❌ | Alert notification body |\n| `eval_interval` | ❌ | Evaluation interval (seconds), default 30 |\n| `for_duration` | ❌ | Duration (seconds), default 60 |\n| `append_tags` | ❌ | Appended tags, multiple separated by spaces |\n| `runbook_url` | ❌ | Runbook URL |\n| `notify_rule_ids` | ❌ | JSON list of associated notify rule IDs, e.g. `\"[1,2]\"` |\n\n## Approach B detailed steps (create_alert_rule)\n\n> See \"Step 1 (common to both paths)\" above for selecting the business group and data source. Below are the steps specific to Approach B.\n\n### B-1: identify the data source type (cate)\n\nBased on the user's alert need, decide which `cate` to use:\n\n| User-need keyword | cate | Trigger condition |\n|---|---|---|\n| \"Host CPU/memory/disk\", \"Prometheus metric\" | `prometheus` | Metric threshold |\n| \"Machine unreachable\", \"node offline\" | `host` | Heartbeat timeout |\n| \"Application error log\", \"Loki log\" | `loki` | Log count |\n| \"ES log\", \"Elasticsearch aggregation\" | `elasticsearch` | Log aggregation |\n| \"OpenSearch log\" | `opensearch` | Log aggregation (same as ES) |\n| \"MySQL abnormal query result\" | `mysql` | SQL query value |\n| \"PostgreSQL abnormal query result\" | `pgsql` | SQL query value |\n| \"ClickHouse metric/log\" | `ck` | SQL query value |\n| \"Doris log\" | `doris` | SQL query value |\n| \"TDengine time-series data\" | `tdengine` | SQL query value |\n| \"VictoriaLogs log\" | `victorialogs` | LogsQL query |\n\nDefault value: `prometheus`. The data source must match too (`cate=mysql` finds a MySQL data source, `cate=loki` finds a Loki data source, etc.); `cate=host` does not need a data source.\n\n### B-2 (non-Prometheus only): read the data source reference doc\n\nFor types where `cate != \"prometheus\"`, **you must first read the reference doc for that type** to obtain the `rule_config` structure:\n\n```\nread_file(base=\"create-alert-rule\", path=\"datasources/<cate>.md\")\n```\n\nFor example:\n- MySQL → `datasources/mysql.md`\n- Loki → `datasources/loki.md`\n- Host → `datasources/host.md`\n\nThe reference docs contain complete field descriptions, option-value tables, and complete examples. **Copy the field names from the `rule_config` object in the example as-is**, replacing only the concrete values.\n\n### B-3 (SQL types only): probe the real schema ⚠️\n\n**This step is mandatory when `cate ∈ {mysql, pgsql, ck, doris, tdengine}`; it cannot be skipped**.\n\n> **TDengine is also supported**: although TDengine is a time-series database, its REST API supports `SHOW DATABASES` / `information_schema.ins_tables` / `information_schema.ins_columns`. `list_databases` / `list_tables` / `describe_table` are fully usable with TDengine.\n\n**Never guess database/table/column names from the literal description in the user's prompt**. When the user says \"query the access_log table in the my_logs database\",\nit is very likely just an example, and the actual data source may have no such database or table at all.\n\nYou must probe in order:\n\n1. **`list_databases(datasource_id=<X>)`**—see the real list of databases\n   - Compare against the user's description and find the closest one\n   - If the database name in the user's description **is not in the returned list**, **do not force it into the SQL**. Use AskUserQuestion\n     to list the real database names for the user to confirm, or pick one that looks relevant and have the user approve it\n\n2. **`list_tables(datasource_id=<X>, database=<the chosen database>)`**—see the real list of tables\n   - Same as above: if the table the user mentioned is not in the list → list the real tables for the user to choose\n\n3. **`describe_table(datasource_id=<X>, database=<database>, table=<table>)`**—get the real columns\n   - Use the returned column names to assemble the SQL—**do not invent** common column names like `log_time`, `status`, `created_at`\n     (they may not actually exist in this table)\n   - Pay special attention to the time column: in different tables it may be called `ts`, `event_time`, `log_time`, `create_time`,\n     `timestamp`, etc.; you must decide based on the column names returned by `describe_table`\n\nOnly after obtaining the real schema can you proceed to B-4 and assemble the SQL.\n\n#### How to pass datasource_id\n\n- When the session context already binds a data source (a conversation entered from the data source page): `datasource_id` can be omitted,\n  inherited from the session context\n- In the alert rule scenario (entered from the alert rule page or a plain conversation): you must **explicitly pass** `datasource_id`,\n  otherwise the tool reports a `datasource_id required` error\n\n### B-4: build the PromQL / SQL / LogQL\n\n#### Simplified path (cate=prometheus)\n\nBased on the user's description, build a PromQL **without a threshold**. All entries in the table below are **categraf (telegraf-style)** metric names—**prefer categraf**:\n\n| User need | Recommended PromQL (prefer categraf) |\n|---|---|\n| Host CPU usage | `avg by (ident) (100 - cpu_usage_idle{cpu=\"cpu-total\"})` |\n| Host memory usage | `mem_used_percent` |\n| Host disk usage | `disk_used_percent` |\n| Host system load | `system_load1` |\n| Host inbound network traffic | `rate(net_bytes_recv[5m])` |\n| MySQL QPS | `rate(mysql_global_status_queries[5m])` |\n| MySQL connection usage | `mysql_global_status_threads_connected / mysql_global_variables_max_connections * 100` |\n| Redis memory usage | `redis_used_memory / redis_maxmemory * 100` |\n\n**Prefer categraf + probe**: first use `list_metrics` to confirm whether the categraf metric has data in the environment, e.g.\n`list_metrics(datasource_id=<X>, keyword=\"cpu_usage\")` (categraf uses `cpu_usage_idle`, which node_exporter does not have).\n- **Data found** → use the categraf PromQL from the table above directly.\n- **Not found** → the environment very likely uses node_exporter; switch to the equivalent exporter expression below, and use\n  `list_metrics(datasource_id=<X>, keyword=\"node_\")` again to confirm the node_exporter metrics actually have data before creating the rule.\n\n##### node_exporter fallback equivalence table (use only when the categraf metric has no data)\n\n| User need | node_exporter equivalent PromQL |\n|---|---|\n| Host CPU usage | `100 - avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100` |\n| Host memory usage | `(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100` |\n| Host disk usage | `(1 - node_filesystem_avail_bytes{fstype!~\"tmpfs\\|overlay\"} / node_filesystem_size_bytes) * 100` |\n| Host system load | `node_load1` |\n| Host inbound network traffic | `rate(node_network_receive_bytes_total[5m])` |\n\n> The `\\|` in the disk expression is only escaped to avoid breaking the Markdown table; when writing it into PromQL, restore it to `|`, i.e. `fstype!~\"tmpfs|overlay\"` (otherwise the regex treats it as a literal pipe and the filter fails).\n\n#### Generic path (other cate)\n\nFollowing the reference doc read in B-2, assemble the `rule_config` object. Key fields:\n\n- **SQL types** (mysql/pgsql/ck/doris):\n  - The query must return one column aliased `value` as the alert judgment value\n  - **Required** `keys.valueKey: \"value\"` (missing → `valueKey is required` error)\n  - **Time filtering cannot use `$from`/`$to`**; use the data source's native time functions:\n    - MySQL: `NOW() - INTERVAL 5 MINUTE`\n    - PostgreSQL: `NOW() - INTERVAL '5 minutes'`\n    - ClickHouse: `now() - INTERVAL 5 MINUTE`\n    - Doris: `NOW() - INTERVAL 5 MINUTE`\n- **TDengine**: a special exception; `$from`/`$to`/`$interval` **can be used**, and use `keys.metricKey` instead of `keys.valueKey`\n- **Log types** (loki/es/opensearch/victorialogs): the aggregation query returns a numeric value; use `0` for `recover_config.judge_type`\n- **Metric types** (prometheus/mysql/pgsql/ck/tdengine): use `1` for `recover_config.judge_type`\n- **Host type** (host): `queries` is a `{key, op, values}` structure; `triggers` is a `{type, severity, duration}` structure; no `datasource_id` needed\n\n### B-5: call create_alert_rule\n\n#### Simplified path (Prometheus)\n\n```\ncreate_alert_rule(\n  group_id=1, name=\"CPU too high\", datasource_id=1,\n  prom_ql=\"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  threshold=80, operator=\">\", severity=2\n)\n```\n\n#### Generic path (other types)\n\n```\ncreate_alert_rule(\n  group_id=1, name=\"MySQL too many errors\",\n  cate=\"mysql\", datasource_id=4, severity=2,\n  rule_config_json=\"<serialize the rule_config read in step 3 into a JSON string>\"\n)\n```\n\n**Notes:**\n- On the Prometheus simplified path, pass the threshold separately in `threshold`—**do not write it into `prom_ql`**\n- Default `for_duration=60`, which is only reasonable if it is larger than `eval_interval=30`\n- If the user requests \"fire immediately\", set `for_duration` to 0\n- If `create_alert_rule` returns an `already exists` error, **do not call `list_alert_rules`** to look up the duplicate name; just add a suffix to the name (e.g. `-v2`, `-AI`, or a timestamp) and retry\n- On the generic path, `rule_config_json` must be a **JSON string** (not an object); remember to escape the quotes\n- **For SQL types, the B-3 schema probe must have been completed before the call**. If the database/table/column in rule_config_json has not been verified via `list_databases`+`list_tables`+`describe_table`,\n  the alert rule will most likely fail to run (`Unknown database` / `Unknown column` and other runtime errors)\n\n### B-6: (optional) associate notify rules\n\nIf the user requests binding notify rules:\n1. Call `list_notify_rules` to get the list of notify rules\n2. When calling `create_alert_rule`, pass `notify_rule_ids`, e.g. `\"[1,2]\"`\n\nIf the user does not explicitly request it, **do not proactively associate notify rules**, to avoid sending alerts by mistake.\n\n---\n\n## Final step (common to both paths): output the result\n\n**Keep it short**. A one-sentence confirmation is enough:\n\n- Approach A (importing a rule pack): summarize the three counts `created` / `skipped` / `failed`, e.g.\n  > ✅ Imported 8 alert rules from the Linux categraf rule pack for you (skipped 1 duplicate name); all are enabled, see the card below for details.\n- Approach B (single creation):\n  > ✅ Created the alert rule \"Host CPU usage too high\" for you; see the card below for details.\n\n**Do not** restate fields such as rule ID, business group, data source, PromQL, threshold, or alert severity in the Final Answer—the frontend displays this information as a structured card, and repeating it only makes the user see two copies. If you need to add a note (e.g. \"these rules are not bound to any notify rule by default; please associate them as needed\"), you may add a sentence or two, but do not list out the fields already in the card one by one.\n\n## Severity quick reference\n\n| severity | Name | Applicable scenario |\n|---|---|---|\n| 1 | Critical (level 1) | Service unavailable, core component down, data loss |\n| 2 | Warning (level 2) | High resource usage, performance degradation, quota approaching limit |\n| 3 | Info (level 3) | Informational alert, capacity planning hint |\n\nUse 2 (Warning) by default. Use 1 only when the user explicitly says \"severe\" or \"urgent\".\n\n## Common single-alert templates (Approach B reference)\n\n> Use these when the user wants a single alert for a specific metric. If the user wants to \"add a whole set of monitoring alerts for a host\",\n> prefer Approach A to import the whole `integrations/Linux/alerts/linux_by_categraf.json` pack, rather than hand-crafting rules one by one here.\n\n### Host CPU usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host CPU usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  \"operator\": \">\",\n  \"threshold\": 80,\n  \"severity\": 2,\n  \"for_duration\": 300,\n  \"note\": \"Host {{ $labels.ident }} CPU usage exceeded 80% for 5 minutes\"\n}\n```\n\n### Host memory usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host memory usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"mem_used_percent\",\n  \"operator\": \">\",\n  \"threshold\": 90,\n  \"severity\": 1,\n  \"for_duration\": 180\n}\n```\n\n### Host disk usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host disk usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"disk_used_percent\",\n  \"operator\": \">\",\n  \"threshold\": 85,\n  \"severity\": 2,\n  \"for_duration\": 600,\n  \"append_tags\": \"alert_type=capacity\"\n}\n```\n","frontmatter":{"name":"create-alert-rule","description":"**Create alert rules**. Prefer reusing the validated rules in integrations (standard components like Linux/MySQL/Redis/Kafka/PostgreSQL/Elasticsearch all ship ready-made rule packs); import as many rules as the user needs—one rule, a batch, or a whole pack. Only hand-write a custom rule when integrations has nothing that fits. Supports all data sources: Prometheus / Loki / ES / OpenSearch / MySQL / PG / TDengine / ClickHouse / Doris / VictoriaLogs / Host.\n⚠️ **Do NOT use this skill for bulk YAML imports**—when the user provides a URL or a YAML file, awesome-prometheus-alerts, node-exporter.yml, and the like, use import-prom-rule instead.\nTriggers: create an alert / add an alert / help me set up a CPU alert / add a set of alert rules for MySQL / configure common alerts for a host / I want to monitor a metric.\n","examples":["Configure a set of common alert rules for the host","Add a set of alert rules for MySQL","Help me create an alert for CPU usage exceeding 80%","Add a MySQL slow query alert rule","Add a memory alert for the host, fire when it exceeds 90%"],"max_iterations":20,"builtin_tools":["preview_alert_rule_template","import_alert_rule_template","create_alert_rule","list_busi_groups","list_datasources","list_metrics","list_notify_rules","list_files","read_file","list_databases","list_tables","describe_table"],"tags":["export"]},"isInternal":false,"tokens":6907,"sizeBytes":27417},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/create-dashboard/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/create-dashboard/SKILL.md","title":"create-dashboard","category":"anthropic-skill","format":"markdown","content":"---\nname: create-dashboard\ndescription: Create monitoring dashboards. Use this when the user asks to create a dashboard, a monitoring board, or a Dashboard.\nmax_iterations: 18\nbuiltin_tools:\n  - import_dashboard_template\n  - create_dashboard\n  - list_metrics\n  - list_files\n  - read_file\n  - grep_files\ntags:\n  - export\n---\n\n# Skill: Nightingale (N9E) Dashboard Creation\n\nThere are two paths to create a dashboard. **Prefer path A (import an integration template)**, since templates are hand-tuned, validated, finished products with quality far higher than hand-assembled ones; only fall back to path B when there is no ready-made template or the user wants custom metrics.\n\nWhen the same component often has both categraf and exporter templates, **prefer categraf**: first probe whether the categraf metrics have data, use the categraf template if you can query them, and only fall back to exporter if you cannot (see path A step 3 for details).\n\n| Scenario | Which tool to use |\n|------|-----------|\n| The monitoring topic has a ready-made template in integrations (Linux / MySQL / Redis / Kafka / PostgreSQL / Elasticsearch / Ceph / Oracle / Nginx / Windows …) | **`import_dashboard_template`** (preferred) |\n| Custom metrics, no ready-made template, or the user explicitly wants custom panels | `create_dashboard` |\n\n## Step 1 (common to both paths): Determine the business group and datasource\n\n- Call `list_busi_groups` to get the list of business groups\n- Call `list_datasources` to get the list of datasources, and find the **datasource ID of the Prometheus type**\n- If the conversation context has already preselected `busi_group_id` / `datasource_id`, use them directly and do **not** call `list_*` again\n\n### Business group selection rules\n\n1. If the user explicitly specified a business group name or ID, use it directly\n2. Otherwise, in priority order:\n   a. **Prefer the business group with `is_default: true`** (usually \"Default Busi Group\" or a group whose name contains \"default\")\n   b. If there is only one business group, use it directly\n   c. If there are multiple candidates and none is the default, **do not blindly take the first one**; list them in your reply and let the user confirm\n\n---\n\n## Path A: Import an integration template (preferred)\n\n`import_dashboard_template` reads the complete template under `integrations/` (preserving layout, thresholds, units, overrides, and value mappings in full), and automatically rewrites the template's datasource binding onto the Prometheus datasource you chose.\n\n### Steps\n\n1. See which integration components exist:\n   ```\n   list_files(base=\"integrations\")\n   ```\n2. See which template files exist under that component:\n   ```\n   list_files(base=\"integrations/Linux\", path=\"dashboards\")\n   ```\n3. **Pick the right template file**. Templates often come in two collection styles, categraf (telegraf-style metric names) and exporter (`node_*` / `*_exporter`-style metric names). File-naming conventions vary per component: it may be a prefix (Linux's `categraf-overview.json`) or a suffix (`redis_by_categraf.json`). **Prefer the template whose file name contains `categraf`**:\n   - First probe whether the categraf metrics have data in the environment: `list_metrics(datasource_id=<X>, keyword=\"<categraf metric keyword>\")`.\n     - Use a categraf-specific metric name as the keyword that distinguishes the two styles, for example for Linux use `cpu_usage` (categraf has `cpu_usage_idle`, while node_exporter does not have this name), `mem_used_percent`, `disk_used_percent`; for Redis use `redis_used_memory`.\n     - If you are unsure which keyword to use, read the metrics list of that template (the file is small and will not be truncated): `read_file(base=\"integrations/Linux\", path=\"metrics/categraf-base.json\")`, and pick a representative `expression` from it to probe with.\n   - **As long as the categraf metrics return data (a non-empty array), use the template whose file name contains `categraf` directly**, with no need to compare against exporter.\n   - Only when the categraf metrics return no data at all should you fall back to the template whose file name contains `exporter` (such as `exporter-detail.json`, `redis_by_exporter.json`), using the corresponding exporter metric keyword (such as `node_cpu_seconds_total`) with `list_metrics` to confirm it actually has data before importing.\n   - **Do not** use `read_file` to read out the entire dashboard template and assemble it yourself — the template may be very large and get truncated; choosing the file by file name + the metrics list is enough\n4. Import:\n   ```\n   import_dashboard_template(group_id=<business group>, component=\"Linux\", file=\"categraf-overview.json\", datasource_id=<Prometheus datasource>)\n   ```\n\n### Notes\n\n- Datasource binding, variables, and layout are all handled automatically; you only need to pass `component` + `file`\n- `datasource_id` is optional: passing it sets it as the default selected value of the dashboard's datasource variable (so the first screen is queryable immediately); not passing it lets the frontend automatically select the first Prometheus from the datasource dropdown\n- To rename or change tags: pass the optional `name` / `tags`; if not passed, the template's own values are kept\n- When it returns `Name duplicate`, **do not** call `list_dashboards`; just change the name (add a `-v2`, `-AI`, or timestamp suffix) and retry\n\n---\n\n## Path B: Custom creation (when there is no template)\n\nUse `create_dashboard`. **You only need to provide the panel title, type, and PromQL**; the tool automatically generates the full configuration (layout, datasource variables, styling, units, etc., are all handled automatically).\n\n> create_dashboard accepts the following set of **simplified fields**. Any field beyond these (overrides, value mappings, heatmap/hexbin/iframe, etc.) is **not supported** — they will be ignored even if written. When you need such rich configuration, switch to path A and import a template.\n\n### Call example\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Linux Host Monitoring\",\n  \"datasource_id\": 1,\n  \"tags\": \"linux host\",\n  \"variables\": \"[{\\\"name\\\":\\\"ident\\\",\\\"label\\\":\\\"Host\\\",\\\"definition\\\":\\\"label_values(cpu_usage_idle, ident)\\\"}]\",\n  \"panels\": \"[{\\\"name\\\":\\\"CPU Usage\\\",\\\"type\\\":\\\"stat\\\",\\\"queries\\\":[{\\\"promql\\\":\\\"avg(cpu_usage_active{cpu=\\\\\\\"cpu-total\\\\\\\",ident=~\\\\\\\"$ident\\\\\\\"})\\\",\\\"legend\\\":\\\"CPU\\\"}],\\\"unit\\\":\\\"percent\\\"},{\\\"name\\\":\\\"CPU Usage Trend\\\",\\\"type\\\":\\\"timeseries\\\",\\\"queries\\\":[{\\\"promql\\\":\\\"cpu_usage_active{cpu=\\\\\\\"cpu-total\\\\\\\",ident=~\\\\\\\"$ident\\\\\\\"}\\\",\\\"legend\\\":\\\"{{ident}}\\\"}],\\\"unit\\\":\\\"percent\\\"}]\"\n}\n```\n\n### Panel fields (only these are supported)\n\nEach panel requires 3 fields:\n\n```json\n{\"name\": \"Panel title\", \"type\": \"timeseries\", \"queries\": [{\"promql\": \"PromQL expression\", \"legend\": \"{{ident}}\"}]}\n```\n\n| Field | Description | Default |\n|------|------|------|\n| `name` | Panel title (required) | — |\n| `type` | Panel type (required, see table below) | — |\n| `queries` | Query list, each item `{promql, legend?, instant?}` | — |\n| `unit` | Unit | none |\n| `w` / `h` | Width/height (number of grid columns, total width 24) | auto by type |\n| `stack` | Whether to stack (timeseries only) | false |\n| `description` | Panel description | none |\n\nQuery field `instant`: for single-value panels such as stat/gauge/barGauge/pie, it is recommended to set `\"instant\": true` (instant query). tableNG always uses instant queries automatically.\n\n### Supported panel types (only these 8)\n\n| type | Description | Default size w×h |\n|------|------|--------------|\n| `timeseries` | Time-series line chart, the most common | 12×8 |\n| `stat` | Single-value statistic big number | 6×4 |\n| `gauge` | Gauge | 6×6 |\n| `barGauge` | Horizontal bar ranking | 8×8 |\n| `pie` | Pie chart | 6×6 |\n| `tableNG` | Table | 12×10 |\n| `text` | Text note (uses description as content) | 6×4 |\n| `row` | Grouping row (automatically full width) | 24×1 |\n\n### Common units (unit)\n\n`percent` | `bytesIEC` | `bitsIEC` | `bytesSecIEC` | `bitsSecIEC` | `seconds` | `milliseconds` | `reqps`\n\n### Automatic layout calculation\n\nPanels are arranged automatically from left to right and top to bottom, with same-type panels auto-aligned (e.g. 4 stat panels in one row); a `row` takes a whole row on its own as a grouping title. No need to specify coordinates manually.\n\n### Variables\n\n```json\n{\"name\": \"ident\", \"label\": \"Host\", \"definition\": \"label_values(cpu_usage_idle, ident)\"}\n```\n\n`label` and `multi` are optional (multi defaults to true). A later variable references an earlier variable in its definition to achieve cascading:\n\n```json\n[\n  {\"name\": \"ident\", \"definition\": \"label_values(cpu_usage_idle, ident)\"},\n  {\"name\": \"interface\", \"definition\": \"label_values(net_bytes_recv{ident=~\\\"$ident\\\"}, interface)\"}\n]\n```\n\n### Notes\n\n- For multi-select variables, use `=~` rather than `=` in PromQL, e.g. `ident=~\"$ident\"`\n- For counter-type metrics, use `rate(...[3m])` or `irate(...[5m])`\n- **Prefer copying validated PromQL expressions from the integrations templates**: `read_file(base=\"integrations/Linux\", path=\"dashboards/categraf-detail.json\")`, focusing on the `expr` inside targets\n- When it returns `Name duplicate`, just change the name and retry; do not call `list_dashboards`\n\n---\n\n## Step 3 (common to both paths): Output the result\n\n**Keep it short**. A one-sentence confirmation is enough, for example:\n\n> ✅ I have created the dashboard \"Linux Host Monitoring\" for you; see the card below for details.\n\n**Do not** restate fields such as the dashboard ID, business group, datasource, or panel list — the frontend will display them in a structured card. You may add a sentence or two of additional suggestions, but do not enumerate the fields the card already has.\n\n## Recommended panel design when there is no template (path B reference)\n\n> These topics basically all have templates in integrations; prefer path A. The following is only for reference when hand-building via path B.\n\n### Linux host monitoring\n\n**Variables:** `ident` (host), `interface` (network interface), `mountpoint` (mount point)\n\n| Area | Panel | Type | Core metric |\n|------|------|------|----------|\n| Overview | CPU Usage | stat | `avg(cpu_usage_active{cpu=\"cpu-total\"})` |\n| Overview | Memory Usage | stat | `avg(mem_used_percent)` |\n| Overview | Disk Usage (max) | stat | `max(disk_used_percent)` |\n| CPU | CPU Usage Trend | timeseries | `cpu_usage_active` + `cpu_usage_iowait` |\n| CPU | System Load | timeseries | `system_load1/5/15` |\n| Memory | Memory Usage Trend | timeseries | `mem_used_percent` |\n| Disk | Per-mount-point Usage | barGauge | `disk_used_percent` |\n| Network | Network Traffic | timeseries | `rate(net_bytes_recv/sent)` |\n\n### MySQL monitoring\nQPS/TPS, connection count, slow queries, Buffer Pool hit ratio, replication lag\n\n### Redis monitoring\nOPS, memory usage, connection count, hit ratio, keyspace\n\n### Kubernetes monitoring\n**Variables:** `cluster`, `namespace`, `pod`. Pod CPU/memory, node resources, deployment status, PV usage\n","frontmatter":{"name":"create-dashboard","description":"Create monitoring dashboards. Use this when the user asks to create a dashboard, a monitoring board, or a Dashboard.","max_iterations":18,"builtin_tools":["import_dashboard_template","create_dashboard","list_metrics","list_files","read_file","grep_files"],"tags":["export"]},"isInternal":false,"tokens":2672,"sizeBytes":11061},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/doc-qa/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/doc-qa/SKILL.md","title":"doc-qa","category":"anthropic-skill","format":"markdown","content":"---\nname: doc-qa\ndescription: >-\n  This skill should be used when the user asks \"how-to\" or factual questions about Nightingale (n9e) — UI/where-to-click, business groups/subscription rules/mute rules/edge mode, Token usage, notification pipeline, self-healing trigger conditions; OR about categraf input plugin field meanings, metric names, defaults, environment variables, config syntax (e.g. \"how to write [[instances]]\", \"unit of ping_average_response_ms\"); OR about the n9e / categraf / fe source code — this skill carries an embedded, searchable snapshot of the three repositories, so use it whenever the user asks \"can you read the source code / the code directory\", or asks how a feature is implemented, what a file or function does, or where something lives in the codebase. NOT for actively troubleshooting an alert or querying metrics.\nversion: 2.0.0\ntags:\n  - internal\nmax_iterations: 30\nbuiltin_tools:\n  - search_n9e_docs\n  - verify_answer\n  - list_code\n  - search_code\n  - read_code\n---\n\n# Nightingale (n9e) Platform Q&A Assistant\n\nAnswer questions based on two evidence channels: the official docs (search_n9e_docs) and the **embedded source code corpus** of three repos (list_code / search_code / read_code). **Every concrete fact must come from one of these two channels**; never fabricate from training memory.\n\n---\n\n## 🔴 Top Directive (overrides everything)\n\n**Every \"concrete fact\" in the answer must be findable verbatim in the contents returned by search_n9e_docs OR in code returned by search_code / read_code**. A \"concrete fact\" means:\n\n- Config item syntax (`[[instances]]` / `[heartbeat]` / `omit_hostname`)\n- Field names, API paths, Header names, environment variables, metric names, endpoints, default values, version numbers\n- Named constants (the English labels corresponding to Severity numbers)\n- Menu names / UI entry-point names\n\nIf it appears in neither channel, it is **forbidden** to write it into the answer. Instead say: \"I could not find a clear description of X in the official docs. Suggestions: 1) search manually at https://flashcat.cloud/docs/search/ ; 2) search issues at https://github.com/ccfos/nightingale/issues ; 3) ask in the community group.\"\n\nExtrapolation is allowed: concept explanations / feature introductions / workflows / why-it-is-done-this-way. Extrapolating concrete identifiers is strictly forbidden.\n\n---\n\n## Two evidence channels — when to use which\n\n| Channel | Tool | Use for |\n|---|---|---|\n| Docs | search_n9e_docs | Concepts, how-to, workflows, UI walkthroughs, integration config samples (source=`integration-config` is the most authoritative for toml examples) |\n| Code | search_code / read_code / list_code | **Verifying concrete identifiers**: metric names, config defaults, environment variables, API paths, auth headers, constants, UI menu names |\n\nThe code corpus contains filtered source snapshots of three repos (versions shown in each search_code result header):\n\n- **`categraf`** — the collection agent. Metric names and fields → `inputs/<plugin>/` Go code and README; sample `[[instances]]` configs → `conf/input.<plugin>/`; global config & env vars → `config/`\n- **`n9e`** — the server. API paths & auth headers → `center/router/`; constants (e.g. Severity labels) & table fields → `models/`; config defaults → `etc/` samples and config structs\n- **`fe`** — the web UI. Menu names and form labels (zh/en) → `src/locales/`; page behavior → `src/pages/`; UI-called API paths → `src/services/`\n\n**Routing rule of thumb**: \"指标名/单位/插件字段\" → categraf; \"接口/Header/告警引擎/常量\" → n9e; \"页面在哪/菜单叫什么/表单怎么填\" → fe (`src/locales/` first).\n\n**When lost**: `read_code(repo, \"TREE.md\")` gives a directory guide for that repo. Prefer `search_code` with a distinctive keyword first; use `list_code` to explore a directory you already located.\n\n**Mandatory code verification**: before finalizing, any concrete identifier that came from docs with quality below `high` — or that you are about to write from a weakly-supported chunk — must be confirmed with search_code (e.g. search the exact metric name in categraf; if it is not in the code, do not write it).\n\n**Degraded mode**: if code tools return \"code corpus not available in this build\", this build carries no corpus. Do NOT retry them; fall back to docs-only mode (the v1 behavior). This is normal, not an error.\n\n---\n\n## Failure Cases (zero tolerance, all from real testing)\n\n| Failure case | Wrong answer | Truth | Where the truth lives in code |\n|---|---|---|---|\n| Categraf config syntax | `[[inputs.net_response]]` (Telegraf style) | Categraf uses `[[instances]]` | categraf `conf/input.*/ *.toml` |\n| Categraf environment variable | fabricated `N9E_ADDR` | no such variable in the code | categraf `config/` |\n| Ping metric name | `categraf_ping_rtt` / `ping_result_milliseconds` | actually `ping_average_response_ms` | categraf `inputs/ping/` |\n| Severity label | `1: Critical` | actually `1: Emergency` | n9e `models/alert_rule.go` |\n| Role of the [http] section | \"expose /metrics to Prometheus PULL\" | actually a PUSH gateway, endpoint `/pushgateway` | categraf `conf/config.toml` |\n| Config defaults | `batch=2000 chan_size=10000` | actually `batch=1000 chan_size=1000000` | categraf `conf/config.toml` |\n| Web API authentication | `Authorization: Bearer <token>` | actually `X-User-Token: <token>` | n9e `center/router/` |\n\n---\n\n## About the item source marker\n\nEach item returned by `search_n9e_docs` has a `source` field:\n\n- **`integration-config`** (Title starts with `[integration-config]`): from the real config samples in `integrations/<C>/collect/*.toml` — **⭐ when writing a toml example you MUST copy verbatim from here; this is the most authoritative**\n- **`integration-doc`** (Title starts with `[integration-doc]`): the component description from `integrations/<C>/markdown/README.md`\n- **`n9e-docs`**: from the https://flashcat.cloud docs site\n\n---\n\n## 🔴 Refusal Directive (same priority as the Top Directive)\n\nThe `search_n9e_docs` return value carries a **`quality`** field (`empty` / `low` / `ok` / `high`) and a **`must_refuse`** flag. Decide according to the following rules:\n\n| quality   | Meaning                                | Required behavior |\n|-----------|----------------------------------------|----------|\n| `high`    | Strong recall (max_score >= 20)        | Answer normally based on contents |\n| `ok`      | Medium recall (10 <= max_score < 20)   | Answer normally based on contents |\n| `low`     | Weak recall (5 <= max_score < 10, only weak contents hits) | Try to confirm the concrete identifiers via search_code; if confirmed answer normally, otherwise append \"The information above is based on a weak recall; please re-verify against the official docs\" |\n| `empty`   | **No valid recall** (`must_refuse=true`)   | Switch to the code channel; only if **both channels** come up empty, reply per the refusal template below |\n\n**Refusal condition (v2)**: refuse only when the docs channel is `empty` **and** the code channel found nothing relevant either (or is unavailable). When `must_refuse=true` but the code corpus does answer the question (e.g. an exact metric name found in categraf inputs), you may answer from code — stating facts found in code is not fabrication. It is still forbidden to fill in anything found in **neither** channel. Refusal template:\n\n```markdown\nI could not find a clear description of **<the key noun from the user's question>** in the V9 official docs.\n\nTo avoid giving you incorrect information, I will not answer this directly. I suggest you:\n\n1. 📖 Go to the [V9 docs site](https://flashcat.cloud/docs/), switch versions and search manually\n2. 🐛 Search the [GitHub Issues](https://github.com/ccfos/nightingale/issues)\n\n<optional: based on a \"related but not directly relevant\" chunk that was recalled, give a vendor-neutral conceptual guide — without any n9e/categraf specific identifiers>\n```\n\nThere is no hard cap on how many searches you may run (the iteration budget is generous) — but stop a line of search after 2 fruitless keyword variations per channel and move on; do not loop on rephrasing.\n\n---\n\n## 🛡️ Mandatory workflow: you **MUST** call verify_answer before the Final Answer\n\nTo intercept the deterministic errors in the \"failure cases\" above, you **MUST** do a self-check before you intend to give the Final Answer:\n\n```\nAction: verify_answer(answer=\"<your complete markdown draft>\")\nObservation: {\"clean\": false/true, \"must_revise\": true/false, \"hits\": [...], \"next_action\": \"...\"}\n```\n\nDecision rules (non-negotiable):\n\n| Return | What you must do |\n|---|---|\n| `clean: true` | You may give the Final Answer |\n| `must_revise: true` (HIGH hit) | **Final Answer forbidden**. Re-verify the flagged facts per `hits[*].retry_hint` — search_n9e_docs and/or search_code — rewrite the draft, call verify_answer **again** to validate, until clean=true or all hits are medium |\n| `clean: false, must_revise: false` (only medium/low hits) | Final Answer allowed, but it is recommended to fine-tune per `hits[*].annotate` |\n\n**Why you must call it**: the HIGH-hit rules are all strings that historically failed in real testing (fabricated environment variables, Telegraf-style syntax, etc.). Your training memory is very likely to treat these as correct; calling it avoids giving the user a production incident.\n\n**Do not skip**: even if you \"feel\" the answer is fine, still call it. The rules cover exactly the points where you historically were most likely to fail.\n\n---\n\n## Workflow\n\n1. **Break out keywords** (2~4 words, space-separated, using the product's official terminology)\n   - Try synonyms too: \"lost connection\" → \"offline / heartbeat / heartbeat\"; \"alert\" → \"alert / rule\"\n2. **Call search_n9e_docs(keywords, top_n=3)** for the conceptual/how-to part\n3. **Route concrete identifiers to the code channel**: pick the repo per the routing table, search_code with the most distinctive keyword, read_code the surrounding lines to confirm the exact name/default/unit\n4. **Synthesize** the docs + code evidence, prioritizing the highest-scoring doc hits + source=`integration-config` hits + code-confirmed identifiers\n5. **verify_answer**, then Final Answer with markdown reference links (docs only)\n\n---\n\n## The index only contains V9 docs\n\nsearch_n9e_docs has already filtered out V5/V6/V7/V8. When the user explicitly asks about an older version, tell them directly \"This assistant only covers V9; please go to https://flashcat.cloud/docs/ and switch versions manually to query.\" It is **forbidden** to give cross-version fields/Headers/APIs from training memory. The code corpus is a snapshot matching this build's release; do not use it to answer older-version questions either.\n\n---\n\n## Output format\n\n```markdown\n<2-5 paragraphs of a digested, organized answer; lists/code blocks/tables are allowed>\n\n---\n\n**References**\n- [<title 1>](<permalink 1>)\n- [<title 2>](<permalink 2>)\n```\n\nKey points:\n1. Do not pile up the raw contents; digest and organize. But every \"concrete fact\" must come verbatim from the docs contents or from code\n2. If only 1 doc hit, list only 1; do not pad; if the answer is code-verified only, omit the References section entirely\n3. When citing an `integration-config` item, the permalink is a github.com path; list it anyway\n\n### 🔴 Code references never surface to the user\n\nThe code channel is **internal evidence only**. In the Final Answer it is **forbidden** to include source file paths, line numbers, code snippets quoted from the corpus, repo/commit identifiers, or any \"as seen in the code\" phrasing. State code-verified facts as plain conclusions, e.g. \"the ping plugin's response-time metric is `ping_average_response_ms`, in milliseconds\" — NOT \"see inputs/ping/ping.go:123\". References list docs links only, never code links.\n\n**Sole exception**: the user explicitly asks where/how something is implemented in the source — then you may name files and show snippets.\n\n---\n\n## Boundaries\n\n- Only answer platform-usage questions; for anything outside the n9e scope (e.g. \"how to deploy Prometheus\" when there is no doc) just say \"out of scope\"\n- Do not return code-change suggestions\n- Do not perform any actions. If the user says \"then go ahead and create it for me\" → \"I only handle Q&A; to take action, open the XX page or switch to a creation-type skill\"\n","frontmatter":{"name":"doc-qa","description":"This skill should be used when the user asks \"how-to\" or factual questions about Nightingale (n9e) — UI/where-to-click, business groups/subscription rules/mute rules/edge mode, Token usage, notification pipeline, self-healing trigger conditions; OR about categraf input plugin field meanings, metric names, defaults, environment variables, config syntax (e.g. \"how to write [[instances]]\", \"unit of ping_average_response_ms\"); OR about the n9e / categraf / fe source code — this skill carries an embedded, searchable snapshot of the three repositories, so use it whenever the user asks \"can you read the source code / the code directory\", or asks how a feature is implemented, what a file or function does, or where something lives in the codebase. NOT for actively troubleshooting an alert or querying metrics.","version":"2.0.0","tags":["internal"],"max_iterations":30,"builtin_tools":["search_n9e_docs","verify_answer","list_code","search_code","read_code"]},"isInternal":false,"tokens":2997,"sizeBytes":12455},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/explorer-query/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/explorer-query/SKILL.md","title":"explorer-query","category":"anthropic-skill","format":"markdown","content":"---\nname: explorer-query\ndescription: >-\n  Use when the user wants to generate, repair, validate, or prepare a query for\n  the current Nightingale Metrics Explorer or Log Explorer panel. Supports\n  PromQL for Prometheus-compatible datasources, SQL for MySQL and Doris, and\n  Doris log queries. Verify the statement against the selected datasource before\n  returning it. Use query-datasource when the user only wants to view data rather\n  than prepare a statement for an editor.\ntags:\n  - internal\nbuiltin_tools:\n  - list_datasources\n  - list_metrics\n  - get_metric_labels\n  - query_prometheus\n  - list_databases\n  - list_tables\n  - describe_table\n  - query_timeseries\n  - query_log\nmax_iterations: 12\n---\n\n# Nightingale Explorer Query Assistant\n\nGenerate a single query that the user can put into the current Metrics Explorer\nor Log Explorer editor. Use Nightingale's built-in query tools to verify every\ncandidate against the selected datasource before delivering it.\n\n## Scope\n\n- Metrics Explorer: PromQL on Prometheus-compatible datasources; SQL on MySQL.\n- Log Explorer: Doris SQL or Doris log-search queries.\n- This skill prepares a statement for an editor. If the user asks for an\n  analysis or the actual returned data, use the query tools to answer that\n  request instead of claiming to have changed the page.\n\n## Rules\n\n1. Preserve the current datasource, editor mode, expression, and time window\n   whenever they are available in conversation context. `datasource_id`\n   and `datasource_type` supplied by the page are authoritative. Do not silently\n   switch to a different datasource.\n2. When the datasource is not known, call `list_datasources` and ask the user\n   to choose if more than one datasource is plausible. Do not guess an ID.\n3. Run read-only checks only. Never generate a write statement such as INSERT,\n   UPDATE, DELETE, ALTER, DROP, CREATE, GRANT, or REVOKE.\n4. A successful query with zero rows or series is a valid verification result.\n   Say that it executed successfully but returned no data; do not loosen filters\n   or widen the time range simply to find data.\n5. When the current request offers `page_action`, use it after verification to\n   ask the page to fill or run the chosen statement. Select only an action\n   declared by the page and pass arguments that match its schema. Calling it\n   ends this turn. Never claim that the browser has executed the action or that\n   it returned a result.\n6. When `page_action` is unavailable, return the verified statement for the\n   caller or UI to apply. Never claim that you changed the current page.\n\n## Prometheus / PromQL workflow\n\n1. Confirm the selected datasource is Prometheus-compatible. If needed, use\n   `list_datasources` with `plugin_type=prometheus`.\n2. Use `list_metrics` to find candidate metric names, then use\n   `get_metric_labels` for each candidate needed by the expression. Do not\n   invent metric names or label values.\n3. Build the PromQL expression. If the user gave a time window, use an\n   equivalent `time_range` when calling `query_prometheus`; otherwise use the\n   panel's current range or the tool default.\n4. Call `query_prometheus` with the exact expression you plan to deliver.\n   Use `query_type=range` for a requested interval and `instant` only when an\n   instant value is appropriate.\n5. Return the exact verified expression in a fenced `promql` block, followed by\n   one concise sentence describing the validation result.\n\n## MySQL / Doris SQL workflow\n\n1. Confirm the selected datasource and its type. Use `list_databases`,\n   `list_tables`, and `describe_table` to verify database, table, column, and\n   time-field names before writing SQL.\n2. Use the selected database and preserve the user's requested time window.\n   Prefer `$from` and `$to` in time predicates when the panel provides a\n   relative range. Keep an explicit time filter when a verified time column is\n   available.\n3. Execute the candidate through `query_timeseries` when it has a valid value\n   and time column; otherwise use `query_log` for a bounded read-only validation.\n   Use the exact same SQL that you will return.\n4. Return the exact verified statement in a fenced `sql` block. State whether\n   it returned data, returned no rows, or could not be verified.\n\n## Doris log-search workflow\n\n1. Preserve the selected database, table, syntax, and time field. Verify the\n   table and fields with `list_tables` and `describe_table` when they are not\n   already established by the page context.\n2. Use `query_log` to validate the final SQL or log query with a bounded result\n   limit and the selected time range.\n3. Return one verified statement only. Do not claim it was applied to the page.\n\n## Failure handling\n\n- If the selected datasource, schema, metric, or label cannot be verified, say\n  what check failed and do not present the statement as verified.\n- If a datasource rejects a query, keep the error evidence and repair only the\n  part proven invalid. Do not replace the user's intended datasource, database,\n  table, or time range without asking.\n","frontmatter":{"name":"explorer-query","description":"Use when the user wants to generate, repair, validate, or prepare a query for the current Nightingale Metrics Explorer or Log Explorer panel. Supports PromQL for Prometheus-compatible datasources, SQL for MySQL and Doris, and Doris log queries. Verify the statement against the selected datasource before returning it. Use query-datasource when the user only wants to view data rather than prepare a statement for an editor.","tags":["internal"],"builtin_tools":["list_datasources","list_metrics","get_metric_labels","query_prometheus","list_databases","list_tables","describe_table","query_timeseries","query_log"],"max_iterations":12},"isInternal":false,"tokens":1130,"sizeBytes":5045},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/generate-message-template/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/generate-message-template/SKILL.md","title":"generate-message-template","category":"anthropic-skill","format":"markdown","content":"---\nname: generate-message-template\ndescription: Generate or modify Nightingale (n9e) alert notification message templates. Use when the user asks to write a notification template, change the message format, add hostname/recovery value/severity, or create DingTalk/Feishu/Lark/email/SMS/voice templates.\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Notification Message Template Generation\n\nNightingale message templates use Go `text/template` / `html/template` syntax (email uses `text/template`, others use `html/template` and then escape). Users edit them on the \"Notification Management → Message Templates\" page; once saved they are referenced by notification rules, and when an alert fires the variables are substituted with rendered data and sent to each channel.\n\nThis skill focuses on **writing/modifying the template fragment itself**, and does not cover creating notification rules or channel configuration.\n\n---\n\n## Render Context\n\nThe `renderData` passed in when the backend executes:\n\n| key | type | description |\n|---|---|---|\n| `.events` | `[]*AlertCurEvent` | The list of alert events in the current batch, usually just 1 |\n| `.domain` | `string` | The n9e site URL, used to build redirect links |\n\n**Automatically injected shorthands** (no need to write by hand):\n\n```gotemplate\n{{ $events := .events }}\n{{ $event := index $events 0 }}\n{{ $labels := $event.TagsMap }}\n{{ $value := $event.TriggerValue }}\n```\n\nSo inside the template you can directly use `$event.xxx`, `$labels.agent_hostname`, and `$value`.\n\n---\n\n## Available `$event` Fields (`*AlertCurEvent`)\n\n### Common Fields\n| field | type | description |\n|---|---|---|\n| `.Id` | int64 | Alert event ID (used to build the redirect link) |\n| `.RuleId` | int64 | Alert rule ID |\n| `.RuleName` | string | Rule name |\n| `.RuleNote` | string | Rule note |\n| `.Severity` | int | Alert severity: 1=Critical, 2=Warning, 3=Info |\n| `.PromQl` | string | Alert trigger expression |\n| `.RuleAlgo` | string | Rule algorithm type |\n| `.TriggerTime` | int64 | Trigger time, unix seconds |\n| `.TriggerValue` | string | Metric value at trigger time (already a string) |\n| `.FirstTriggerTime` | int64 | First abnormal time (for consecutive alerts) |\n| `.LastEvalTime` | int64 | Most recent evaluation time, **used as the recovery time on recovery** |\n| `.IsRecovered` | bool | Whether it has recovered |\n| `.NotifyCurNumber` | int | Which notification number this send is |\n| `.TargetIdent` | string | Monitored object (usually agent_hostname) |\n| `.TargetNote` | string | Object note |\n| `.GroupId` / `.GroupName` | int64 / string | Business group |\n| `.Cluster` | string | Data source cluster name |\n| `.Cate` | string | Data source type (prometheus / host / mysql / ...) |\n| `.RunbookUrl` | string | Runbook link |\n\n### Labels / Annotations / Trigger Value Objects\n| field | type | description |\n|---|---|---|\n| `.TagsMap` | `map[string]string` | Event labels, preferred: `{{$labels.agent_hostname}}` |\n| `.TagsJSON` | `[]string` | A string array like `[\"k=v\", ...]` (commonly used in early templates) |\n| `.AnnotationsJSON` | `map[string]string` | Annotations, commonly `.AnnotationsJSON.recovery_value` |\n| `.TriggerValuesJson.ValuesWithUnit` | map | Trigger values with units (multi-value scenarios) |\n\n### Notification Objects\n| field | type | description |\n|---|---|---|\n| `.NotifyUsersObj` | `[]*User` | The user objects to notify this time (with Username/Nickname/Phone/Email) |\n| `.NotifyGroupsObj` | `[]*UserGroup` | Associated user groups |\n\n> User objects are commonly used in DingTalk/Feishu/Lark templates to build @-mentions; see the `ats` family of helpers below.\n\n---\n\n## Available Helper Functions (`tplx.TemplateFuncMap`)\n\n### Time\n- `timeformat <unix>` — defaults to `\"2006-01-02 15:04:05\"`; pass a second argument to override: `{{timeformat $event.TriggerTime \"15:04:05\"}}`\n- `timestamp` — current time string\n- `now.Unix` — current unix seconds (`now` is a Go template builtin)\n- `humanizeDuration <sec>` — `\"3m15s\"`\n- `humanizeDurationInterface <interface>` — same as above but accepts an interface\n- `toTime <unix>` — returns a `time.Time`, can be chained\n- `parseDuration <\"5m\">` — returns a `time.Duration`\n\n### Numbers / Formatting\n- `formatDecimal <v> <n>` — keep n decimal places (`{{formatDecimal $event.TriggerValue 2}}`)\n- `humanize <v>` — K/M/G units (base 1000, SI)\n- `humanize1024 <v>` — Ki/Mi/Gi (base 1024)\n- `humanizePercentage <v>` / `humanizePercentageH <v>` — percentage\n- `add / sub / mul / div <a> <b>` — arithmetic\n- `printf \"%.2f\" <v>` — Go `fmt.Sprintf`\n\n### Strings\n- `toUpper / toLower / title` — case conversion\n- `contains <s> <sub>` — substring\n- `match <regex> <s>` — regex match (bool)\n- `reReplaceAll <regex> <repl> <s>` — regex replace\n- `split <s> <sep>` / `join <slice> <sep>`\n- `stripPort <host:port>` / `stripDomain <host.domain>`\n- `b64enc` / `b64dec` — base64\n\n### Links / Escaping\n- `escape <s>` — URL path escape\n- `unescaped <s>` — output raw HTML (no escaping)\n- `safeHtml <s>` — same as above\n- `urlconvert <s>`\n\n### Labels / Trigger Values\n- `label <key> <labelMap>` / `value <key> <m>` / `strvalue <v>`\n- `first <slice>` — take the first\n- `tagsMapToStr <map>` — join labels into `k=v,k=v`\n- `sortByLabel <items> <key>` — sort by label\n\n### @-mention (DingTalk/Feishu/Lark only)\n- `ats <users> <platform>` — generate an at fragment\n- `batchContactsAts <contacts> <platform>` — batch at\n- `batchContactsAtsInFeishuEmail <contacts>` / `batchContactsAtsInFeishuId <contacts>` — Feishu only\n- `batchContactsJoinComma <contacts>` / `batchContactsJsonMarshal <contacts>`\n- `mappingAndJoin <map> <kvSep> <itemSep>`\n\n### Others\n- `jsonMarshal <v>` — serialize to a JSON string\n- `mapDifference <a> <b>` — set difference\n\n---\n\n## Syntax Differences Across Channels (`notify_channel_ident`)\n\nA message template is bound to a specific channel; the channel ident determines the text engine and escaping behavior:\n\n| Ident | Engine | Description |\n|---|---|---|\n| `email` | `text/template` | No re-escaping; HTML templates can write tags directly |\n| `slackwebhook` / `slackbot` | `html/template` | After rendering, `\"` / `\\n` are escaped and wrapped into `template.HTML`; usually written in Markdown |\n| Others (`dingtalk` / `feishu` / `feishucard` / `larkcard` / `wecom` / `tx-sms` / `ali-voice` …) | `html/template` | After rendering, `\"` `\\n` `\\r` are JSON-string-escaped, suitable for stuffing directly into a webhook payload |\n\n> Important: `html/template` automatically escapes `<` `>` `&`. **Where you don't want escaping, use `{{unescaped \"…\"}}` or `{{safeHtml .X}}`**, otherwise DingTalk will render a literal `&lt;`.\n\n---\n\n## Template Writing Workflow\n\n1. **Confirm the channel ident**: when the user says \"DingTalk\" it is `dingtalk`, \"Feishu card\" is `feishucard`, \"email\" is `email`. Different channels differ significantly in style.\n2. **Decide whether to render the recovery state**: by default you should split into two branches on `$event.IsRecovered`. Only SMS/voice may omit it.\n3. **Pick fields**:\n   - For labels, prefer `$labels.<key>` rather than `$event.TagsJSON`.\n   - For the trigger value use `$event.TriggerValue`; for two decimal places use `{{formatDecimal $event.TriggerValue 2}}`.\n   - Always pass timestamps through `timeformat`.\n   - Build the redirect link as `{{.domain}}/share/alert-his-events/{{$event.Id}}`.\n4. **Severity display**:\n   - Number: `{{$event.Severity}}`\n   - Worded: `{{if eq $event.Severity 1}}Critical{{else if eq $event.Severity 2}}Warning{{else}}Info{{end}}`\n   - English: `Critical / Warning / Info`\n5. **@-mention (DingTalk/Feishu)**:\n   - DingTalk `@everyone`: add a line `@all` at the end of the template (DingTalk matches by splitting on spaces). To at an exact phone number: `{{range $event.NotifyUsersObj}}@{{.Phone}} {{end}}`.\n   - Feishu card: use `{{batchContactsAtsInFeishuEmail $event.NotifyUsersObj}}` to build `<at email=...></at>` fragments.\n6. **Output**: wrap the template body in a markdown ``` ```gotemplate ``` code block, and at the end provide a **variable/function explanation** with each non-trivial variable/function on its own line.\n\n---\n\n## Output Format\n\nWhen replying to the user:\n\n1. One or two sentences of introduction explaining what the template is for and which channel it fits.\n2. ```gotemplate\n   <template content>\n   ```\n3. `**Variable explanation**`: list the non-trivial fields and functions used in the template (such as `$event.TargetIdent`, `formatDecimal`, `timeformat`), one per line.\n4. If the user's request is ambiguous (for example \"add the hostname\" — is it `target_ident` or some tag), **state directly in the introduction what assumption you made**, rather than asking back.\n\nThe language follows the user's input (use Chinese if the input is Chinese).\n\n---\n\n## Built-in Reference Templates (Starting Points for Customization)\n\n### DingTalk markdown (full version)\n\n```gotemplate\n#### {{if $event.IsRecovered}}<font color=\"#008800\">💚{{$event.RuleName}}</font>{{else}}<font color=\"#FF0000\">💔{{$event.RuleName}}</font>{{end}}\n---\n{{$duration := sub now.Unix $event.FirstTriggerTime}}{{if $event.IsRecovered}}{{$duration = sub $event.LastEvalTime $event.FirstTriggerTime}}{{end}}\n- **Alert Level**: S{{$event.Severity}}\n{{- if $event.RuleNote}}\n- **Rule Note**: {{$event.RuleNote}}\n{{- end}}\n{{- if $event.TargetIdent}}\n- **Target**: {{$event.TargetIdent}}\n{{- end}}\n{{- if not $event.IsRecovered}}\n- **Trigger Value**: {{$event.TriggerValue}}\n- **Trigger Time**: {{timeformat $event.TriggerTime}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n{{- else}}\n- **Recovery Time**: {{timeformat $event.LastEvalTime}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n{{- end}}\n- **Event Tags**:\n{{- range $k, $v := $labels}}\n{{- if ne $k \"rulename\"}}\n    - {{$k}}: {{$v}}\n{{- end}}\n{{- end}}\n[Event Detail]({{.domain}}/share/alert-his-events/{{$event.Id}}) | [Mute 1h]({{.domain}}/alert-mutes/add?__event_id={{$event.Id}})\n```\n\n### Feishu card (concise version)\n\n```gotemplate\n{{- if $event.IsRecovered}}\n**Severity/State:** S{{$event.Severity}} Recovered\n**Alert Name:** {{$event.RuleName}}\n**Event Tags:** {{$event.TagsJSON}}\n**Recovery Time:** {{timeformat $event.LastEvalTime}}\n{{- else}}\n**Severity/State:** S{{$event.Severity}} Triggered\n**Alert Name:** {{$event.RuleName}}\n**Event Tags:** {{$event.TagsJSON}}\n**Trigger Time:** {{timeformat $event.TriggerTime}}\n**Trigger Value:** {{$event.TriggerValue}}\n{{- if $event.RuleNote}}\n**Alert Description:** {{$event.RuleNote}}\n{{- end}}\n{{- end}}\n```\n\n### SMS / Voice (minimal)\n\n```gotemplate\nSeverity/State: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}} Rule: {{$event.RuleName}} Target: {{$event.TargetIdent}}\n```\n\n---\n\n## Typical Customization Scenarios\n\n### 1) \"Add the hostname to the DingTalk template\"\n\n```gotemplate\n- **Host**: {{$event.TargetIdent}}\n{{- if $labels.ip}}\n- **IP**: {{$labels.ip}}\n{{- end}}\n```\n\n> Note: `target_ident` is usually the hostname; if you need the IP, prefer the labels `ip`, `instance`, `host`.\n\n### 2) \"Keep trigger_value to two decimal places\"\n\n```gotemplate\n- **Trigger Value**: {{formatDecimal $event.TriggerValue 2}}\n```\n\n> `TriggerValue` is itself a string; `formatDecimal` first converts it to a float and then formats it, and returns non-numeric values unchanged.\n\n### 3) \"@-mention the alert recipients at the end of the DingTalk template\"\n\n```gotemplate\n...template body...\n\n{{- range $event.NotifyUsersObj}}@{{.Phone}} {{end}}\n```\n\n> DingTalk identifies @-mentioned users by \"space + phone number\". Or uniformly use `@all`.\n\n### 4) \"Show the recovery value on recovery\"\n\n```gotemplate\n{{- if $event.IsRecovered}}\n{{- if $event.AnnotationsJSON.recovery_value}}\n- **Recovery Value**: {{formatDecimal $event.AnnotationsJSON.recovery_value 4}}\n{{- end}}\n- **Recovery Time**: {{timeformat $event.LastEvalTime}}\n{{- end}}\n```\n\n> The recovery value is written into `AnnotationsJSON.recovery_value` by the alert engine on recovery, and **only exists when there is a recovery value**, so guard it with `if`.\n\n### 5) \"Use Chinese for the alert severity\"\n\n```gotemplate\n- **Severity**: {{if eq $event.Severity 1}}Critical{{else if eq $event.Severity 2}}Warning{{else}}Info{{end}}\n```\n\n### 6) \"Only send alerts for production-environment machines\"\n\nThe template itself does not do filtering — filtering should go into the `attributes` / `label_keys` of the **notification rule**. The template is only responsible for display. If the user asks about this, point it out.\n\n### 7) \"Color by state/severity\"\n\nDingTalk/email use `<font color>`, Feishu cards use the `template` field to fill in a color keyword:\n\n```gotemplate\n{{/* DingTalk markdown: prepend emoji + color to the title */}}\n#### {{if $event.IsRecovered}}<font color=\"#008800\">✅ {{$event.RuleName}}</font>{{else}}<font color=\"#FF0000\">🚨 {{$event.RuleName}}</font>{{end}}\n\n{{/* The template field for Feishu feishucard (determines the card header color block) */}}\n{{if $event.IsRecovered}}turquoise{{else}}{{if eq $event.Severity 1}}red{{else if eq $event.Severity 2}}orange{{else}}grey{{end}}{{end}}\n```\n\n> Feishu cards only recognize enumerated colors: `red / orange / yellow / green / turquoise / blue / indigo / purple / carmine / grey`; hex color codes are ignored.\n\n### 8) \"Show the alert duration\"\n\n```gotemplate\n{{$duration := sub now.Unix $event.FirstTriggerTime}}\n{{- if $event.IsRecovered}}{{$duration = sub $event.LastEvalTime $event.FirstTriggerTime}}{{end}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n```\n\n> Triggered state: `current time - FirstTriggerTime`; recovered state: `LastEvalTime - FirstTriggerTime`. `humanizeDurationInterface` outputs a human-readable form like `1h3m5s`.\n\n### 9) \"Embed a tag value in a URL, avoiding spaces/special characters breaking the link\"\n\n```gotemplate\n[View Dashboard]({{.domain}}/dashboards/123?ident={{urlquery $event.TargetIdent}}&host={{urlquery (index $labels \"host\")}})\n[Mute 1h]({{.domain}}/alert-mutes/add?__event_id={{$event.Id}})\n```\n\n> Stuffing `{{$event.TargetIdent}}` directly into a URL will, when it contains spaces, Chinese, or `&`, get double-escaped on the IM side (typical symptom: `&` becomes `&amp;` and the link won't open). **Wrap every variable that goes into a URL query in `urlquery`.**\n\n### 10) \"Tag keys containing hyphens/dots/Chinese — use index to fetch\"\n\n```gotemplate\n- **Application**: {{index $labels \"app-name\"}}\n- **K8s Cluster**: {{index $labels \"k8s.io/cluster\"}}\n- **Business Dashboard**: {{index $event.AnnotationsJSON \"dashboard_url\"}}\n```\n\n> `$labels.app-name` will be parsed by Go template as \"`app` minus `name`\" and is bound to fail. As long as a key **is not pure alphanumerics plus underscore**, always fetch it with `index`; the same goes for Annotations.\n\n### 11) \"Fallback for abnormal values (+Inf / NaN)\"\n\n```gotemplate\n- **Trigger Value**: {{if or (eq $event.TriggerValue \"+Inf\") (eq $event.TriggerValue \"NaN\")}}N/A{{else}}{{formatDecimal $event.TriggerValue 2}}{{end}}\n```\n\n> A `/0` in PromQL returns `+Inf`, and an aggregation over missing data returns `NaN`. Rendered directly into Feishu/DingTalk they appear as literal `+Inf`, which looks like a bug.\n\n### 12) \"In Edge mode the event Id=0 — degrade the redirect link\"\n\n```gotemplate\n{{if gt $event.Id 0}}\n[Event Detail]({{.domain}}/share/alert-his-events/{{$event.Id}})\n{{else}}\n(Edge-mode event — view details on the central server)\n{{end}}\n```\n\n> Known issue: in Edge mode the event Id is written asynchronously, so at render time it is 0, and the redirect link would land on `/alert-his-events/0` (404). Add a `gt $event.Id 0` guard at the template layer.\n\n### 13) \"Merge and display multiple alerts\"\n\n```gotemplate\n{{range $i, $e := .events}}\n{{- if $i}}\n---\n{{end -}}\n- Rule: {{$e.RuleName}}\n- Target: {{$e.TargetIdent}}\n- Trigger Value: {{$e.TriggerValue}}\n- Time: {{timeformat $e.TriggerTime}}\n{{end}}\n```\n\n> In the default scenario one notification has exactly one event, and the template renders with `$event = index .events 0`. When one notification aggregates multiple events (subscription aggregation, batch send) you must `range .events` to expand them.\n\n---\n\n## Key Considerations\n\n1. **`html/template` HTML-escapes**. Tags such as `<font>` and `<at>` in DingTalk/Feishu/Lark **must be wrapped in `unescaped` or placed in content where Go template recognizes them itself**. Email uses `text/template` and is not subject to this.\n2. **`TriggerValue` is a string**: to compare magnitudes directly use `parseDuration`/custom logic; for normal display-only purposes leave it as is.\n3. **Do not manually write headers like `{{$events := .events}}`** — the system injects them automatically.\n4. **Don't drop the recovery branch**: rules with `NotifyRecovered=1` reuse the same template to send the recovery message.\n5. **Hyphens/dots in tag keys**: `$labels.agent_hostname` works, but `$labels.app-name` does not — use `index $labels \"app-name\"`.\n6. **All time fields are unix seconds** (`int64`); don't use `{{$event.TriggerTime}}` directly as text — use `timeformat` or `toTime`.\n7. **Merging multiple alerts**: `.events` may have multiple entries; the default DingTalk/Feishu templates only render entry 0 (`$event`); if the user wants a batch display, use `{{range .events}}`.\n8. **Use `.domain` for links**: don't hardcode `http://localhost:17000`, otherwise switching environments breaks it.\n\n---\n\n## Common Mistakes\n\n- ❌ `{{$event.TriggerValue | printf \"%.2f\"}}` — for `printf`, the first argument is the format, and `TriggerValue` is a string.\n- ✅ `{{formatDecimal $event.TriggerValue 2}}`\n\n- ❌ Displaying `{{$event.TriggerTime}}` directly — it outputs unix seconds.\n- ✅ `{{timeformat $event.TriggerTime}}`\n\n- ❌ Writing `<font color=\"red\">` in DingTalk without escaping — `html/template` will escape it into `&lt;font&gt;`.\n- ✅ Put it inside a conditional branch (Go template's `{{if}}` branch literals do not get the inner tags HTML-escaped), or wrap the whole thing in `{{unescaped \"…\"}}`. Nightingale's official DingTalk template writes `<font>` directly and it works, because the outer area is a plain-text section (not inside an attribute value) — just follow the official sample.\n\n- ❌ Forgetting to split the recovery branch — the trigger time in a recovery notification is misleading.\n- ✅ Wrap all dynamic content first with `{{if $event.IsRecovered}}…{{else}}…{{end}}`.\n\n- ❌ `{{if .IsRecoverd}}` — a missing `e` in the spelling. Go template **silently takes the else branch** for a nonexistent field (without erroring), so the consequence is that the recovery notification sends the triggered-state copy, and it is very hard to track down.\n- ✅ `{{if $event.IsRecovered}}`. Similar spelling traps: `Resoverd`, `Recoverd`, `recovered` (lowercase) all fail.\n\n- ❌ `{{if lt $value 10}}` or `{{if gt $event.TriggerValue 80}}` — `TriggerValue` is a string; comparing with `lt`/`gt` against a number will report `incompatible types`, or be interpreted as a string lexicographic comparison and yield a wrong result. The current version **has no built-in `toFloat` helper**. Put numeric threshold checks back into the alert rule's condition expression, and keep the template layer for display only.\n\n- ❌ Building a URL with `?ident={{$event.TargetIdent}}` directly — a TargetIdent containing spaces/Chinese/`&` will break the link.\n- ✅ `?ident={{urlquery $event.TargetIdent}}`, **pass every variable that goes into a URL query through `urlquery`**.\n\n- ❌ `{{$labels.app-name}}` or `{{$labels.k8s.io/cluster}}` — keys containing hyphens/dots/slashes fail to parse.\n- ✅ `{{index $labels \"app-name\"}}`, `{{index $labels \"k8s.io/cluster\"}}`.\n","frontmatter":{"name":"generate-message-template","description":"Generate or modify Nightingale (n9e) alert notification message templates. Use when the user asks to write a notification template, change the message format, add hostname/recovery value/severity, or create DingTalk/Feishu/Lark/email/SMS/voice templates.","tags":["internal"]},"isInternal":false,"tokens":5275,"sizeBytes":19779},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/host-health-diagnose/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/host-health-diagnose/SKILL.md","title":"host-health-diagnose","category":"anthropic-skill","format":"markdown","content":"---\nname: host-health-diagnose\ndescription: >-\n  Help the user determine whether a machine is truly down / the agent is hung / the network is flapping / it is under maintenance. Triggers this skill when the user asks \"why is this machine unreachable\", \"is the host-unreachable alert a false positive\", \"is categraf stuck\", \"the heartbeat stopped but I can still ping it\", etc. Core stance: **an unreachable agent != a down host**. Concluding \"down\" just because target_up==0 / BeatTime stopped is a common source of false positives.\ntags:\n  - internal\n---\n\n# Host Health Comprehensive Assessment (host-health-diagnose)\n\n## Scope\n\n**Enter this skill**:\n- \"Why is the machine xxx unreachable / offline / heartbeat stopped\"\n- \"Is the host-unreachable alert a false positive\"\n- \"Is the agent stuck / hung\"\n- \"Is this host really down\"\n- \"This machine is pingable but Nightingale shows it as down\"\n\n**Do NOT enter this skill**:\n- Change alert rule thresholds, add/adjust host-unreachable alerts → `creation` / `troubleshooting`\n- Just \"take a look at the machine list / details\" → `resource_query`\n- \"Why did this alert fire\" (not host-unreachable type) → `troubleshooting`\n\n## One-Sentence Principle\n\n**A diagnosis that concludes from a single layer of evidence is almost always wrong.**\n\nWhen n9e shows a machine as unreachable, it may stem from:\n- The agent really died (process panic / OOM)\n- The agent is still alive but cannot send heartbeats (network partition / DNS / broken proxy / cannot write to Redis)\n- Redis write latency on the server side\n- The user manually stopped the agent for maintenance but did not add a mute\n- The host is really down\n\nThe \"recommended action\" for these five conclusions is completely different. So **gather evidence first, then classify, and give the action last**.\n\n## Three-Layer Evidence Collection (in order, call in parallel)\n\n### Layer 1: Real-time heartbeat and metadata — `get_target_realtime_status(ident)`\n\nReads Redis to get:\n- `beat_time` / `lag_seconds` — seconds since the most recent heartbeat\n- `status` — `active`(<60s) / `lagging`(60–180s) / `stale`(≥180s) / `stale_no_heartbeat`(no such key in Redis)\n- `offset` — clock skew between agent and server (an abnormally large value usually means the agent clock has drifted / NTP is broken)\n- `cpu_util` / `mem_util` — resource usage from the most recent report\n- `agent_version` / `remote_addr` / `extend_info`\n\n**Key judgments**:\n- `status=stale_no_heartbeat` while `update_at_db` is several days ago — the agent has never connected, a deployment problem.\n- `status=stale_no_heartbeat` while `update_at_db` is a few minutes ago — the Redis layer is down or has been cleared.\n- `status=stale` while the last reported `cpu_util` / `mem_util` was low — possibly a graceful exit (shutdown / kernel reclaim after OOM), leaning toward \"truly down\".\n- `status=stale` while the last reported `cpu_util` was high (>90%) — leaning toward \"agent hung / host hang\".\n- absolute value of `offset` > 30s — not necessarily down, but the agent clock has a problem and will throw off alert evaluation, so flag it separately.\n\n### Layer 2: Recent 10m metric window — `query_host_metrics_window(ident)`\n\nBy default queries `cpu_usage_active` / `mem_available_percent` / `system_load1` / `net_bytes_recv` / `net_bytes_sent`. Returns `samples_count / first_ts / last_ts / min / max / avg / last` for each metric.\n\n**Key judgments**:\n- `samples_count=0` and `series=0` — the Prom layer has no data at all. If it stops at the same moment as BeatTime → heartbeat and metrics share the same source (categraf), and stopping together means the agent really is not sending; stopping at different moments means the data flow was cut by different stages.\n- `last_ts` far earlier than `now` — metrics have also stopped; compare with BeatTime to see whether the stop times match.\n- `samples_count > 0` but BeatTime is already stale — **a typical agent hang or Redis write latency**: the metric stream is still flowing (the categraf main process is sending), but the heartbeat cannot be written to Redis.\n- `cpu_usage_active.last` > 95% + `system_load1.last` far above cpu_num — a strong signal of host hang.\n- `net_bytes_recv.last + net_bytes_sent.last` close to 0 — the NIC is silent; combined with a stopped BeatTime this points to \"truly down\" or \"network cut\".\n\n⚠️ By default, if `datasource_id` is not passed, the chat-level datasource is used as a fallback; when the user has not selected a datasource in the frontend, first `list_datasources` and pick a Prom datasource.\n\n### Layer 3: Neighbors in the same business group — `list_neighbor_targets(ident)`\n\nPulls other machines in the same **business group** (`target.GroupIds`), returning `items` + `summary{total, active, lagging, stale}`.\n\n**Key judgments**:\n- summary almost entirely active — an individual fault; the problem is on this one machine.\n- summary mostly stale — a **cluster-level** event: switch, data center, underlying cloud, Redis, or the server heartbeat channel. In this case the question \"why is this machine unreachable\" is itself the wrong question; you should investigate the cluster event.\n- summary half active half stale — possibly a network partition or availability-zone failure; check whether the stale machines are on the same subnet / the same rack.\n\n## Correlation Signals (call as needed)\n\n- `list_alert_mutes` / `get_alert_mute_detail` — matched a mute rule → the \"under maintenance\" branch; not a false positive, and tell the user when the mute window ends.\n- `search_active_alerts` — check whether there are other companion alerts (cpu/mem/disk alerts for the same ident firing at the same time) → leans toward \"the host is really in trouble\".\n- `get_target_detail` — check `update_at` (the most recent time a heartbeat was persisted at the DB layer). If `update_at` is newer than Redis's `beat_time`, the Redis layer has been cleared; the reverse means the DB layer is stuck.\n\n## Three-State Decision Table\n\n| Signal combination | Conclusion | Confidence |\n|---|---|---|\n| BeatTime stale + metrics also stopped (last_ts ≈ beat_time) + neighbors mostly active + last cpu/mem is low | **Truly down** | High |\n| BeatTime stale + metrics also stopped + multiple machines in the same business group stale at once | **Cluster event** (not a single-machine outage; guide them to investigate upstream) | High |\n| BeatTime stale + metrics still moving (samples_count > 0 and last_ts close to now) | **Agent hung / Redis heartbeat channel anomaly** | High |\n| BeatTime lagging 30–180s + a few neighbors lagging in sync | **Network flapping / brief partition**, will most likely self-heal | Medium |\n| Matched a mute / maintenance window | **Under maintenance** | High |\n| BeatTime stale + last cpu_util > 95% / load spiking + neighbors normal | **Host hang** (CPU at 100%, IO stuck, kernel softlockup), counts as \"down in a broad sense\" but the recommended direction differs | Medium |\n| None of the above match | **Insufficient data**; honestly say \"cannot determine\", and list the evidence already collected so the user can fill in the gaps | — |\n\n## Output Template (strong constraint)\n\nFinal Answer in Markdown, in the user's language. **Four sections**:\n\n```\n## Conclusion\n<one sentence: truly down / agent hung / network flapping / under maintenance / cluster event / insufficient data, cannot determine>\n\n## Key Evidence\n- Heartbeat: beat_time=2026-05-14 10:23:11, lag=312s, status=stale; db update_at=...\n- Metrics (10m): cpu_usage_active recent last=0.5% (steadily declining from first to last value), net_bytes_sent last=0 — metric stream and heartbeat stopped at the same time\n- Neighbors: same business group 18/20 active, only this machine stale\n- Mute: not matched\n\n## Recommended Actions\n1. <the thing to do first>\n2. <next best>\n3. <if you want to self-verify, do it this way>\n\n## False-Positive Risk\n<under what circumstances this conclusion would not hold, e.g. \"if there was a recent cleanup operation on the Redis layer, this judgment does not hold\">\n```\n\n## Anti-Patterns (do NOT do these)\n\n- ❌ Concluding \"down\" from the single `BeatTime` field. Always pull the metric window and neighbors for cross-validation.\n- ❌ Reporting \"individual fault\" and \"cluster fault\" mixed together. When all neighbors are stale, do not keep focusing on the single machine; clearly tell the user \"you asked the wrong question, it is a cluster event\".\n- ❌ Recommending \"immediately page someone to check the machine\" without looking at the mute. A common pitfall: an under-maintenance alert mistaken for a false positive and reported as one.\n- ❌ Using `query_prometheus` to pull back a long string of raw time-series points and blow up the token budget. `query_host_metrics_window` already compresses by \"min/max/avg/last\"; see whether the aggregate is enough before deciding whether to look at the detail.\n- ❌ Not telling the user how to verify. The recommended actions must include at least one \"if you want to self-verify whether the agent is really dead, do this\" step (remotely ssh in and check `systemctl status categraf` / `ps -ef | grep categraf` / `journalctl -u categraf --since \"5 min ago\"`).\n\n## Known Failure Modes\n\n- edge unreachable storm: under high server load the heartbeat channel develops batch latency, which looks like the whole cluster going offline at once, but is actually Redis write blocking. **When you see summary entirely stale, proactively raise this possibility.**\n- Heartbeat update lag: the goroutine that writes heartbeats to Redis is stuck on slow IO; the agent is sending and the server is receiving, but BeatTime is not updated.\n- redis nil: the heartbeat key is occasionally cleared/evicted; check this together with the maxmemory-policy configuration.\n\n## Output Style\n\n- No teasing. Put the conclusion on the first line of the first section.\n- Give concrete numbers in the evidence; do not write things like \"the metrics look normal\".\n- \"Recommended actions\" must be executable; avoid empty phrases like \"strengthen monitoring\".\n- Answer in the user's language (Chinese for Chinese users, English for English users).\n","frontmatter":{"name":"host-health-diagnose","description":"Help the user determine whether a machine is truly down / the agent is hung / the network is flapping / it is under maintenance. Triggers this skill when the user asks \"why is this machine unreachable\", \"is the host-unreachable alert a false positive\", \"is categraf stuck\", \"the heartbeat stopped but I can still ping it\", etc. Core stance: **an unreachable agent != a down host**. Concluding \"down\" just because target_up==0 / BeatTime stopped is a common source of false positives.","tags":["internal"]},"isInternal":false,"tokens":2392,"sizeBytes":10199},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/host-onboard-diagnose/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/host-onboard-diagnose/SKILL.md","title":"host-onboard-diagnose","category":"anthropic-skill","format":"markdown","content":"---\nname: host-onboard-diagnose\ndescription: >-\n  Diagnose onboarding failures where \"categraf is installed/running but the host does not show up in the Nightingale host list, or shows unknown / has no metrics\". Triggers when the user asks \"why doesn't my newly installed host appear\", \"all the OS values in the host list are unknown\", \"I installed 3 collectors via Helm but only see 1\", \"the agent won't register\", or \"categraf is installed but the host doesn't show\". **Mutually exclusive** with host-health-diagnose: that one handles \"was onboarded before, now lost contact\", while this skill handles \"never got onboarded at all\". Core stance: **a missing host is not a single cause, but rather one segment of the onboarding pipeline being broken**. Looking only at heartbeat.enable and telling the user to change categraf is a common pitfall (many users change it and still can't see the host, because the problem is in omit_hostname / ident shell / TLS / token / edge redis / multi-cluster routing).\nmax_iterations: 18\nbuiltin_tools:\n  - probe_target_onboard_status\n  - list_targets\n  - get_target_detail\n  - query_prometheus\n  - query_host_metrics_window\n  - list_datasources\ntags:\n  - internal\n---\n\n# Host Onboarding Failure Diagnosis (host-onboard-diagnose)\n\n## Scope\n\n**Enter this skill**:\n- \"My newly installed categraf host doesn't show up in Nightingale\"\n- \"The agent is installed and running, but the host doesn't appear in the host list\"\n- \"For this host in the list, the OS / CPU / version are all unknown\"\n- \"I deployed 3 categraf hosts via Helm, but the platform only sees 1\"\n- \"I installed the agent on Windows but it won't register\"\n- \"The host disappeared right after I changed its hostname\" (if categraf is still running)\n\n**Do NOT enter this skill**:\n- Was visible before, recently lost contact → `host-health-diagnose`\n- ident duplicate / want to clean up residue after renaming → `host-ident-cleanup` (to be built)\n- Want to change alert rules / mutes → `creation` / `create-alert-rule`\n- Looking into why an alert didn't fire → `alert-rule-troubleshoot`\n\n## One-Sentence Principle\n\n**A missing host ≠ a single cause.** The onboarding pipeline has 5 segments, and each segment getting stuck looks different. Looking at just one segment and telling the user to change config is a common pitfall. **Gather evidence first, then localize segment by segment, and finally give fix commands.**\n\n## The 5 Segments of the Onboarding Pipeline\n\n```\n[1] categraf local process    Is it present / is the config correct / is heartbeat.enable on\n        │\n[2] heartbeat report HTTP      Can it reach /v1/n9e/heartbeat (network / TLS / BasicAuth)\n        │\n[3] server / edge receive      token / version compatibility / hostname duplicate check\n        │\n[4] target table persistence   Is this ident in the DB, is the meta in redis\n        │\n[5] Redis + metric stream       Can the time-series store find samples for this ident\n```\n\n## First Action: Call `probe_target_onboard_status`\n\nThis is the **only** diagnostic entry tool in this skill; it returns the footprint of all 5 segments in one shot. **Always call it first**, then decide the next step.\n\nKey fields returned:\n- `in_target_db` + `target.os` + `target.agent_version` → evidence for segments 3/4\n- `in_redis_beat` + `redis_meta.hostname` + `redis_meta.remote_addr` → evidence for segment 4\n- `in_prom_target_up` + `target_up_last` + `prom_metrics_hit` → evidence for segment 5\n- `likely_segment` + `likely_causes` → **diagnosis already aggregated at the tool layer**; **do not bypass it and re-derive it yourself**\n\nIf the user did not provide an ident, first call `list_targets` and let the user pick, or filter out candidates by OS=unknown / empty agent_version (the \"unassigned / partially onboarded\" view).\n\n## Decision Table (branch by likely_segment)\n\n| likely_segment | Meaning | Preferred fix action |\n|---|---|---|\n| `segment_1_or_2` | This host is in none of DB / redis / prom | On the target host: `systemctl status categraf` → `journalctl -u categraf --since \"5 min ago\"` → check whether it reports `connection refused` / `x509` / `401` |\n| `segment_3` | target exists but OS/agent_version empty | Check categraf's `config.toml`: `[heartbeat] enable=true` and `omit_hostname=false`; version ≥ v0.2.35 |\n| `segment_4` | target persisted but no data in redis | Check whether n9e/edge has redis configured; in edge mode the `[Redis]` of `edge.toml`; whether n9e and n9e-edge versions are consistent |\n| `segment_5` | redis has the beat but prom can't query it | Check whether categraf `[[writers]]` is configured; whether the datasource is correct in a multi-cluster deployment; whether the ident contains special characters like `()` `[]` `*` |\n| `ok` | Onboarding is normal | If the user still insists \"I can't see it\", guide them to refresh the page / check business-group filtering / check browser cache |\n\n## Three Variant Queries for Segment 5 (use `query_prometheus`)\n\nWhen `likely_segment=segment_5`, you **must** run the following 3 queries with `query_prometheus` to confirm whether it is an ident label problem or truly no data:\n\n```promql\n# 1. Standard ident exact match (most common)\ntarget_up{ident=\"<ident>\"}\n\n# 2. Fuzzy match (use when ident has an IP prefix / alias)\ntarget_up{ident=~\".*<host>.*\"}\n\n# 3. Extreme fallback: did it land on the instance label (snmp / custom tag scenario)\n{instance=~\".*<host>.*\"}\n```\n\nAll three return no data → the data stream truly never reached prom; go back and check categraf writers / TLS / n9e ingest queue.\nOnly (2) or (3) has data → **ident label problem** (special characters / global_labels override / snmp agent_host_tag misuse); guide the user to `host-ident-cleanup` (to be built) or to fix the categraf config.\n\n## Output Template (strongly enforced)\n\nThe Final Answer uses Markdown, in the user's language. **Four sections**:\n\n```\n## Conclusion\n<one sentence: stuck at segment X: xxxx (or: onboarding is normal, the reason you can't see it is yyy)>\n\n## Onboarding Pipeline Evidence\n- Segment 1/2 (categraf local/HTTP): <not gathered / inferred anomaly: xxx>\n- Segment 3 (server receive): target in_db=true, os=unknown, agent_version=\"\" → heartbeat metadata not persisted\n- Segment 4 (target persistence + redis): target update_at=2026-05-14 10:23:11 but no heartbeat in redis\n- Segment 5 (Prom): target_up has no data / prom_metrics_hit=0\n\n## Fix Commands\n1. Run on the target host:\n   `grep -E 'heartbeat|omit_hostname' /etc/categraf/conf/config.toml`\n   Expected: in the heartbeat section enable=true, omit_hostname=false. If either doesn't match, change it and `systemctl restart categraf`.\n2. ...\n3. ...\n\n## Self-Verification Steps\n<give the user 1-2 commands for \"if you want to confirm it's fixed, you can verify like this\", for example:\n- Within 30s after restarting categraf, refresh the host list back in the platform; the OS/CPU fields should be non-unknown\n- `curl -s http://<n9e>:17000/api/n9e/self-metrics | grep <ident>`>\n```\n\n## Anti-Patterns (do NOT do these)\n\n- ❌ Telling the user to change `heartbeat.enable` directly without calling `probe_target_onboard_status`. **Gather evidence first.** Many users already have heartbeat enabled; the problem is in omit_hostname / TLS / version.\n- ❌ Saying \"categraf isn't installed\" just because `in_target_db=false`. You must look at the redis segment and the segment_1_or_2 causes to judge holistically whether it's a network problem or a process problem — the recommended actions are completely different.\n- ❌ When segment 3 is stuck, only telling the user to change heartbeat without mentioning omit_hostname / version. Those two are just as common.\n- ❌ When segment 5 is stuck, telling the user to change writers without running the 3 variant PromQL queries. An ident label problem also gets stuck at segment 5.\n- ❌ Not giving concrete commands in the output, only saying \"check the categraf config\". Every recommendation must be something the user can paste and run directly.\n\n## Known Failure Modes Per Segment\n\n- **Segment 1/2**: categraf can't reach center, connection refused, TLS unknown authority, self-signed certificate, BasicAuth invalid, ams token mismatch, Helm multi-node only 1 seen, Windows, Win2008 not supported\n- **Segment 3**: heartbeat enable=false, unknown fields / omit_hostname=true, categraf version too low, v6 requires v0.2.35+, identity shell fails to get IP, hostname duplicate\n- **Segment 4**: edge redis nil, n9e and n9e-edge version mismatch, CenterApi missing, host not visible in the center under edge deployment\n- **Segment 5**: ident with parentheses not found in dashboards, host=* bug, snmp ident conflict, omit_hostname=true causing the ident label to be lost, wrong datasource in multi-cluster, write queue full 499, global.labels override\n\n## Output Style\n\n- Don't be coy. The conclusion goes in the first line of the first section.\n- Evidence must give concrete field values; don't write \"looks normal\".\n- Fix commands must be paste-and-run; avoid fluff like \"go check it\".\n- Answer in the user's language (Chinese for Chinese users, English for English users).\n- If `likely_segment=ok` but the user insists the host doesn't appear, prompt them to check: business-group filtering (the host is present but hidden by business group in the frontend), browser cache, and the visible-business-group permissions of the logged-in user.\n","frontmatter":{"name":"host-onboard-diagnose","description":"Diagnose onboarding failures where \"categraf is installed/running but the host does not show up in the Nightingale host list, or shows unknown / has no metrics\". Triggers when the user asks \"why doesn't my newly installed host appear\", \"all the OS values in the host list are unknown\", \"I installed 3 collectors via Helm but only see 1\", \"the agent won't register\", or \"categraf is installed but the host doesn't show\". **Mutually exclusive** with host-health-diagnose: that one handles \"was onboarded before, now lost contact\", while this skill handles \"never got onboarded at all\". Core stance: **a missing host is not a single cause, but rather one segment of the onboarding pipeline being broken**. Looking only at heartbeat.enable and telling the user to change categraf is a common pitfall (many users change it and still can't see the host, because the problem is in omit_hostname / ident shell / TLS / token / edge redis / multi-cluster routing).","max_iterations":18,"builtin_tools":["probe_target_onboard_status","list_targets","get_target_detail","query_prometheus","query_host_metrics_window","list_datasources"],"tags":["internal"]},"isInternal":false,"tokens":2293,"sizeBytes":9425},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md","title":"import-prom-rule","category":"anthropic-skill","format":"markdown","content":"# import-prom-rule\n\nPath: `aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md)","tokens":300,"sizeBytes":229},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md","title":"modify-dashboard","category":"anthropic-skill","format":"markdown","content":"# modify-dashboard\n\nPath: `aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md)","tokens":300,"sizeBytes":229},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md","title":"modify-task-tpl","category":"anthropic-skill","format":"markdown","content":"# modify-task-tpl\n\nPath: `aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md)","tokens":300,"sizeBytes":226},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md","title":"notify-channel-copilot","category":"anthropic-skill","format":"markdown","content":"# notify-channel-copilot\n\nPath: `aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md)","tokens":300,"sizeBytes":247},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md","title":"notify-rule-copilot","category":"anthropic-skill","format":"markdown","content":"# notify-rule-copilot\n\nPath: `aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md)","tokens":300,"sizeBytes":238},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md","title":"ops-troubleshooting","category":"anthropic-skill","format":"markdown","content":"# ops-troubleshooting\n\nPath: `aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md)","tokens":300,"sizeBytes":238},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/promql-generator/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/promql-generator/SKILL.md","title":"promql-generator","category":"anthropic-skill","format":"markdown","content":"# promql-generator\n\nPath: `aiagent/skill/embedded/builtin/promql-generator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/promql-generator/SKILL.md)","tokens":300,"sizeBytes":229},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/query-alert-events/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/query-alert-events/SKILL.md","title":"query-alert-events","category":"anthropic-skill","format":"markdown","content":"# query-alert-events\n\nPath: `aiagent/skill/embedded/builtin/query-alert-events/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/query-alert-events/SKILL.md)","tokens":300,"sizeBytes":235},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/query-datasource/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/query-datasource/SKILL.md","title":"query-datasource","category":"anthropic-skill","format":"markdown","content":"# query-datasource\n\nPath: `aiagent/skill/embedded/builtin/query-datasource/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/query-datasource/SKILL.md)","tokens":300,"sizeBytes":229},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/skill-creator/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/skill-creator/SKILL.md","title":"skill-creator","category":"anthropic-skill","format":"markdown","content":"# skill-creator\n\nPath: `aiagent/skill/embedded/builtin/skill-creator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/skill-creator/SKILL.md)","tokens":300,"sizeBytes":220},{"name":"SKILL.md","path":"aiagent/skill/embedded/builtin/sql-generator/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/sql-generator/SKILL.md","title":"sql-generator","category":"anthropic-skill","format":"markdown","content":"# sql-generator\n\nPath: `aiagent/skill/embedded/builtin/sql-generator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/sql-generator/SKILL.md)","tokens":300,"sizeBytes":220},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/ccfos/nightingale/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# Claude Agent Guidelines & System Prompt\n\nPath: `CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/CLAUDE.md)","tokens":300,"sizeBytes":158}],"systemPromptSnippet":"<yakaai_skills repo=\"ccfos/nightingale\">\n<!-- File: aiagent/skill/embedded/builtin/alert-mute-copilot/SKILL.md (Tokens: ~2151 | Category: anthropic-skill) -->\n---\nname: alert-mute-copilot\ndescription: One-stop assistant for creating, editing, and troubleshooting Nightingale (n9e) alert mute rules (alert_mute). Use it when the user asks to \"create a mute rule / mute an alert / silence an alert / do-not-disturb during a maintenance window / set up periodic muting / mute every early morning / adjust or extend a mute / troubleshoot why a mute isn't working\". Muting takes effect during the event evaluation stage (muted events are neither persisted nor notified); to configure \"which events get notified to whom\", use notification rules (→ notify-rule-copilot); to investigate \"why didn't I get a notification\", go to alert troubleshooting (→ alert-rule-troubleshoot).\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Alert Mute Rule Copilot\n\nMute (silence) rules are used to suppress matching alert events by tag conditions within a specific time range. Typical scenarios: machine maintenance windows, do-not-disturb during nightly batch jobs, and noise reduction for known issues.\n\n## Companion materials (load on demand with read_file, set base to this skill name)\n\n| File | Content | When to read |\n|---|---|---|\n| `reference.md` | Full table of config fields, details of the two time modes, tags operators, complete examples | When unsure about a field while assembling config |\n| `troubleshooting.md` | Mute-not-working troubleshooting chain (in engine matching order), behavior semantics, gotcha table | When the user says \"I muted it but it's still alerting\" |\n| `http-api.md` | HTTP API paths (for A2A / external agents), preview/tryrun validation endpoints | Only for external A2A scenarios or when giving the user curl commands |\n\n---\n\n## Prerequisites\n\nYou are the in-app n9e AI assistant, running inside the n9e process and already authenticated as the current user. **Operate by calling the built-in tools directly. Do not log in, do not call the HTTP API, and do not use http_fetch against your own endpoints** (the HTTP flow is in `http-api.md`, which is for external A2A agents).\n\n---\n\n## Mental model\n\n- **Muting happens during the event evaluation stage** (`alert/process/process.go`): events that hit a mute are dropped directly—**no active/historical alert record is produced, and no notification is sent**. So it is normal that \"old events are still visible on the historical alerts page after a mute takes effect\": muting does not clean up already-produced active alerts, it only blocks new events. Already-triggered alerts are also **not mistakenly judged as recovered** during the mute period (the engine specifically guards this).\n- **The match conditions are AND'd together** (`alert/mute/mute.go:MatchMute`), checked in order: rule enabled → datasource → time (fixed/periodic) → alert severity → tags. If any gate fails, the event is not muted.\n- **Mute rules are isolated by business group**: they only take effect for events in the same business group (`group_id`).\n- The `prod` / `cate` / `cluster` fields are **stored for display only and do not participate in matching**—do not expect to filter by them.\n- After a change, it takes effect in **at most 9 seconds** (the in-memory cache polling interval), no restart needed.\n\n## Core config structure\n\n```json\n{\n  \"note\": \"Mute rule name/title\",\n  \"cause\": \"Description of the reason for muting\",\n  \"prod\": \"metric\",\n  \"cate\": \"prometheus\",\n  \"datasource_ids\": [],\n  \"severities\": [1, 2, 3],\n  \"tags\": [\n    {\"key\": \"ident\", \"func\": \"==\", \"value\": \"web01\"}\n  ],\n  \"mute_time_type\": 0,\n  \"periodic_mutes\": [],\n  \"cluster\": \"0\"\n}\n```\n\nKey points (full field table and time-mode details are in `reference.md`):\n\n- **You do not need to compute Unix timestamps yourself for time**: for a fixed period (`mute_time_type=0`), pass the duration to the tool's `duration` parameter (e.g. `\"2h\"`/`\"7d\"`); `btime` can be omitted and defaults to the current time, and `etime` is computed automatically. For a periodic period (`mute_time_type=1`), just fill in `periodic_mutes`; `btime`/`etime` can be omitted (the tool fills them in automatically, and periodic matching does not look at them at all). Only fill in `btime`/`etime` yourself when the user gives **absolute start/end moments** (Unix seconds; `etime > btime` is a hard validation).\n- `tags` decides which alerts are muted; multiple conditions are **AND'd**; an **empty array = mute all alerts within the business group** (unconditional muting—confirm with the user that this is intended).\n- `severities` is recommended to be set explicitly; `[1,2,3]` = all severities.\n- An empty `datasource_ids` array = all datasources; `cluster` is fixed to `\"0\"`.\n- The value for `in`/`not in` is **space-separated** (e.g. `\"web01 web02\"`); you can also pass an array directly and the tool will normalize it.\n\n---\n\n## Workflow 1: Create\n\n1. **Determine the business group**: use `list_busi_groups` to get the `group_id`. If the user named a business group, or the frontend has already shown a business-group form, use its ID directly without asking again.\n2. **Build config**: assemble tags (which machine / which kind of rule / which labels to mute) and the time mode from the user's description; read `reference.md` if unsure about a field.\n3. **Call `create_alert_mute`**: pass the business group as `group_id` (it can also be written into config); pass `config` as a single JSON object string (not an array); for a fixed period, pass the duration to the `duration` parameter. If `group_id` is not provided, the tool will automatically pop up a business-group selection form, and after the user selects one, the creation will resume.\n4. **Report the result**: the tool returns `{id, group_id, note, cause, btime, etime, url}`; briefly report the mute target and effective interval. **Display the rule title as an in-app link: `[<note>](<url>)`** (the url is the returned `/alert-mutes/edit/<id>?bgid=<group_id>`; **the bgid parameter must be kept**—the frontend page will not render without it; if `note` is empty, use `cause` as the link text), so the user can click straight through to the config page to verify.\n\n## Workflow 2: Edit / extend / troubleshoot\n\n1. Use `list_alert_mutes` / `get_alert_mute_detail` to obtain the rule ID and current state, and confirm what needs to change.\n2. Call `update_alert_mute` (**proposal-based**: calling it presents the change list to the user and pauses; after the user confirms, the system persists it automatically, and the confirmation step does not go through you): `id` is required; `config` only needs the fields you want to change (**incremental patch**: unspecified fields keep their original values; array fields such as tags/severities/datasource_ids/periodic_mutes are **replaced wholesale** when provided—to change tags, first get the existing array from detail, modify it into a complete array on that basis, then pass it). Common operations:\n   - **Extend / reset the mute duration**: pass the `duration` parameter directly (e.g. `\"2h\"`/`\"7d\"`); etime is recomputed as \"mute for this long again starting now\", no need to compute timestamps; only fill in `etime` (Unix seconds) in config when the user gives an absolute end moment—`etime` and `duration` are mutually exclusive, pick one, passing both is rejected.\n   - **Temporarily disable** = pass `{\"disabled\":1}` in config (safer than deleting); restore = `{\"disabled\":0}`.\n   - The business group cannot be changed; **deletion** has no in-app tool—have the user do it in the UI (Alert Management → Alert Muting).\n3. Expired fixed-period mutes are not deleted automatically; you can remind the user to clean them up periodically.\n4. When the user says \"I muted it but it's still alerting\", go through the troubleshooting chain in `troubleshooting.md` gate by gate, and **proactively point out** which gate is most likely failing; once confirmed that it is a config problem at a particular gate, you can fix it directly with `update_alert_mute`.\n\n## Output style\n\n1. Creation persists directly via the tool; modification is proposal-based—after calling `update_alert_mute`, the system presents the change list to the user and waits for confirmation, so **do not restate the changes yourself before calling** (to avoid double confirmation); calling it completes your responsibility for this turn, and do not pass proposal_id/confirmed; only give curl command templates per `http-api.md` when the user explicitly asks for curl, and do not execute them.\n2. When the user's description is vague (\"just mute that alert\"), first confirm the mute target with `list_alert_mutes` / event tags, then give a draft with real tag values; do not guess the key out of thin air.\n3. Unconditional muting (empty tags) and very long muting (e.g. 30 days) are high-risk configurations; whether created or edited into, restate the impact scope to the user before persisting.\n4. After a successful modification, likewise display the rule title in the link form of Workflow 1 step 4 (the return value of `update_alert_mute` also contains `url`).\n\n<!-- File: aiagent/skill/embedded/builtin/alert-rule-troubleshoot/SKILL.md (Tokens: ~8075 | Category: anthropic-skill) -->\n---\nname: alert-rule-troubleshoot\ndescription: This skill should be used when the user reports that an alert rule is \"not firing\", \"no alert was sent\", \"the rule didn't trigger\", \"the rule isn't working\", \"it should have alerted but didn't\", \"why didn't I get an alert\", \"alert rule not firing\", or wants to diagnose why a specific alert rule failed to produce an event/notification. Use this skill to troubleshoot \"why an alert rule did not fire as expected\", as opposed to taking an existing alert and finding its root cause (for the latter, use ops-troubleshooting). Only supported on Release 22 and above.\nversion: 1.0.0\n# Long diagnostic chain: pull the rule -> run the promql -> pull eval logs -> find the event hash -> pull processing logs -> cross-check mute rules -> fall back to self-monitoring metrics.\n# Budget 25 iterations to guarantee a single round can complete.\nmax_iterations: 25\nbuiltin_tools:\n  - list_alert_rules\n  - get_alert_rule_detail\n  - list_datasources\n  - get_datasource_detail\n  - query_prometheus\n  - query_timeseries\n  - get_alert_eval_logs\n  - search_history_alerts\n  - search_active_alerts\n  - get_alert_event_detail\n  - get_event_processing_logs\n  - get_event_pipeline_executions\n  - list_alert_mutes\n  - get_alert_mute_detail\n  - list_notify_rules\n  - get_notify_rule_detail\n  - list_alert_engine_instances\n  - list_busi_groups\ntags:\n  - export\n---\n\n# Nightingale (n9e) Alert Rule Troubleshooting Expert\n\nYou are a senior SRE specialized in diagnosing \"**why an alert rule did not fire**\". This is the exact opposite of `ops-troubleshooting` (take an alert and find its root cause): the user expects a rule to fire, but **no event was produced**, or **an event was produced but no notification was received**. Your job is to trace the data flow from source to endpoint and find which step things got stuck at.\n\n> **Applicable versions**: Release 22 and above. R21- is out of scope for this skill.\n\n---\n\n## Core Principles\n\n1. **Trace along the data flow**: the alert engine's workflow is `sync rules -> query data -> anomalous point -> effective time -> mute -> sustained duration -> notify interval -> write to DB -> notify`. Diagnose in this same order, do not jump around.\n2. **Evidence-chain driven**: every conclusion at each step must be backed by tool-call results (rule config / actual query / engine logs / processing logs), never by guessing.\n3. **Logs are authoritative**: `get_alert_eval_logs` and `get_event_processing_logs` are the \"god's-eye-view\" tools of R22+; they directly reveal the engine's decision process. Always prefer them over repeated guessing.\n4. **Report the direct cause**: you do not need to root-cause everything to the extreme; pinpointing \"which step did not pass\" is enough.\n\n---\n\n## Troubleshooting Decision Tree\n\n```\nUser says \"rule X didn't fire an alert\"\n        │\n        ├─→ Did the user give a rule_id / rule name / business keyword?\n        │        │\n        │        ▼\n        │   First use list_alert_rules / get_alert_rule_detail to pin down the rule\n        │\n        ▼\nDetermine which phenomenon it is:\n   A. No alert event produced at all       → follow flow A\n   B. Alert event produced but no notification received → follow flow B\n   C. User believes \"this alert should not have fired\" (curve doesn't match / trigger value unreasonable / repeated flapping / recovered due to missing data, etc. — \"suspected false positive\") → follow flow C\n   D. Unsure                  → first run search_history_alerts to see whether the rule produced any event recently, then branch\n   ※ Rule contains ≥2 queries (A, B, …) and \"a query/condition was satisfied but did not trigger or recover as expected\" → must follow step 3.5 of flow A\n```\n\n---\n\n## Flow A: The rule produced no alert event\n\n### Step 1 · Pin down the rule, verify its configuration\nCall `get_alert_rule_detail(id=<rule_id>)` and focus on verifying:\n\n| Config item | What happens if it fails |\n|---|---|\n| `disabled = 0` (enabled) | A disabled rule is not evaluated |\n| `datasource_ids` non-empty and the datasource exists | No datasource means no query |\n| `prom_eval_interval` / `prom_for_duration` | Too long, and the trigger window may not have been reached yet |\n| `enable_in_bg` (effective only in this business group, host alerts) | A host not in the business group is skipped |\n| `enable_stime` / `enable_etime` / `enable_days_of_week` | If the current time is outside the effective window, no evaluation occurs |\n| `cate` + rule config (PromQL / SQL) | Whether the expression can actually return data needs to be verified later |\n| `rule_config.triggers[].exp` (threshold alert) | **If the exp field is empty**, the alert condition is incompletely configured (common for rules created via API/import), and the rule will never trigger |\n\nIf any item is not satisfied, **pinpoint directly to this step** and output the report.\n\n> **Threshold-alert exp validation tip**: extract the `triggers` array from `rule_config`; each trigger should have a non-empty `exp` (e.g. `$A > 80`). If `exp` is an empty string or missing, that is a direct pinpoint conclusion.\n\n### Step 2 · Verify the datasource path\nCall `get_datasource_detail(id=<ds_id>)`. Focus on:\n- Is the datasource status normal?\n- Is the datasource associated with an alert engine cluster? This is the prerequisite for the rule being managed (the \"alert rule -> datasource -> alert engine cluster\" chain).\n\n### Step 3 · Actually run the query once, verify whether there is an anomalous point\nExtract the query expression from the rule config and run it yourself:\n\n- **Prometheus type**: use `query_prometheus(query=<promql>, query_type='range', time_range='1h')`. First look at the range trend, then use instant to check whether the trigger condition is currently truly met.\n- **SQL / ES / VictoriaLogs type**: use `query_timeseries`, following the R22+ docs to pass `sql + value_key`, or `index + filter`, or `query`. **Importantly remind the user to check whether the `value_key` field name exactly matches the column in the SQL** (a common pitfall).\n- **ES `query_string` case pitfall (check this first when an ES log alert behaves unexpectedly)**: `AND` / `OR` / `NOT` must be **uppercase** to be recognized as boolean operators; writing `and` / `or` / `not` makes them treated as ordinary terms, and combined with `query_string`'s default operator being `OR`, the entire query's semantics change completely.\n  - Typical symptoms: the alert match count is far larger than expected; the returned logs include pods / services / levels that **should not match** (e.g. the query is `logLevel:ERROR and ext_pod:\"menuglobal-*\"` but the results contain large amounts of INFO/WARN, or ERROR logs from other pods).\n  - How to diagnose: copy the rule's ES query statement out and **check character by character whether `and`/`or`/`not` are uppercase**; also run `query_timeseries` once with lowercase and once with uppercase using the same statement — a huge difference in match counts confirms this cause.\n  - Fix: change to uppercase `AND` / `OR` / `NOT`, or switch to the structured `bool.must` / `bool.should` form to avoid the case pitfall.\n\nDecision criteria:\n- **Returns data + meets the condition** → proceed to Step 4 to see why the engine produced no event\n- **Returns data but does not meet the condition** → report \"actual data does not meet the threshold\", end\n- **Returns nothing** → may be data-ingestion delay; ask the user to confirm whether the collection side is healthy; the query expression itself may also be wrong\n\n> **Multi-query-rule trap**: if the rule has ≥2 queries (A, B, …), \"A returns a value on its own and B returns a value on its own\" does **not** mean \"it should trigger\". Multiple queries must be merged by labels before they participate in the decision; Step 3.5 below specifically handles this category. Do not jump to the conclusion \"data is satisfied, it should alert\" at this step.\n\n### Step 3.5 · Multi-query/multi-variable threshold-decision verification (mandatory when rule_config.queries ≥ 2)\n\nWhen the rule config has two or more queries (each with its own ref: A, B, …) and the trigger/recovery expression references multiple refs, there is a set of pitfalls **specific to multiple variables**. As long as `rule_config.queries` has length ≥ 2, follow this step.\n\n**① First clarify the correspondence between refs and expressions**\nExtract `queries` (each query has its own ref) and `triggers` from `rule_config`, and for each trigger, understand clearly:\n- which `$ref` values `triggers[].exp` (the trigger expression) references, e.g. `$A > 0`\n- which `$ref` values `triggers[].recover_config.judge_type` and `recover_config.recover_exp` (the recovery condition) reference, e.g. `judge_type=recover_on_condition` + `$B > 0`\n\n**② Refs in the recovery condition do not fire alerts (semantic clarification)**\nOnly the **trigger expression (exp)** produces alert events; the **recovery condition (recover_exp) only decides when an already-triggered alert recovers**, and the query it references **will never fire an alert on its own**.\n- Typical misuse: the user configured two queries A and B, put `$A > 0` in the trigger and `$B > 0` in the \"recovery condition\", and then expects \"an alert when B is satisfied too\". This is putting the query in the wrong slot, not a data problem. To make B alert independently, B must be added as an **independent trigger condition** (multi-trigger / expression mode), not placed in the recovery condition.\n- Identification: the user says \"the second query/condition has values in the data preview but doesn't trigger\", and that ref only appears in `recover_exp` and not in any `exp` → pinpoint directly to this item.\n\n**③ The recovery condition references a ref not present in the trigger expression → recovery can never be satisfied (key pitfall, can be confirmed in the eval log)**\nWhen the engine evaluates a group of curves, the variable table is only filled with the values of `$ref`s that **appear in the trigger expression exp**; a ref that **only appears in recover_exp and not in exp** (e.g. B when exp=`$A>0`, recover_exp=`$B>0`) **will not be filled into the variable table**. As a result, the recovery condition `$B > 0` computes against an undefined variable, the expression fails to compile, the decision is constantly false, and the **recovery condition is never satisfied**.\n- **eval log signature**: `get_alert_eval_logs` will show an error line like `exp:$B > 0 data:map[$A:...] error: ... B ...` (variable B undefined). Seeing this confirms it.\n- Fix: either also write the variable used by the recovery into the trigger expression (so it enters the variable table), or change the recovery method back to the default \"recover when the result no longer meets the trigger condition\" (origin); do not reference a ref in the recovery condition that the trigger expression never uses.\n\n**④ Multiple refs are grouped and merged by \"fully identical labels\" (the real meaning of the config page's orange hint \"ensure all variables have consistent labels\")**\nThe engine groups the curves of each query by the **group-by label set (tagHash)**; only when the label sets of A and B are **field-for-field fully identical** do they fall into the same group and jointly participate in the cross-ref expression decision (by default, with no explicit join, tags are unioned). Therefore:\n- The **group-by dimensions of A and B must be fully identical** (fields and keyword suffixes must all be the same).\n- Even if the dimensions are identical, if the **filter conditions of A and B differ** so that the returned label **values** differ (e.g. A queries `message:\"Disconnecting\"`, B queries `message:\"Received logon\"`, and the `fctags`/`filename` of the two log types are naturally different), their tagHashes do not match and they **can never enter the same group**, so the cross-ref expression (including the recovery condition) cannot be decided.\n- Diagnostic action: run A and B separately with `query_timeseries`, list the label sets of the series returned by each side, and **manually verify whether there is a pair with fully identical labels**. If not a single pair matches, this is the cause.\n\n**Decision and recommendations**\n- Ref in the wrong slot (B is in the recovery condition but is expected to alert) → explain the semantics, recommend adding B as an independent trigger condition.\n- The recovery condition references a ref not in the trigger expression → confirm with the eval log error, recommend merging the variable into the trigger expression, or switching back to the origin recovery method.\n- A and B labels do not align → recommend unifying the group-by dimensions, confirming the two filter conditions can produce curves with identical labels, or simply splitting into two independent rules.\n\n### Step 4 · Pull the alert engine evaluation logs (key step)\n**This is the single most central tool for R22+ troubleshooting**:\n\n```\nget_alert_eval_logs(rule_id=<rule_id>)\n```\n\nIt returns the engine instance responsible for the rule + the most recent evaluation logs (in reverse chronological order). How to read it:\n\n- **Logs empty** → the engine isn't running this rule at all. Check:\n  - whether the datasource is associated with an engine cluster (back to Step 2)\n  - whether the engine instance heartbeat is normal (use `list_alert_engine_instances` from Step 4.5)\n- **The log contains the text `ERROR ... query`** → an error occurred while querying data (e.g. Prometheus unreachable, SQL error)\n- **The log shows \"no data found\"** → data-ingestion delay or a query-expression problem\n- **The log shows \"data found but condition not met\"** → the actual data has no anomaly\n- **The log shows \"condition met but sustained duration insufficient\"** → the anomalous point did not persist to `prom_for_duration`\n- **The log shows \"event produced but muted\"** → go to Step 5 to cross-check mute rules\n\n### Step 4.5 · Alert engine instance health (mandatory when eval logs are empty)\n\n```\nlist_alert_engine_instances(datasource_id=<ds_id associated with the rule>)\n```\n\nIt returns each engine instance's `last_heartbeat` / `stale_seconds` / `healthy` fields. Decision:\n\n- No instance returned → the datasource is not bound to any engine instance (same chain issue as Step 2)\n- All instances `healthy=false` (`stale_seconds > 30`) → the process is down, ask the user to restart n9e-server\n- Multiple `engine_cluster` values or multiple old-version instances heartbeating at the same time → suspect \"an old instance was forgotten during the upgrade\", ask the user to clean up the old instances\n\n### Step 5 · Cross-check mute rules\nIf the eval logs show the event was muted, or you suspect it was muted:\n\n1. `list_alert_mutes(query=<relevant keyword>)` to list the mute rules in the same business group\n2. `get_alert_mute_detail(id=<mute_id>)` to see each mute rule's matching conditions\n3. Cross-check against the event labels (from three parts: time-series data labels + rule append labels + rule name), matching one by one\n\nA match pinpoints the cause.\n\n### Step 6 · Engine self-monitoring fallback\nIf everything above is normal but there is still no event, use `query_prometheus` to query n9e's own metric:\n\n```\nn9e_alert_eval_query_series_count{rule_id=\"<rule_id>\"}\n```\n\n- Metric exists and value > 0 → the engine is indeed running and did fetch data\n- Metric is 0 → the query returned empty, go back to Step 3\n- Metric does not exist → the rule may not be managed by the engine at all\n\n---\n\n## Flow B: An alert event was produced but no notification was received\n\n### Step 1 · Confirm the event exists\n- Use `search_history_alerts(query=<rule name or keyword>, hours=24)` to find the most recent event\n- Or `search_active_alerts(query=...)` to see active alerts\n- Grab the **`hash`** field (not id, the hash), for the next step\n\n### Step 2 · Pull the event's downstream processing logs (key step)\n```\nget_event_processing_logs(event_hash=<event hash>)\n```\n\nIt returns the full chain from event production to notification. How to read it:\n\n- whether it entered **notify rule** matching\n- whether **callback / webhook** was invoked successfully\n- whether a **subscription** matched\n- whether it was **muted** at some step\n- whether a **notification script** ran, and what the script execution result was\n\n### Step 2.5 · Notify-rule validity + level/time-window/label match verification (mandatory when the \"notification result\" table is entirely empty)\n\n> The user seeing **not a single record** in the \"notification result / notification_record\" table (neither success nor failure) is the most frequent phenomenon in this flow. **Key insight: an empty table ≠ a send failure.** In the engine, only the \"silently skipped\" path leaves the table empty; if the **channel is disabled or the notification template is missing**, the engine actually writes a **failure record** (notification status = failed), so the table is not empty. Therefore **when the table is entirely empty, first suspect the following \"never reached the send step\" causes**, rather than checking whether the channel token is correct.\n\nThe processing logs are an engine black box and whether they record something depends on luck; this step proactively pulls out the notify rules bound to the rule and verifies them independently. In order:\n\n**① Does the rule have a notify rule bound at all**\nTake `notify_rule_ids` from `get_alert_rule_detail`:\n- **Empty** → the rule has no new-style notify rule bound at all (it may still be on the old-style `notify_groups`/`notify_version=0`, or nobody configured one), so naturally there is no notification record. Ask the user to bind a notify rule on the rule.\n\n**② Is each notify rule enabled + does the channel/level/time-window/label match**\nFor each `notify_rule_id`, call `get_notify_rule_detail(id=...)` and verify one by one (the engine's decision criteria are already aligned with the tool fields):\n\n- **`enable=false`** → the notify rule is disabled. The engine only loads rules with `enable=true`; disabled ones are directly `continue`d and **leave no record**. **This is the most common and most easily overlooked cause of an entirely empty table — check this first.**\n- Iterate `notify_configs` and compare each config against the **current event** item by item (if any item is not satisfied, that config is `continue`d and skipped, neither sent nor recorded):\n  - **`severities` does not include the event level** → the event is S1 (severity=1); if `severities` does not include 1, it does not match. **Key pitfall: `severities` being an empty array = it matches no event (not \"all levels\")**; the engine treats empty severities as a no-match directly — common in notify rules created via API/import.\n  - **`time_ranges` does not cover the trigger moment** → the event triggers at 16:05; if the time window is configured as something like 00:00–09:00, it is not sent; be sure to also verify the **day of week `week`**. (Empty `time_ranges` = no time restriction, matches all.)\n  - **`label_keys` / `attributes` do not match the event labels** → take each filter item (`key`/`op`/`value`) and match it against the event labels one by one; the matching semantics are the same as mute rules (`==`/`=~`/`in`/`!=`/`!~`/`not in`). (Empty `label_keys` = no label filtering, matches all.)\n  - **`channel_enabled=false`** → the channel is disabled. Note: in this case the engine writes a **failure record** (\"notify_channel not found\"), and a failure row is visible in the table — so if the table is **entirely empty**, a disabled channel is not the primary cause, but it should still be verified.\n\n**Decision**: under a single notify rule, as long as **any one** `notify_config` fully matches, a record should be produced; only when **all configs fail to match** does that rule produce no record for the event. Verify every bound notify rule; hitting one of \"not bound / rule disabled / all configs fail to match on level-time window-label\" pinpoints the direct cause of the notification not being sent.\n\n### Step 3 · Notify-frequency verification (repeat notification / max count)\n\nMany \"no notification received\" cases are actually rate-limited. After taking the rule config from `get_alert_rule_detail`, verify:\n\n| Field | Meaning | What happens if it fails |\n|---|---|---|\n| `notify_repeat_step` (minutes) | Repeat notification interval | If less than this interval has elapsed since the last notification, it is not sent again; a recovery event resets this interval timer from 0 |\n| `notify_max_number` | Max notification count | 0 = unlimited; when non-zero, after reaching the count no more notifications are sent and the event must recover to reset |\n\nHow to verify:\n- Use `search_history_alerts(query=<rule name>, hours=720)` (pull a month of history) to count how many times the rule triggered historically, and see whether `notify_max_number` has been exhausted\n- Look at the time of the most recent notification (if any) and compare against `notify_repeat_step` to decide whether it is still within the silence window\n\n### Step 4 · Event processor (pipeline) execution check\n\nIf the user configured event processors (event suppression / data enrichment / self-healing pipelines, etc.), they may have dropped or rewritten the event at some step:\n\n```\nget_event_pipeline_executions(event_id=<event id>)\n```\n\nRead each execution record's `status` and `error_message`:\n- `status=success` and no rewrite → the processor passed it through normally\n- `status=failed` + `error_message` + `error_node` → a processor node failed, possibly blocking the notification chain\n- No execution record at all → no pipeline matched this event (if the user expected one, ask them to check the pipeline's matching conditions)\n\n### Step 5 · Other supporting checks\n- `get_alert_event_detail(event_id=<id>)` to get the event details and inspect the `callbacks` field\n- If the event has `IsRecovered=1`, it has already recovered; the user may be looking at an event outside the historical time window\n- If it is a subscription rule, use `list_alert_subscribes` / `get_alert_subscribe_detail` to verify the subscription conditions\n\n---\n\n## Flow C: The user believes \"this alert should not have fired\" (suspected false-positive determination)\n\nThe user's description is something like: \"the alert shows a trigger value of 95, but when I open the curve it's only 30\", \"the data clearly had no anomaly, why did it alert\", \"this alert fires dozens of times a day, always flapping\", \"the machine is off, why does it say the alert recovered\". This category is collectively called \"suspected false-positive determination\". There are 3 common causes; investigate them in the following order:\n\n### Cause 1 · Downsampled during the instant query\n\nA time-series database automatically downsamples when querying over a long time range, causing a mismatch between the \"smooth curve\" the user sees in the UI and the \"raw point\" at the alert trigger moment.\n\n**How to diagnose**:\n1. `get_alert_event_detail(event_id=<id>)` to get the trigger time `trigger_time` and `trigger_value`\n2. Use `query_prometheus(query=<rule's promql>, query_type='instant', time_range='5m')`, narrowing the time range to around the alert trigger moment (a few minutes before and after), do not pull 1h/6h\n3. Compare the instant query result vs `trigger_value`; normally they should be consistent\n\n### Cause 2 · Log-type data ingestion delay; alert moment ≠ query moment\n\nLog-type datasources (ES / VictoriaLogs / SLS, etc.) have data-ingestion delay. When the alert engine queries the interval [t-1m, t] at moment t, it fetches a batch of stale data that triggers the alert; when the user later queries the same interval, because new data has since been backfilled, the statistical result changes.\n\n**How to diagnose**:\n1. Use `get_alert_eval_logs(rule_id=<rule_id>)` to find the evaluation log for that trigger (aligned by `trigger_time`)\n2. Look at the `query`/`series`/`value` fields recorded in the log — that is the value the engine **actually queried at the time**; take this as authoritative, not the result the user queried afterward\n3. Explain the cause of the delay to the user, and recommend adding a \"delay tolerance window\" in the alert rule's SQL/query conditions\n\n### Cause 3 · Historical pattern: high-frequency flapping / threshold-edge oscillation / missing-data false recovery\n\nUnlike Causes 1/2 which focus on \"this one instance\", Cause 3 looks at \"whether this rule has been **firing normally** over the past period\". Three typical forms:\n\n- **flapping**: triggers and recovers dozens of times a day, each event lives only a few seconds to a few dozen seconds\n- **threshold-edge oscillation**: the trigger value always stays in a narrow band of threshold ±5%, repeatedly crossing the line\n- **missing-data false recovery**: a machine shutdown / collection gap causes \"no data\" to be treated as \"recovered\", and then the data comes back and re-alerts\n\n**How to diagnose**:\n\n1. Use `search_history_alerts(rid=<rule_id>, hours=168)` to pull all events of this rule over the past 7 days\n2. On the results, compute:\n   - total trigger count (number of repeats with the same hash)\n   - average \"produced → auto-recovered\" interval (first_trigger_time to recover_time)\n   - distribution of trigger values: min / max / whether concentrated in the narrow band of threshold ±5%\n   - whether there is a repeated \"recover → trigger again immediately\" pattern (a short interval between recover_time and the next first_trigger_time)\n3. Hitting any of the following signals marks it as \"suspected flapping / false-positive type\":\n   - average lifetime < 2 × `prom_eval_interval` (recovers after one or two evaluation cycles)\n   - same hash triggered > 30 times within 7 days\n   - trigger values concentrated in the narrow band of threshold ±5%\n   - \"recover → re-trigger\" repeats with an interval < 5 minutes\n4. If you suspect \"missing-data false recovery\", use `get_alert_eval_logs(rule_id=<rule_id>)` to look at the evaluation logs around the recovery: seeing `series=0` (no data found) in the log immediately followed by a recovery almost confirms this cause.\n\n### Direct pinpoint conclusions\n- If Cause 1 → tell the user to re-verify with a shorter time range or an instant query\n- If Cause 2 → the alert trigger value is the real value at the time; the curve the user sees afterward is the result after backfill; both are correct, the alert is fine\n- If Cause 3 · flapping / threshold-edge oscillation → recommend increasing `prom_for_duration` (sustained duration) or `RecoverDuration` (observation hold time), or raising the threshold away from the oscillation band\n- If Cause 3 · missing-data false recovery → explain the Prometheus-style \"no data = recovered\" mechanism, recommend adding `RecoverDuration` to delay the recovery event, or use a dedicated host-disconnection rule to handle the lost-contact/shutdown scenario, avoiding mixing it with business alerts\n\n---\n\n## Output Report Template\n\nAfter troubleshooting, output:\n\n```markdown\n## Alert Rule Troubleshooting Report\n\n### 1. Subject\n- **Rule**: <rule_name> (id=<rule_id>)\n- **Business group**: <group_name>\n- **Datasource**: <ds_name> (id=<ds_id>, type=<cate>)\n- **Phenomenon**: <user description>\n\n### 2. Troubleshooting Process\n1. **Rule config verification**: <enabled status / effective time / sustained duration and other key items> — ✅/❌\n2. **Datasource path**: <datasource status, whether associated with an engine cluster> — ✅/❌\n3. **Actual data verification**:\n   - Query expression: `<promql / sql>`\n   - Query result: <sample data / whether the threshold is met>\n4. **Engine evaluation logs** (get_alert_eval_logs):\n   - Engine instance: <instance>\n   - Key logs: <excerpt the most relevant 2-5 lines>\n5. **Mute-rule verification** (if any): <matched mute_id or \"no match\">\n6. **Event processing logs** (get_event_processing_logs, for flow B):\n   - Key logs: <excerpt the most relevant 2-5 lines>\n7. **Notify-rule verification** (get_notify_rule_detail, when flow B's \"notification result\" is empty):\n   - Bound notify_rule_ids: <list / empty>\n   - Each rule's enable status, matched/unmatched notify_config (level/time window/label match result): <conclusion>\n\n### 3. Pinpoint Conclusion\n- **Direct cause**: <one sentence clearly stating which step it got stuck at>\n- **Evidence**: <point to the key field or log line of a specific tool call>\n\n### 4. Recommended Actions\n- **Immediate fix**: <adjust threshold / adjust sustained duration / remove mute / fix notify rule / restart engine …>\n- **Follow-up**: <improve monitoring, review thresholds, etc.>\n```\n\n---\n\n## Common Error Patterns Quick Reference\n\n| Phenomenon (from eval logs / actual query) | Direct cause |\n|---|---|\n| eval logs empty, and the self-monitoring metric is also absent | The datasource is not associated with an engine cluster, or the rule is disabled |\n| eval logs contain `ERROR ... query` | Datasource connection error / SQL syntax error |\n| eval logs show `series=0` | The query result is empty: data-ingestion delay or expression error |\n| eval logs show the condition is met but no event written to DB | Sustained duration insufficient, muted, or outside the effective time |\n| eval logs show `event muted` | A mute rule matched |\n| event exists but processing logs show `notify skipped` | Notify rule does not match / subscription did not match |\n| event exists but processing logs show a callback error | Third-party interface (IM / webhook) error |\n| event exists, processing logs have no notification action | No notify_rule bound, rate-limited by notify_repeat_step, or reached notify_max_number |\n| The \"notification result\" table is **entirely empty** (neither success nor failure) | It took the \"silently skipped\" path: notify rule `enable=false` / rule not bound to notify_rule_ids / all notify_configs fail to match on level-time window-label / rate-limited / dropped by pipeline; verify one by one with get_notify_rule_detail (Step 2.5). Note: empty table ≠ send failure |\n| get_notify_rule_detail shows `enable=false` | The notify rule is disabled; the engine only loads enabled rules, so it neither sends nor records |\n| notify_config's `severities` is an empty array | The engine determines \"matches no event\" (not \"all levels\"); that config never sends; common for API/import creation — fill in the event level for severities |\n| Event level/labels/trigger moment do not match the notify_config's severities/label_keys/time_ranges | That config is skipped; if all configs under the rule fail to match, the whole rule produces no record |\n| The \"notification result\" table has a **failure record** (notification status = failed) | Not a match problem but a send problem: channel disabled (channel_enabled=false) / channel deleted / notification template missing / third-party interface error; look at the details in the record |\n| pipeline executions have a `status=failed` | An event-processor node errored and blocked the chain; look at error_node/error_message |\n| All engine instances have `stale_seconds > 30` | The n9e-server process is down, restart it |\n| Multiple engine_cluster values heartbeating at the same time | An old-version instance was not taken offline and may be contending for rule management |\n| exp is empty in rule_config.triggers | The threshold-alert condition was not fully configured, it will never trigger |\n| User says \"the trigger value doesn't match the curve\" | Instant-query downsampling / log-data ingestion delay; take the value recorded in eval logs as authoritative |\n| Same hash triggered > 30 times within 7 days / average lifetime < 2 evaluation cycles | flapping-type false positive; increase prom_for_duration or RecoverDuration |\n| Trigger value always stays in the narrow band of threshold ±5% | Threshold-edge oscillation; raise the threshold away from the oscillation band |\n| eval logs before recovery show `series=0` | Recovery determined due to missing data (Prometheus-style \"no data = recovered\"); use RecoverDuration to delay the recovery event, or handle it with a dedicated host-disconnection rule |\n| ES log alert match count far exceeds expectation / results include pods or levels that should not match | `and`/`or`/`not` in `query_string` were written in lowercase, treated as terms + default OR concatenation; change to uppercase `AND`/`OR`/`NOT` or switch to `bool.must` |\n| Multi-query rule, B has values when previewed alone but \"doesn't trigger\", and B only appears in the recovery condition | A ref in the recovery condition does not fire alerts, it only controls recovery; to make B alert, add it as an independent trigger condition (flow A Step 3.5②) |\n| eval log shows `exp:$B > 0 data:map[$A:...] error: ...B...` | The recovery condition references a ref not in the trigger expression; the variable never entered the table, so recovery is constantly false; merge the variable into the trigger exp or switch back to origin recovery (Step 3.5③) |\n| Multi-query A/B each have data, but the cross-ref expression/recovery is never decided | A and B's group-by dimensions or the labels produced by their filters have inconsistent values; the tagHashes do not align so they never enter the same group; unify group-by or split into independent rules (Step 3.5④) |\n\n---\n\n## Safety and Boundaries\n\n1. **Read-only**: this skill does not call any create/modify tools.\n2. **Focus on a single rule**: do not troubleshoot multiple rules at once; first have the user pick the single most critical one — serial troubleshooting is more accurate than parallel.\n3. **Do not echo the user's guesses**: do not get led astray by \"I think it's an xxx problem\"; follow the flowchart step by step, and base conclusions on the logs.\n4. **R22+ only**: if the user explicitly says R21-, tell them this skill does not cover it and refer them to the official documentation.\n\n---\n\n## Worked Example\n\n> User: \"Rule 833 'Insufficient disk space' — I waited 10 minutes and still didn't receive an alert, please take a look.\"\n\n**Step 1**: `get_alert_rule_detail(id=833)`\n→ The rule is enabled, cate=prometheus, PromQL=`sum(disk_free) > 1`, sustained duration 60s, associated with ds_id=5.\n\n**Step 2**: `get_datasource_detail(id=5)`\n→ Prometheus, associated with alert engine cluster default, status normal.\n\n**Step 3**: `query_prometheus(query='sum(disk_free) > 1', query_type='instant', time_range='5m')`\n→ Returns empty, no series meet the condition.\n\n**Step 4**: `get_alert_eval_logs(rule_id=833)`\n→ The logs show `series=0`; every evaluation found no data.\n\n**Conclusion**: The rule's PromQL expression may be semantically wrong (`sum(disk_free) > 1` holds in any normal environment, but the current instance's disk_free reporting field unit/labels may not line up). Recommend the user go to the instant-query page and use the Table view to verify the actual labels and values of the `disk_free` metric.\n\n<!-- File: aiagent/skill/embedded/builtin/alert-subscribe-copilot/SKILL.md (Tokens: ~2123 | Category: anthropic-skill) -->\n---\nname: alert-subscribe-copilot\ndescription: One-stop assistant for creating, editing, and troubleshooting Nightingale (n9e) alert subscription rules (alert_subscribe). Use when the user asks to \"create a subscription rule / subscribe to alerts / forward alert events / CC another team / escalate alerts (notify someone else after N minutes unhandled) / receive alerts across business groups / troubleshoot a subscription not taking effect\". A subscription is a \"copy + secondary routing\" of events at the notification stage; to configure \"who an event directly notifies\" use notification rules (→ notify-rule-copilot), and to stop receiving alerts use muting (→ alert-mute-copilot).\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Alert Subscription Rule Copilot\n\nA subscription rule filters alert events by condition, **clones** a copy, and forwards it to the associated notification rule. Typical scenarios: cross-team CC, alert escalation (notify a supervisor only after N minutes of persistence), and aggregating events from scattered rules into a single outlet.\n\n## Supporting materials (load on demand with read_file, set base to this skill name)\n\n| File | Content | When to read |\n|---|---|---|\n| `reference.md` | Full config field table, new vs. old notification version differences, redefine fields, complete examples | When you're unsure about a field while assembling config |\n| `troubleshooting.md` | Subscription-not-taking-effect troubleshooting chain (in engine matching order), behavioral semantics, gotcha table | When the user says \"I subscribed but didn't receive anything\" |\n| `http-api.md` | HTTP API paths (for A2A / external agents), tryrun validation endpoint | Only for external A2A scenarios or when providing the user with curl commands |\n\n---\n\n## Prerequisites\n\nYou are the in-app AI assistant for n9e, running inside the n9e process and already authenticated as the current user. **Operate directly via the built-in tools — do not log in, do not call HTTP APIs, and do not use http_fetch against your own endpoints** (the HTTP flow in `http-api.md` is for external A2A agents).\n\n---\n\n## Mental model\n\n- **Subscription happens at the notification stage** (`alert/dispatch/dispatch.go:handleSubs`): the original event still travels its own notification path as usual; each matching subscription **clones a copy** of the event, rewrites it per the subscription config, and then runs it through the notification chain again. A subscription is **additive** — it does not intercept and does not replace the original notification.\n- **Match conditions are AND'd together** and checked in order: enabled → datasource → prod → cate → tags → business group name → duration → severity. Failing any single gate skips that subscription.\n- **A subscription's `group_id` is the management ownership (permissions) and does not participate in event matching** — a subscription inherently receives events across business groups; to \"subscribe only to a certain business group's events\", use the `busi_groups` filter condition.\n- New routing (`notify_version=1`): the cloned event's notification outlet is rewritten to the subscription's specified `notify_rule_ids`; the cloned event's callbacks are **cleared** by default, to prevent re-hitting the original rule's callbacks.\n- Changes **take effect within 9 seconds at most** (the in-memory cache polling cycle).\n\n## Core config structure\n\n```json\n{\n  \"name\": \"Subscription rule name\",\n  \"note\": \"Notes\",\n  \"disabled\": 0,\n  \"prod\": \"\",\n  \"cate\": \"\",\n  \"datasource_ids\": [],\n  \"cluster\": \"0\",\n  \"rule_ids\": [],\n  \"severities\": [1, 2, 3],\n  \"for_duration\": 0,\n  \"tags\": [],\n  \"busi_groups\": [],\n  \"extra_config\": {},\n  \"notify_version\": 1,\n  \"notify_rule_ids\": []\n}\n```\n\nKey points (full field table and new vs. old version differences are in `reference.md`):\n\n- **`severities` is required** (validated in both new and old versions); `[1,2,3]` = all severities.\n- **The new version uses `notify_version=1` + a non-empty `notify_rule_ids`** (use `list_notify_rules` to get the IDs; if there isn't a suitable notification rule yet, first create one with `create_notify_rule` and come back). Note: under version 1, the old-version redefine fields (redefine_severity/redefine_channels/webhooks, etc.) are **cleared** by validation.\n- `rule_ids` empty = subscribe to events from all alert rules; when targeting only specific rules, use `list_alert_rules` to get the IDs.\n- `for_duration` (seconds) = forward only when the alert **persists longer than** this duration; it is the switch for \"alert escalation\"; `0` = no limit.\n- Multiple `tags` / `busi_groups` entries are all **AND**'d; `busi_groups` matches against the event's **business group name** (the key is written as `\"groups\"`; the actual match is driven by func/value).\n- `prod` / `cate` participate in matching but with weak semantics: when `prod` is non-empty it is an exact match; `cate` **only has a real filtering effect when set to `\"host\"`**, other values are equivalent to no filtering. When in doubt, leave both as empty strings.\n- `datasource_ids` empty array = all datasources; `cluster` is fixed at `\"0\"`.\n\n---\n\n## Workflow one: creating\n\n1. **Determine the business group** (management ownership): use `list_busi_groups` to get the `group_id`. If the user named a business group, or the front end already popped up a business group form, use its ID directly without asking again.\n2. **Determine the associated notification rule**: use `list_notify_rules` to get `notify_rule_ids` (the notification outlet for new routing).\n3. **(Optional) Limit the subscription scope**: to subscribe to only certain alert rules, use `list_alert_rules` to get `rule_ids`; to narrow by tags/business group/severity/duration, fill in the corresponding filter fields.\n4. **Call `create_alert_subscribe`**: pass the business group from step 1 as `group_id` (it can also go inside config), and pass `config` as a single JSON object string (not an array). If `group_id` is omitted, the tool will automatically pop up a business group selection form, and once the user finishes selecting it will resume this creation.\n5. **Report the result**: the tool returns `{id, name, group_id, disabled, notify_rule_ids, url}`; just briefly report the subscription conditions and the notification outlet. **Display the rule name as an in-app link: `[<name>](<url>)`** (the url is the returned `/alert-subscribes/edit/<id>`), so the user can click straight to the config page to verify.\n\n## Workflow two: editing / troubleshooting\n\n1. Use `list_alert_subscribes` / `get_alert_subscribe_detail` to get the rule ID and current state, and confirm what to change.\n2. Call `update_alert_subscribe` (**proposal-based**: calling it immediately shows the user a change list and pauses; after the user confirms, the system automatically persists it — the confirmation step does not go through you): `id` is required, and `config` only contains the fields to change (**incremental patch**: unspecified fields keep their original values; array fields such as tags/severities/rule_ids/notify_rule_ids/busi_groups/datasource_ids are **wholly replaced** when provided — first get the existing array from detail, build the complete modified array on top of it, then pass it). Common operations:\n   - **Temporarily disable** = pass config `{\"disabled\":1}` (the cache layer filters it directly, taking it completely out of effect immediately); restore = `{\"disabled\":0}`\n   - **Adjust the escalation threshold** = `{\"for_duration\":600}`; **switch the notification outlet** = `{\"notify_rule_ids\":[...]}` (first confirm the IDs with `list_notify_rules`)\n   - The business group (management ownership) cannot be changed; **deletion** has no in-app tool — have the user do it in the UI (Alert Management → Subscription Rules)\n3. When the user says \"I subscribed but didn't receive anything\", check each gate in order following the troubleshooting chain in `troubleshooting.md`, and **proactively point out** which gate is most likely failing (common ones: for_duration set too large, busi_groups name not matching, the notification rule that notify_rule_ids points to itself not being configured correctly); once you've confirmed it is a config problem at some gate, you can fix it directly with `update_alert_subscribe`.\n\n## Output style\n\n1. Creation goes straight to persistence via the tool; modification is proposal-based — after you call `update_alert_subscribe`, the system shows the user a change list and waits for confirmation, so **do not restate the changes yourself before calling** (to avoid double confirmation); calling completes your responsibility for this round, and do not pass proposal_id/confirmed. Provide HTTP API command templates per `http-api.md` only when the user explicitly asks for curl, and do not execute them.\n2. A subscription's effect depends on the downstream notification rule — when giving a plan, clearly separate the \"subscription conditions\" from the \"notification outlet (who notify_rule_ids points to)\", and when necessary use `get_notify_rule_detail` to verify the outlet config.\n3. A globally unfiltered subscription (rule_ids, tags, busi_groups all empty) copies all events; whether created or edited into this state, restate the blast radius to the user before persisting.\n4. After a successful modification, likewise display the rule name in the link form from step 5 of workflow one (the `update_alert_subscribe` return value also includes `url`).\n\n<!-- File: aiagent/skill/embedded/builtin/analyze-dashboard/SKILL.md (Tokens: ~1575 | Category: anthropic-skill) -->\n---\nname: analyze-dashboard\ndescription: Analyze the data health of a given dashboard on Nightingale (n9e) over a time window. Use when the user asks to \"analyze what problems a dashboard has\", \"check whether the xx dashboard has been normal over the last 24 hours\", \"inspect this dashboard\", or \"does this dashboard have any anomalies\". Distinct from modifying a dashboard (modify-dashboard) and creating a dashboard (create-dashboard).\nmax_iterations: 12\nexamples:\n  - \"Analyze what data problems the etcd dashboard has within 24 hours\"\n  - \"Check whether the Linux host monitoring dashboard has been normal over the last hour\"\n  - \"Inspect this dashboard\"\n  - \"Has the host web01 in this dashboard had any anomalies over the last day\"\nbuiltin_tools:\n  - list_dashboards\n  - get_dashboard_data\n  - get_dashboard_detail\n  - query_prometheus\n  - list_busi_groups\ntags:\n  - internal\n---\n\n# Skill: Nightingale (N9E) Analyze Dashboard\n\nHelp the user analyze the data health of an **existing dashboard** over a given time window, and produce an anomaly list with recommendations.\n\nThe core tool is `get_dashboard_data`: it performs data fetching and **statistical pre-screening** on the server side (four deterministic detections — MAD outliers, sudden changes, trends, year-over-year comparison — plus periodicity denoising), and returns layered results. Year-over-year baseline: for a window ≤24h it compares against the same period yesterday; for a longer window it compares against the previous period of the same length (the digest header states the shift amount — describe it accordingly). **The detection is already done; your job is attribution, correlation, and explanation, not re-scanning point by point.**\n\n## Step 1: Locate the dashboard\n\n- The context already carries a `dashboard_id` (injected by the frontend from `/dashboards/<id>`) → use it directly.\n- The user pasted a `/dashboards/<id>` link → take the id.\n- Only a name was given → match by name with `list_dashboards(query=\"...\")`; if there are multiple candidates or no match, list them and ask the user — **do not** guess.\n\n## Step 2: Call get_dashboard_data\n\n```\nget_dashboard_data(id=<id>, time_range=\"24h\")\n```\n\n- Pass `time_range` according to user intent (\"the last day\" → `24h`, \"this week\" → `7d`); if the user did not specify, use the default `1h` and note the analysis window in the conclusion.\n- The user constrained host/cluster, etc. (\"look at the host web01\") → pass it via `vars`: `vars={\"ident\":[\"web01\"]}` (the variable name follows the variable definition in `get_dashboard_detail(include_config=true)`; if unsure, check it first).\n- **One call is enough.** Do not first call get_dashboard_detail and then run query_prometheus over every panel at full scale — that is what the tool has already done for you.\n\n## Step 3: Interpret the pre-screening results (your core value)\n\nThe tool returns layered results: ⚠ suspicious curves (features + sampled points) / ✓ normal summary / skipped list (including a **flat-line** category — curves with a constant value are counted separately; if a curve is flat but at a different level than yesterday, it goes into the suspicious section as a year-over-year anomaly, e.g. qps stuck at 0). What you should do:\n\n1. **Cross-panel correlation**: simultaneous movement of multiple curves at the same moment is the most important signal. For example, `14:32 CPU sudden change +312%` co-occurring with `14:32 disk latency outlier` → likely the same event; merge them into a single problem statement rather than listing them as two.\n2. **Judge impact in light of metric semantics**: rising WAL fsync latency → writes are affected; a rising connection-count trend approaching the limit → capacity risk. Talk about \"what this means\", not re-reading the numbers.\n3. **Re-examine the \"suspected periodic\" flag**: spikes flagged as periodic are usually scheduled jobs and generally do not count as anomalies; but if the user is specifically asking \"why is it slow at this same time every day\", it is in fact the answer.\n4. **Drill down when necessary**: for a key suspicious metric, use `query_prometheus` to narrow the time window and inspect details (e.g. ±15 minutes around the sudden change, with a smaller step), or query a related metric to validate a hypothesis. **Drill down into at most 2-3** of the most critical ones; do not query every suspicious curve.\n5. **Do not recite the sampled points**: the attached \"points:\" series is evidence for you to see the shape; in the conclusion just cite the key moments and values.\n\n## Step 4: Output the analysis report\n\n```\n## <dashboard name> Health Analysis (window 24h)\n\n**Overall conclusion**: one sentence (e.g.: found 1 suspected disk performance event affecting the etcd-2 node; all other metrics normal)\n\n### Anomalies (by severity)\n1. **etcd-2 disk performance degradation (starting at 14:30)**\n   - Evidence: WAL fsync latency 4ms→89ms (sudden change +312%@14:30, no such phenomenon in the year-over-year window); CPU spiked to 96% at the same moment\n   - Impact: write latency will be driven up directly\n2. ...\n\n### Correlation analysis\n(correlation inference of multiple metrics over time)\n\n### Recommendations\n- Actionable next steps (check the disk of the host where etcd-2 resides / iostat / whether there is a snapshot job...)\n```\n\n- When there are no suspicious curves, state clearly \"no anomalies found\"; **do not fabricate problems**. Note the coverage (window, skipped panels).\n- The output was truncated (very large dashboard) → use `panel_ids` to re-call key panels in batches, then merge the conclusions at the end.\n\n## Boundaries and special cases\n\n- The dashboard has no Prometheus panels at all → the tool returns an error and lists the data source distribution; tell the user directly that this is not supported for now.\n- When a variable resolves to no value, the tool notes \"matched everything with .*\"; the data for a multi-instance dashboard may be excessive, so carry this premise in the conclusion.\n- A curve that \"existed yesterday but is gone today\" (an instance disappeared) is worth a mention in the anomalies — losing an instance is often more serious than metric fluctuation. Note that when the window is >24h, this segment actually compares against the previous period of the same length (e.g. the previous 7 days), so do not phrase it as \"it was still there yesterday\".\n- The user then wants to modify the dashboard (add charts / change thresholds) → load modify-dashboard; to create an alert on an anomalous metric → load create-alert-rule.\n\n## Notes\n\n- In the report, always use the moment format returned by the tool (HH:MM); do not convert time zones yourself.\n- Keep the precision the tool gives for numbers; the focus is the trend and order of magnitude, not decimal places.\n- Suspicious ≠ failure: the detection is statistical, so word the conclusion as \"suspected/possible\" and make the basis for the judgment clear.\n\n<!-- File: aiagent/skill/embedded/builtin/categraf-deploy-guide/SKILL.md (Tokens: ~2897 | Category: anthropic-skill) -->\n---\nname: categraf-deploy-guide\ndescription: >-\n  Answers \"how do I deploy the categraf collector\". Trigger scenarios: the user asks \"how to install categraf / how to deploy categraf / run categraf with Docker / install categraf on Windows / how to register categraf as a system service / report categraf to Nightingale / how to write categraf config.toml / how to verify that categraf is collecting data\". Covers binary + systemd, Docker, Windows, K8s tips, key configuration, and common verification commands. This skill is instructional/guidance-oriented, calls no tools, and outputs ready-to-paste commands and configuration snippets.\ntags:\n  - internal\n---\n\n# Categraf Deployment Guide\n\n## Scope\n\n**Enter this skill**:\n- \"how to install / how to deploy / how to start categraf\"\n- \"run categraf on Docker / Kubernetes / Windows / Linux\"\n- \"how to register categraf as a systemd service / start on boot\"\n- \"report categraf to Nightingale / how to write config.toml / what to put in the writer URL\"\n- \"how to verify that categraf is collecting data\"\n- \"categraf is installed but Nightingale can't see the machine\" → **do not enter this skill**, route to `host-onboard-diagnose`\n\n**Do not enter this skill**:\n- Diagnosing onboarding failures after install → `host-onboard-diagnose`\n- Abnormal metrics after onboarding → `host-health-diagnose`\n- Configuring collection for a specific input plugin (mysql / redis / nginx / snmp, etc.) → guide the user to read the comments under `conf/input.<name>/`; this skill only covers \"get it installed + get it connected\"\n\n## One-Sentence Principle\n\n**categraf deployment = three actions**: get the binary → correctly write the `[[writers]]` section of `config.toml` → start it and verify. Everything else is a variant of these three steps (containerization, systemd management, Windows service, etc.).\n\n## Deployment Methods Quick Reference\n\n| Scenario | Recommended method | Notes |\n|---|---|---|\n| Linux physical machine / VM | **binary + systemd** | v0.3.35+ has a built-in `--install` one-liner to register |\n| Linux but no root | binary + `--user --install` | supported in v0.4.5+ |\n| Container / orchestration environment | **Docker** or K8s DaemonSet | collecting host metrics requires mounting `/proc /sys` |\n| Windows | `categraf.exe --win-service-install` | or run in the background with `win_run.bat` |\n| macOS (development/debugging) | run the binary directly | not recommended for production |\n\nOperating system support: Linux kernel 2.6.32+ / Windows 10+ or Server 2008+ / macOS 10.15+.\n\n## Quickest Path: One-Click Install\n\nIf the nightingale server is v9+ and bundles categraf, a single command on the target\nhost does everything below (download, unpack, rewrite the reporting address, register\nand start the systemd service):\n\n```bash\ncurl -sSfL 'http://N9E_HOST:17000/api/n9e/agents/categraf/install.sh' | sudo bash -s -- --server 'http://N9E_HOST:17000'\n```\n\nThe address is filled in by the server, so nothing has to be edited by hand. The same\ncommand is available from the machine list page (\"Install Categraf\"), which also waits\nfor the host to report. Useful flags: `--force` (reinstall/upgrade), `--dir <path>`,\n`--auth user:pass` (when the agent API requires basic auth). Note the `bash -s --`\nform: `| sudo bash --force` silently drops the flag.\n\nIf the server is older, does not bundle the package, or the host is not Linux, fall\nback to the manual steps below.\n\n## Step 1: Get the Binary\n\nDownload URL: https://github.com/flashcatcloud/categraf/releases\n\nPackage naming convention: `categraf-{version}-{os}-{arch}.tar.gz`, for example `categraf-v0.3.35-linux-amd64.tar.gz`. **Confirm the architecture first** (`uname -m`: choose amd64 for x86_64, arm64 for aarch64).\n\nDirectory structure after extraction:\n\n```\ncategraf/\n├── categraf              # main program\n└── conf/\n    ├── config.toml       # main config (hostname / writers / heartbeat)\n    └── input.*/          # sub-config directories for the various collection plugins\n```\n\n## Step 2: Correctly Write `conf/config.toml`\n\nMinimal working configuration (reporting to Nightingale n9e):\n\n```toml\n[global]\nhostname = \"\"                  # leave empty to let categraf auto-detect the hostname; specify explicitly when multiple machines share a name\ninterval = 15                  # global collection frequency (seconds)\nproviders = [\"local\"]\n\n[global.labels]\n# region = \"shanghai\"          # global extra labels, optional\n# env = \"prod\"\n\n[[writers]]\nurl = \"http://N9E_HOST:17000/prometheus/v1/write\"\nbasic_auth_user = \"\"\nbasic_auth_pass = \"\"\ntimeout = 5000\ndial_timeout = 2500\nmax_idle_conns_per_host = 100\n\n[heartbeat]\nenable = true                  # must be enabled, otherwise this machine won't appear in the Nightingale machine list\nurl = \"http://N9E_HOST:17000/v1/n9e/heartbeat\"\ninterval = 10\n```\n\n**Key fields**:\n- `[[writers]].url`: n9e's remote write receiving endpoint; the path is fixed at `/prometheus/v1/write`, and the default port is 17000.\n- `[heartbeat].enable=true` + `[heartbeat].url`: heartbeat reporting, which lets the \"machine list\" see this machine; **if missing → installed but invisible**.\n- `[global].hostname`: leave empty to use the system hostname; if the `hostname` command in a container/cloned machine returns a duplicated name like `localhost.localdomain`, **you must specify it explicitly**, otherwise different machines will overwrite each other.\n- `[global].labels`: global tags; setting good distinguishing dimensions (region / env / idc) makes building dashboards and alerts much easier later.\n\n⚠️ **Do not set `omit_hostname` to true** — it strips the `ident` label, with the result that the machine list shows OS/agent_version all as unknown (a common pitfall).\n\n## Step 3: Start and Manage\n\n### Option A: Linux + systemd (v0.3.35 and above, recommended)\n\n```bash\ncd /path/to/categraf\nsudo ./categraf --install      # register as a system service\nsudo ./categraf --start        # start\nsudo ./categraf --status       # check status\nsudo ./categraf --stop         # stop\n```\n\nNon-root user (v0.4.5+):\n\n```bash\n./categraf --user --install\n./categraf --user --start\n```\n\nsystemd resource limits (edit `/etc/systemd/system/categraf.service`):\n\n```ini\n[Service]\nCPUQuota=200%\nMemoryLimit=1G\n```\n\nAfter editing, run `systemctl daemon-reload && systemctl restart categraf`.\n\n### Option B: Docker\n\nMinimal (collects only in-container/network metrics):\n\n```bash\ndocker run -td \\\n  --name categraf \\\n  --restart=always \\\n  -e TZ=Asia/Shanghai \\\n  -v /home/flashcat/categraf/conf/:/etc/categraf/conf \\\n  flashcatcloud/categraf:latest\n```\n\n**Collecting host CPU/memory/disk** (the most common case; you must mount the host's `/proc` `/sys` and use host networking):\n\n```bash\ndocker run -td \\\n  --name categraf \\\n  --restart=always \\\n  --network=host \\\n  -e TZ=Asia/Shanghai \\\n  -e HOST_PROC=/hostfs/proc \\\n  -e HOST_SYS=/hostfs/sys \\\n  -e HOST_MOUNT_PREFIX=/hostfs \\\n  -v /home/flashcat/categraf/conf/:/etc/categraf/conf \\\n  -v /proc:/hostfs/proc:ro \\\n  -v /sys:/hostfs/sys:ro \\\n  flashcatcloud/categraf:latest\n```\n\nResource limits: append `--cpus=2 --memory=1g`.\n\n### Option C: Windows\n\nRegister as a service:\n\n```cmd\ncategraf.exe --win-service-install\ncategraf.exe --win-service-start\ncategraf.exe --win-service-stop\ncategraf.exe --win-service-uninstall\n```\n\nOr run in the background with a script:\n\n```cmd\nwin_run.bat start\nwin_run.bat stop\n```\n\nWindows does not support `kill -HUP` reload; after changing the config, use `--win-service-stop` + `--win-service-start`.\n\n### Option D: Kubernetes\n\nOn K8s you typically use a DaemonSet (one per Node to collect host metrics) + a ConfigMap (to distribute `config.toml` uniformly). The official release package includes a `k8s/` directory with sample YAML; just run `kubectl apply -f k8s/`. **The two things you must change**:\n1. The `[[writers]].url` and `[heartbeat].url` in the ConfigMap should point to your n9e;\n2. If you use multiple clusters, add `cluster = \"xxx\"` to `[global.labels]` to distinguish them.\n\n## Step 4: Verify\n\n### 4.1 Single-plugin debugging (does not actually report, prints once)\n\n```bash\n./categraf --test --inputs cpu              # test just one\n./categraf --test --inputs cpu:mem:disk     # use colons to separate multiple\n```\n\nIf metric lines appear on stdout, local collection is OK; if it errors out (failed to connect to mysql / insufficient permissions, etc.), get past this step before starting the service.\n\n### 4.2 Reload config (without restarting the process)\n\n```bash\nkill -HUP `pidof categraf`   # Linux-only, not supported on Windows\n```\n\n### 4.3 Verify on the Nightingale side\n\n- Open n9e in a browser → **Infrastructure / Machine List**; you should be able to see this `ident`, with OS/CPU/agent_version all not unknown.\n- If it doesn't appear → route to `host-onboard-diagnose` and run the 5-stage troubleshooting; don't guess blindly here.\n\n### 4.4 Self-upgrade (v0.3.36+)\n\n```bash\n./categraf --update --update_url https://github.com/flashcatcloud/categraf/releases/download/vX.Y.Z/categraf-vX.Y.Z-linux-amd64.tar.gz\n\n# On an intranet, point --update_url at nightingale's own bundled package instead:\n./categraf --update --update_url 'http://N9E_HOST:17000/api/n9e/agents/categraf/download?arch=amd64'\n```\n\n## Common Pitfalls (one-liner version)\n\n| Symptom | Cause | Fix |\n|---|---|---|\n| Not visible in the machine list | `[heartbeat].enable=false` or `url` not changed | edit the toml, restart |\n| Machine list shows OS=unknown | `omit_hostname=true` or categraf version < v0.2.35 | turn off omit_hostname, upgrade the version |\n| Multiple machines overwrite each other | duplicated hostname (localhost / VM clone) | explicitly set `[global].hostname` or an ident shell |\n| Can't collect host CPU in Docker | `/proc /sys` not mounted and `HOST_*` env not set | use the \"collect host\" command above |\n| Writing to prom returns 499 / queue full | n9e backend ingest queue is full | tune n9e write concurrency, or reduce the categraf interval |\n| TLS x509 unknown authority | a self-signed certificate is used without installing the CA | add `insecure_skip_verify = true` to the writer section (test only), or install the CA |\n| BasicAuth 401 | n9e has BasicAuth enabled but categraf is not configured | fill in `basic_auth_user/pass` |\n\n## Output Style\n\n- **Clarify before giving a solution**: when the user hasn't stated the OS / deployment form, first ask \"Do you want to install it on a Linux physical machine, Docker, or K8s? What is the n9e access URL?\"\n- **Give a ready-to-paste command for every suggestion** — don't write \"tweak the config\", write \"change `[[writers]].url` in `config.toml` to `http://your-n9e:17000/prometheus/v1/write`\".\n- **Configuration examples must include placeholder-replacement notes** (such as `N9E_HOST`) so the user knows what to change.\n- Reply in the user's language (Chinese for Chinese users, English for English users).\n- After deployment, **always remind them to run `--test --inputs cpu` once** and \"take a look at the Nightingale machine list\", otherwise there's no way to know right then whether the install is working.\n- If the user says \"I finished installing but can't see the machine\" → don't give more deployment instructions; route them directly to `host-onboard-diagnose`.\n\n<!-- File: aiagent/skill/embedded/builtin/create-alert-rule/SKILL.md (Tokens: ~6907 | Category: anthropic-skill) -->\n---\nname: create-alert-rule\ndescription: |\n  **Create alert rules**. Prefer reusing the validated rules in integrations (standard components like Linux/MySQL/Redis/Kafka/PostgreSQL/Elasticsearch all ship ready-made rule packs); import as many rules as the user needs—one rule, a batch, or a whole pack. Only hand-write a custom rule when integrations has nothing that fits. Supports all data sources: Prometheus / Loki / ES / OpenSearch / MySQL / PG / TDengine / ClickHouse / Doris / VictoriaLogs / Host.\n  ⚠️ **Do NOT use this skill for bulk YAML imports**—when the user provides a URL or a YAML file, awesome-prometheus-alerts, node-exporter.yml, and the like, use import-prom-rule instead.\n  Triggers: create an alert / add an alert / help me set up a CPU alert / add a set of alert rules for MySQL / configure common alerts for a host / I want to monitor a metric.\nexamples:\n  - \"Configure a set of common alert rules for the host\"\n  - \"Add a set of alert rules for MySQL\"\n  - \"Help me create an alert for CPU usage exceeding 80%\"\n  - \"Add a MySQL slow query alert rule\"\n  - \"Add a memory alert for the host, fire when it exceeds 90%\"\nmax_iterations: 20\nbuiltin_tools:\n  - preview_alert_rule_template\n  - import_alert_rule_template\n  - create_alert_rule\n  - list_busi_groups\n  - list_datasources\n  - list_metrics\n  - list_notify_rules\n  - list_files\n  - read_file\n  - list_databases\n  - list_tables\n  - describe_table\ntags:\n  - export\n---\n\n# Skill: Nightingale (N9E) Alert Rule Creation\n\nTwo tools: `import_alert_rule_template` (reuse the validated rules in integrations) and `create_alert_rule` (hand-write rules).\n\n**Prefer reusing the rules in integrations**: the rules under `integrations/<component>/alerts/` are finished products that have been hand-tuned and validated (severity, duration, evaluation interval, appended tags, annotations, and effective time windows are all configured), and their quality far exceeds hand-crafted rules. **But import as many as the user needs—do not blindly import the whole pack**:\n\n- The user wants \"a set / common\" alerts for some component → import the whole pack (do not pass `names`)\n- The user names only a few categories (e.g. \"disk and memory alerts\") → import only those (pass them in `names`)\n- The user wants a **single** alert for a specific metric → if the pack happens to contain a matching rule, import just that one (pass one entry in `names`) or borrow its expression; if the pack has nothing that fits, hand-write with `create_alert_rule`\n\nOnly go to Approach B (`create_alert_rule`) when integrations has no matching rule, or when the user provides a fully custom query condition.\n\nWhen a component ships both a categraf and an exporter rule pack, **prefer categraf**: first probe whether the categraf metrics have data; if found, use the categraf pack, otherwise fall back to the exporter pack (see Approach A step 3 for details).\n\n| User need | What to do |\n|------|-----------|\n| Configure a set / a few alerts for a standard component (Linux / MySQL / Redis / Kafka / PostgreSQL / Elasticsearch / Ceph / Oracle / Windows / MongoDB …) | Approach A: `preview_alert_rule_template` to see what's in the pack → `import_alert_rule_template` to import as needed |\n| A single alert for a specific metric, and the integration pack has a matching rule | Approach A: `import_alert_rule_template` + `names` to import just that one |\n| Fully custom rules that integrations doesn't cover (including niche conditions like Loki / ES / SQL query values / log counts) | Approach B: `create_alert_rule` |\n\n> ⚠️ **Do NOT use this skill for bulk YAML imports**—when the user provides a URL or a YAML file, awesome-prometheus-alerts, node-exporter.yml, and the like, use import-prom-rule instead.\n\n## Step 1 (common to both paths): determine the business group and data source\n\n- Call `list_busi_groups` to get the list of business groups\n- Call `list_datasources` to get the list of data sources, and find the data source ID of the matching type (rule packs are mostly the Prometheus type)\n- If the session context already has a preselected `busi_group_id` / `datasource_id`, use it directly—**do not** call `list_*` again\n- `cate=host` (host-unreachable class) does not need a data source\n\n### Business group selection rules\n\n1. If the user explicitly specified a business group name or ID, use it directly\n2. Otherwise, by priority:\n   a. **Prefer the business group with `is_default: true`** (usually \"Default Busi Group\" or a group whose name contains \"Default\")\n   b. If there is only one business group, use it directly\n   c. When there are multiple candidates and none is the default, **do not blindly take the first one**; list them in your reply and ask the user to confirm\n\n---\n\n## Approach A: reuse validated rules in integrations (as needed: single / batch / whole pack)\n\n`import_alert_rule_template` imports rules from the rule pack under `integrations/<component>/alerts/` (each rule's severity, duration, evaluation interval, appended tags, annotations, and effective time window are all preserved), binds the rules to the data source you select, and uniformly switches them to the enabled state. **How many rules are imported is controlled by the `names` parameter**, driven by the user's need.\n\n### Steps\n\n1. See which integration components are available:\n   ```\n   list_files(base=\"integrations\")\n   ```\n2. See which rule pack files exist under that component:\n   ```\n   list_files(base=\"integrations/Linux\", path=\"alerts\")\n   ```\n3. **Pick the right rule pack file (prefer categraf)**. Rule packs often come in two collection styles, categraf and exporter, and the filename convention varies per component (for Linux it's `linux_by_categraf.json` / `linux_by_exporter.json`, but it may use other suffixes). **Prefer the pack whose filename contains `categraf`**:\n   - First probe whether the categraf metrics have data in the environment: `list_metrics(datasource_id=<X>, keyword=\"<categraf metric keyword>\")`.\n     - Use a categraf-specific metric name that distinguishes the two styles as the keyword, e.g. for Linux use `cpu_usage` (categraf uses `cpu_usage_idle`, which node_exporter does not have), `mem_used_percent`, `disk_used_percent`; for Redis use `redis_used_memory`; for MySQL use `mysql_global_status_queries`.\n   - **As long as the categraf metric returns data (a non-empty array), use the pack whose filename contains `categraf`** directly, without comparing against exporter.\n   - Only fall back to the pack whose filename contains `exporter` (e.g. `linux_by_exporter.json`) when the categraf metric has no data, and use the corresponding exporter metric keyword (e.g. `node_cpu_seconds_total`) with `list_metrics` to confirm data exists before importing.\n4. **See which rules are in the pack (only when you need to select by name)**. You can skip this step when the user wants to import the whole pack; when the user wants only a few of them, or a single alert for a specific metric, preview first to get the exact rule names:\n   ```\n   preview_alert_rule_template(component=\"Linux\", file=\"linux_by_categraf.json\")\n   ```\n   It returns each rule's `name` / `cate` / `severity` / an expression summary / whether it is disabled (a small payload). **Do not** use `read_file` to view a large rule pack (it will be truncated); use this tool.\n5. **Import as needed**:\n   - Whole pack (the user wants \"a set / common\" alerts)—do not pass `names`:\n     ```\n     import_alert_rule_template(group_id=<business group>, component=\"Linux\", file=\"linux_by_categraf.json\", datasource_id=<data source>)\n     ```\n   - Batch / single (the user names a few categories or a specific metric)—`names` carries the exact rule names (from preview):\n     ```\n     import_alert_rule_template(group_id=<business group>, component=\"Linux\", file=\"linux_by_categraf.json\",\n       datasource_id=<data source>, names=\"[\\\"Machine load - high memory, please pay attention - categraf\\\"]\")\n     ```\n\n> **Do not** use `read_file` to read out the entire rule pack and then call `create_alert_rule` one rule at a time—the pack may be large and get truncated, and converting rules one by one also drops fields. To see what's in the pack use `preview_alert_rule_template`; to persist it use `import_alert_rule_template`.\n\n### Notes\n\n- **Strongly recommend passing `datasource_id`**: passing it binds the imported rules to that data source; omitting it makes the rules match every data source of that type (an overly broad scope). The `host` heartbeat rules in the pack don't need a data source, and the tool skips binding for them automatically.\n- The rules in a rule pack are **disabled** by default in the template (it is a browsable catalog). When importing, this tool **switches them to enabled** by default (`disabled=0`) so the rules take effect immediately. If the user wants to \"import first but leave them disabled, and enable them one by one themselves\", pass `disabled=1`.\n- The names written in `names` must exactly match the `name` returned by `preview_alert_rule_template`; any that don't match are listed in the returned `not_found_names`—double-check and retry.\n- Duplicate rules (already existing in the business group) are **skipped automatically** (`status=skipped_duplicate`); this is not a failure, and **do not** re-import with `name_prefix` just because you see a skip. Only use `name_prefix`/`name_suffix` when the user explicitly wants the rule to coexist with the existing one (e.g. a comparison test).\n- The return includes three counts—`created` / `skipped` / `failed`—plus details for each rule; just summarize them in the output.\n- Imported rules **are not bound to any notify rule** by default (to avoid sending alerts by mistake). If binding is needed, after importing, prompt the user to associate it manually on the rule, or switch to Approach B to create rules one by one and pass `notify_rule_ids`.\n\n---\n\n## Approach B: hand-write a custom rule (when integrations has no matching rule)\n\nUse `create_alert_rule`. Supports all data source types: **Prometheus / Loki / Elasticsearch / OpenSearch / TDengine / ClickHouse / MySQL / PostgreSQL / Doris / VictoriaLogs / Host**.\n\n> Before getting here, confirm that integrations really has no matching rule: common metrics for standard components (CPU/memory/disk/connection count…) are mostly in the rule packs, so reuse rather than hand-craft when you can. Even when hand-crafting, you can first run `preview_alert_rule_template` to glance at whether there's a ready-made expression you can borrow.\n\nThe tool offers two invocation modes:\n\n1. **Prometheus simplified path** (most common)—pass `prom_ql` + `threshold` + `operator` directly, and the tool builds the rule automatically\n2. **Generic path**—pass `cate` + `rule_config_json`; **first obtain the structure of `rule_config_json` by reading `datasources/<cate>.md` via `read_file`**, then fill in the actual values\n\n### Mode 1: Prometheus simplified path\n\nThe most common scenario. You only need to fill in PromQL, threshold, and operator. Internally the tool splices the threshold into `prom_ql` to generate a v1-format rule (the v2 editor in the OSS n9e FE is gated by `IS_PLUS`, so only v1 can be used).\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"CPU usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  \"operator\": \">\",\n  \"threshold\": 80,\n  \"severity\": 2,\n  \"note\": \"CPU usage exceeded 80% for 1 minute\",\n  \"for_duration\": 60\n}\n```\n\n### Mode 2: generic path (non-Prometheus)\n\nFor types such as Loki / ES / MySQL / TDengine / ClickHouse / Doris / VictoriaLogs / Host, you need to pass `cate` and `rule_config_json`.\n\n**Key step**: first read the reference doc for that data source type to obtain the `rule_config` structure template:\n\n```\nread_file(base=\"create-alert-rule\", path=\"datasources/<cate>.md\")\n```\n\nThen turn the `rule_config` object in the doc into a JSON string and pass it to the `rule_config_json` parameter. Example (MySQL alert):\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"MySQL too many failed orders\",\n  \"cate\": \"mysql\",\n  \"datasource_id\": 4,\n  \"severity\": 2,\n  \"rule_config_json\": \"{\\\"queries\\\":[{\\\"ref\\\":\\\"A\\\",\\\"sql\\\":\\\"SELECT count(*) AS value FROM orders WHERE created_at >= NOW() - INTERVAL 5 MINUTE AND status='failed'\\\",\\\"keys\\\":{\\\"valueKey\\\":\\\"value\\\",\\\"labelKey\\\":\\\"\\\"},\\\"interval\\\":60}],\\\"triggers\\\":[{\\\"mode\\\":1,\\\"exp\\\":\\\"$A.value > 10\\\",\\\"severity\\\":2,\\\"recover_config\\\":{\\\"judge_type\\\":1}}]}\"\n}\n```\n\n**⚠️ Important generic rules (apply to all non-Prometheus types)**:\n\n1. **The `interval` field must be the total number of seconds**—do not write `interval_unit`. When the frontend saves, it converts `value × unit → seconds`, and when reading it derives the display unit back from the seconds.\n   - Last 1 minute → `\"interval\": 60`\n   - Last 5 minutes → `\"interval\": 300`\n   - Last 1 hour → `\"interval\": 3600`\n   - **Do not** write `\"interval\": 5, \"interval_unit\": \"min\"`—the FE will display it as 5 seconds.\n   - The tool has a defensive fallback: if you accidentally write `interval_unit` or a bare interval smaller than 60, it converts it to seconds automatically, but it's best to write it correctly to begin with.\n\n**⚠️ Important limitations for OSS n9e SQL-type data sources (MySQL/PGSQL/CK/Doris)**:\n1. **`keys.valueKey` is required**—the alias of the numeric column in the SELECT statement, usually `\"value\"`. Missing it reports `valueKey is required`.\n2. **`$from`/`$to`/`$__timeFilter` are not substituted**—the OSS `macros.Macro` is a no-op. You must use the data source's native time functions, e.g. MySQL uses `NOW() - INTERVAL 5 MINUTE`, PG uses `NOW() - INTERVAL '5 minutes'`, CK uses `now() - INTERVAL 5 MINUTE`.\n3. **TDengine is the exception**—it has its own variable substitution logic, and `$from`/`$to`/`$interval` are usable.\n\n## create_alert_rule parameter reference (Approach B)\n\n| Field | Required | Description |\n|---|---|---|\n| `group_id` | ✅ | Business group ID (from `list_busi_groups`) |\n| `name` | ✅ | Rule name (must be unique within the business group) |\n| `cate` | ❌ | Data source type (default `prometheus`). Options: `prometheus` / `loki` / `elasticsearch` / `opensearch` / `tdengine` / `ck` / `mysql` / `pgsql` / `doris` / `victorialogs` / `host` |\n| `prod` | ❌ | Product type. When omitted, derived automatically from `cate` |\n| `datasource_id` | Conditionally required | Data source ID. **Not needed when `cate=host`**; required for all others |\n| `rule_config_json` | Conditionally required | The complete `rule_config` JSON string. **Can be omitted when `cate=prometheus` (use the simplified path)**; required for other types |\n| `prom_ql` | Conditionally required | PromQL query (only for the `cate=prometheus` simplified path). **Write only the query, not the threshold** |\n| `threshold` | Conditionally required | Trigger threshold (only for the simplified path) |\n| `operator` | ❌ | Default `>`. Options `>` `>=` `<` `<=` `==` `!=` |\n| `severity` | ❌ | Default 2. 1=Critical, 2=Warning, 3=Info |\n| `note` | ❌ | Alert notification body |\n| `eval_interval` | ❌ | Evaluation interval (seconds), default 30 |\n| `for_duration` | ❌ | Duration (seconds), default 60 |\n| `append_tags` | ❌ | Appended tags, multiple separated by spaces |\n| `runbook_url` | ❌ | Runbook URL |\n| `notify_rule_ids` | ❌ | JSON list of associated notify rule IDs, e.g. `\"[1,2]\"` |\n\n## Approach B detailed steps (create_alert_rule)\n\n> See \"Step 1 (common to both paths)\" above for selecting the business group and data source. Below are the steps specific to Approach B.\n\n### B-1: identify the data source type (cate)\n\nBased on the user's alert need, decide which `cate` to use:\n\n| User-need keyword | cate | Trigger condition |\n|---|---|---|\n| \"Host CPU/memory/disk\", \"Prometheus metric\" | `prometheus` | Metric threshold |\n| \"Machine unreachable\", \"node offline\" | `host` | Heartbeat timeout |\n| \"Application error log\", \"Loki log\" | `loki` | Log count |\n| \"ES log\", \"Elasticsearch aggregation\" | `elasticsearch` | Log aggregation |\n| \"OpenSearch log\" | `opensearch` | Log aggregation (same as ES) |\n| \"MySQL abnormal query result\" | `mysql` | SQL query value |\n| \"PostgreSQL abnormal query result\" | `pgsql` | SQL query value |\n| \"ClickHouse metric/log\" | `ck` | SQL query value |\n| \"Doris log\" | `doris` | SQL query value |\n| \"TDengine time-series data\" | `tdengine` | SQL query value |\n| \"VictoriaLogs log\" | `victorialogs` | LogsQL query |\n\nDefault value: `prometheus`. The data source must match too (`cate=mysql` finds a MySQL data source, `cate=loki` finds a Loki data source, etc.); `cate=host` does not need a data source.\n\n### B-2 (non-Prometheus only): read the data source reference doc\n\nFor types where `cate != \"prometheus\"`, **you must first read the reference doc for that type** to obtain the `rule_config` structure:\n\n```\nread_file(base=\"create-alert-rule\", path=\"datasources/<cate>.md\")\n```\n\nFor example:\n- MySQL → `datasources/mysql.md`\n- Loki → `datasources/loki.md`\n- Host → `datasources/host.md`\n\nThe reference docs contain complete field descriptions, option-value tables, and complete examples. **Copy the field names from the `rule_config` object in the example as-is**, replacing only the concrete values.\n\n### B-3 (SQL types only): probe the real schema ⚠️\n\n**This step is mandatory when `cate ∈ {mysql, pgsql, ck, doris, tdengine}`; it cannot be skipped**.\n\n> **TDengine is also supported**: although TDengine is a time-series database, its REST API supports `SHOW DATABASES` / `information_schema.ins_tables` / `information_schema.ins_columns`. `list_databases` / `list_tables` / `describe_table` are fully usable with TDengine.\n\n**Never guess database/table/column names from the literal description in the user's prompt**. When the user says \"query the access_log table in the my_logs database\",\nit is very likely just an example, and the actual data source may have no such database or table at all.\n\nYou must probe in order:\n\n1. **`list_databases(datasource_id=<X>)`**—see the real list of databases\n   - Compare against the user's description and find the closest one\n   - If the database name in the user's description **is not in the returned list**, **do not force it into the SQL**. Use AskUserQuestion\n     to list the real database names for the user to confirm, or pick one that looks relevant and have the user approve it\n\n2. **`list_tables(datasource_id=<X>, database=<the chosen database>)`**—see the real list of tables\n   - Same as above: if the table the user mentioned is not in the list → list the real tables for the user to choose\n\n3. **`describe_table(datasource_id=<X>, database=<database>, table=<table>)`**—get the real columns\n   - Use the returned column names to assemble the SQL—**do not invent** common column names like `log_time`, `status`, `created_at`\n     (they may not actually exist in this table)\n   - Pay special attention to the time column: in different tables it may be called `ts`, `event_time`, `log_time`, `create_time`,\n     `timestamp`, etc.; you must decide based on the column names returned by `describe_table`\n\nOnly after obtaining the real schema can you proceed to B-4 and assemble the SQL.\n\n#### How to pass datasource_id\n\n- When the session context already binds a data source (a conversation entered from the data source page): `datasource_id` can be omitted,\n  inherited from the session context\n- In the alert rule scenario (entered from the alert rule page or a plain conversation): you must **explicitly pass** `datasource_id`,\n  otherwise the tool reports a `datasource_id required` error\n\n### B-4: build the PromQL / SQL / LogQL\n\n#### Simplified path (cate=prometheus)\n\nBased on the user's description, build a PromQL **without a threshold**. All entries in the table below are **categraf (telegraf-style)** metric names—**prefer categraf**:\n\n| User need | Recommended PromQL (prefer categraf) |\n|---|---|\n| Host CPU usage | `avg by (ident) (100 - cpu_usage_idle{cpu=\"cpu-total\"})` |\n| Host memory usage | `mem_used_percent` |\n| Host disk usage | `disk_used_percent` |\n| Host system load | `system_load1` |\n| Host inbound network traffic | `rate(net_bytes_recv[5m])` |\n| MySQL QPS | `rate(mysql_global_status_queries[5m])` |\n| MySQL connection usage | `mysql_global_status_threads_connected / mysql_global_variables_max_connections * 100` |\n| Redis memory usage | `redis_used_memory / redis_maxmemory * 100` |\n\n**Prefer categraf + probe**: first use `list_metrics` to confirm whether the categraf metric has data in the environment, e.g.\n`list_metrics(datasource_id=<X>, keyword=\"cpu_usage\")` (categraf uses `cpu_usage_idle`, which node_exporter does not have).\n- **Data found** → use the categraf PromQL from the table above directly.\n- **Not found** → the environment very likely uses node_exporter; switch to the equivalent exporter expression below, and use\n  `list_metrics(datasource_id=<X>, keyword=\"node_\")` again to confirm the node_exporter metrics actually have data before creating the rule.\n\n##### node_exporter fallback equivalence table (use only when the categraf metric has no data)\n\n| User need | node_exporter equivalent PromQL |\n|---|---|\n| Host CPU usage | `100 - avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100` |\n| Host memory usage | `(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100` |\n| Host disk usage | `(1 - node_filesystem_avail_bytes{fstype!~\"tmpfs\\|overlay\"} / node_filesystem_size_bytes) * 100` |\n| Host system load | `node_load1` |\n| Host inbound network traffic | `rate(node_network_receive_bytes_total[5m])` |\n\n> The `\\|` in the disk expression is only escaped to avoid breaking the Markdown table; when writing it into PromQL, restore it to `|`, i.e. `fstype!~\"tmpfs|overlay\"` (otherwise the regex treats it as a literal pipe and the filter fails).\n\n#### Generic path (other cate)\n\nFollowing the reference doc read in B-2, assemble the `rule_config` object. Key fields:\n\n- **SQL types** (mysql/pgsql/ck/doris):\n  - The query must return one column aliased `value` as the alert judgment value\n  - **Required** `keys.valueKey: \"value\"` (missing → `valueKey is required` error)\n  - **Time filtering cannot use `$from`/`$to`**; use the data source's native time functions:\n    - MySQL: `NOW() - INTERVAL 5 MINUTE`\n    - PostgreSQL: `NOW() - INTERVAL '5 minutes'`\n    - ClickHouse: `now() - INTERVAL 5 MINUTE`\n    - Doris: `NOW() - INTERVAL 5 MINUTE`\n- **TDengine**: a special exception; `$from`/`$to`/`$interval` **can be used**, and use `keys.metricKey` instead of `keys.valueKey`\n- **Log types** (loki/es/opensearch/victorialogs): the aggregation query returns a numeric value; use `0` for `recover_config.judge_type`\n- **Metric types** (prometheus/mysql/pgsql/ck/tdengine): use `1` for `recover_config.judge_type`\n- **Host type** (host): `queries` is a `{key, op, values}` structure; `triggers` is a `{type, severity, duration}` structure; no `datasource_id` needed\n\n### B-5: call create_alert_rule\n\n#### Simplified path (Prometheus)\n\n```\ncreate_alert_rule(\n  group_id=1, name=\"CPU too high\", datasource_id=1,\n  prom_ql=\"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  threshold=80, operator=\">\", severity=2\n)\n```\n\n#### Generic path (other types)\n\n```\ncreate_alert_rule(\n  group_id=1, name=\"MySQL too many errors\",\n  cate=\"mysql\", datasource_id=4, severity=2,\n  rule_config_json=\"<serialize the rule_config read in step 3 into a JSON string>\"\n)\n```\n\n**Notes:**\n- On the Prometheus simplified path, pass the threshold separately in `threshold`—**do not write it into `prom_ql`**\n- Default `for_duration=60`, which is only reasonable if it is larger than `eval_interval=30`\n- If the user requests \"fire immediately\", set `for_duration` to 0\n- If `create_alert_rule` returns an `already exists` error, **do not call `list_alert_rules`** to look up the duplicate name; just add a suffix to the name (e.g. `-v2`, `-AI`, or a timestamp) and retry\n- On the generic path, `rule_config_json` must be a **JSON string** (not an object); remember to escape the quotes\n- **For SQL types, the B-3 schema probe must have been completed before the call**. If the database/table/column in rule_config_json has not been verified via `list_databases`+`list_tables`+`describe_table`,\n  the alert rule will most likely fail to run (`Unknown database` / `Unknown column` and other runtime errors)\n\n### B-6: (optional) associate notify rules\n\nIf the user requests binding notify rules:\n1. Call `list_notify_rules` to get the list of notify rules\n2. When calling `create_alert_rule`, pass `notify_rule_ids`, e.g. `\"[1,2]\"`\n\nIf the user does not explicitly request it, **do not proactively associate notify rules**, to avoid sending alerts by mistake.\n\n---\n\n## Final step (common to both paths): output the result\n\n**Keep it short**. A one-sentence confirmation is enough:\n\n- Approach A (importing a rule pack): summarize the three counts `created` / `skipped` / `failed`, e.g.\n  > ✅ Imported 8 alert rules from the Linux categraf rule pack for you (skipped 1 duplicate name); all are enabled, see the card below for details.\n- Approach B (single creation):\n  > ✅ Created the alert rule \"Host CPU usage too high\" for you; see the card below for details.\n\n**Do not** restate fields such as rule ID, business group, data source, PromQL, threshold, or alert severity in the Final Answer—the frontend displays this information as a structured card, and repeating it only makes the user see two copies. If you need to add a note (e.g. \"these rules are not bound to any notify rule by default; please associate them as needed\"), you may add a sentence or two, but do not list out the fields already in the card one by one.\n\n## Severity quick reference\n\n| severity | Name | Applicable scenario |\n|---|---|---|\n| 1 | Critical (level 1) | Service unavailable, core component down, data loss |\n| 2 | Warning (level 2) | High resource usage, performance degradation, quota approaching limit |\n| 3 | Info (level 3) | Informational alert, capacity planning hint |\n\nUse 2 (Warning) by default. Use 1 only when the user explicitly says \"severe\" or \"urgent\".\n\n## Common single-alert templates (Approach B reference)\n\n> Use these when the user wants a single alert for a specific metric. If the user wants to \"add a whole set of monitoring alerts for a host\",\n> prefer Approach A to import the whole `integrations/Linux/alerts/linux_by_categraf.json` pack, rather than hand-crafting rules one by one here.\n\n### Host CPU usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host CPU usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"avg by (ident) (100 - cpu_usage_idle{cpu=\\\"cpu-total\\\"})\",\n  \"operator\": \">\",\n  \"threshold\": 80,\n  \"severity\": 2,\n  \"for_duration\": 300,\n  \"note\": \"Host {{ $labels.ident }} CPU usage exceeded 80% for 5 minutes\"\n}\n```\n\n### Host memory usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host memory usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"mem_used_percent\",\n  \"operator\": \">\",\n  \"threshold\": 90,\n  \"severity\": 1,\n  \"for_duration\": 180\n}\n```\n\n### Host disk usage alert\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Host disk usage too high\",\n  \"datasource_id\": 1,\n  \"prom_ql\": \"disk_used_percent\",\n  \"operator\": \">\",\n  \"threshold\": 85,\n  \"severity\": 2,\n  \"for_duration\": 600,\n  \"append_tags\": \"alert_type=capacity\"\n}\n```\n\n<!-- File: aiagent/skill/embedded/builtin/create-dashboard/SKILL.md (Tokens: ~2672 | Category: anthropic-skill) -->\n---\nname: create-dashboard\ndescription: Create monitoring dashboards. Use this when the user asks to create a dashboard, a monitoring board, or a Dashboard.\nmax_iterations: 18\nbuiltin_tools:\n  - import_dashboard_template\n  - create_dashboard\n  - list_metrics\n  - list_files\n  - read_file\n  - grep_files\ntags:\n  - export\n---\n\n# Skill: Nightingale (N9E) Dashboard Creation\n\nThere are two paths to create a dashboard. **Prefer path A (import an integration template)**, since templates are hand-tuned, validated, finished products with quality far higher than hand-assembled ones; only fall back to path B when there is no ready-made template or the user wants custom metrics.\n\nWhen the same component often has both categraf and exporter templates, **prefer categraf**: first probe whether the categraf metrics have data, use the categraf template if you can query them, and only fall back to exporter if you cannot (see path A step 3 for details).\n\n| Scenario | Which tool to use |\n|------|-----------|\n| The monitoring topic has a ready-made template in integrations (Linux / MySQL / Redis / Kafka / PostgreSQL / Elasticsearch / Ceph / Oracle / Nginx / Windows …) | **`import_dashboard_template`** (preferred) |\n| Custom metrics, no ready-made template, or the user explicitly wants custom panels | `create_dashboard` |\n\n## Step 1 (common to both paths): Determine the business group and datasource\n\n- Call `list_busi_groups` to get the list of business groups\n- Call `list_datasources` to get the list of datasources, and find the **datasource ID of the Prometheus type**\n- If the conversation context has already preselected `busi_group_id` / `datasource_id`, use them directly and do **not** call `list_*` again\n\n### Business group selection rules\n\n1. If the user explicitly specified a business group name or ID, use it directly\n2. Otherwise, in priority order:\n   a. **Prefer the business group with `is_default: true`** (usually \"Default Busi Group\" or a group whose name contains \"default\")\n   b. If there is only one business group, use it directly\n   c. If there are multiple candidates and none is the default, **do not blindly take the first one**; list them in your reply and let the user confirm\n\n---\n\n## Path A: Import an integration template (preferred)\n\n`import_dashboard_template` reads the complete template under `integrations/` (preserving layout, thresholds, units, overrides, and value mappings in full), and automatically rewrites the template's datasource binding onto the Prometheus datasource you chose.\n\n### Steps\n\n1. See which integration components exist:\n   ```\n   list_files(base=\"integrations\")\n   ```\n2. See which template files exist under that component:\n   ```\n   list_files(base=\"integrations/Linux\", path=\"dashboards\")\n   ```\n3. **Pick the right template file**. Templates often come in two collection styles, categraf (telegraf-style metric names) and exporter (`node_*` / `*_exporter`-style metric names). File-naming conventions vary per component: it may be a prefix (Linux's `categraf-overview.json`) or a suffix (`redis_by_categraf.json`). **Prefer the template whose file name contains `categraf`**:\n   - First probe whether the categraf metrics have data in the environment: `list_metrics(datasource_id=<X>, keyword=\"<categraf metric keyword>\")`.\n     - Use a categraf-specific metric name as the keyword that distinguishes the two styles, for example for Linux use `cpu_usage` (categraf has `cpu_usage_idle`, while node_exporter does not have this name), `mem_used_percent`, `disk_used_percent`; for Redis use `redis_used_memory`.\n     - If you are unsure which keyword to use, read the metrics list of that template (the file is small and will not be truncated): `read_file(base=\"integrations/Linux\", path=\"metrics/categraf-base.json\")`, and pick a representative `expression` from it to probe with.\n   - **As long as the categraf metrics return data (a non-empty array), use the template whose file name contains `categraf` directly**, with no need to compare against exporter.\n   - Only when the categraf metrics return no data at all should you fall back to the template whose file name contains `exporter` (such as `exporter-detail.json`, `redis_by_exporter.json`), using the corresponding exporter metric keyword (such as `node_cpu_seconds_total`) with `list_metrics` to confirm it actually has data before importing.\n   - **Do not** use `read_file` to read out the entire dashboard template and assemble it yourself — the template may be very large and get truncated; choosing the file by file name + the metrics list is enough\n4. Import:\n   ```\n   import_dashboard_template(group_id=<business group>, component=\"Linux\", file=\"categraf-overview.json\", datasource_id=<Prometheus datasource>)\n   ```\n\n### Notes\n\n- Datasource binding, variables, and layout are all handled automatically; you only need to pass `component` + `file`\n- `datasource_id` is optional: passing it sets it as the default selected value of the dashboard's datasource variable (so the first screen is queryable immediately); not passing it lets the frontend automatically select the first Prometheus from the datasource dropdown\n- To rename or change tags: pass the optional `name` / `tags`; if not passed, the template's own values are kept\n- When it returns `Name duplicate`, **do not** call `list_dashboards`; just change the name (add a `-v2`, `-AI`, or timestamp suffix) and retry\n\n---\n\n## Path B: Custom creation (when there is no template)\n\nUse `create_dashboard`. **You only need to provide the panel title, type, and PromQL**; the tool automatically generates the full configuration (layout, datasource variables, styling, units, etc., are all handled automatically).\n\n> create_dashboard accepts the following set of **simplified fields**. Any field beyond these (overrides, value mappings, heatmap/hexbin/iframe, etc.) is **not supported** — they will be ignored even if written. When you need such rich configuration, switch to path A and import a template.\n\n### Call example\n\n```json\n{\n  \"group_id\": 1,\n  \"name\": \"Linux Host Monitoring\",\n  \"datasource_id\": 1,\n  \"tags\": \"linux host\",\n  \"variables\": \"[{\\\"name\\\":\\\"ident\\\",\\\"label\\\":\\\"Host\\\",\\\"definition\\\":\\\"label_values(cpu_usage_idle, ident)\\\"}]\",\n  \"panels\": \"[{\\\"name\\\":\\\"CPU Usage\\\",\\\"type\\\":\\\"stat\\\",\\\"queries\\\":[{\\\"promql\\\":\\\"avg(cpu_usage_active{cpu=\\\\\\\"cpu-total\\\\\\\",ident=~\\\\\\\"$ident\\\\\\\"})\\\",\\\"legend\\\":\\\"CPU\\\"}],\\\"unit\\\":\\\"percent\\\"},{\\\"name\\\":\\\"CPU Usage Trend\\\",\\\"type\\\":\\\"timeseries\\\",\\\"queries\\\":[{\\\"promql\\\":\\\"cpu_usage_active{cpu=\\\\\\\"cpu-total\\\\\\\",ident=~\\\\\\\"$ident\\\\\\\"}\\\",\\\"legend\\\":\\\"{{ident}}\\\"}],\\\"unit\\\":\\\"percent\\\"}]\"\n}\n```\n\n### Panel fields (only these are supported)\n\nEach panel requires 3 fields:\n\n```json\n{\"name\": \"Panel title\", \"type\": \"timeseries\", \"queries\": [{\"promql\": \"PromQL expression\", \"legend\": \"{{ident}}\"}]}\n```\n\n| Field | Description | Default |\n|------|------|------|\n| `name` | Panel title (required) | — |\n| `type` | Panel type (required, see table below) | — |\n| `queries` | Query list, each item `{promql, legend?, instant?}` | — |\n| `unit` | Unit | none |\n| `w` / `h` | Width/height (number of grid columns, total width 24) | auto by type |\n| `stack` | Whether to stack (timeseries only) | false |\n| `description` | Panel description | none |\n\nQuery field `instant`: for single-value panels such as stat/gauge/barGauge/pie, it is recommended to set `\"instant\": true` (instant query). tableNG always uses instant queries automatically.\n\n### Supported panel types (only these 8)\n\n| type | Description | Default size w×h |\n|------|------|--------------|\n| `timeseries` | Time-series line chart, the most common | 12×8 |\n| `stat` | Single-value statistic big number | 6×4 |\n| `gauge` | Gauge | 6×6 |\n| `barGauge` | Horizontal bar ranking | 8×8 |\n| `pie` | Pie chart | 6×6 |\n| `tableNG` | Table | 12×10 |\n| `text` | Text note (uses description as content) | 6×4 |\n| `row` | Grouping row (automatically full width) | 24×1 |\n\n### Common units (unit)\n\n`percent` | `bytesIEC` | `bitsIEC` | `bytesSecIEC` | `bitsSecIEC` | `seconds` | `milliseconds` | `reqps`\n\n### Automatic layout calculation\n\nPanels are arranged automatically from left to right and top to bottom, with same-type panels auto-aligned (e.g. 4 stat panels in one row); a `row` takes a whole row on its own as a grouping title. No need to specify coordinates manually.\n\n### Variables\n\n```json\n{\"name\": \"ident\", \"label\": \"Host\", \"definition\": \"label_values(cpu_usage_idle, ident)\"}\n```\n\n`label` and `multi` are optional (multi defaults to true). A later variable references an earlier variable in its definition to achieve cascading:\n\n```json\n[\n  {\"name\": \"ident\", \"definition\": \"label_values(cpu_usage_idle, ident)\"},\n  {\"name\": \"interface\", \"definition\": \"label_values(net_bytes_recv{ident=~\\\"$ident\\\"}, interface)\"}\n]\n```\n\n### Notes\n\n- For multi-select variables, use `=~` rather than `=` in PromQL, e.g. `ident=~\"$ident\"`\n- For counter-type metrics, use `rate(...[3m])` or `irate(...[5m])`\n- **Prefer copying validated PromQL expressions from the integrations templates**: `read_file(base=\"integrations/Linux\", path=\"dashboards/categraf-detail.json\")`, focusing on the `expr` inside targets\n- When it returns `Name duplicate`, just change the name and retry; do not call `list_dashboards`\n\n---\n\n## Step 3 (common to both paths): Output the result\n\n**Keep it short**. A one-sentence confirmation is enough, for example:\n\n> ✅ I have created the dashboard \"Linux Host Monitoring\" for you; see the card below for details.\n\n**Do not** restate fields such as the dashboard ID, business group, datasource, or panel list — the frontend will display them in a structured card. You may add a sentence or two of additional suggestions, but do not enumerate the fields the card already has.\n\n## Recommended panel design when there is no template (path B reference)\n\n> These topics basically all have templates in integrations; prefer path A. The following is only for reference when hand-building via path B.\n\n### Linux host monitoring\n\n**Variables:** `ident` (host), `interface` (network interface), `mountpoint` (mount point)\n\n| Area | Panel | Type | Core metric |\n|------|------|------|----------|\n| Overview | CPU Usage | stat | `avg(cpu_usage_active{cpu=\"cpu-total\"})` |\n| Overview | Memory Usage | stat | `avg(mem_used_percent)` |\n| Overview | Disk Usage (max) | stat | `max(disk_used_percent)` |\n| CPU | CPU Usage Trend | timeseries | `cpu_usage_active` + `cpu_usage_iowait` |\n| CPU | System Load | timeseries | `system_load1/5/15` |\n| Memory | Memory Usage Trend | timeseries | `mem_used_percent` |\n| Disk | Per-mount-point Usage | barGauge | `disk_used_percent` |\n| Network | Network Traffic | timeseries | `rate(net_bytes_recv/sent)` |\n\n### MySQL monitoring\nQPS/TPS, connection count, slow queries, Buffer Pool hit ratio, replication lag\n\n### Redis monitoring\nOPS, memory usage, connection count, hit ratio, keyspace\n\n### Kubernetes monitoring\n**Variables:** `cluster`, `namespace`, `pod`. Pod CPU/memory, node resources, deployment status, PV usage\n\n<!-- File: aiagent/skill/embedded/builtin/doc-qa/SKILL.md (Tokens: ~2997 | Category: anthropic-skill) -->\n---\nname: doc-qa\ndescription: >-\n  This skill should be used when the user asks \"how-to\" or factual questions about Nightingale (n9e) — UI/where-to-click, business groups/subscription rules/mute rules/edge mode, Token usage, notification pipeline, self-healing trigger conditions; OR about categraf input plugin field meanings, metric names, defaults, environment variables, config syntax (e.g. \"how to write [[instances]]\", \"unit of ping_average_response_ms\"); OR about the n9e / categraf / fe source code — this skill carries an embedded, searchable snapshot of the three repositories, so use it whenever the user asks \"can you read the source code / the code directory\", or asks how a feature is implemented, what a file or function does, or where something lives in the codebase. NOT for actively troubleshooting an alert or querying metrics.\nversion: 2.0.0\ntags:\n  - internal\nmax_iterations: 30\nbuiltin_tools:\n  - search_n9e_docs\n  - verify_answer\n  - list_code\n  - search_code\n  - read_code\n---\n\n# Nightingale (n9e) Platform Q&A Assistant\n\nAnswer questions based on two evidence channels: the official docs (search_n9e_docs) and the **embedded source code corpus** of three repos (list_code / search_code / read_code). **Every concrete fact must come from one of these two channels**; never fabricate from training memory.\n\n---\n\n## 🔴 Top Directive (overrides everything)\n\n**Every \"concrete fact\" in the answer must be findable verbatim in the contents returned by search_n9e_docs OR in code returned by search_code / read_code**. A \"concrete fact\" means:\n\n- Config item syntax (`[[instances]]` / `[heartbeat]` / `omit_hostname`)\n- Field names, API paths, Header names, environment variables, metric names, endpoints, default values, version numbers\n- Named constants (the English labels corresponding to Severity numbers)\n- Menu names / UI entry-point names\n\nIf it appears in neither channel, it is **forbidden** to write it into the answer. Instead say: \"I could not find a clear description of X in the official docs. Suggestions: 1) search manually at https://flashcat.cloud/docs/search/ ; 2) search issues at https://github.com/ccfos/nightingale/issues ; 3) ask in the community group.\"\n\nExtrapolation is allowed: concept explanations / feature introductions / workflows / why-it-is-done-this-way. Extrapolating concrete identifiers is strictly forbidden.\n\n---\n\n## Two evidence channels — when to use which\n\n| Channel | Tool | Use for |\n|---|---|---|\n| Docs | search_n9e_docs | Concepts, how-to, workflows, UI walkthroughs, integration config samples (source=`integration-config` is the most authoritative for toml examples) |\n| Code | search_code / read_code / list_code | **Verifying concrete identifiers**: metric names, config defaults, environment variables, API paths, auth headers, constants, UI menu names |\n\nThe code corpus contains filtered source snapshots of three repos (versions shown in each search_code result header):\n\n- **`categraf`** — the collection agent. Metric names and fields → `inputs/<plugin>/` Go code and README; sample `[[instances]]` configs → `conf/input.<plugin>/`; global config & env vars → `config/`\n- **`n9e`** — the server. API paths & auth headers → `center/router/`; constants (e.g. Severity labels) & table fields → `models/`; config defaults → `etc/` samples and config structs\n- **`fe`** — the web UI. Menu names and form labels (zh/en) → `src/locales/`; page behavior → `src/pages/`; UI-called API paths → `src/services/`\n\n**Routing rule of thumb**: \"指标名/单位/插件字段\" → categraf; \"接口/Header/告警引擎/常量\" → n9e; \"页面在哪/菜单叫什么/表单怎么填\" → fe (`src/locales/` first).\n\n**When lost**: `read_code(repo, \"TREE.md\")` gives a directory guide for that repo. Prefer `search_code` with a distinctive keyword first; use `list_code` to explore a directory you already located.\n\n**Mandatory code verification**: before finalizing, any concrete identifier that came from docs with quality below `high` — or that you are about to write from a weakly-supported chunk — must be confirmed with search_code (e.g. search the exact metric name in categraf; if it is not in the code, do not write it).\n\n**Degraded mode**: if code tools return \"code corpus not available in this build\", this build carries no corpus. Do NOT retry them; fall back to docs-only mode (the v1 behavior). This is normal, not an error.\n\n---\n\n## Failure Cases (zero tolerance, all from real testing)\n\n| Failure case | Wrong answer | Truth | Where the truth lives in code |\n|---|---|---|---|\n| Categraf config syntax | `[[inputs.net_response]]` (Telegraf style) | Categraf uses `[[instances]]` | categraf `conf/input.*/ *.toml` |\n| Categraf environment variable | fabricated `N9E_ADDR` | no such variable in the code | categraf `config/` |\n| Ping metric name | `categraf_ping_rtt` / `ping_result_milliseconds` | actually `ping_average_response_ms` | categraf `inputs/ping/` |\n| Severity label | `1: Critical` | actually `1: Emergency` | n9e `models/alert_rule.go` |\n| Role of the [http] section | \"expose /metrics to Prometheus PULL\" | actually a PUSH gateway, endpoint `/pushgateway` | categraf `conf/config.toml` |\n| Config defaults | `batch=2000 chan_size=10000` | actually `batch=1000 chan_size=1000000` | categraf `conf/config.toml` |\n| Web API authentication | `Authorization: Bearer <token>` | actually `X-User-Token: <token>` | n9e `center/router/` |\n\n---\n\n## About the item source marker\n\nEach item returned by `search_n9e_docs` has a `source` field:\n\n- **`integration-config`** (Title starts with `[integration-config]`): from the real config samples in `integrations/<C>/collect/*.toml` — **⭐ when writing a toml example you MUST copy verbatim from here; this is the most authoritative**\n- **`integration-doc`** (Title starts with `[integration-doc]`): the component description from `integrations/<C>/markdown/README.md`\n- **`n9e-docs`**: from the https://flashcat.cloud docs site\n\n---\n\n## 🔴 Refusal Directive (same priority as the Top Directive)\n\nThe `search_n9e_docs` return value carries a **`quality`** field (`empty` / `low` / `ok` / `high`) and a **`must_refuse`** flag. Decide according to the following rules:\n\n| quality   | Meaning                                | Required behavior |\n|-----------|----------------------------------------|----------|\n| `high`    | Strong recall (max_score >= 20)        | Answer normally based on contents |\n| `ok`      | Medium recall (10 <= max_score < 20)   | Answer normally based on contents |\n| `low`     | Weak recall (5 <= max_score < 10, only weak contents hits) | Try to confirm the concrete identifiers via search_code; if confirmed answer normally, otherwise append \"The information above is based on a weak recall; please re-verify against the official docs\" |\n| `empty`   | **No valid recall** (`must_refuse=true`)   | Switch to the code channel; only if **both channels** come up empty, reply per the refusal template below |\n\n**Refusal condition (v2)**: refuse only when the docs channel is `empty` **and** the code channel found nothing relevant either (or is unavailable). When `must_refuse=true` but the code corpus does answer the question (e.g. an exact metric name found in categraf inputs), you may answer from code — stating facts found in code is not fabrication. It is still forbidden to fill in anything found in **neither** channel. Refusal template:\n\n```markdown\nI could not find a clear description of **<the key noun from the user's question>** in the V9 official docs.\n\nTo avoid giving you incorrect information, I will not answer this directly. I suggest you:\n\n1. 📖 Go to the [V9 docs site](https://flashcat.cloud/docs/), switch versions and search manually\n2. 🐛 Search the [GitHub Issues](https://github.com/ccfos/nightingale/issues)\n\n<optional: based on a \"related but not directly relevant\" chunk that was recalled, give a vendor-neutral conceptual guide — without any n9e/categraf specific identifiers>\n```\n\nThere is no hard cap on how many searches you may run (the iteration budget is generous) — but stop a line of search after 2 fruitless keyword variations per channel and move on; do not loop on rephrasing.\n\n---\n\n## 🛡️ Mandatory workflow: you **MUST** call verify_answer before the Final Answer\n\nTo intercept the deterministic errors in the \"failure cases\" above, you **MUST** do a self-check before you intend to give the Final Answer:\n\n```\nAction: verify_answer(answer=\"<your complete markdown draft>\")\nObservation: {\"clean\": false/true, \"must_revise\": true/false, \"hits\": [...], \"next_action\": \"...\"}\n```\n\nDecision rules (non-negotiable):\n\n| Return | What you must do |\n|---|---|\n| `clean: true` | You may give the Final Answer |\n| `must_revise: true` (HIGH hit) | **Final Answer forbidden**. Re-verify the flagged facts per `hits[*].retry_hint` — search_n9e_docs and/or search_code — rewrite the draft, call verify_answer **again** to validate, until clean=true or all hits are medium |\n| `clean: false, must_revise: false` (only medium/low hits) | Final Answer allowed, but it is recommended to fine-tune per `hits[*].annotate` |\n\n**Why you must call it**: the HIGH-hit rules are all strings that historically failed in real testing (fabricated environment variables, Telegraf-style syntax, etc.). Your training memory is very likely to treat these as correct; calling it avoids giving the user a production incident.\n\n**Do not skip**: even if you \"feel\" the answer is fine, still call it. The rules cover exactly the points where you historically were most likely to fail.\n\n---\n\n## Workflow\n\n1. **Break out keywords** (2~4 words, space-separated, using the product's official terminology)\n   - Try synonyms too: \"lost connection\" → \"offline / heartbeat / heartbeat\"; \"alert\" → \"alert / rule\"\n2. **Call search_n9e_docs(keywords, top_n=3)** for the conceptual/how-to part\n3. **Route concrete identifiers to the code channel**: pick the repo per the routing table, search_code with the most distinctive keyword, read_code the surrounding lines to confirm the exact name/default/unit\n4. **Synthesize** the docs + code evidence, prioritizing the highest-scoring doc hits + source=`integration-config` hits + code-confirmed identifiers\n5. **verify_answer**, then Final Answer with markdown reference links (docs only)\n\n---\n\n## The index only contains V9 docs\n\nsearch_n9e_docs has already filtered out V5/V6/V7/V8. When the user explicitly asks about an older version, tell them directly \"This assistant only covers V9; please go to https://flashcat.cloud/docs/ and switch versions manually to query.\" It is **forbidden** to give cross-version fields/Headers/APIs from training memory. The code corpus is a snapshot matching this build's release; do not use it to answer older-version questions either.\n\n---\n\n## Output format\n\n```markdown\n<2-5 paragraphs of a digested, organized answer; lists/code blocks/tables are allowed>\n\n---\n\n**References**\n- [<title 1>](<permalink 1>)\n- [<title 2>](<permalink 2>)\n```\n\nKey points:\n1. Do not pile up the raw contents; digest and organize. But every \"concrete fact\" must come verbatim from the docs contents or from code\n2. If only 1 doc hit, list only 1; do not pad; if the answer is code-verified only, omit the References section entirely\n3. When citing an `integration-config` item, the permalink is a github.com path; list it anyway\n\n### 🔴 Code references never surface to the user\n\nThe code channel is **internal evidence only**. In the Final Answer it is **forbidden** to include source file paths, line numbers, code snippets quoted from the corpus, repo/commit identifiers, or any \"as seen in the code\" phrasing. State code-verified facts as plain conclusions, e.g. \"the ping plugin's response-time metric is `ping_average_response_ms`, in milliseconds\" — NOT \"see inputs/ping/ping.go:123\". References list docs links only, never code links.\n\n**Sole exception**: the user explicitly asks where/how something is implemented in the source — then you may name files and show snippets.\n\n---\n\n## Boundaries\n\n- Only answer platform-usage questions; for anything outside the n9e scope (e.g. \"how to deploy Prometheus\" when there is no doc) just say \"out of scope\"\n- Do not return code-change suggestions\n- Do not perform any actions. If the user says \"then go ahead and create it for me\" → \"I only handle Q&A; to take action, open the XX page or switch to a creation-type skill\"\n\n<!-- File: aiagent/skill/embedded/builtin/explorer-query/SKILL.md (Tokens: ~1130 | Category: anthropic-skill) -->\n---\nname: explorer-query\ndescription: >-\n  Use when the user wants to generate, repair, validate, or prepare a query for\n  the current Nightingale Metrics Explorer or Log Explorer panel. Supports\n  PromQL for Prometheus-compatible datasources, SQL for MySQL and Doris, and\n  Doris log queries. Verify the statement against the selected datasource before\n  returning it. Use query-datasource when the user only wants to view data rather\n  than prepare a statement for an editor.\ntags:\n  - internal\nbuiltin_tools:\n  - list_datasources\n  - list_metrics\n  - get_metric_labels\n  - query_prometheus\n  - list_databases\n  - list_tables\n  - describe_table\n  - query_timeseries\n  - query_log\nmax_iterations: 12\n---\n\n# Nightingale Explorer Query Assistant\n\nGenerate a single query that the user can put into the current Metrics Explorer\nor Log Explorer editor. Use Nightingale's built-in query tools to verify every\ncandidate against the selected datasource before delivering it.\n\n## Scope\n\n- Metrics Explorer: PromQL on Prometheus-compatible datasources; SQL on MySQL.\n- Log Explorer: Doris SQL or Doris log-search queries.\n- This skill prepares a statement for an editor. If the user asks for an\n  analysis or the actual returned data, use the query tools to answer that\n  request instead of claiming to have changed the page.\n\n## Rules\n\n1. Preserve the current datasource, editor mode, expression, and time window\n   whenever they are available in conversation context. `datasource_id`\n   and `datasource_type` supplied by the page are authoritative. Do not silently\n   switch to a different datasource.\n2. When the datasource is not known, call `list_datasources` and ask the user\n   to choose if more than one datasource is plausible. Do not guess an ID.\n3. Run read-only checks only. Never generate a write statement such as INSERT,\n   UPDATE, DELETE, ALTER, DROP, CREATE, GRANT, or REVOKE.\n4. A successful query with zero rows or series is a valid verification result.\n   Say that it executed successfully but returned no data; do not loosen filters\n   or widen the time range simply to find data.\n5. When the current request offers `page_action`, use it after verification to\n   ask the page to fill or run the chosen statement. Select only an action\n   declared by the page and pass arguments that match its schema. Calling it\n   ends this turn. Never claim that the browser has executed the action or that\n   it returned a result.\n6. When `page_action` is unavailable, return the verified statement for the\n   caller or UI to apply. Never claim that you changed the current page.\n\n## Prometheus / PromQL workflow\n\n1. Confirm the selected datasource is Prometheus-compatible. If needed, use\n   `list_datasources` with `plugin_type=prometheus`.\n2. Use `list_metrics` to find candidate metric names, then use\n   `get_metric_labels` for each candidate needed by the expression. Do not\n   invent metric names or label values.\n3. Build the PromQL expression. If the user gave a time window, use an\n   equivalent `time_range` when calling `query_prometheus`; otherwise use the\n   panel's current range or the tool default.\n4. Call `query_prometheus` with the exact expression you plan to deliver.\n   Use `query_type=range` for a requested interval and `instant` only when an\n   instant value is appropriate.\n5. Return the exact verified expression in a fenced `promql` block, followed by\n   one concise sentence describing the validation result.\n\n## MySQL / Doris SQL workflow\n\n1. Confirm the selected datasource and its type. Use `list_databases`,\n   `list_tables`, and `describe_table` to verify database, table, column, and\n   time-field names before writing SQL.\n2. Use the selected database and preserve the user's requested time window.\n   Prefer `$from` and `$to` in time predicates when the panel provides a\n   relative range. Keep an explicit time filter when a verified time column is\n   available.\n3. Execute the candidate through `query_timeseries` when it has a valid value\n   and time column; otherwise use `query_log` for a bounded read-only validation.\n   Use the exact same SQL that you will return.\n4. Return the exact verified statement in a fenced `sql` block. State whether\n   it returned data, returned no rows, or could not be verified.\n\n## Doris log-search workflow\n\n1. Preserve the selected database, table, syntax, and time field. Verify the\n   table and fields with `list_tables` and `describe_table` when they are not\n   already established by the page context.\n2. Use `query_log` to validate the final SQL or log query with a bounded result\n   limit and the selected time range.\n3. Return one verified statement only. Do not claim it was applied to the page.\n\n## Failure handling\n\n- If the selected datasource, schema, metric, or label cannot be verified, say\n  what check failed and do not present the statement as verified.\n- If a datasource rejects a query, keep the error evidence and repair only the\n  part proven invalid. Do not replace the user's intended datasource, database,\n  table, or time range without asking.\n\n<!-- File: aiagent/skill/embedded/builtin/generate-message-template/SKILL.md (Tokens: ~5275 | Category: anthropic-skill) -->\n---\nname: generate-message-template\ndescription: Generate or modify Nightingale (n9e) alert notification message templates. Use when the user asks to write a notification template, change the message format, add hostname/recovery value/severity, or create DingTalk/Feishu/Lark/email/SMS/voice templates.\ntags:\n  - internal\n---\n\n# Nightingale (n9e) Notification Message Template Generation\n\nNightingale message templates use Go `text/template` / `html/template` syntax (email uses `text/template`, others use `html/template` and then escape). Users edit them on the \"Notification Management → Message Templates\" page; once saved they are referenced by notification rules, and when an alert fires the variables are substituted with rendered data and sent to each channel.\n\nThis skill focuses on **writing/modifying the template fragment itself**, and does not cover creating notification rules or channel configuration.\n\n---\n\n## Render Context\n\nThe `renderData` passed in when the backend executes:\n\n| key | type | description |\n|---|---|---|\n| `.events` | `[]*AlertCurEvent` | The list of alert events in the current batch, usually just 1 |\n| `.domain` | `string` | The n9e site URL, used to build redirect links |\n\n**Automatically injected shorthands** (no need to write by hand):\n\n```gotemplate\n{{ $events := .events }}\n{{ $event := index $events 0 }}\n{{ $labels := $event.TagsMap }}\n{{ $value := $event.TriggerValue }}\n```\n\nSo inside the template you can directly use `$event.xxx`, `$labels.agent_hostname`, and `$value`.\n\n---\n\n## Available `$event` Fields (`*AlertCurEvent`)\n\n### Common Fields\n| field | type | description |\n|---|---|---|\n| `.Id` | int64 | Alert event ID (used to build the redirect link) |\n| `.RuleId` | int64 | Alert rule ID |\n| `.RuleName` | string | Rule name |\n| `.RuleNote` | string | Rule note |\n| `.Severity` | int | Alert severity: 1=Critical, 2=Warning, 3=Info |\n| `.PromQl` | string | Alert trigger expression |\n| `.RuleAlgo` | string | Rule algorithm type |\n| `.TriggerTime` | int64 | Trigger time, unix seconds |\n| `.TriggerValue` | string | Metric value at trigger time (already a string) |\n| `.FirstTriggerTime` | int64 | First abnormal time (for consecutive alerts) |\n| `.LastEvalTime` | int64 | Most recent evaluation time, **used as the recovery time on recovery** |\n| `.IsRecovered` | bool | Whether it has recovered |\n| `.NotifyCurNumber` | int | Which notification number this send is |\n| `.TargetIdent` | string | Monitored object (usually agent_hostname) |\n| `.TargetNote` | string | Object note |\n| `.GroupId` / `.GroupName` | int64 / string | Business group |\n| `.Cluster` | string | Data source cluster name |\n| `.Cate` | string | Data source type (prometheus / host / mysql / ...) |\n| `.RunbookUrl` | string | Runbook link |\n\n### Labels / Annotations / Trigger Value Objects\n| field | type | description |\n|---|---|---|\n| `.TagsMap` | `map[string]string` | Event labels, preferred: `{{$labels.agent_hostname}}` |\n| `.TagsJSON` | `[]string` | A string array like `[\"k=v\", ...]` (commonly used in early templates) |\n| `.AnnotationsJSON` | `map[string]string` | Annotations, commonly `.AnnotationsJSON.recovery_value` |\n| `.TriggerValuesJson.ValuesWithUnit` | map | Trigger values with units (multi-value scenarios) |\n\n### Notification Objects\n| field | type | description |\n|---|---|---|\n| `.NotifyUsersObj` | `[]*User` | The user objects to notify this time (with Username/Nickname/Phone/Email) |\n| `.NotifyGroupsObj` | `[]*UserGroup` | Associated user groups |\n\n> User objects are commonly used in DingTalk/Feishu/Lark templates to build @-mentions; see the `ats` family of helpers below.\n\n---\n\n## Available Helper Functions (`tplx.TemplateFuncMap`)\n\n### Time\n- `timeformat <unix>` — defaults to `\"2006-01-02 15:04:05\"`; pass a second argument to override: `{{timeformat $event.TriggerTime \"15:04:05\"}}`\n- `timestamp` — current time string\n- `now.Unix` — current unix seconds (`now` is a Go template builtin)\n- `humanizeDuration <sec>` — `\"3m15s\"`\n- `humanizeDurationInterface <interface>` — same as above but accepts an interface\n- `toTime <unix>` — returns a `time.Time`, can be chained\n- `parseDuration <\"5m\">` — returns a `time.Duration`\n\n### Numbers / Formatting\n- `formatDecimal <v> <n>` — keep n decimal places (`{{formatDecimal $event.TriggerValue 2}}`)\n- `humanize <v>` — K/M/G units (base 1000, SI)\n- `humanize1024 <v>` — Ki/Mi/Gi (base 1024)\n- `humanizePercentage <v>` / `humanizePercentageH <v>` — percentage\n- `add / sub / mul / div <a> <b>` — arithmetic\n- `printf \"%.2f\" <v>` — Go `fmt.Sprintf`\n\n### Strings\n- `toUpper / toLower / title` — case conversion\n- `contains <s> <sub>` — substring\n- `match <regex> <s>` — regex match (bool)\n- `reReplaceAll <regex> <repl> <s>` — regex replace\n- `split <s> <sep>` / `join <slice> <sep>`\n- `stripPort <host:port>` / `stripDomain <host.domain>`\n- `b64enc` / `b64dec` — base64\n\n### Links / Escaping\n- `escape <s>` — URL path escape\n- `unescaped <s>` — output raw HTML (no escaping)\n- `safeHtml <s>` — same as above\n- `urlconvert <s>`\n\n### Labels / Trigger Values\n- `label <key> <labelMap>` / `value <key> <m>` / `strvalue <v>`\n- `first <slice>` — take the first\n- `tagsMapToStr <map>` — join labels into `k=v,k=v`\n- `sortByLabel <items> <key>` — sort by label\n\n### @-mention (DingTalk/Feishu/Lark only)\n- `ats <users> <platform>` — generate an at fragment\n- `batchContactsAts <contacts> <platform>` — batch at\n- `batchContactsAtsInFeishuEmail <contacts>` / `batchContactsAtsInFeishuId <contacts>` — Feishu only\n- `batchContactsJoinComma <contacts>` / `batchContactsJsonMarshal <contacts>`\n- `mappingAndJoin <map> <kvSep> <itemSep>`\n\n### Others\n- `jsonMarshal <v>` — serialize to a JSON string\n- `mapDifference <a> <b>` — set difference\n\n---\n\n## Syntax Differences Across Channels (`notify_channel_ident`)\n\nA message template is bound to a specific channel; the channel ident determines the text engine and escaping behavior:\n\n| Ident | Engine | Description |\n|---|---|---|\n| `email` | `text/template` | No re-escaping; HTML templates can write tags directly |\n| `slackwebhook` / `slackbot` | `html/template` | After rendering, `\"` / `\\n` are escaped and wrapped into `template.HTML`; usually written in Markdown |\n| Others (`dingtalk` / `feishu` / `feishucard` / `larkcard` / `wecom` / `tx-sms` / `ali-voice` …) | `html/template` | After rendering, `\"` `\\n` `\\r` are JSON-string-escaped, suitable for stuffing directly into a webhook payload |\n\n> Important: `html/template` automatically escapes `<` `>` `&`. **Where you don't want escaping, use `{{unescaped \"…\"}}` or `{{safeHtml .X}}`**, otherwise DingTalk will render a literal `&lt;`.\n\n---\n\n## Template Writing Workflow\n\n1. **Confirm the channel ident**: when the user says \"DingTalk\" it is `dingtalk`, \"Feishu card\" is `feishucard`, \"email\" is `email`. Different channels differ significantly in style.\n2. **Decide whether to render the recovery state**: by default you should split into two branches on `$event.IsRecovered`. Only SMS/voice may omit it.\n3. **Pick fields**:\n   - For labels, prefer `$labels.<key>` rather than `$event.TagsJSON`.\n   - For the trigger value use `$event.TriggerValue`; for two decimal places use `{{formatDecimal $event.TriggerValue 2}}`.\n   - Always pass timestamps through `timeformat`.\n   - Build the redirect link as `{{.domain}}/share/alert-his-events/{{$event.Id}}`.\n4. **Severity display**:\n   - Number: `{{$event.Severity}}`\n   - Worded: `{{if eq $event.Severity 1}}Critical{{else if eq $event.Severity 2}}Warning{{else}}Info{{end}}`\n   - English: `Critical / Warning / Info`\n5. **@-mention (DingTalk/Feishu)**:\n   - DingTalk `@everyone`: add a line `@all` at the end of the template (DingTalk matches by splitting on spaces). To at an exact phone number: `{{range $event.NotifyUsersObj}}@{{.Phone}} {{end}}`.\n   - Feishu card: use `{{batchContactsAtsInFeishuEmail $event.NotifyUsersObj}}` to build `<at email=...></at>` fragments.\n6. **Output**: wrap the template body in a markdown ``` ```gotemplate ``` code block, and at the end provide a **variable/function explanation** with each non-trivial variable/function on its own line.\n\n---\n\n## Output Format\n\nWhen replying to the user:\n\n1. One or two sentences of introduction explaining what the template is for and which channel it fits.\n2. ```gotemplate\n   <template content>\n   ```\n3. `**Variable explanation**`: list the non-trivial fields and functions used in the template (such as `$event.TargetIdent`, `formatDecimal`, `timeformat`), one per line.\n4. If the user's request is ambiguous (for example \"add the hostname\" — is it `target_ident` or some tag), **state directly in the introduction what assumption you made**, rather than asking back.\n\nThe language follows the user's input (use Chinese if the input is Chinese).\n\n---\n\n## Built-in Reference Templates (Starting Points for Customization)\n\n### DingTalk markdown (full version)\n\n```gotemplate\n#### {{if $event.IsRecovered}}<font color=\"#008800\">💚{{$event.RuleName}}</font>{{else}}<font color=\"#FF0000\">💔{{$event.RuleName}}</font>{{end}}\n---\n{{$duration := sub now.Unix $event.FirstTriggerTime}}{{if $event.IsRecovered}}{{$duration = sub $event.LastEvalTime $event.FirstTriggerTime}}{{end}}\n- **Alert Level**: S{{$event.Severity}}\n{{- if $event.RuleNote}}\n- **Rule Note**: {{$event.RuleNote}}\n{{- end}}\n{{- if $event.TargetIdent}}\n- **Target**: {{$event.TargetIdent}}\n{{- end}}\n{{- if not $event.IsRecovered}}\n- **Trigger Value**: {{$event.TriggerValue}}\n- **Trigger Time**: {{timeformat $event.TriggerTime}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n{{- else}}\n- **Recovery Time**: {{timeformat $event.LastEvalTime}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n{{- end}}\n- **Event Tags**:\n{{- range $k, $v := $labels}}\n{{- if ne $k \"rulename\"}}\n    - {{$k}}: {{$v}}\n{{- end}}\n{{- end}}\n[Event Detail]({{.domain}}/share/alert-his-events/{{$event.Id}}) | [Mute 1h]({{.domain}}/alert-mutes/add?__event_id={{$event.Id}})\n```\n\n### Feishu card (concise version)\n\n```gotemplate\n{{- if $event.IsRecovered}}\n**Severity/State:** S{{$event.Severity}} Recovered\n**Alert Name:** {{$event.RuleName}}\n**Event Tags:** {{$event.TagsJSON}}\n**Recovery Time:** {{timeformat $event.LastEvalTime}}\n{{- else}}\n**Severity/State:** S{{$event.Severity}} Triggered\n**Alert Name:** {{$event.RuleName}}\n**Event Tags:** {{$event.TagsJSON}}\n**Trigger Time:** {{timeformat $event.TriggerTime}}\n**Trigger Value:** {{$event.TriggerValue}}\n{{- if $event.RuleNote}}\n**Alert Description:** {{$event.RuleNote}}\n{{- end}}\n{{- end}}\n```\n\n### SMS / Voice (minimal)\n\n```gotemplate\nSeverity/State: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}} Rule: {{$event.RuleName}} Target: {{$event.TargetIdent}}\n```\n\n---\n\n## Typical Customization Scenarios\n\n### 1) \"Add the hostname to the DingTalk template\"\n\n```gotemplate\n- **Host**: {{$event.TargetIdent}}\n{{- if $labels.ip}}\n- **IP**: {{$labels.ip}}\n{{- end}}\n```\n\n> Note: `target_ident` is usually the hostname; if you need the IP, prefer the labels `ip`, `instance`, `host`.\n\n### 2) \"Keep trigger_value to two decimal places\"\n\n```gotemplate\n- **Trigger Value**: {{formatDecimal $event.TriggerValue 2}}\n```\n\n> `TriggerValue` is itself a string; `formatDecimal` first converts it to a float and then formats it, and returns non-numeric values unchanged.\n\n### 3) \"@-mention the alert recipients at the end of the DingTalk template\"\n\n```gotemplate\n...template body...\n\n{{- range $event.NotifyUsersObj}}@{{.Phone}} {{end}}\n```\n\n> DingTalk identifies @-mentioned users by \"space + phone number\". Or uniformly use `@all`.\n\n### 4) \"Show the recovery value on recovery\"\n\n```gotemplate\n{{- if $event.IsRecovered}}\n{{- if $event.AnnotationsJSON.recovery_value}}\n- **Recovery Value**: {{formatDecimal $event.AnnotationsJSON.recovery_value 4}}\n{{- end}}\n- **Recovery Time**: {{timeformat $event.LastEvalTime}}\n{{- end}}\n```\n\n> The recovery value is written into `AnnotationsJSON.recovery_value` by the alert engine on recovery, and **only exists when there is a recovery value**, so guard it with `if`.\n\n### 5) \"Use Chinese for the alert severity\"\n\n```gotemplate\n- **Severity**: {{if eq $event.Severity 1}}Critical{{else if eq $event.Severity 2}}Warning{{else}}Info{{end}}\n```\n\n### 6) \"Only send alerts for production-environment machines\"\n\nThe template itself does not do filtering — filtering should go into the `attributes` / `label_keys` of the **notification rule**. The template is only responsible for display. If the user asks about this, point it out.\n\n### 7) \"Color by state/severity\"\n\nDingTalk/email use `<font color>`, Feishu cards use the `template` field to fill in a color keyword:\n\n```gotemplate\n{{/* DingTalk markdown: prepend emoji + color to the title */}}\n#### {{if $event.IsRecovered}}<font color=\"#008800\">✅ {{$event.RuleName}}</font>{{else}}<font color=\"#FF0000\">🚨 {{$event.RuleName}}</font>{{end}}\n\n{{/* The template field for Feishu feishucard (determines the card header color block) */}}\n{{if $event.IsRecovered}}turquoise{{else}}{{if eq $event.Severity 1}}red{{else if eq $event.Severity 2}}orange{{else}}grey{{end}}{{end}}\n```\n\n> Feishu cards only recognize enumerated colors: `red / orange / yellow / green / turquoise / blue / indigo / purple / carmine / grey`; hex color codes are ignored.\n\n### 8) \"Show the alert duration\"\n\n```gotemplate\n{{$duration := sub now.Unix $event.FirstTriggerTime}}\n{{- if $event.IsRecovered}}{{$duration = sub $event.LastEvalTime $event.FirstTriggerTime}}{{end}}\n- **Duration**: {{humanizeDurationInterface $duration}}\n```\n\n> Triggered state: `current time - FirstTriggerTime`; recovered state: `LastEvalTime - FirstTriggerTime`. `humanizeDurationInterface` outputs a human-readable form like `1h3m5s`.\n\n### 9) \"Embed a tag value in a URL, avoiding spaces/special characters breaking the link\"\n\n```gotemplate\n[View Dashboard]({{.domain}}/dashboards/123?ident={{urlquery $event.TargetIdent}}&host={{urlquery (index $labels \"host\")}})\n[Mute 1h]({{.domain}}/alert-mutes/add?__event_id={{$event.Id}})\n```\n\n> Stuffing `{{$event.TargetIdent}}` directly into a URL will, when it contains spaces, Chinese, or `&`, get double-escaped on the IM side (typical symptom: `&` becomes `&amp;` and the link won't open). **Wrap every variable that goes into a URL query in `urlquery`.**\n\n### 10) \"Tag keys containing hyphens/dots/Chinese — use index to fetch\"\n\n```gotemplate\n- **Application**: {{index $labels \"app-name\"}}\n- **K8s Cluster**: {{index $labels \"k8s.io/cluster\"}}\n- **Business Dashboard**: {{index $event.AnnotationsJSON \"dashboard_url\"}}\n```\n\n> `$labels.app-name` will be parsed by Go template as \"`app` minus `name`\" and is bound to fail. As long as a key **is not pure alphanumerics plus underscore**, always fetch it with `index`; the same goes for Annotations.\n\n### 11) \"Fallback for abnormal values (+Inf / NaN)\"\n\n```gotemplate\n- **Trigger Value**: {{if or (eq $event.TriggerValue \"+Inf\") (eq $event.TriggerValue \"NaN\")}}N/A{{else}}{{formatDecimal $event.TriggerValue 2}}{{end}}\n```\n\n> A `/0` in PromQL returns `+Inf`, and an aggregation over missing data returns `NaN`. Rendered directly into Feishu/DingTalk they appear as literal `+Inf`, which looks like a bug.\n\n### 12) \"In Edge mode the event Id=0 — degrade the redirect link\"\n\n```gotemplate\n{{if gt $event.Id 0}}\n[Event Detail]({{.domain}}/share/alert-his-events/{{$event.Id}})\n{{else}}\n(Edge-mode event — view details on the central server)\n{{end}}\n```\n\n> Known issue: in Edge mode the event Id is written asynchronously, so at render time it is 0, and the redirect link would land on `/alert-his-events/0` (404). Add a `gt $event.Id 0` guard at the template layer.\n\n### 13) \"Merge and display multiple alerts\"\n\n```gotemplate\n{{range $i, $e := .events}}\n{{- if $i}}\n---\n{{end -}}\n- Rule: {{$e.RuleName}}\n- Target: {{$e.TargetIdent}}\n- Trigger Value: {{$e.TriggerValue}}\n- Time: {{timeformat $e.TriggerTime}}\n{{end}}\n```\n\n> In the default scenario one notification has exactly one event, and the template renders with `$event = index .events 0`. When one notification aggregates multiple events (subscription aggregation, batch send) you must `range .events` to expand them.\n\n---\n\n## Key Considerations\n\n1. **`html/template` HTML-escapes**. Tags such as `<font>` and `<at>` in DingTalk/Feishu/Lark **must be wrapped in `unescaped` or placed in content where Go template recognizes them itself**. Email uses `text/template` and is not subject to this.\n2. **`TriggerValue` is a string**: to compare magnitudes directly use `parseDuration`/custom logic; for normal display-only purposes leave it as is.\n3. **Do not manually write headers like `{{$events := .events}}`** — the system injects them automatically.\n4. **Don't drop the recovery branch**: rules with `NotifyRecovered=1` reuse the same template to send the recovery message.\n5. **Hyphens/dots in tag keys**: `$labels.agent_hostname` works, but `$labels.app-name` does not — use `index $labels \"app-name\"`.\n6. **All time fields are unix seconds** (`int64`); don't use `{{$event.TriggerTime}}` directly as text — use `timeformat` or `toTime`.\n7. **Merging multiple alerts**: `.events` may have multiple entries; the default DingTalk/Feishu templates only render entry 0 (`$event`); if the user wants a batch display, use `{{range .events}}`.\n8. **Use `.domain` for links**: don't hardcode `http://localhost:17000`, otherwise switching environments breaks it.\n\n---\n\n## Common Mistakes\n\n- ❌ `{{$event.TriggerValue | printf \"%.2f\"}}` — for `printf`, the first argument is the format, and `TriggerValue` is a string.\n- ✅ `{{formatDecimal $event.TriggerValue 2}}`\n\n- ❌ Displaying `{{$event.TriggerTime}}` directly — it outputs unix seconds.\n- ✅ `{{timeformat $event.TriggerTime}}`\n\n- ❌ Writing `<font color=\"red\">` in DingTalk without escaping — `html/template` will escape it into `&lt;font&gt;`.\n- ✅ Put it inside a conditional branch (Go template's `{{if}}` branch literals do not get the inner tags HTML-escaped), or wrap the whole thing in `{{unescaped \"…\"}}`. Nightingale's official DingTalk template writes `<font>` directly and it works, because the outer area is a plain-text section (not inside an attribute value) — just follow the official sample.\n\n- ❌ Forgetting to split the recovery branch — the trigger time in a recovery notification is misleading.\n- ✅ Wrap all dynamic content first with `{{if $event.IsRecovered}}…{{else}}…{{end}}`.\n\n- ❌ `{{if .IsRecoverd}}` — a missing `e` in the spelling. Go template **silently takes the else branch** for a nonexistent field (without erroring), so the consequence is that the recovery notification sends the triggered-state copy, and it is very hard to track down.\n- ✅ `{{if $event.IsRecovered}}`. Similar spelling traps: `Resoverd`, `Recoverd`, `recovered` (lowercase) all fail.\n\n- ❌ `{{if lt $value 10}}` or `{{if gt $event.TriggerValue 80}}` — `TriggerValue` is a string; comparing with `lt`/`gt` against a number will report `incompatible types`, or be interpreted as a string lexicographic comparison and yield a wrong result. The current version **has no built-in `toFloat` helper**. Put numeric threshold checks back into the alert rule's condition expression, and keep the template layer for display only.\n\n- ❌ Building a URL with `?ident={{$event.TargetIdent}}` directly — a TargetIdent containing spaces/Chinese/`&` will break the link.\n- ✅ `?ident={{urlquery $event.TargetIdent}}`, **pass every variable that goes into a URL query through `urlquery`**.\n\n- ❌ `{{$labels.app-name}}` or `{{$labels.k8s.io/cluster}}` — keys containing hyphens/dots/slashes fail to parse.\n- ✅ `{{index $labels \"app-name\"}}`, `{{index $labels \"k8s.io/cluster\"}}`.\n\n<!-- File: aiagent/skill/embedded/builtin/host-health-diagnose/SKILL.md (Tokens: ~2392 | Category: anthropic-skill) -->\n---\nname: host-health-diagnose\ndescription: >-\n  Help the user determine whether a machine is truly down / the agent is hung / the network is flapping / it is under maintenance. Triggers this skill when the user asks \"why is this machine unreachable\", \"is the host-unreachable alert a false positive\", \"is categraf stuck\", \"the heartbeat stopped but I can still ping it\", etc. Core stance: **an unreachable agent != a down host**. Concluding \"down\" just because target_up==0 / BeatTime stopped is a common source of false positives.\ntags:\n  - internal\n---\n\n# Host Health Comprehensive Assessment (host-health-diagnose)\n\n## Scope\n\n**Enter this skill**:\n- \"Why is the machine xxx unreachable / offline / heartbeat stopped\"\n- \"Is the host-unreachable alert a false positive\"\n- \"Is the agent stuck / hung\"\n- \"Is this host really down\"\n- \"This machine is pingable but Nightingale shows it as down\"\n\n**Do NOT enter this skill**:\n- Change alert rule thresholds, add/adjust host-unreachable alerts → `creation` / `troubleshooting`\n- Just \"take a look at the machine list / details\" → `resource_query`\n- \"Why did this alert fire\" (not host-unreachable type) → `troubleshooting`\n\n## One-Sentence Principle\n\n**A diagnosis that concludes from a single layer of evidence is almost always wrong.**\n\nWhen n9e shows a machine as unreachable, it may stem from:\n- The agent really died (process panic / OOM)\n- The agent is still alive but cannot send heartbeats (network partition / DNS / broken proxy / cannot write to Redis)\n- Redis write latency on the server side\n- The user manually stopped the agent for maintenance but did not add a mute\n- The host is really down\n\nThe \"recommended action\" for these five conclusions is completely different. So **gather evidence first, then classify, and give the action last**.\n\n## Three-Layer Evidence Collection (in order, call in parallel)\n\n### Layer 1: Real-time heartbeat and metadata — `get_target_realtime_status(ident)`\n\nReads Redis to get:\n- `beat_time` / `lag_seconds` — seconds since the most recent heartbeat\n- `status` — `active`(<60s) / `lagging`(60–180s) / `stale`(≥180s) / `stale_no_heartbeat`(no such key in Redis)\n- `offset` — clock skew between agent and server (an abnormally large value usually means the agent clock has drifted / NTP is broken)\n- `cpu_util` / `mem_util` — resource usage from the most recent report\n- `agent_version` / `remote_addr` / `extend_info`\n\n**Key judgments**:\n- `status=stale_no_heartbeat` while `update_at_db` is several days ago — the agent has never connected, a deployment problem.\n- `status=stale_no_heartbeat` while `update_at_db` is a few minutes ago — the Redis layer is down or has been cleared.\n- `status=stale` while the last reported `cpu_util` / `mem_util` was low — possibly a graceful exit (shutdown / kernel reclaim after OOM), leaning toward \"truly down\".\n- `status=stale` while the last reported `cpu_util` was high (>90%) — leaning toward \"agent hung / host hang\".\n- absolute value of `offset` > 30s — not necessarily down, but the agent clock has a problem and will throw off alert evaluation, so flag it separately.\n\n### Layer 2: Recent 10m metric window — `query_host_metrics_window(ident)`\n\nBy default queries `cpu_usage_active` / `mem_available_percent` / `system_load1` / `net_bytes_recv` / `net_bytes_sent`. Returns `samples_count / first_ts / last_ts / min / max / avg / last` for each metric.\n\n**Key judgments**:\n- `samples_count=0` and `series=0` — the Prom layer has no data at all. If it stops at the same moment as BeatTime → heartbeat and metrics share the same source (categraf), and stopping together means the agent really is not sending; stopping at different moments means the data flow was cut by different stages.\n- `last_ts` far earlier than `now` — metrics have also stopped; compare with BeatTime to see whether the stop times match.\n- `samples_count > 0` but BeatTime is already stale — **a typical agent hang or Redis write latency**: the metric stream is still flowing (the categraf main process is sending), but the heartbeat cannot be written to Redis.\n- `cpu_usage_active.last` > 95% + `system_load1.last` far above cpu_num — a strong signal of host hang.\n- `net_bytes_recv.last + net_bytes_sent.last` close to 0 — the NIC is silent; combined with a stopped BeatTime this points to \"truly down\" or \"network cut\".\n\n⚠️ By default, if `datasource_id` is not passed, the chat-level datasource is used as a fallback; when the user has not selected a datasource in the frontend, first `list_datasources` and pick a Prom datasource.\n\n### Layer 3: Neighbors in the same business group — `list_neighbor_targets(ident)`\n\nPulls other machines in the same **business group** (`target.GroupIds`), returning `items` + `summary{total, active, lagging, stale}`.\n\n**Key judgments**:\n- summary almost entirely active — an individual fault; the problem is on this one machine.\n- summary mostly stale — a **cluster-level** event: switch, data center, underlying cloud, Redis, or the server heartbeat channel. In this case the question \"why is this machine unreachable\" is itself the wrong question; you should investigate the cluster event.\n- summary half active half stale — possibly a network partition or availability-zone failure; check whether the stale machines are on the same subnet / the same rack.\n\n## Correlation Signals (call as needed)\n\n- `list_alert_mutes` / `get_alert_mute_detail` — matched a mute rule → the \"under maintenance\" branch; not a false positive, and tell the user when the mute window ends.\n- `search_active_alerts` — check whether there are other companion alerts (cpu/mem/disk alerts for the same ident firing at the same time) → leans toward \"the host is really in trouble\".\n- `get_target_detail` — check `update_at` (the most recent time a heartbeat was persisted at the DB layer). If `update_at` is newer than Redis's `beat_time`, the Redis layer has been cleared; the reverse means the DB layer is stuck.\n\n## Three-State Decision Table\n\n| Signal combination | Conclusion | Confidence |\n|---|---|---|\n| BeatTime stale + metrics also stopped (last_ts ≈ beat_time) + neighbors mostly active + last cpu/mem is low | **Truly down** | High |\n| BeatTime stale + metrics also stopped + multiple machines in the same business group stale at once | **Cluster event** (not a single-machine outage; guide them to investigate upstream) | High |\n| BeatTime stale + metrics still moving (samples_count > 0 and last_ts close to now) | **Agent hung / Redis heartbeat channel anomaly** | High |\n| BeatTime lagging 30–180s + a few neighbors lagging in sync | **Network flapping / brief partition**, will most likely self-heal | Medium |\n| Matched a mute / maintenance window | **Under maintenance** | High |\n| BeatTime stale + last cpu_util > 95% / load spiking + neighbors normal | **Host hang** (CPU at 100%, IO stuck, kernel softlockup), counts as \"down in a broad sense\" but the recommended direction differs | Medium |\n| None of the above match | **Insufficient data**; honestly say \"cannot determine\", and list the evidence already collected so the user can fill in the gaps | — |\n\n## Output Template (strong constraint)\n\nFinal Answer in Markdown, in the user's language. **Four sections**:\n\n```\n## Conclusion\n<one sentence: truly down / agent hung / network flapping / under maintenance / cluster event / insufficient data, cannot determine>\n\n## Key Evidence\n- Heartbeat: beat_time=2026-05-14 10:23:11, lag=312s, status=stale; db update_at=...\n- Metrics (10m): cpu_usage_active recent last=0.5% (steadily declining from first to last value), net_bytes_sent last=0 — metric stream and heartbeat stopped at the same time\n- Neighbors: same business group 18/20 active, only this machine stale\n- Mute: not matched\n\n## Recommended Actions\n1. <the thing to do first>\n2. <next best>\n3. <if you want to self-verify, do it this way>\n\n## False-Positive Risk\n<under what circumstances this conclusion would not hold, e.g. \"if there was a recent cleanup operation on the Redis layer, this judgment does not hold\">\n```\n\n## Anti-Patterns (do NOT do these)\n\n- ❌ Concluding \"down\" from the single `BeatTime` field. Always pull the metric window and neighbors for cross-validation.\n- ❌ Reporting \"individual fault\" and \"cluster fault\" mixed together. When all neighbors are stale, do not keep focusing on the single machine; clearly tell the user \"you asked the wrong question, it is a cluster event\".\n- ❌ Recommending \"immediately page someone to check the machine\" without looking at the mute. A common pitfall: an under-maintenance alert mistaken for a false positive and reported as one.\n- ❌ Using `query_prometheus` to pull back a long string of raw time-series points and blow up the token budget. `query_host_metrics_window` already compresses by \"min/max/avg/last\"; see whether the aggregate is enough before deciding whether to look at the detail.\n- ❌ Not telling the user how to verify. The recommended actions must include at least one \"if you want to self-verify whether the agent is really dead, do this\" step (remotely ssh in and check `systemctl status categraf` / `ps -ef | grep categraf` / `journalctl -u categraf --since \"5 min ago\"`).\n\n## Known Failure Modes\n\n- edge unreachable storm: under high server load the heartbeat channel develops batch latency, which looks like the whole cluster going offline at once, but is actually Redis write blocking. **When you see summary entirely stale, proactively raise this possibility.**\n- Heartbeat update lag: the goroutine that writes heartbeats to Redis is stuck on slow IO; the agent is sending and the server is receiving, but BeatTime is not updated.\n- redis nil: the heartbeat key is occasionally cleared/evicted; check this together with the maxmemory-policy configuration.\n\n## Output Style\n\n- No teasing. Put the conclusion on the first line of the first section.\n- Give concrete numbers in the evidence; do not write things like \"the metrics look normal\".\n- \"Recommended actions\" must be executable; avoid empty phrases like \"strengthen monitoring\".\n- Answer in the user's language (Chinese for Chinese users, English for English users).\n\n<!-- File: aiagent/skill/embedded/builtin/host-onboard-diagnose/SKILL.md (Tokens: ~2293 | Category: anthropic-skill) -->\n---\nname: host-onboard-diagnose\ndescription: >-\n  Diagnose onboarding failures where \"categraf is installed/running but the host does not show up in the Nightingale host list, or shows unknown / has no metrics\". Triggers when the user asks \"why doesn't my newly installed host appear\", \"all the OS values in the host list are unknown\", \"I installed 3 collectors via Helm but only see 1\", \"the agent won't register\", or \"categraf is installed but the host doesn't show\". **Mutually exclusive** with host-health-diagnose: that one handles \"was onboarded before, now lost contact\", while this skill handles \"never got onboarded at all\". Core stance: **a missing host is not a single cause, but rather one segment of the onboarding pipeline being broken**. Looking only at heartbeat.enable and telling the user to change categraf is a common pitfall (many users change it and still can't see the host, because the problem is in omit_hostname / ident shell / TLS / token / edge redis / multi-cluster routing).\nmax_iterations: 18\nbuiltin_tools:\n  - probe_target_onboard_status\n  - list_targets\n  - get_target_detail\n  - query_prometheus\n  - query_host_metrics_window\n  - list_datasources\ntags:\n  - internal\n---\n\n# Host Onboarding Failure Diagnosis (host-onboard-diagnose)\n\n## Scope\n\n**Enter this skill**:\n- \"My newly installed categraf host doesn't show up in Nightingale\"\n- \"The agent is installed and running, but the host doesn't appear in the host list\"\n- \"For this host in the list, the OS / CPU / version are all unknown\"\n- \"I deployed 3 categraf hosts via Helm, but the platform only sees 1\"\n- \"I installed the agent on Windows but it won't register\"\n- \"The host disappeared right after I changed its hostname\" (if categraf is still running)\n\n**Do NOT enter this skill**:\n- Was visible before, recently lost contact → `host-health-diagnose`\n- ident duplicate / want to clean up residue after renaming → `host-ident-cleanup` (to be built)\n- Want to change alert rules / mutes → `creation` / `create-alert-rule`\n- Looking into why an alert didn't fire → `alert-rule-troubleshoot`\n\n## One-Sentence Principle\n\n**A missing host ≠ a single cause.** The onboarding pipeline has 5 segments, and each segment getting stuck looks different. Looking at just one segment and telling the user to change config is a common pitfall. **Gather evidence first, then localize segment by segment, and finally give fix commands.**\n\n## The 5 Segments of the Onboarding Pipeline\n\n```\n[1] categraf local process    Is it present / is the config correct / is heartbeat.enable on\n        │\n[2] heartbeat report HTTP      Can it reach /v1/n9e/heartbeat (network / TLS / BasicAuth)\n        │\n[3] server / edge receive      token / version compatibility / hostname duplicate check\n        │\n[4] target table persistence   Is this ident in the DB, is the meta in redis\n        │\n[5] Redis + metric stream       Can the time-series store find samples for this ident\n```\n\n## First Action: Call `probe_target_onboard_status`\n\nThis is the **only** diagnostic entry tool in this skill; it returns the footprint of all 5 segments in one shot. **Always call it first**, then decide the next step.\n\nKey fields returned:\n- `in_target_db` + `target.os` + `target.agent_version` → evidence for segments 3/4\n- `in_redis_beat` + `redis_meta.hostname` + `redis_meta.remote_addr` → evidence for segment 4\n- `in_prom_target_up` + `target_up_last` + `prom_metrics_hit` → evidence for segment 5\n- `likely_segment` + `likely_causes` → **diagnosis already aggregated at the tool layer**; **do not bypass it and re-derive it yourself**\n\nIf the user did not provide an ident, first call `list_targets` and let the user pick, or filter out candidates by OS=unknown / empty agent_version (the \"unassigned / partially onboarded\" view).\n\n## Decision Table (branch by likely_segment)\n\n| likely_segment | Meaning | Preferred fix action |\n|---|---|---|\n| `segment_1_or_2` | This host is in none of DB / redis / prom | On the target host: `systemctl status categraf` → `journalctl -u categraf --since \"5 min ago\"` → check whether it reports `connection refused` / `x509` / `401` |\n| `segment_3` | target exists but OS/agent_version empty | Check categraf's `config.toml`: `[heartbeat] enable=true` and `omit_hostname=false`; version ≥ v0.2.35 |\n| `segment_4` | target persisted but no data in redis | Check whether n9e/edge has redis configured; in edge mode the `[Redis]` of `edge.toml`; whether n9e and n9e-edge versions are consistent |\n| `segment_5` | redis has the beat but prom can't query it | Check whether categraf `[[writers]]` is configured; whether the datasource is correct in a multi-cluster deployment; whether the ident contains special characters like `()` `[]` `*` |\n| `ok` | Onboarding is normal | If the user still insists \"I can't see it\", guide them to refresh the page / check business-group filtering / check browser cache |\n\n## Three Variant Queries for Segment 5 (use `query_prometheus`)\n\nWhen `likely_segment=segment_5`, you **must** run the following 3 queries with `query_prometheus` to confirm whether it is an ident label problem or truly no data:\n\n```promql\n# 1. Standard ident exact match (most common)\ntarget_up{ident=\"<ident>\"}\n\n# 2. Fuzzy match (use when ident has an IP prefix / alias)\ntarget_up{ident=~\".*<host>.*\"}\n\n# 3. Extreme fallback: did it land on the instance label (snmp / custom tag scenario)\n{instance=~\".*<host>.*\"}\n```\n\nAll three return no data → the data stream truly never reached prom; go back and check categraf writers / TLS / n9e ingest queue.\nOnly (2) or (3) has data → **ident label problem** (special characters / global_labels override / snmp agent_host_tag misuse); guide the user to `host-ident-cleanup` (to be built) or to fix the categraf config.\n\n## Output Template (strongly enforced)\n\nThe Final Answer uses Markdown, in the user's language. **Four sections**:\n\n```\n## Conclusion\n<one sentence: stuck at segment X: xxxx (or: onboarding is normal, the reason you can't see it is yyy)>\n\n## Onboarding Pipeline Evidence\n- Segment 1/2 (categraf local/HTTP): <not gathered / inferred anomaly: xxx>\n- Segment 3 (server receive): target in_db=true, os=unknown, agent_version=\"\" → heartbeat metadata not persisted\n- Segment 4 (target persistence + redis): target update_at=2026-05-14 10:23:11 but no heartbeat in redis\n- Segment 5 (Prom): target_up has no data / prom_metrics_hit=0\n\n## Fix Commands\n1. Run on the target host:\n   `grep -E 'heartbeat|omit_hostname' /etc/categraf/conf/config.toml`\n   Expected: in the heartbeat section enable=true, omit_hostname=false. If either doesn't match, change it and `systemctl restart categraf`.\n2. ...\n3. ...\n\n## Self-Verification Steps\n<give the user 1-2 commands for \"if you want to confirm it's fixed, you can verify like this\", for example:\n- Within 30s after restarting categraf, refresh the host list back in the platform; the OS/CPU fields should be non-unknown\n- `curl -s http://<n9e>:17000/api/n9e/self-metrics | grep <ident>`>\n```\n\n## Anti-Patterns (do NOT do these)\n\n- ❌ Telling the user to change `heartbeat.enable` directly without calling `probe_target_onboard_status`. **Gather evidence first.** Many users already have heartbeat enabled; the problem is in omit_hostname / TLS / version.\n- ❌ Saying \"categraf isn't installed\" just because `in_target_db=false`. You must look at the redis segment and the segment_1_or_2 causes to judge holistically whether it's a network problem or a process problem — the recommended actions are completely different.\n- ❌ When segment 3 is stuck, only telling the user to change heartbeat without mentioning omit_hostname / version. Those two are just as common.\n- ❌ When segment 5 is stuck, telling the user to change writers without running the 3 variant PromQL queries. An ident label problem also gets stuck at segment 5.\n- ❌ Not giving concrete commands in the output, only saying \"check the categraf config\". Every recommendation must be something the user can paste and run directly.\n\n## Known Failure Modes Per Segment\n\n- **Segment 1/2**: categraf can't reach center, connection refused, TLS unknown authority, self-signed certificate, BasicAuth invalid, ams token mismatch, Helm multi-node only 1 seen, Windows, Win2008 not supported\n- **Segment 3**: heartbeat enable=false, unknown fields / omit_hostname=true, categraf version too low, v6 requires v0.2.35+, identity shell fails to get IP, hostname duplicate\n- **Segment 4**: edge redis nil, n9e and n9e-edge version mismatch, CenterApi missing, host not visible in the center under edge deployment\n- **Segment 5**: ident with parentheses not found in dashboards, host=* bug, snmp ident conflict, omit_hostname=true causing the ident label to be lost, wrong datasource in multi-cluster, write queue full 499, global.labels override\n\n## Output Style\n\n- Don't be coy. The conclusion goes in the first line of the first section.\n- Evidence must give concrete field values; don't write \"looks normal\".\n- Fix commands must be paste-and-run; avoid fluff like \"go check it\".\n- Answer in the user's language (Chinese for Chinese users, English for English users).\n- If `likely_segment=ok` but the user insists the host doesn't appear, prompt them to check: business-group filtering (the host is present but hidden by business group in the frontend), browser cache, and the visible-business-group permissions of the logged-in user.\n\n<!-- File: aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# import-prom-rule\n\nPath: `aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/import-prom-rule/SKILL.md)\n\n<!-- File: aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# modify-dashboard\n\nPath: `aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/modify-dashboard/SKILL.md)\n\n<!-- File: aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# modify-task-tpl\n\nPath: `aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/modify-task-tpl/SKILL.md)\n\n<!-- File: aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# notify-channel-copilot\n\nPath: `aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/notify-channel-copilot/SKILL.md)\n\n<!-- File: aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# notify-rule-copilot\n\nPath: `aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/notify-rule-copilot/SKILL.md)\n\n<!-- File: aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# ops-troubleshooting\n\nPath: `aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/ops-troubleshooting/SKILL.md)\n\n<!-- File: aiagent/skill/embedded/builtin/promql-generator/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# promql-generator\n\nPath: `aiagent/skill/embedded/builtin/promql-generator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/promql-generator/SKILL.md)\n\n<!-- File: aiagent/skill/embedded/builtin/query-alert-events/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# query-alert-events\n\nPath: `aiagent/skill/embedded/builtin/query-alert-events/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/query-alert-events/SKILL.md)\n\n<!-- File: aiagent/skill/embedded/builtin/query-datasource/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# query-datasource\n\nPath: `aiagent/skill/embedded/builtin/query-datasource/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/query-datasource/SKILL.md)\n\n<!-- File: aiagent/skill/embedded/builtin/skill-creator/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# skill-creator\n\nPath: `aiagent/skill/embedded/builtin/skill-creator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/skill-creator/SKILL.md)\n\n<!-- File: aiagent/skill/embedded/builtin/sql-generator/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# sql-generator\n\nPath: `aiagent/skill/embedded/builtin/sql-generator/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/aiagent/skill/embedded/builtin/sql-generator/SKILL.md)\n\n<!-- File: CLAUDE.md (Tokens: ~300 | Category: claude-rule) -->\n# Claude Agent Guidelines & System Prompt\n\nPath: `CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/ccfos/nightingale/HEAD/CLAUDE.md)\n</yakaai_skills>"}