Make your agentic workflows durable with embeddable primitives that compose with your existing infra. No server, no dedicated runtime, no new infra.
개요
Make your agentic workflows durable with embeddable primitives that compose with your existing infra. No server, no dedicated runtime, no new infra. Agentic workflows fail in a frustratingly expensive way: they run for minutes or hours, make costly LLM calls, execute tools with real-world side effects, and often pause for human input or external systems. Then the process crashes, times out, gets redeployed, or simply disappears. Since the state was in memory, naive retry means re-paying for answers the model already gave you, re-firing side effects that were supposed to happen once, and pushing recovery complexity back into app code. kassette solves this with an append-only journal. It records completed work so that a replay fast-forwards through the journal on the next invocation and resumes live from the first unfinished step. Your existing queue or job runner can retry normally.
README
kassette
Make your agentic workflows durable with embeddable primitives that compose with your existing infra. No server, no dedicated runtime, no new infra.
Agentic workflows fail in a frustratingly expensive way: they run for minutes or hours, make costly LLM calls, execute tools with real-world side effects, and often pause for human input or external systems. Then the process crashes, times out, gets redeployed, or simply disappears. Since the state was in memory, naive retry means re-paying for answers the model already gave you, re-firing side effects that were supposed to happen once, and pushing recovery complexity back into app code.
kassette solves this with an append-only journal. It records completed work so that a replay fast-forwards through the journal on the next invocation and resumes live from the first unfinished step. Your existing queue or job runner can retry normally. kassette ensures those re-invocations are safe by replaying completed steps instead of running them again.
Use kassette when the problem is not “how do I run this again?” but “how do I avoid doing the same work twice?” Ie, your existing stack makes retries easy but not safe.
For the longer rationale, see Why kassette.
Properties
Embeddable, not a runtime. kassette is a library that runs inside your process, like SQLite. It’s not a separate server or sidecar or worker that runs or schedules your code.
No new infrastructure. Composes with your existing infra. No database to host, no server to deploy, no queue to provision. Re-invocation is wired up using the queue, webhook, or job runner you already have. Uses the filesystem or object storage.
Plain-text state. Each run is a JSONL journal. Dump it, pipe it to jq, save it as a test fixture, or query S3-backed journals with Athena. See operations.
No compute while waiting. Waiting on human approval, a CI pipeline, or a webhook? The process simply exits. When the signal arrives, replay to the point you suspended and continue.
Serverless-native. The journal is the state; the process is a disposable. Any later process with access to the journal can continue the run. Copy it, sync it, ship it across regions.
What kassette ships, what you bring
Durable execution decomposes into two halves: a journal that records completed work, and a dispatcher that invokes the same runId again after a crash or timeout. Temporal and Inngest ship both. kassette ships only the journal.
You wire up the dispatcher that’s already in your stack: a queue, job runner, or anything else that can run the same work again after failure. If a run doesn’t succeed, the dispatcher invokes it again, and replay skips the steps that already finished. You don’t need heartbeats, sweepers, or a run-status table.
Because the dispatch key and journal key are the same runId, retries are safe. kassette replays completed steps instead of running them again. If an old worker keeps running after a retry has taken over, its next journal write is rejected.
For concrete queue/job running wiring patterns, see Wiring the dispatcher.
Guarantees
- At-most-once journaling. A step result is recorded at most once. Later invocations replay the recorded value. Entries are append-only, ordered, and immutable. (A step
fnmay run more than once if its work finishes but its result is not journaled — eg crashes, fencing, or concurrent suspension — so side-effect idempotency remains the caller’s responsibility.) - Journal authority. Recorded work stays settled. A retried
resumekeeps the first recorded event, even if a later retry passes a different payload. kassette never invalidates, expires, or garbage-collects a journal. - Atomic appends. Each entry commits fully or not at all. Multi-entry operations like
resume,fork, and auto-cancel are crash-recoverable. Backend details are covered in Local storage and Remote storage. - Single-writer fencing. Only one session can append to a run. If an older process keeps running after a replacement starts, its next append fails with
FencedError. Fencing is per run. - Terminal-state protection. Once
complete,error, orcancellands, a laterstart/resume/forkon that run will throwTerminalRunError. Dispatchers can catch this and ack stale retries without re-running work. - Deterministic replay. With the same journal and code, replay rebuilds the same state and resumes at the first unfinished step. Replay state comes only from the journal.
- Opt-in version check. Pass an optional
versionoption to reject incompatible workflow code withVersionMismatchError.
kassette also throws on event-name collisions, changed start metadata, and mismatched parallel branch names on replay.
Example
Just write a normal async function, wrapping anything you want to journal in ctx.step(). On replay, kassette will return the recorded result instead of running the step again. Use ctx.suspend() to pause the workflow and return control to the caller. When you resume, kassette will replay to that point and continue with the event payload.
There is no DSL, decorator system, or special control flow. if, while, try/catch, and early return work normally. Put the workflow inside a framework callback, middleware, or wherever your code already runs.
import { kassette, LocalStorage } from '@usekassette/kassette';
const storage = new LocalStorage('.kassette');
type Events = {
'human-approval': { approved: boolean; notes?: string };
};
const agent = kassette(
async (ctx, ticket) => {
const analysis = await ctx.step('analyze', () => llm.chat('Diagnose this issue and recommend a fix', { ticket }));
if (analysis.destructive) {
const approval = await ctx.suspend('human-approval');
if (!approval.approved) return { outcome: 'skipped', reason: approval.notes };
}
const result = await ctx.step('apply-fix', () => executeTool(analysis.suggestedAction));
return { outcome: 'resolved', result };
},
{ storage },
);
let result = await agent.start(ticket);
// → { status: 'suspended', event: 'human-approval', runId }
// ─── your process exits ───────────────────----------------------------
// resume from an external signal like a webhook, UI approval, or callback:
result = await agent.resume(result.runId, {
eventName: 'human-approval',
value: { approved: true },
});
// the llm call is replayed. same result, no new spend. execution continues
// from the approval point
// → { status: 'success', result: { outcome: 'resolved', ... }, runId }
Install
npm install @usekassette/kassette
@usekassette/kassette — Workflow API: kassette(), Context, step, suspend, parallel, fork.
@usekassette/core — Low-level durability primitives: start, record, waitForEvent, Storage. Use this directly if you want to embed durability into an existing agent loop without the workflow wrapper.
@usekassette/s3 — S3 storage backend.
@usekassette/cli — CLI for inspecting and forking journals. See CLI.
Zero runtime dependencies.
API
The Context passed to your workflow function:
| Method | What it does |
|---|---|
ctx.step(name, fn) |
Runs and records a unit of work. On replay, returns the recorded result instead of running fn again. If fn throws, retries in-process according to { retry: { maxAttempts, delay, backoffRate, maxDelay } }, then propagates the error. |
ctx.suspend(eventName, { timeout?, reason? }) |
Suspends the workflow and returns { status: 'suspended' } to the caller. resume delivers the event payload. Event payloads are typed with the TEvents generic. If timeout expires before resume, the run is canceled terminally. |
ctx.parallel(branches) |
Runs named branches concurrently. If any branch suspends, the whole parallel block suspends. |
ctx.sleep(ms) |
On replay, waits only for the remaining time. |
The Kassette object returned by kassette():
| Method | What it does |
|---|---|
workflow.start(input) |
Starts a workflow run and returns RunResult. |
workflow.resume(runId, { eventName, value }) |
Delivers an event to a suspended run. Replays to the suspend point and then continues. |
workflow.fork(source) |
Creates a new run from part of an existing journal. The new run replays to the fork point, then diverges. |
Forking
fork() copies part of an existing journal into a new run. The new run replays that prefix in memory, then continues live from the fork point.
Use this to branch one recorded prefix into several speculative continuations, or to go back to an earlier step after the original run went wrong. You keep the expensive work before the fork without paying for it again. See coding-agent.
Code versioning
Replay assumes the workflow code still has the same shape. If you add, remove, or reorder steps, old journal entries may map to the wrong code. This will matter for agent runs that suspend across deployments.
For incompatible changes to a step, rename the step. The new name will miss the old journal entry and run live.
// Before
const analysis = await ctx.step('analyze', () => llm.chat(prompt));
// After: incompatible prompt change
const analysis = await ctx.step('analyze-v2', () => llm.chat(newPrompt));
Renaming is not enough for every change. Reordering or removing steps can shift the journal mapping after that point. For those changes, configure version on the workflow definition as a safety check. A mismatch throws VersionMismatchError, so you can discard the journal and restart.
const agent = kassette(fn, {
storage,
version: 'workflow-v3',
});
const result = await agent.start(input);
When possible, tie version to the workflow shape, not the deploy SHA. Read more about Versioning.
Local storage
LocalStorage stores each run as a JSONL file on disk.
- Atomic appends: each entry is written with
write()and flushed withfsync(), no torn writes. - Single-writer fencing: each
runIdhas a lockfile created with atomiclink(2). PID-based stale detection reclaims locks from dead processes.
Remote storage
@usekassette/s3 stores each run as one object in S3-compatible storage.
- Atomic appends: each
PutObjectwrites the full journal atomically - Single-writer fencing: each append uses conditional
PutObjectwithIf-Matchon the previous etag. Two sessions may briefly coexist, but the older one is fenced on its next append.
Required backend semantics: read-after-write consistency with conditional writes. S3, R2, and GCS all support this.
Performance
Choose storage by where the run can resume, not by raw speed. For kassette’s target workload — 10-100 steps, small journal entries, and waits dominated by LLMs, tools, humans, webhooks, or timeouts — storage latency should not be the bottleneck.
LocalStorage appends are O(1): one fsync, ~100µs per entry. Use it when the retrying process can read the same persistent filesystem.
RemoteStorage / @usekassette/s3 stores each run as one object. Each append reads the current object and uploads the whole journal with one more line. This makes start, resume, and fork cheap because readAll is one object read, but write cost grows with the journal. Total uploaded bytes over a run are O(N²).
That tradeoff is usually fine for agent runs. For large entries, hundreds of steps, or multi-GB journals, prefer LocalStorage when possible.
kassette does not clean up, expire, or snapshot journals so storage will grow across runs. Use lifecycle policies or cleanup jobs if you need retention limits.
See Storage backends and Object storage design.
Agent observability
kassette journals are designed to be easy for agents to inspect. Agentic runs are usually small enough to inspect directly: tens of expensive steps, KB-MB journals, and one append-only JSONL log per run. A debugging agent can read the whole run, answer “what already happened?”, “where did this suspend or fail?”, and “where can I safely fork?” without a service console, database shell, snapshot protocol, or application-specific inspector.
Agent skill
This repo bundles an agent skill at skills/kassette/SKILL.md. Use it when an agent needs to inspect, debug, resume, or fork a kassette run. It covers storage discovery, journal structure, jq patterns, status interpretation, suspend/resume debugging, and safe forking.
CLI
@usekassette/cli ships a kassette command for inspecting and forking journals against either backend. list, status , dump [--offset N], fork --from-offset N. Pass --storage file: or --storage s3://[/]; @usekassette/s3 is a soft dep, install alongside for s3://.
Documentation
Get started
Concepts
Going to production
Reference
Examples
- agent-loop A minimal durable think-act-observe loop. LLM calls and tool executions are wrapped in
step(). After a crash, replay skips finished work and continues at the first unfinished step. - loan-underwriting Parallel data gathering with human approval gates.
parallel()runs the credit check and property appraisal. Each branch cansuspend()for reviewer sign-off.fork()can re-run the final decision with earlier analysis replayed. - deploy-assistant A webhook-driven deployment assistant. Uses
suspend()for clarifying replies and production approvals, andsleep()while dispatched jobs take effect. - coding-agent Speculative branching and backtracking with
fork(). Reuse a recorded plan while trying multiple implementations, or backtrack to planning if none pass. - vercel-ai-sdk Middleware for recording LLM calls through the Vercel AI SDK, including streaming responses. Replay returns the recorded response without calling the provider.
- cloudflare-worker A workflow running inside a Cloudflare Worker and journaling to R2. Any isolate or region with access to R2 can resume the run.
- cloudflare-queue Adds a queue in front of the Worker. Queue redelivery acts as the crash detector, a fresh isolate replays from R2 and continues live.
License
MIT
추천 도구
다른 키워드를 입력하거나 필터를 제거해 보세요.
설치
npx skillfish add lostinpatterns/kassette