# Query (/docs/query)



Query is one surface in the **frontend integration**
[thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) —
siblings of [react](/docs/react), [server](/docs/server),
[api-routes](/docs/api-routes), and [devtools](/docs/devtools).

`@pleach/core/query` is the server-side read API over persisted
harness data. Every function takes a Supabase service-role
`QueryClient` as its first argument and returns plain data — no
side effects, no writes.

**Server only.** This subpath assumes a service-role Supabase
client and bypasses RLS. Never import it into browser bundles.
The hooks in `@pleach/core/react` are the browser-safe equivalent
backed by RLS-bound clients.

```typescript
import { createClient } from "@supabase/supabase-js";
import {
  getChatUsage,
  getSessionReview,
  queryHarnessEvents,
  // ... see catalog below
} from "@pleach/core/query";

const client = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
);

const usage = await getChatUsage(client, "sess_abc");
```

Every function returns plain data. Errors surface as `QueryError`,
which carries one of three `code` values: `INVALID_INPUT` (bad input
shape, missing required field, or wrong client type), `DB_ERROR` (the
underlying Supabase call failed), and `NOT_FOUND` (a required parent
row is missing). Most readers return an empty array or `null` for
missing rows rather than throwing — the exception is functions that
must resolve a parent chat first, such as `getSessionReview`, which
throws `NOT_FOUND` when the chat doesn't exist.

<SourceMeta subpath="@pleach/core/query" source="{ label: &#x22;src/query/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/query&#x22; }" />

## Usage and costs [#usage-and-costs]

| Function                                 | Returns                             | Use                            |
| ---------------------------------------- | ----------------------------------- | ------------------------------ |
| `getChatUsage(client, chatId, options?)` | `UsageResult` (token counts + cost) | Per-chat cost rollups          |
| `lookupModelCost(modelId)`               | `ModelCostEntry`                    | Per-call cost calculation      |
| `MODEL_COSTS`                            | `Record<string, ModelCostEntry>`    | Static map of supported prices |

```typescript
const usage = await getChatUsage(client, chatId);
// → { chat_id, model, provider,
//     tokens: { input, output, total, context_window, utilization_pct },
//     cost_estimate_usd, message_count, tool_call_count,
//     total_duration_ms, models_used }

const cost = lookupModelCost("claude-sonnet-4-5");
// → { input: 0.003, output: 0.015, contextWindow: 200000 }  // input/output are USD per 1K tokens
```

`MODEL_COSTS` ships with the package and gets updated as
providers publish new prices. Treat it as a snapshot — for
production billing, override with your own pricing table that
matches your contracted rates.

### Per-model cost rollup [#per-model-cost-rollup]

`getChatUsage` returns the per-chat cost estimate plus a
`models_used` breakdown — token share per model across the
conversation, including provider fallback and mid-stream retries:

```typescript
const usage = await getChatUsage(client, chatId);

console.log(usage.cost_estimate_usd);   // total USD for the chat
for (const m of usage.models_used ?? []) {
  console.log(m.model, m.tokens, `${m.pct}%`);
}
// → claude-sonnet-4-5 18432 72%
//   gpt-4o-mini        7104 28%
```

Per-turn granularity isn't shipped as a convenience reader. The
`harness_auditable_calls` table carries `turn_id` (stable across
retries within a turn) and per-call token usage — query it directly
through the service-role client when you need a cost report keyed to
user-visible turns.

## Sessions, transcripts, and review [#sessions-transcripts-and-review]

| Function                                             | Returns                                | Use                                                                     |
| ---------------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------- |
| `getSessionReview(client, chatId, options?)`         | Tool calls + completions for a session | History UIs                                                             |
| `getEnrichedSessionReview(client, chatId, options?)` | Same plus citations/usage/entities     | When you opt in via `includeCitations`/`includeUsage`/`includeEntities` |
| `configureQueryEnrichers({ citations, entities })`   | –                                      | Register domain extractors                                              |

