Two defects that together produce the reported symptom — automation logs
that read `status: 'success'` with `steps_executed: []`.
1. The dispatch loop in the inbound webhook was fire-and-forget inside the
route's `after()` block. `after()` only keeps the function alive for
promises it can see, so the run could be frozen after the log row was
inserted but before any step executed. This is the same failure mode as
issue #301, recurring one level down — the comments on the AI auto-reply
and `message.received` dispatches immediately below already spell out
the invariant this violated. Now awaited; the per-iteration `.catch` is
kept so one trigger type can't skip the rest of the loop.
2. `executeAutomation` seeded the log row with `status: 'success'` before
running anything. A run that died mid-flight therefore left a permanent
success with no steps, indistinguishable from an automation that
genuinely had nothing to do. Seeded as `'failed'` instead, so the status
only becomes success if execution actually reaches a terminal path. No
migration needed — 'failed' is already in the column's CHECK constraint,
and every terminal path overwrites it.
Two more points from the issue are deliberately not addressed here, as
both change existing behaviour and want a maintainer decision: switching
`contains` keyword matching to word boundaries (would alter which
automations fire for current users) and re-opening a closed conversation
on an inbound reply.
Refs #409.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The settings page derived its active panel from `useSearchParams()` but
wasn't wrapped in a Suspense boundary. In Next 16 that fails the
production build ("missing Suspense with CSR bailout") and forces the
page to bail to full client-side rendering — shipping a settings screen
whose rail click handlers never wire up. Because the account-menu
Settings link targets `?tab=whatsapp`, users land on the WhatsApp panel
and can't navigate to any other section ("locked on WhatsApp").
Wrap the `useSearchParams` consumer in `<Suspense>` on the three
affected dashboard pages — settings, inbox, automations/new — mirroring
the split login/signup already use. Also add the missing
`Settings.sections.quick-replies` label, which was rendering as a raw
translation key in the settings rail.
Verified: production build now succeeds (previously errored out during
static generation); every settings rail section navigates correctly in a
production build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Login signed in client-side then soft-navigated with router.push into
/dashboard, a route the middleware gates on the Supabase session cookie.
The soft navigation can reach the protected route before the server
observes the freshly written cookies, so the middleware bounces it back
to /login -- which looks like the page "just refreshing" instead of
signing in. It surfaced over ngrok/HTTPS tunnels (added latency widens
the window) while staying hidden on localhost.
- login: navigate with window.location.href after signInWithPassword,
mirroring the deliberate full reload the invite-accept flow already
uses in join/[token]/page.tsx; drops the now-unused useRouter.
- next.config: allow-list common dev tunnel origins (ngrok, cloudflared,
localtunnel) so Next 16 stops 403-ing dev-only resources over the
tunnel, extendable via ALLOWED_DEV_ORIGINS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An inbound message could create a second conversation for a contact
under a race (Meta retries a delivery, or a batch fans out to concurrent
after() runs). There was no UNIQUE index and no unique-violation
backstop on conversations, unlike contacts (migration 022). Worse, once
two conversations existed the `.single()` lookup errored on every later
message and the webhook created yet another conversation each time,
snowballing into a wall of duplicate chats.
- findOrCreateConversation now resolves oldest-first with .limit(1)
instead of .single(), so pre-existing duplicates converge, and catches
23505 on insert to re-resolve the winning row.
- Same hardening applied to the public-API resolver (resolve-conversation).
- Migration 036 merges existing duplicate conversations into the oldest
thread (re-pointing messages, reactions, deals, flow_runs,
notifications, ai_usage_log — no data loss) then adds a
UNIQUE (account_id, contact_id) index as the authoritative guarantee.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add full support for WhatsApp Cloud API interactive messages across the
inbox, automations, and a new quick-replies feature. Extends the
interactive plumbing that previously existed only for the Flows engine.
Sending
- send-message core handles `interactive` (reply buttons ≤3, list ≤10
rows) via the existing Meta senders; validated server-side before send.
- Inbox composer gains a "+" menu → builder dialog with live WhatsApp-style
preview + Meta char limits (button 20, list row 24, body 1024).
- Automation "Send Buttons" / "Send List" steps in the builder.
- Public v1 /messages accepts type=interactive.
Storage / round-trip
- migration 035: messages.interactive_payload JSONB + quick_replies table.
- Outbound interactive persists the structured payload (composer,
automations, and Flows) so the thread re-renders the buttons/rows.
Receiving
- Bubble renders outbound interactive via the shared preview; inbound taps
(button_reply/list_reply) keep the "chosen option" affordance.
Automations
- New `interactive_reply` trigger matches on tapped button/list IDs
(exact), enabling chained multi-step menus.
Quick replies
- New account-scoped quick_replies (text or interactive snippets), managed
in Settings and insertable from the composer; "Save as quick reply" in
the builder.
Shared core in src/lib/whatsapp/interactive.ts + src/components/interactive/.
Includes unit tests for the validator, send-param validation, the
interactive_reply trigger match, and the new automation step/trigger
validation. typecheck + lint clean; full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings in the i18n PR (#348) that landed on main and localizes the new
strings this branch added so nothing regresses the new next-intl
convention:
- ai-config.tsx: resolved import conflict; handoff-target picker strings
moved to Settings.aiConfig (handoffTo / handoffToDesc / handoffQueue).
- message-bubble.tsx: "AI" badge + tooltip → Inbox.bubble
(aiBadge / aiBadgeTitle).
- ai-thread-banner.tsx: take-over/resume banner + toasts → new
Inbox.aiBanner namespace.
The AI Agents section (/agents playground, setup tabs, usage card) is
still English on main, so those strings are left as-is for a future
section-wide i18n pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Correctness:
- Resume AI now releases ANY assignment (not just the caller's own).
A handoff-assigned thread previously stayed assigned after resume, so
the eligibility gate silently kept the bot muted — Resume was a no-op.
- GET /api/ai/usage: guard days >= 1 (Number(null)===0 is finite, so the
30-day default was unreachable and the window collapsed to 1 day).
- GET /api/ai/usage: align the query cutoff to the start of the oldest
charted local day so rows in the oldest partial day no longer count in
totals while missing from the daily series (totals now match the bars).
- ai-thread-banner: cache account AI-status per accountId and never cache
a transient fetch failure — fixes cross-account leak and the banner
staying hidden all session after one failed /api/ai/config call.
- /api/ai/draft: construct the service-role client inside a guard and
fire-and-forget the usage log, so a missing service-role key (or any
log failure) can't 500 an already-generated, already-billed draft.
Efficiency:
- Don't await usage logging on the reply hot path (auto-reply + draft);
logAiUsage swallows its own errors, so fire-and-forget is safe and
removes a DB round-trip from the customer-facing send.
Reuse / consistency:
- usage route reuses date-utils (daysAgoStart/lastNDayKeys/localDayKey)
and now buckets by LOCAL day like every other dashboard chart (was UTC).
- extract formatCompactNumber (shared with formatCurrencyShort); drop the
hand-rolled month table in favour of date-fns; extract
fetchAccountMembers/memberLabel and use them in the AI settings picker.
Resume still resets the per-conversation reply count (a deliberate,
manual, rate-limited re-engagement — documented in the handler).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surfaces the ai_usage_log data added in the previous commit:
- GET /api/ai/usage — admin-only aggregation (totals, per-mode,
per-model, zero-filled daily series) over a 7/30/90-day window,
mirroring the ai_usage_log SELECT policy
- AiUsageCard — stat tiles + daily token bar chart + by-model list,
rendered under a new admin-only "Usage" tab on the AI Agents page
Counts only; no message content is stored or shown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Makes the AI auto-reply bot visible and controllable from the inbox,
completes the handoff, and records token spend. Builds on the existing
lib/ai engine rather than replacing it.
Track A — user-facing
- messages.ai_generated flag → small "AI" badge on bot-generated bubbles
- "Take over" / "Resume AI" banner per conversation
(POST /api/ai/autoreply/[id]) — take-over pauses + assigns to the agent
- handoff now assigns to a configurable target (agent or unassigned
queue) and writes an internal note summarizing the exchange; the
existing on_conversation_assigned trigger notifies the assignee
- Settings → AI: "Hand off to" target picker
Track B — observability + safety
- providers return normalized token usage; logged per run to ai_usage_log
(admin-readable) from both auto-reply and draft
- account-wide auto-reply rate limit (30/min) on top of the
per-conversation cap
Migration: 033_ai_reply_polish.sql (messages.ai_generated,
ai_configs.handoff_agent_id, conversations.ai_handoff_summary,
ai_usage_log). Tests + typecheck + lint pass (607 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flows and automations write routes verify only authentication/membership
(via the RLS client, whose *_select policies have no minimum role) and
then mutate with the service-role client, which bypasses the agent-gated
*_insert/update/delete RLS policies. A read-only `viewer` therefore had
full write power.
H3 (High): add requireRole('agent') to the flows write handlers —
PUT/DELETE (flows/[id]), activate (flows/[id]/activate), and create
(flows POST). GET/list stay open to viewers.
M2 (Medium): same gate on automations POST, the engine trigger
(automations/engine, which sends outbound WhatsApp), and the [id]
mutation handlers (PATCH/DELETE/duplicate). The cron route is unchanged —
it is gated by the x-cron-secret shared secret, not user auth.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add 'Broadcasts' namespace to en.json for all messaging flows
- Localize broadcast list and detail views (stats, recipient tables, funnel)
- Localize 4-step Broadcast creation wizard (Choose Template, Select Audience, Personalize, Schedule)
- Refactor status configuration to use translation keys instead of hardcoded labels
- Pass full typechecking across all wizard components
Promotes the AI assistant out of Settings into a first-class "AI Agents"
sidebar area.
- Sidebar: new "AI Agents" item → /agents.
- /agents page with two tabs:
- Playground — a test chat (POST /api/ai/playground) that runs the
real auto-reply path (KB retrieval + provider, auto_reply prompt,
multi-turn, shows handoff). loadAiConfig gains requireActive so you
can test before enabling.
- Setup — the existing provider/key + knowledge-base config, relocated.
- Removed the AI section from Settings (settings-sections + page); same
account config, new home. No migration.
Tests +6 (query, loadAiConfig requireActive). 593 pass; typecheck, lint,
build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Security: REVOKE ALL FROM PUBLIC + GRANT EXECUTE (authenticated,
service_role) on the match RPCs so anon can't read a KB by supplying
an account_id (mirrors migrations 018/025).
- Ingest: store chunks even when embedding fails (insert first, rethrow
after) so a doc stays lexically searchable; the "lexical still works"
warning is now truthful.
- Retrieval: short-circuit on an empty KB (cheap COUNT) to avoid a paid
query embedding + 2 RPCs on every draft/auto-reply.
- Migration: HNSW instead of IVFFlat (robust on small/growing tables);
language-neutral 'simple' FTS config (was 'english'); semantic RPC
takes text and casts ::vector(1536) to remove PostgREST bind ambiguity.
- Embeddings key: distinguish corrupt-from-absent (loadEmbeddingsKey
returns {key,corrupt}); log on decrypt failure; reindex now reports
failure instead of false success; POST/PATCH warn on keyword-only save.
- embedTexts: fail loud when the provider omits result indices (was
silently misaligning chunks with vectors).
Tests: +3 (empty-KB guard, embed-failure-still-indexes, missing-index).
587 pass; typecheck/lint/build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grounds the AI assistant (0.5.0) in the account's own content so it can
answer instead of handing off.
- Hybrid retrieval: Postgres full-text search for every account; optional
semantic search (pgvector + OpenAI text-embedding-3-small) when an
embeddings key is set. Semantic-primary, topped up with FTS.
- src/lib/ai: chunk, embeddings, knowledge (ingest + retrieve), query;
buildSystemPrompt gains a knowledge block; config carries an
(optional) embeddings key.
- Wired into /api/ai/draft and the auto-reply bot (best-effort — never
blocks a reply).
- KB API (/api/ai/knowledge CRUD + /reindex) and a Settings → AI
Assistant knowledge-base manager + embeddings-key field.
- Migration 030: pgvector, ai_knowledge_documents, ai_knowledge_chunks
(fts + embedding), match RPCs, ai_configs.embeddings_api_key.
Migration required: supabase/migrations/030_ai_knowledge.sql.
Stacked on feat/ai-reply-assistant (#329).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an AI reply assistant powered by the account's own OpenAI or
Anthropic key (stored AES-256-GCM-encrypted; no per-seat AI fee).
- AI-drafted replies in the inbox composer (✨ button, agent+), via
read-only POST /api/ai/draft.
- Optional auto-reply bot on inbound messages that no Flow consumed and
no agent owns, with a per-conversation cap and clean [[HANDOFF]] to a
human. Stands down when a message-level automation would also reply.
- Settings → AI Assistant (admin+): provider/model, key, business
context, toggles, and a Test-key button that verifies before saving.
- Provider layer (src/lib/ai) with OpenAI + Anthropic adapters behind
one interface; migration 029 (ai_configs + per-conversation columns +
atomic claim_ai_reply_slot RPC).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This introduces the base i18n setup using next-intl without routing. It adds the default messages/en.json and sets up the app layout and next.config.ts to consume the locale from the environment. The login page has been migrated as a first example.
Completes the public API roadmap (#245): register HTTPS endpoints to be
POSTed to when things happen in an account, so automations can react to
inbound activity instead of polling.
- Migration 028: account-scoped webhook_endpoints (secret stored
AES-GCM-encrypted) + an atomic record_webhook_failure() function.
- Scope webhooks:manage; management API GET/POST /api/v1/webhooks and
GET/PATCH/DELETE /api/v1/webhooks/{id} (secret returned once).
- Events message.received / conversation.created / message.status_updated
dispatched from the inbound webhook handler's after() block.
- Signed delivery (X-Wacrm-Signature = HMAC-SHA256 over t.body), single
best-effort attempt, auto-disable after consecutive failures.
- Versioned 0.4.0; docs/public-api.md Webhooks section; CHANGELOG.
Fixes from the adversarial code review of this diff:
- SSRF: block private/loopback/link-local (incl. cloud metadata) targets
at delivery time + redirect:'manual' so a public URL can't bounce
internal (src/lib/webhooks/ssrf.ts).
- Status mirror UPDATE no longer uses .select().maybeSingle() (message_id
is non-unique); account resolved via a bounded limit(1) read.
- Atomic failure counter via SQL RPC (was a racy read-modify-write).
- conversation.created emitted before the reaction short-circuit.
- Per-delivery id added for receiver dedupe; docs state at-least-once /
unordered delivery.
- Signature verify tolerates hex case / header whitespace.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete the public API roadmap on top of the existing key-auth layer
(#290). Re-lands POST /api/v1/messages (lost when the stacked #291 never
reached main) by extracting the dashboard send route's core into
`sendMessageToConversation`, then adds:
- Contacts: GET/POST /api/v1/contacts, GET/PATCH /api/v1/contacts/{id}
- Conversations: GET list + /{id} + /{id}/messages
- Broadcasts: POST /api/v1/broadcasts + GET /{id} (persist rows, fan out
in after(), poll for status)
All list endpoints share one keyset-pagination contract
({ data, meta: { next_cursor } }). No migration required — scopes and
tables already exist. Versioned as 0.3.0 (folded into [Unreleased]);
docs/public-api.md updated; outbound webhooks left as the sole roadmap
item. API stays v1 (additive).
Includes fixes from an adversarial code review: cursor validation
against filter injection, broadcast counts deferred to the DB aggregate
trigger, terminal broadcast status reflects failures, duplicate-phone
de-dup, up-front send-param validation (no orphan conversations),
PATCH null-clear semantics, error-checked tag sync, bounded tag filter,
unified audit-user attribution, and a maxDuration on the broadcast
fan-out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds contact-based filters to the Inbox conversation list (issue #272):
- Multi-select tag filter with OR logic (a conversation matches if its
contact has any selected tag), consistent with Broadcast audiences.
- Company filter (exact match) over companies present in the inbox.
- Filters stack with the existing status filter and search; active
selections show as removable chips with a clear-all control.
Tags are hydrated via a shared CONVERSATION_SELECT embed and normalized
onto contact.tags, so the realtime hydrate path keeps new conversations
matching an active filter. Filtering logic lives in a pure, unit-tested
matchesContactFilters helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "Send template" action to the contact detail view so the
business can initiate (or re-open) a WhatsApp conversation with one
known contact by sending an approved template — without the awkward
single-recipient broadcast workaround.
The /api/whatsapp/send route now accepts `contact_id` as an
alternative to `conversation_id`: it verifies the contact is in the
caller's account, find-or-creates the contact's conversation
server-side, then runs the existing send path (phone-variant retry,
template send-builder, message + conversation persistence, flow
pause). The contact view reuses the existing inbox TemplatePicker for
template selection + variable/header/button input, keeping Meta
compliance unchanged (approved templates only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The POST handler ack'd Meta with 200 and then ran processWebhook() as a
detached promise. On Vercel the function can be frozen/terminated the moment
the response is sent, so those DB writes were not guaranteed to complete.
This dropped a non-deterministic subset of inbound messages: the contact and
conversation rows (written first) landed, but the message insert (written
after) often did not — leaving conversations that appear in the inbox with an
empty thread, with no durable logs to explain it (issue #301).
Move the work into next/server `after()`, which keeps the function alive until
the callback resolves (within the route's maxDuration) while still ack'ing Meta
fast enough to avoid retry-driven duplicate inserts. Add maxDuration=60 for
headroom since processing can fan out to per-media Meta verification calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Templates with an IMAGE/VIDEO/DOCUMENT header could not be broadcast:
the wizard never offered a place to provide the header media URL, so
the send either shipped the template's stale stored URL or — when none
was stored — threw "image header requires a media link or id at send
time", failing the whole broadcast. The send path already supported
`messageParams.headerMediaUrl`; only the UI/hook never collected it.
- step3-personalize: show a required "Header media" URL field (with
http(s) validation and an image preview) when the selected template
has a media header; seed it from the template's stored sample URL and
block Next until a valid URL is provided.
- new-broadcast page: thread headerMediaUrl through wizard state.
- use-broadcast-sending: pass it as messageParams.headerMediaUrl to the
broadcast API for media-header templates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the machine-to-machine auth layer the public REST API needs so
external scripts and automations can drive wacrm (issue #245). This is
the groundwork release; data endpoints land one at a time on top of it.
- Migration 026: account-scoped `api_keys` table. SHA-256 hash at rest
(plaintext shown once), display `key_prefix`, `scopes[]`, RLS via
`is_account_member` (members read, admin+ manage).
- `src/lib/api-keys/`: key gen/hash (wacrm_live_ prefix, timing-safe),
scope vocabulary, service-role auth-path store.
- `requireApiKey(request, scope?)` (src/lib/auth/api-context.ts):
bearer parse -> hash lookup -> liveness -> per-key rate limit ->
scopes-only authz. Returns a service-role-scoped account context.
- `/api/v1` envelope ({data} / {error:{code,message}}) + GET /api/v1/me
reference endpoint that exercises the whole stack.
- Dashboard: admin+ key management routes + Settings -> API keys tab.
- Docs (docs/public-api.md), CHANGELOG, README, .env note.
Checks: typecheck clean, lint 0 errors, full suite green (+27 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "Filter by tags" popover (OR across selected tags) next to the
search box, with active-tag chips and clear-all. Resolves#270.
Filtering is resolved server-side via the filter_contacts_by_tags RPC so
pagination and the total count stay accurate even when a tag covers many
contacts (avoids PostgREST's row/IN-clause caps). A fetch-sequence guard
prevents out-of-order responses from rendering stale rows, and stale tag
selections are pruned when the tag list refreshes.
Note: requires migration 025_filter_contacts_by_tags.sql to be applied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a live presence layer so the Settings → Team members roster and the
inbox Assign dropdown show who is actively using the dashboard, idle, or
gone. Implements #269.
How it works
- New `member_presence` table + `touch_presence` SECURITY DEFINER RPC
(migration 024). Account-scoped SELECT RLS mirrors the read-only
roster; the RPC derives account_id server-side, never client-supplied.
- The active client heartbeats 'online'/'away' every ~30s; 'offline' is
derived by viewers from staleness, so a closed tab resolves to offline
without an unreliable unload write.
- Pure, unit-tested derivation/formatting in src/lib/presence.ts.
- `usePresence` reads the table, subscribes to Realtime (subscribe-then-
merge so events landing mid-fetch aren't clobbered), and re-derives
offline on a 15s tick.
- Roster: avatar badge + tooltip + "N online · N away · N offline"
summary, with an aria-label so AT/keyboard users get presence too.
Inbox Assign dropdown: matching dot before each teammate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a desktop-only toggle in the message-thread header to show/hide the
contact sidebar (tags, deals, notes). On smaller laptops the always-on
panel crowds the message thread; agents who only want to read and reply
can now reclaim that horizontal space.
The page owns the open/closed state and conditionally renders the
sidebar; the thread renders the toggle button and reflects the state.
The choice is persisted to localStorage (device-scoped, like the theme
prefs) so it survives reloads. To avoid a hydration mismatch we default
to open on the server and reconcile from storage in an effect after
mount. Mobile is unchanged — the sidebar is never a permanent panel
there and the toggle is hidden below lg.
Closes#258
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Settings page was the only dashboard route capped at
max-w-[1040px] mx-auto, so it floated centered with large empty
gutters on wide monitors while every other page is full-bleed.
Remove the cap so the page fills the available width like the rest of
the app, and spend the space where it helps readability:
- Overview status tiles go 3-up on xl (from 2-up).
- Templates list goes 2-up on xl.
- Form-heavy panels (Profile, Login & security, Deals) keep a readable
max-w-2xl; Fields & tags and Appearance use max-w-3xl — so inputs
don't stretch edge-to-edge on large displays.
- Grid uses minmax(0,1fr) to avoid blowout from long content.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat 8-tab Settings page with a cleaner information
architecture: a grouped left rail (Account / Workspace) plus a new
Overview landing with live status tiles. Related concerns are merged —
"Login & security" (password + sessions) and "Fields & tags" (tags +
custom fields).
- New shell: settings-sections (section model + legacy ?tab= mapping,
tags/custom-fields -> fields), settings-rail, settings-overview
(live counts + identity), security-panel, fields-and-tags-panel,
settings-panel-head, settings-chip, role-meta (shared role chip
metadata, de-duped from members-tab).
- Restyle the light panels to the mockup; reframe the heavy panels
(WhatsApp, Templates) with the shared panel header, internals intact.
- Tokens: add --card-2 (light+dark) and @theme mappings for
primary-hover/soft/soft-2 so the redesign stays fully tokenized.
- ?tab= remains the URL source of truth, keeping existing sidebar/header
deep-links working.
Overview decouples the slow WhatsApp Meta health-ping from the cheap
count queries so a flaky ping never blanks the whole landing; template
counts use head-only count queries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>