Skip to content
Back to skills

Tenant Triage

ASecurity

Evidence-first triage for "tenant X says Y broke, check why" reports on Delta Agents prod. Resolves the tenant by fuzzy slug match, time-scopes from the user's report, pulls parallel evidence (sessions, messages, voice calls + tool events, followups, alerts/audit/escalations) via the Supabase MCP, greps ECS gateway/worker logs by tenant id, correlates with recent deploys, and emits a triage summary BEFORE any hypothesis. Use for a tenant or customer complaint with a fuzzy symptom (a tenant, u...

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 27, 2026
ai-agentsgoshellbashsqlawsgitdatabasebackend

Works with

  • mcp

Security analysis

A100/100

Scanned September 27, 2026

npx -y skills add zalogarcia/zalo-claude-code-setup --skill tenant-triage --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Tenant Triage?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Tenant Triage
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/zalogarcia-tenant-triage/badge)](https://www.skillsdirectory.com/skills/zalogarcia-tenant-triage)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: tenant-triage
description: Evidence-first triage for "tenant X says Y broke, check why" reports on Delta Agents prod. Resolves the tenant by fuzzy slug match, time-scopes from the user's report, pulls parallel evidence (sessions, messages, voice calls + tool events, followups, alerts/audit/escalations) via the Supabase MCP, greps ECS gateway/worker logs by tenant id, correlates with recent deploys, and emits a triage summary BEFORE any hypothesis. Use for a tenant or customer complaint with a fuzzy symptom (a tenant, user or customer reports that something broke and wants to know why).
---

Turn a fuzzy customer complaint ("raqm says voice broke a couple days ago") into a grounded evidence table before anyone hypothesizes.

All SQL below uses REAL column names verified against `docs/SCHEMA-PROD.md` (in the delta-agents repo). If any query fails on a column/table name, run the `schema-snapshot` skill to refresh the snapshot — do NOT guess a replacement column.

## When to invoke

- The user relays a customer/tenant complaint: "tenant X says…", "our user at X reports…", "customer reported…", "check why X isn't getting replies".
- You have: a tenant name/slug fragment + a fuzzy symptom + (optionally) a timeframe.
- Skip for: bugs reproduced locally with a known stack trace (go straight to `/bug`), and platform-wide outages (start with `docs/RUNBOOK.md` first-5-minutes checklist instead — that's fleet-level, this is tenant-level).

## Preflight — transport check

Run `SELECT 1` via `mcp__supabase__execute_sql` (`project_id: $DELTA_PROD_PROJECT_REF`) first. If it fails, the MCP transport is down — back off and report; do not rewrite queries trying to "fix" them.

> **Resolving `$DELTA_PROD_PROJECT_REF`:** `jq -r '.env.DELTA_PROD_PROJECT_REF' ~/.claude/settings.local.json` (gitignored). Fallback: `mcp__supabase__list_projects` and pick the delta-agents prod project. Never paste the literal ref back into this file — it is committed to a public repo.

**Read-only contract: every query in this skill is a SELECT. Never write to prod during triage.**

**Caller data stays masked.** Every free-text column this skill prints (config head, last turn, contact name, message body, call summary, tool error, alert message) goes through the mask `voice-call-triage` uses: emails become `<email>`, any 10+ digit run and any US number with separators keep only the last 4 digits (`<phone ..1234>`). Ids stay whole, since the mask would corrupt uuids (its digit passes hit 152 of 1,243 address-free session keys), EXCEPT when an id carries `@`, `+<digit>`, or ends in a bare 10 to 15 digit run: a native email, SMS or WhatsApp contact's id IS its address (a WhatsApp Cloud `wa_id` is digits with no `+`), so `session_key`, the varchar `contact_id` columns and `audit_log.resource_id` (a `message_processed` row stores the session key there) get the mask behind that guard (as of 2026-09-23: 5 of 1,248 session keys, all `native:<tenant>:<address>`, and 31 audit rows). Put the same mask on any free-text column you add; it misses emails and numbers spelled out in words, so redact those by hand before quoting.

## Step 1 — Resolve the tenant (fuzzy slug match)

There is NO `tenants.name` column — the slug is the identifier; display name lives in `config` JSONB. Fuzzy-match on slug first:

```sql
SELECT id, slug, status, tenant_type, ingest_suspended, lead_recovery_suspended,
       voice_enabled, voice_outbound_enabled, created_at
FROM tenants
WHERE slug ILIKE '%<fragment>%'
ORDER BY (slug = '<fragment>') DESC, slug
LIMIT 10;
```

- **Exactly one row** → that's the tenant. Note `id` (used everywhere below) AND the posture flags: `status <> 'active'`, `ingest_suspended = true`, or a false `voice_enabled` on a voice complaint is often the entire answer — surface it immediately.
- **Multiple rows** → list the candidates (slug + status + created_at) and ask the user which one. Do not pick silently.
- **Zero rows** → fall back to the display name inside config:

```sql
SELECT id, slug, status,
       left(regexp_replace(regexp_replace(regexp_replace(config::text,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g'), 120) AS config_head
FROM tenants
WHERE config::text ILIKE '%<fragment>%'
LIMIT 10;
```

Still zero → report "no tenant matches" with the exact fragments tried; do not proceed on a guessed id.

## Step 2 — Time-scope from the report

Convert the user's phrasing to a window; when in doubt, widen:

| User said                                | Window            |
| ---------------------------------------- | ----------------- |
| "just now" / "today"                     | 24h               |
| "yesterday"                              | 48h               |
| "a couple days ago" / no timeframe given | **72h (default)** |
| "last week" / "a while ago"              | 7d                |

Use the same window in every SQL `interval` and in the epoch-ms `--start-time` for log pulls. If Step 3 returns zero rows everywhere, double the window ONCE, then stop and report.

## Step 3 — Parallel evidence pull

Each block is one `mcp__supabase__execute_sql` call (`project_id: $DELTA_PROD_PROJECT_REF`). They are independent — run them in parallel (single message, multiple tool calls). Substitute `<tenant_id>` and the window. Skip 3c/3d when the symptom is clearly not voice; run everything else always.

**3a — Recent sessions + last-turn shape** (`conversation` is a jsonb array of turns; take the raw last element rather than guessing its keys):

```sql
SELECT CASE WHEN session_key ~ '@|\+[0-9]|(^|:)[0-9]{10,15}$' THEN regexp_replace(regexp_replace(regexp_replace(session_key,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g')
            ELSE session_key END AS session_key,
       status, channel_type, contact_id, trace_id,
       last_activity_at, jsonb_array_length(conversation) AS turns,
       left(regexp_replace(regexp_replace(regexp_replace((conversation->-1)::text,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g'), 200) AS last_turn
FROM tenant_sessions
WHERE tenant_id = '<tenant_id>'
  AND last_activity_at >= now() - interval '72 hours'
ORDER BY last_activity_at DESC
LIMIT 20;
```

**3b — Recent messages** (did the agent actually send/receive anything?):

```sql
SELECT m.created_at, m.direction, m.channel, m.platform,
       regexp_replace(regexp_replace(regexp_replace(c.name,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g') AS contact_name,
       left(regexp_replace(regexp_replace(regexp_replace(m.body,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g'), 160) AS body_preview
FROM tenant_messages m
JOIN tenant_contacts c ON c.id = m.contact_id
WHERE m.tenant_id = '<tenant_id>'
  AND m.created_at >= now() - interval '72 hours'
ORDER BY m.created_at DESC
LIMIT 30;
```

**3c — Voice calls** (voice symptoms only). Two engines write this table: `retell`, and `openai-live` (GPT-Live over Twilio, where `retell_call_id` holds the Twilio CallSid `CA...`). Split by engine first, then list the calls:

```sql
SELECT engine, count(*) AS calls,
       count(*) FILTER (WHERE booking_claim_unbacked)  AS unbacked_booking_claims,
       count(*) FILTER (WHERE booking_claim_unbacked IS NULL AND call_status = 'ended') AS not_audited,
       count(*) FILTER (WHERE engine = 'openai-live'
                          AND metadata->'engine_session'->>'backend_model' IS NULL) AS no_delegation
FROM tenant_voice_calls
WHERE tenant_id = '<tenant_id>'
  AND created_at >= now() - interval '72 hours'
GROUP BY engine;
```

```sql
SELECT engine, retell_call_id, direction, call_status, disconnection_reason,
       duration_seconds, call_successful, user_sentiment, agent_id,
       booking_claim_unbacked,
       metadata->'engine_session'->>'backend_model' AS backend_model,
       metadata->'engine_session'->>'greeting_mode' AS greeting_mode,
       started_at, ended_at,
       left(regexp_replace(regexp_replace(regexp_replace(summary,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g'), 200) AS summary_preview
FROM tenant_voice_calls
WHERE tenant_id = '<tenant_id>'
  AND created_at >= now() - interval '72 hours'
ORDER BY created_at DESC
LIMIT 20;
```

For openai-live rows: `backend_model` null means no delegation completed (no tool could run, so any booking the agent claimed was invented); `booking_claim_unbacked` is a whole-call verdict (a booking that succeeded later in the call clears it even when the agent said "you're booked" first). For one suspicious call, run `voice-call-triage` ("openai-live calls: Steps 1L to 4L"): it reads the in-call booking guard lines from the `voice-bridge` log. These columns are newer than some checkouts' `docs/SCHEMA-PROD.md`; if sql-guard blocks one, confirm it in `information_schema.columns` and refresh the snapshot, never drop the column.

**3d — Voice tool events, failures first** (voice symptoms only; join by `retell_call_id`, there is no voice_call FK):

```sql
SELECT created_at, engine, retell_call_id, tool_name, status, duration_ms,
       regexp_replace(regexp_replace(regexp_replace(error,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g') AS error
FROM tenant_voice_tool_events
WHERE tenant_id = '<tenant_id>'
  AND created_at >= now() - interval '72 hours'
ORDER BY (error IS NOT NULL) DESC, created_at DESC
LIMIT 40;
```

**3e — Followups due/fired in window** (schedule column is `due_at`, NOT `scheduled_at`; `contact_id` here is **varchar**, not uuid — do not join `tenant_contacts` without a cast, per the 42P08 trap in SCHEMA-PROD):

```sql
SELECT id,
       CASE WHEN contact_id ~ '@|\+[0-9]|(^|:)[0-9]{10,15}$' THEN regexp_replace(regexp_replace(regexp_replace(contact_id,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g')
            ELSE contact_id END AS contact_id,
       sequence_id, step_number, due_at, status, updated_at
FROM tenant_followups
WHERE tenant_id = '<tenant_id>'
  AND (due_at >= now() - interval '72 hours'
       OR updated_at >= now() - interval '72 hours')
ORDER BY due_at DESC
LIMIT 30;
```

**3f — Error-ish rows: alerts, audit trail, escalations** (audit table is `audit_log`, NOT `tenant_audit_log`):

```sql
SELECT 'alert' AS kind, created_at, alert_type AS what, severity,
       status,
       left(regexp_replace(regexp_replace(regexp_replace(message,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g'), 200) AS detail
FROM tenant_alerts
WHERE tenant_id = '<tenant_id>'
  AND created_at >= now() - interval '72 hours'
ORDER BY created_at DESC
LIMIT 30;
```

```sql
SELECT created_at, action, resource_type,
       CASE WHEN resource_id ~ '@|\+[0-9]|(^|:)[0-9]{10,15}$' THEN regexp_replace(regexp_replace(regexp_replace(resource_id,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g')
            ELSE resource_id END AS resource_id,
       trace_id
FROM audit_log
WHERE tenant_id = '<tenant_id>'
  AND created_at >= now() - interval '72 hours'
ORDER BY created_at DESC
LIMIT 50;
```

```sql
SELECT created_at, escalation_type, escalation_target, status,
       CASE WHEN contact_id ~ '@|\+[0-9]|(^|:)[0-9]{10,15}$' THEN regexp_replace(regexp_replace(regexp_replace(contact_id,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g')
            ELSE contact_id END AS contact_id,
       CASE WHEN session_key ~ '@|\+[0-9]|(^|:)[0-9]{10,15}$' THEN regexp_replace(regexp_replace(regexp_replace(session_key,
              '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', '<email>', 'g'),
              '\+?[0-9]{6,}([0-9]{4})', '<phone ..\1>', 'g'),
              '(\+?1[-. ]*)?\(?[0-9]{3}\)?[-. ]*[0-9]{3}[-. ]*([0-9]{4})', '<phone ..\2>', 'g')
            ELSE session_key END AS session_key
FROM tenant_escalations
WHERE tenant_id = '<tenant_id>'
  AND created_at >= now() - interval '72 hours'
ORDER BY created_at DESC
LIMIT 20;
```

Carry `trace_id` values forward — they link sessions ↔ audit rows ↔ log lines.

## Step 4 — ECS runtime logs (gateway + worker)

Log groups (from `docs/RUNBOOK.md`; `aws logs describe-log-groups --log-group-name-prefix /ecs/delta-agents` lists them): `/ecs/delta-agents/gateway`, `/ecs/delta-agents/worker`, `/ecs/delta-agents/embedding-worker`, and for openai-live voice calls `/ecs/delta-agents/voice-bridge` (its lines carry the tenant UUID as `tenantId` and the call as `metadata.callSid`). Read `~/.claude/projects/-Users-zalo-dev/memory/aws-filter-log-events-undercounts.md` before counting anything from these pulls. Logs are structured JSON containing the tenant **UUID**; webhook-ingress lines also carry the **slug** (URL path `/hooks/:crm_type/:tenant_slug`). Filter by UUID first; add a slug pass when the symptom is "messages never arrive".

Every pull goes through `pull`, which counts ALL matching events and prints a bounded slice. The old `--output text | tail -80` printed 5 "lines" for 391 events (raqm, 2026-09-23): text output joins a whole page onto one line with tabs. The slice shows ids and event names only, never message text; `/tmp/triage-<name>.json` keeps the full lines (`jq -r '.[]? | fromjson? | objects | select(.event == "<event>")'`), which are unmasked, so mask anything you quote from them. `pull` writes them owner-only; delete them when the triage is done: `rm -f /tmp/triage-*.json`.

```bash
TENANT_ID='<tenant_id>'
START=$(date -v-72H +%s)000; END=$(date +%s)000   # macOS; Linux: $(date -d '72 hours ago' +%s)000
CAP=50000   # --max-items is a safety cap; never --limit, it stops after one page

pull() {   # pull <name> <log group> <filter pattern>
  ( umask 077   # the raw lines are unmasked prod data: owner-only file
    aws logs filter-log-events --region us-east-1 --log-group-name "/ecs/delta-agents/$2" \
    --filter-pattern "$3" --start-time "$START" --end-time "$END" --max-items "$CAP" \
    --query 'events[].message' --output json > "/tmp/triage-$1.json" ) || { echo "$1: aws failed"; return 1; }
  N=$(jq -n '[inputs | length] | add // 0' "/tmp/triage-$1.json")
  echo "== $1: $N events$([ "$N" -ge "$CAP" ] && echo ', TRUNCATED at the cap: narrow the window')"
  jq -r '.[]? | ((fromjson? | objects) // {level: "-", event: "(non-JSON line)"}) | "\(.level)\t\(.event)"' \
    "/tmp/triage-$1.json" | sort | uniq -c | sort -rn | awk '$2 != "info" || ++i <= 15'   # all warn/error names, top 15 info
  jq -r '.[]? | fromjson? | objects | [.timestamp, .level, .event, (.metadata.callSid? // .traceId // "")] | @tsv' \
    "/tmp/triage-$1.json" | sort | tail -30                           # the 30 latest
}

pull gateway gateway "\"$TENANT_ID\""                # every line naming the tenant
pull voice-bridge voice-bridge "{ \$.tenantId = \"$TENANT_ID\" && (\$.level = \"warn\" || \$.level = \"error\") }"
pull worker worker "\"$TENANT_ID\" \"error\""         # two quoted terms = AND
```

For "no reply" symptoms, also check the intentional bailed-silent paths (these are NOT bugs):

```bash
# same shell as the block above (it defines pull, START, END, CAP)
pull bailed-silent worker "{ \$.tenantId = \"$TENANT_ID\" && (\$.event = \"agent_after_hours_skip\" || \$.event = \"paused_contact_blocked\" || \$.event = \"manual_reply_cooldown_blocked\") }"
```

## Step 5 — Deploy correlation

```bash
gh run list --workflow "Deploy to ECS" --limit 10 \
  --json databaseId,displayTitle,conclusion,headSha,updatedAt
```

Mark every deploy whose `updatedAt` falls inside (or just before) the symptom window. A deploy landing hours before symptom onset is the single most common root cause — but it is CORRELATION at this stage; record it as evidence, not verdict.

## Step 6 — Output contract (summary BEFORE hypothesis)

Emit this exact shape. The **Hypothesis** section must come last and must cite evidence lines above it — never lead with a theory.

```markdown
## Triage Summary — <slug> (<tenant_id>)

**Reported symptom:** <verbatim-ish>
**Window:** <e.g. 72h, 2026-06-29T14:00Z → now>
**Tenant posture:** status=<...>, voice_enabled=<...>, ingest_suspended=<...>

**Evidence:**

- Sessions (3a): <N in window; last activity; anomalies>
- Messages (3b): <in/out counts; last outbound at>
- Voice (3c/3d): <calls per engine (retell / openai-live); disconnection_reasons; unbacked booking claims; openai-live calls with no delegation; failed tool events: tool_name → error>
- Followups (3e): <due vs fired vs stuck-pending counts>
- Alerts/audit/escalations (3f): <notable rows or "clean">
- Runtime logs (4): <error lines / bailed-silent events / "clean">
- Deploys (5): <run id, sha, time, relation to symptom onset>

**What's broken (facts only):** <observable failures, quoted evidence>
**Next probe:** <the ONE cheapest check that would discriminate between causes>

---

**Hypothesis (only after the above):** <cause ranked by evidence, cite rows/lines>
```

## Anti-patterns

- ❌ **Hypothesizing before the evidence table.** The whole point is intake discipline — 13 past sessions started with a theory and paid for it.
- ❌ Guessing column names instead of using this skill's SQL / `docs/SCHEMA-PROD.md`. Known traps: no `tenants.name` (use `slug`), no `tenant_audit_log` (use `audit_log`), `tenant_followups.due_at` not `scheduled_at`, `tenant_followups.contact_id` is varchar.
- ❌ Local `psql` / pg scripts against prod — the Supabase MCP is the only sanctioned prod read path.
- ❌ Any INSERT/UPDATE/DELETE during triage. Read-only, always.
- ❌ Filtering CloudWatch by slug alone — most log lines carry the tenant UUID, not the slug.
- ❌ Silently picking one of several slug matches.
- ❌ Treating bailed-silent worker events (DND, after-hours, pause, manual-reply cooldown) as bugs — they are intentional non-reply paths.

## Edge cases

- **Ambiguous slug** → present candidates, wait for the user. (Agency setups often have `parent_tenant_id` families with similar slugs.)
- **Zero evidence in window** → widen ×2 once. Still zero → report "no evidence in window" honestly; the symptom may predate retention or belong to a different tenant.
- **Symptom contradicts posture** (voice complaint but `voice_enabled = false`; no-messages complaint but `ingest_suspended = true`) → the posture flag IS the finding; report it in the summary and stop pulling deeper evidence.
- **`tenant_followups` join needs contact names** → cast explicitly (`c.id = f.contact_id::uuid`) and expect cast failures on non-UUID external ids — prefer reporting the raw varchar id.
- **Query fails on a column this skill names** → prod schema drifted; run `schema-snapshot` to refresh, then fix the query from the new snapshot.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…