pleach
Build

Query

Server-only read API over persisted harness data — usage, transcripts, events, jobs, assets, tools, analytics. Uses a service-role Supabase client and bypasses RLS; never import into browser bundles.

Query is one surface in the frontend integration thematic island — siblings of react, server, api-routes, and 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.

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.

Subpath@pleach/core/querySourcesrc/query/

Usage and costs

FunctionReturnsUse
getChatUsage(client, chatId, options?)UsageResult (token counts + cost)Per-chat cost rollups
lookupModelCost(modelId)ModelCostEntryPer-call cost calculation
MODEL_COSTSRecord<string, ModelCostEntry>Static map of supported prices
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

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:

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

FunctionReturnsUse
getSessionReview(client, chatId, options?)Tool calls + completions for a sessionHistory UIs
getEnrichedSessionReview(client, chatId, options?)Same plus citations/usage/entitiesWhen 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:

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

FunctionReturnsUse
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 walkAudit a HITL decision
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:

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

FunctionReturnsUse
getChatManifest(client, chatId)Full job manifestLong-running async work status
getManifestJobsByStatus(client, chatId, status)Filtered manifest entries"Show me pending jobs"
getConversationState(client, chatId)High-level snapshotSidebar previews
refreshManifestFromEvents(client, chatId)Rebuild manifest from event log
getChatAssets(client, chatId)Asset listMaterialized artifacts
getChatAsset(client, { chatId, assetId })One assetDetail view
diffAssets(a, b)Asset diffCompare two asset sets
getBatchJobStatus(client, jobIds)Status mapBatch poll
getChatJobLinks(client, chatId)Job linksJob provenance
recordJobLink(client, link)Write helper retained for parity

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).

FunctionReturnsUse
listTools(options?)ToolListResult — every tool in the registrySettings panel
getToolDetails(name)Tool descriptor + name (or null)Detail view
getToolSchemaQuery(name)JSON schema (or null) — aliased getToolSchemaSchema-driven forms
getToolResults(client, chatId, { toolName?, statusFilter?, limit? })Recent results for the chatHistory panel
getToolSummary()Record<string, string[]> — tool names grouped by categoryDashboard

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).

FunctionReturnsUse
getAggregateUsage(client, store, filter)Token / cost rollupsOrg dashboards
compareAgents(client, { agentIds })Side-by-side metricsA/B comparison
getToolEffectiveness(client, store, filter)Success rate + latency per toolTool-quality reviews
getPlanDashboard(client, filter)Plan completion statsPlanner observability
getSessionTimeline(client, filter)Session activity timelinePer-user views
getLearningHealth(client, filter)Memory / learning healthEval pipelines
getIntentDistribution(client, store, filter)Intent classification distributionRouting analysis
detectAnomalies(client, store, filter)OutliersDrift 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

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

TableRead by
harness_sessionsSessions, conversation state
harness_event_logEvents, manifests, interrupt chains
ai_chat_messagesUsage, costs, model rollups
harness_outboxJob links
chat_session_linksProvenance 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

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.

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

On this page