Skip to content

Library API

Source: packages/core/src/api.tspackages/cli/src/api.ts · Spec: §2, §12.1 · ADR: 0010

Beetl’s capabilities are importable, not just executable. The published beetl package has two entry points from one install:

beetl
├── bin.beetl → dist/index.mjs the CLI
└── exports["."] → dist/api.mjs the library
import { SessionStore, searchSessions, transition, BeetlError } from "beetl";
const store = SessionStore.open(process.cwd());
const ctx = {
actor: { kind: "agent", tool: "my-bot" },
at: new Date().toISOString(),
};
const session = store.create(
{ title: "flaky auth redirect", type: "defect" },
ctx,
);
const hits = searchSessions(store, "auth redirect", 5);
store.move(session.id, "reproducing", ctx);

The point is process-per-call amortization: a host that touches 200 sessions through the CLI pays 200 spawns, each re-reading and re-indexing the whole store (searchSessions builds its MiniSearch index on demand, per invocation). In-process, one SessionStore instance answers repeatedly.

Surface Contract
Session JSON schema semver-tracked (schemaVersion)
CLI --json output semver-tracked (ADR-0006, snapshot-tested)
TypeScript library API experimental — may change in 0.x minors
Terminal UX not tracked

Experimental is the honest label for a surface designed before anyone has used it. It is reversible in the direction that matters: promoting to tracked costs nothing, demoting costs a major.

  • Node ≥ 22.12 only (ADR-0001). ESM only — no CommonJS require.
  • Fully synchronous. SessionStore uses readdirSync, statSync, and execFileSync by design (a deliberate CLI choice, not a bug). It is therefore unsuitable for a request-serving hot path and impossible in a browser or worker.
  • One store instance per logical operation. lastWriteFindings and its siblings describe the most recent write on that instance; sharing one instance across concurrent logical operations makes them ambiguous.
  • exports closes deep imports. beetl/dist/... is not reachable; the facade is the whole public surface.
Area Exported
Store SessionStore, findRoot, initStore, adoptStore, fileComplete, applyUpdates, doctor · types WriteContext, ListFilters, InitOptions, InitResult, DoctorIssue, DoctorReport, ConfigFormat
Lifecycle transition, canTransition, unmetGates, allUnmetGates, checklist, nextStatus, isTerminal, auditSession · types UnmetGate, TransitionOptions, ChecklistItem, AuditIssue, AuditOptions
Search searchSessions, similarSessions, similarToDraft · type SearchHit
Patterns computeStructuralReport, computeBlame, collectRefixChains, failureLog, nudgesForSession · types StructuralReport, BlameReport, RefixChain, NudgeHit, Hotspot, LogEntry, and their filters
Agents AgentRunner, resolveRunner, detectRunner, NoneRunner · types RunnerName, RunnerExecContext, the outcome unions, ClusterPack
Identity formatActor, parseActor, isSessionId, parseId
Errors BeetlError · type ErrorCode
Types Session, SessionDraft, Actor, Status, ProjectConfig, SessionEvent, Attribution, GithubIssueRef, and the other sub-record types
Constants STATUSES, OPEN_STATUSES, TERMINAL_STATUSES, SESSION_TYPES, SEVERITIES, PRIORITIES, RELIABILITIES, FIX_APPROACHES, BUILTIN_CATEGORIES

Anything whose signature is an implementation detail we intend to keep changing, and anything whose export would weaken a guarantee:

  • Concurrency primitivesacquireLock, withLock, atomicWriteFile, ownerLiveness. ADR-0004 owns this; callers get the guarantees through SessionStore and must not hand-roll them.
  • Privacy internalscompileRules, BUILTIN_RULES, scanSession, applyDetections. ADR-0002 makes privacy fail-closed with no bypass; exporting the pipeline’s seams is the fastest way to hand someone one. SessionStore writes already run it.
  • Global-layer internalsmirrorPath, registryPath, syncProject. The ~/.beetl layout is not a contract.
  • Caches, prompt builders, report writersstoreFingerprint, buildClassifyPrompt, writeBlameReport (which writes into the consumer’s repo; ADR-0008 governs attribution egress).
  • Raw zod schemas — exporting a ZodObject value would force zod into the public signature permanently. The types are z.infer-derived, but zod is bundled into the emitted declarations and is not a dependency.

Excluded is not gone: these remain in @beetl/core for the CLI. Promoting one later is a minor bump; demoting one after publish is a major.

Gates are enforced identically to the CLI. Field changes go through applyUpdates, which logs the update event the store’s lost-update guard requires — a bare mutate that changes the body without an event is rejected as CONCURRENT_MODIFICATION.

import { SessionStore, applyUpdates, unmetGates, BeetlError } from "beetl";
const store = SessionStore.open(process.cwd());
const ctx = {
actor: { kind: "agent", tool: "my-bot" },
at: new Date().toISOString(),
};
const session = store.create(
{
title: "flaky auth redirect",
report: { symptoms: "login bounces back to /login" },
},
ctx,
);
store.move(session.id, "reproducing", ctx);
store.mutate(session.id, (current) =>
applyUpdates(current, { "reproduction.reliability": "always" }, ctx),
);
store.move(session.id, "diagnosing", ctx);
// Gates are inspectable before you attempt the move.
console.log(unmetGates(store.read(session.id), "fixing"));
// [{ gate: "diagnosis.rootCause", message: "record the root cause before fixing" }, …]

Every failure is one typed class. code is the stable discriminant; exitCode mirrors what the CLI would return (1 user/validation, 2 store corruption, 3 runner failure), and toJSON() produces the same envelope as --json (ADR-0006).

import { BeetlError } from "beetl";
try {
store.move(session.id, "resolved", ctx);
} catch (error) {
if (error instanceof BeetlError && error.code === "GATE_UNMET") {
console.error(error.toJSON()); // { error: { code, message, …details } }
} else throw error;
}

AgentRunner is the extension seam: implement three methods to bring your own model wiring without forking. Every entry point degrades gracefully — a runner either returns a result or a well-formed prompt for a human/host agent to run.

import type { AgentRunner } from "beetl";
class MyRunner implements AgentRunner {
readonly name = "none" as const;
readonly grounding = "metadata-only" as const;
async classify(draft, config, execCtx) {
return { kind: "prompt", prompt: "" };
}
async synthesize(packs, config, execCtx) {
return { kind: "prompt", prompt: "" };
}
async diagnose(session, repoContext, config) {
return { kind: "prompt", prompt: "" };
}
}
  • packages/cli/test/api.test.ts — black-box against the built dist/api.mjs (the exact file exports["."] resolves to): full lifecycle in a temp repo, search, typed BeetlError, doctor, read-only write results, plus a snapshot of the export surface.
  • packages/core/src/api.test.ts — the canonical export list, assertions that the deliberate exclusions stay excluded, and type-level checks (Session resolves structurally, AgentRunner is implementable) enforced by tsc -b in pnpm check.
  • CI pack-smoke — imports the library from the installed tarball across 3 OS × Node 22/24, and runs publint + attw --profile esm-only. The esm-only profile is correct here: node10 resolution and require()-of-ESM are expected misses for an ESM-only package, not defects.