Two extractors that previously lived in this package — chat
citations and chat entities — were relocated to the host
application layer because they pull domain-coupled rules. Wire
them back in via `configureQueryEnrichers`:

```typescript
import { configureQueryEnrichers, getEnrichedSessionReview } from "@pleach/core/query";

configureQueryEnrichers({
  citations: myCitationExtractor,
  entities:  myEntityExtractor,
});

const review = await getEnrichedSessionReview(client, chatId, {
  includeCitations: true,
  includeUsage: true,
  includeEntities: true,
});
```

Enrichment is driven by the `include*` option flags, not by which
enrichers are registered. Each flag you omit leaves that section off
the result entirely — so with no options `getEnrichedSessionReview`
returns exactly the `getSessionReview` shape. `includeUsage` runs
`getChatUsage` directly and needs no enricher; `includeCitations` and
`includeEntities` invoke the registered extractors, or resolve to an
empty result when no extractor has been configured.

## Events [#events]

| Function                                               | Returns                                              | Use                                               |
| ------------------------------------------------------ | ---------------------------------------------------- | ------------------------------------------------- |
| `queryHarnessEvents(client, filter)`                   | `EventQueryResult` (`{ events, nextCursor, count }`) | Filter event log by type/session, cursor-paginate |
| `getAllChatEvents(client, chatId)`                     | `HarnessEvent[]`                                     | All events for one chat (auto-paginated)          |
| `countEventsByType(client, chatId)`                    | `Record<string, number>`                             | Aggregate counts                                  |
| `getInterruptChain(client, chatId, toolCallId, opts?)` | Interrupt → resolution walk                          | Audit a HITL decision                             |

```typescript
const errors = await queryHarnessEvents(client, {
  chat_id: chatId,
  event_types: ["error", "tool.failed"],
  severity: "error",
});
// → { events: [...], nextCursor: "…" | null, count: number }

const counts = await countEventsByType(client, chatId);
// → { "message.added": 12, "tool.completed": 8, "checkpoint.created": 4, ... }
```

`queryHarnessEvents` returns `{ events, nextCursor, count }`, with
`events` ordered by `created_at` ascending. When more rows remain,
`nextCursor` is a base64-encoded `created_at|id` compound key (the
compound form avoids skipping events that share a timestamp); pass it
back as `cursor` to fetch the next page. A `null` `nextCursor` means
there are no more rows. Page size is capped at 200.

Walk a long-running chat page by page:

```typescript
let cursor: string | undefined;

while (true) {
  const page = await queryHarnessEvents(client, {
    chat_id: "chat-018f-abc",
    cursor,
    limit: 200,
  });
  for (const ev of page.events) handle(ev);
  if (!page.nextCursor) break;
  cursor = page.nextCursor;
}
```

## Manifests, jobs, assets [#manifests-jobs-assets]

| Function                                          | Returns                   | Use                              |
| ------------------------------------------------- | ------------------------- | -------------------------------- |
| `getChatManifest(client, chatId)`                 | Full job manifest         | Long-running async work status   |
| `getManifestJobsByStatus(client, chatId, status)` | Filtered manifest entries | "Show me pending jobs"           |
| `getConversationState(client, chatId)`            | High-level snapshot       | Sidebar previews                 |
| `refreshManifestFromEvents(client, chatId)`       | –                         | Rebuild manifest from event log  |
| `getChatAssets(client, chatId)`                   | Asset list                | Materialized artifacts           |
| `getChatAsset(client, { chatId, assetId })`       | One asset                 | Detail view                      |
| `diffAssets(a, b)`                                | Asset diff                | Compare two asset sets           |
| `getBatchJobStatus(client, jobIds)`               | Status map                | Batch poll                       |
| `getChatJobLinks(client, chatId)`                 | Job links                 | Job provenance                   |
| `recordJobLink(client, link)`                     | –                         | Write helper retained for parity |

## Tool inspection [#tool-inspection]

The registry inspectors (`listTools` / `getToolDetails` / `getToolSchemaQuery` /
`getToolSummary`) read the in-process tool registry — they are **session-agnostic**
and take no client. Only `getToolResults` is DB-backed (chat-keyed).

