Version: 1.0.0 Status: Stable License: Apache-2.0
This document is the binding contract between @takk/agenticstash and its consumers. Behavior described here is covered by SemVer: breaking changes require a major version bump and a deprecation cycle (see SEMVER POLICY).
agenticstash is a universal, zero-runtime-dependency library and CLI that records every source of non-determinism a Massive Intelligence (IM) agent run touches, then serves it back on replay, so an irreproducible production run becomes step-through-debuggable. It is the rr of the agentic world, the git stash of agent runs:
- Record model outputs, tool and MCP responses, the clock, randomness, and any wrapped call.
- Replay them deterministically, re-throwing recorded errors at the original site.
- Detect divergence between a recording and the code under replay, in one pass.
- Fork a run at a decision point to explore an alternate branch.
- Diff two recordings to find exactly where they differ.
- Seal a recording with a tamper-evident SHA-256 digest, and verify it later.
- Redact secrets and PII before they ever reach a recording.
It is library-shaped, not service-shaped: no central server, no SaaS dependency, no SDK lock-in. Determinism is by substitution: the library replays the values the original run observed, it does not make a model deterministic. A code path that changes its calls or their order will, by design, diverge.
The package ships ten subpath exports, each with separate import (ESM) and require (CJS) conditions and matching .d.ts / .d.cts files:
| Subpath | Default | Use |
|---|---|---|
. |
./dist/index.{js,cjs} |
Core: createStash, loadStash, re-exports, types, errors |
./record |
./dist/record/index.{js,cjs} |
createRecorder, DROP |
./replay |
./dist/replay/index.{js,cjs} |
createReplayer |
./storage |
./dist/storage/index.{js,cjs} |
BlobStore, encodeRecording, decodeRecording, recordingStats |
./fork |
./dist/fork/index.{js,cjs} |
fork |
./diff |
./dist/diff/index.{js,cjs} |
diffRecordings |
./interceptors |
./dist/interceptors/index.{js,cjs} |
createDeterministicClock, createSeededRandom, wrap, wrapSync |
./mcp |
./dist/mcp/index.{js,cjs} |
interceptMcpClient, recordMcpTool |
./seal |
./dist/seal/index.{js,cjs} |
sealRecording, verifyRecording |
./edge |
./dist/edge/index.{js,cjs} |
The full core under a worker condition |
./package.json |
./package.json |
Manifest access for tooling |
An agenticstash binary is exposed via package.json#bin -> ./dist/cli/index.js.
The whole engine is Node-free (the seal uses the Web Crypto API, not node:crypto), so ./edge re-exports the entire core verbatim; there is no reduced subset.
The unified record-or-replay facade. Instrument an agent once; construct without a replay recording to record, with one to replay the same calls.
interface Stash {
readonly mode: 'record' | 'replay';
intercept<T>(channel, key, produce: () => Promise<T> | T, options?: InterceptOptions): Promise<T>;
interceptSync<T>(channel, key, produce: () => T, options?: InterceptOptions): T;
value<T>(channel, key, value: T, options?: InterceptOptions): T;
recording(): Recording;
save(): string;
consumed(): number; // replay mode; 0 when recording
remaining(): RecordedEvent[];// replay mode; [] when recording
divergences(): Divergence[]; // replay mode with onDivergence: 'collect'
report(): DivergenceReport;
}loadStash(json, { strict?, onDivergence? }) decodes a recording and returns a replay-mode stash.
createRecorder(options?)returns aRecorder(record,recordAsync,value,recording,size).DROPis the sentinel aRedactFnreturns to store a metadata-only event.createReplayer(recording, { strict?, onDivergence? })returns aReplayer(replay,replayAsync,consumed,remaining,divergences,report). WithonDivergence: 'collect'it records every divergence and keeps replaying so one pass yields a fullDivergenceReport.
BlobStore,encodeRecording(recording): string,decodeRecording(json): Recording(validates shape, throwsERR_INVALID_RECORDING),recordingStats(recording): RecordingStats.fork(recording, { at, override?, id? }): Recording.diffRecordings(a, b): DiffResult.sealRecording(recording): Promise<RecordingSeal>,verifyRecording(recording, seal): Promise<VerifyResult>. The seal is a SHA-256 hash chain, an integrity seal, not a digital signature.
createDeterministicClock(stash, source?, key?),createSeededRandom(stash, seed?, key?),wrap(stash, channel, key, fn),wrapSync(...).interceptMcpClient(stash, client),recordMcpTool(stash, name, handler), both duck-typed, no MCP SDK import.
Agentic Stash throws a single error type on purpose. There is no class hierarchy; branch on the stable code, never on the message.
type AgenticStashErrorCode =
| 'ERR_DIVERGENCE' // a strict replay saw a call that does not match the recording
| 'ERR_RECORDING_EXHAUSTED' // replay asked for an event past the recording (no live tail)
| 'ERR_INVALID_RECORDING' // a recording failed validation on load
| 'ERR_INVALID_INPUT' // a malformed argument, option, or non-serializable value
| 'ERR_NOT_FOUND'; // a missing blob, event, or recordisAgenticStashError(value): value is AgenticStashError is the exported type guard. details is safe to log and never contains secrets.
The atom is RecordedEvent; a Recording is the ordered tape plus a content-addressed blob table.
interface RecordedEvent {
seq: number; // global monotonic order, zero-based
channel: string; // 'llm', 'tool', 'mcp', 'clock', 'random', or custom
key: string; // stable call-site key within the channel
ordinal: number; // ordinal within (channel, key)
outcome: 'return' | 'throw';
ref: string; // content-addressed reference into Recording.blobs
inputRef?: string; // content hash of the input, when supplied (divergence detection)
at?: number; // informational wall-clock time; never used for replay
label?: string;
}
interface Recording {
version: 1;
id: string;
events: readonly RecordedEvent[];
blobs: Readonly<Record<string, unknown>>;
meta: Readonly<Record<string, unknown>>;
}The stable, SemVer-protected shapes are: Channel, Outcome, StashMode, RecordedEvent, Recording, InterceptOptions, StashOptions, Stash, RecordingStats, Divergence, DivergenceKind, DivergenceReport, RedactContext, RedactFn, RecordingSeal, VerifyResult, plus the per-module option and result types (RecorderOptions, ReplayerOptions, ForkOptions, DiffResult, EventOnly, EventChange, DeterministicClock, SeededRandom, McpToolCall, McpClientLike).
+-----------------------------------------+
| Caller code (instrumented once) |
| const s = createStash() // or {replay} |
| await s.intercept('llm','plan', call) |
+-------------------+---------------------+
| record OR replay (same call site)
+-----------+-----------+
v v
+----------------+ +----------------------+
| record | | replay |
| capture value | | serve recorded value |
| or throw | | re-throw recorded err|
| store as event | | check input hash |
+-------+--------+ | collect divergences |
| +----------+-----------+
v |
+-----------------------------------------+
| storage: content-addressed Recording |
| (BlobStore dedup + encode/decode) |
+-------------------+---------------------+
+-----------+-----------+-----------------+
v v v v
+--------+ +--------+ +-----------+ +-------------+
| fork | | diff | | seal | | redact |
| branch | | a vs b | | SHA-256 | | mask / DROP |
+--------+ +--------+ | hash chain| | at record |
+-----------+ +-------------+
The recorder runs a producer, stores its return value or thrown error as a content-addressed blob, and appends an event keyed by (channel, key, ordinal). The replayer serves the next recorded event for a (channel, key) pair in order. When an input is supplied, its content hash is recorded; on a strict replay a mismatch is a divergence.
onDivergence: 'throw' (default) raises ERR_DIVERGENCE on the first input mismatch. onDivergence: 'collect' records each divergence (input-mismatch, extra-call, missing-call) and keeps replaying, so report() returns the full DivergenceReport with firstDivergence in one pass.
BlobStore deduplicates structurally equal payloads via a dependency-free cyrb53 content hash (a non-cryptographic addressing hash with full-content collision safety), with structuredClone defensive copies. Recordings serialize to portable JSON.
fork keeps every event before at, optionally overrides the decision at at with a new value, and drops the tail. Replaying the fork serves the shared prefix then runs live (an opt-in live tail), so a single recorded run roots an alternate branch.
diffRecordings aligns events by (channel, key, ordinal) identity, not raw sequence, and reports added, removed, and changed events plus the earliest firstDivergence.
sealRecording folds the recording id and every event (with its value and input payloads) into a SHA-256 hash chain via the Web Crypto API and returns the root digest. verifyRecording recomputes the chain and reports whether the recording is byte-for-byte the one sealed. Any change to an event, a value, their order, or the id breaks the root. It is an integrity seal, not a digital signature.
A RedactFn on the stash or recorder transforms each value and input before storage. Return a masked value to keep structure, or DROP to store only a marker (metadata-only). Redaction is one-way: a redacted field replays as its redacted form.
The library is small; targets here are runtime characteristics, not service SLOs.
| Target | Budget |
|---|---|
| Runtime dependencies (required) | 0 |
ESM core bundle (dist/index.js, brotli) |
<= 12 kB |
CJS core bundle (dist/index.cjs, brotli) |
<= 12 kB |
seal subpath (brotli) |
<= 5 kB |
edge ESM bundle (brotli) |
<= 12 kB |
| Tarball size (full package) | <= 200 KB |
| Engines | Node >= 20.0.0 |
| Runtime (Node, Deno, Bun, edge/worker, browser bundlers) | full core in every target |
The bundle budgets are enforced in CI via size-limit (.size-limit.json); the rest are design intent.
For 1.0.0 onward:
- Every name exported from
./dist/index.{js,cjs,d.ts}and from each subpath export. - Every type, interface, function signature, and string-literal union variant reachable from those exports.
- The shape of every type listed in §2.4.
- The
AgenticStashErrorCodevalues. - The recording envelope (
version: 1) and the seal hash-chain algorithm (a change to either breaks existing recordings or seals and is a major). - The CLI flags and subcommands of
agenticstash.
Not part of the public API:
- Anything inside
src/not re-exported from a documented entry point. - The internal blob-addressing hash (
cyrb53), as long as content addressing and dedup hold within a recording. - The exact wording of error messages and CLI help text.
| Change | Bump |
|---|---|
| Bug fix, internal refactor, doc-only | patch (1.0.0 -> 1.0.1) |
| New export, new optional field, new optional surface | minor (1.0.0 -> 1.1.0) |
| Renaming/removing an export, signature change, recording-shape or seal-format change, CLI flag removal | major (1.0.0 -> 2.0.0) |
Breaking a public API requires:
- Announce the deprecation in a minor release of the current major: add
@deprecatedJSDoc on the export and a runtimeconsole.warn(debounced once per process). - Ship the deprecated API for at least one further minor of the same major. Consumers must always have a non-deprecated path.
- Remove only in the next major release, accompanied by a
MIGRATING.mdwith a migration recipe.
Security-driven exceptions ship in the next patch across all supported majors with a ### Security CHANGELOG entry.
- License stays Apache-2.0 within a major.
NOTICEis preserved verbatim in the tarball.- Every release is published with
--provenance(SLSA attestation by GitHub Actions). Consumers can verify vianpm view @takk/agenticstash@<version> --json | jq .dist.attestations.
agenticstashis a library; it makes no network calls and reads no environment at import time.- Recording and replay are deterministic given the same calls in the same order; the only stateful surface is the in-memory event tape the stash holds.
- The recorder is transparent: it returns the producer's value (or re-throws its error) unchanged, so wrapping a call does not alter behavior while recording.
- No function persists to disk. The CLI reads only the input files you point it at and writes only to the paths you specify.
- Unit tests for hashing and serialization, the recorder, the replayer (including divergence collection), storage, fork, diff, interceptors, the MCP bridge, sealing and verification, redaction, and the error model.
- CLI tests that drive the command logic in-process via an injected
CliIOand assert exit codes (sysexits: 0 success, 64 usage, 65 bad data, 66 unreadable input, 1 on a failed seal) and output. - A dist smoke script (
scripts/smoke-dist.mjs) that exercises the built artefact end-to-end, including record-then-replay determinism, divergence detection, fork override, the seal, redaction, the MCP bridge, and the CLI exit codes.
Coverage thresholds are enforced via vitest.config.ts. Current run (1.0.0): 86 tests passing across 14 suites, statements 87.96%, branches 76.94%, functions 89.83%, lines 88.62%.
- A hosted replay UI or SaaS. Agentic Stash is an embeddable library; step-by-step state-inspection UI is a separate product.
- Making a model deterministic. Determinism is by substitution of recorded values, not by constraining the model.
- A digital signature. The seal is tamper-evident (a hash chain); authorship signing is deferred.
- Concurrent same-key call ordering. Replay assumes a deterministic per-key call order; use distinct keys for concurrent same-key calls, or wait for the input-matched mode planned for 1.1.
- Automatic instrumentation. Calls must be wrapped explicitly (
intercept/wrap); provider adapters that lower this friction are on the roadmap.
See TASK.md for the live deferred-work list.