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.
@pleach/core/querySourcesrc/query/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 |
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 tokensMODEL_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
| 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:
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
| 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 |
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
| 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
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
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
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
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
React
Browser-side hooks for the same data, RLS-bound.
HarnessServer
Framework-agnostic write handlers — the runtime-side counterpart to these read functions.
API routes
The HTTP + SSE wire contract these queries observe the results of.
DevTools
Browser-console surface for inspecting in-memory state before it reaches these queries.