| Function                                                               | Returns                                                     | Use                 |
| ---------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------- |
| `listTools(options?)`                                                  | `ToolListResult` — every tool in the registry               | Settings panel      |
| `getToolDetails(name)`                                                 | Tool descriptor + `name` (or `null`)                        | Detail view         |
| `getToolSchemaQuery(name)`                                             | JSON schema (or `null`) — aliased `getToolSchema`           | Schema-driven forms |
| `getToolResults(client, chatId, { toolName?, statusFilter?, limit? })` | Recent results for the chat                                 | History panel       |
| `getToolSummary()`                                                     | `Record<string, string[]>` — tool names grouped by category | Dashboard           |

## Cross-session analytics [#cross-session-analytics]

Four of these also take a `BaseStore` as the middle argument (they read
meta-learning / aggregate state from the store, not just the DB): `getAggregateUsage`,
`getToolEffectiveness`, `getIntentDistribution`, and `detectAnomalies`. The other four
are `(client, filter)`.

| Function                                       | Returns                            | Use                   |
| ---------------------------------------------- | ---------------------------------- | --------------------- |
| `getAggregateUsage(client, store, filter)`     | Token / cost rollups               | Org dashboards        |
| `compareAgents(client, { agentIds })`          | Side-by-side metrics               | A/B comparison        |
| `getToolEffectiveness(client, store, filter)`  | Success rate + latency per tool    | Tool-quality reviews  |
| `getPlanDashboard(client, filter)`             | Plan completion stats              | Planner observability |
| `getSessionTimeline(client, filter)`           | Session activity timeline          | Per-user views        |
| `getLearningHealth(client, filter)`            | Memory / learning health           | Eval pipelines        |
| `getIntentDistribution(client, store, filter)` | Intent classification distribution | Routing analysis      |
| `detectAnomalies(client, store, filter)`       | Outliers                           | Drift detection       |

These accept a filter object with `{ from, to, userId?, organizationId?, modelId?, ... }`. The full filter shape is in the
TypeScript types — your IDE autocomplete is the canonical reference.

## Required tables [#required-tables]

The query layer reads from the same tables the runtime writes to:

| Table                | Read by                             |
| -------------------- | ----------------------------------- |
| `harness_sessions`   | Sessions, conversation state        |
| `harness_event_log`  | Events, manifests, interrupt chains |
| `ai_chat_messages`   | Usage, costs, model rollups         |
| `harness_outbox`     | Job links                           |
| `chat_session_links` | Provenance to upstream chat         |

All reads use service-role credentials and bypass RLS. The query
functions construct typed SQL against these tables — they don't
re-read the schema bundle at runtime, so additive schema
migrations are forward-compatible.

## `QueryError` [#queryerror]

The only error type the query layer throws. Its `code` field is one
of `INVALID_INPUT` (bad input shape, missing required field, or wrong
client type), `DB_ERROR` (the underlying Supabase call failed), or
`NOT_FOUND` (a required parent row is missing). Most missing rows
return empty arrays or `null` rather than throwing; the exception is
readers that must resolve a parent chat first — `getSessionReview`
throws `NOT_FOUND` when the chat doesn't exist.

```typescript
import { QueryError } from "@pleach/core/query";

try {
  const review = await getSessionReview(client, "");
} catch (err) {
  if (err instanceof QueryError) {
    // Programmer error — log and fix the caller.
  }
  throw err;
}
```

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="React" href="/docs/react" description="Browser-side hooks for the same data, RLS-bound." />

  <Card title="HarnessServer" href="/docs/server" description="Framework-agnostic write handlers — the runtime-side counterpart to these read functions." />

  <Card title="API routes" href="/docs/api-routes" description="The HTTP + SSE wire contract these queries observe the results of." />

  <Card title="DevTools" href="/docs/devtools" description="Browser-console surface for inspecting in-memory state before it reaches these queries." />
</Cards>
