Library API
Source: packages/core/src/api.ts → packages/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 libraryimport { 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.
Stability
Section titled “Stability”| 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.
Constraints
Section titled “Constraints”- Node ≥ 22.12 only (ADR-0001). ESM only — no CommonJS
require. - Fully synchronous.
SessionStoreusesreaddirSync,statSync, andexecFileSyncby 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.
lastWriteFindingsand its siblings describe the most recent write on that instance; sharing one instance across concurrent logical operations makes them ambiguous. exportscloses deep imports.beetl/dist/...is not reachable; the facade is the whole public surface.
The surface
Section titled “The 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 |
Not exported, deliberately
Section titled “Not exported, deliberately”Anything whose signature is an implementation detail we intend to keep changing, and anything whose export would weaken a guarantee:
- Concurrency primitives —
acquireLock,withLock,atomicWriteFile,ownerLiveness. ADR-0004 owns this; callers get the guarantees throughSessionStoreand must not hand-roll them. - Privacy internals —
compileRules,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.SessionStorewrites already run it. - Global-layer internals —
mirrorPath,registryPath,syncProject. The~/.beetllayout is not a contract. - Caches, prompt builders, report writers —
storeFingerprint,buildClassifyPrompt,writeBlameReport(which writes into the consumer’s repo; ADR-0008 governs attribution egress). - Raw zod schemas — exporting a
ZodObjectvalue would force zod into the public signature permanently. The types arez.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.
Worked examples
Section titled “Worked examples”Walking a session through the lifecycle
Section titled “Walking a session through the lifecycle”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" }, …]Handling errors
Section titled “Handling errors”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;}Supplying your own agent runner
Section titled “Supplying your own agent runner”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: "…" }; }}Verification
Section titled “Verification”packages/cli/test/api.test.ts— black-box against the builtdist/api.mjs(the exact fileexports["."]resolves to): full lifecycle in a temp repo, search, typedBeetlError,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 (Sessionresolves structurally,AgentRunneris implementable) enforced bytsc -binpnpm check.- CI
pack-smoke— imports the library from the installed tarball across 3 OS × Node 22/24, and runspublint+attw --profile esm-only. The esm-only profile is correct here:node10resolution andrequire()-of-ESM are expected misses for an ESM-only package, not defects.