Quickstart

To quickly understand kassette, let’s build a small durable workflow that can stop and later continue without redoing completed work. It runs locally without any additional infra to install.

The example will model a typical agent flow:

  1. Classify a support ticket (using an expensive LLM call).
  2. If the ticket is high severity, pause for human review.
  3. Draft a response.
  4. Return the result.

Without kassette, retrying the workflow would run the expensive call again. You would need to write separate code to store the review result state and load it when waking the workflow. With kassette, you write one async function. Completed steps are recorded in the journal, replay skips them, and resume() continues from the suspend point with the review result.

Prerequisites

  • Node.js 24+ (runs TypeScript natively).
  • npm install @usekassette/kassette.
  • An ESM project ("type": "module" in package.json).

1. Write the workflow

// workflow.ts
import { kassette, LocalStorage } from '@usekassette/kassette';

type Ticket = { id: string; subject: string; body: string };
type Verdict = { approved: boolean; reviewer: string };
type Events = { 'manual-review': Verdict };

export const triage = kassette<Ticket, string, Events>(
  async (ctx, ticket) => {
    const classification = await ctx.step('classify', async () => {
      console.log(`[${ticket.id}] LLM classify (memoize me)`);
      await new Promise((r) => setTimeout(r, 500));
      return { category: 'bug', severity: 5 };
    });

    let note: string | undefined;
    if (classification.severity >= 4) {
      const verdict = await ctx.suspend('manual-review', {
        reason: `severity ${classification.severity}`,
      });
      if (!verdict.approved) return `rejected by ${verdict.reviewer}`;
      note = `approved by ${verdict.reviewer}`;
    }

    return await ctx.step('draft', async () => {
      console.log(`[${ticket.id}] LLM draft`);
      await new Promise((r) => setTimeout(r, 500));
      return `Triaged as ${classification.category}. ${note ?? ''}`;
    });
  },
  { storage: new LocalStorage('.kassette') },
);

You can hand kassette just an ordinary function with an ordinary control flow like if, return, async/await. ctx.step() will journal the return value so that on replay it can use that memoized value rather than re-running it. ctx.suspend() exits the run with a suspended status so that the next resume() replays up to the suspend point and then continues with the event payload from there.

2. Run it

// start.ts
import { createRunId } from '@usekassette/kassette';
import { triage } from './workflow.ts';

const runId = createRunId();
const result = await triage.start({ id: 't1', subject: 'broken', body: '500s on /orders' }, { runId });

console.log(JSON.stringify(result, null, 2));
console.log(`runId: ${runId}`);
$ node start.ts
[t1] LLM classify (memoize me)
{
  "status": "suspended",
  "event": "manual-review",
  "runId": "..."
}
runId: ...

The process exits cleanly. The LLM call ran once and was journaled. Now the run is paused waiting for the manual-review event.

3. Inspect the journal

$ cat .kassette/<runId>.jsonl | jq
{"type":"start","metadata":{"id":"t1", ...},"timestamp":"...","session":1,"offset":0}
{"type":"step","name":"classify","result":{"category":"bug","severity":5}, ...}
{"type":"suspend","waitingFor":"manual-review","reason":"severity 5", ...}

4. Resume

When we’re ready, we can resume the run. resume will normally be triggered by an external event from a webhook handler, a Slack action, a CLI command, etc.

// resume.ts
import { triage } from './workflow.ts';

const runId = process.argv[2]!;
const result = await triage.resume(runId, {
  eventName: 'manual-review',
  value: { approved: true, reviewer: 'alice' },
});
console.log(JSON.stringify(result, null, 2));
$ node resume.ts <runId>
[t1] LLM draft
{
  "status": "success",
  "result": "Triaged as bug. approved by alice",
  "runId": "..."
}

Note that only draft ran live. classify did not run again because its result was already journaled for this runId.

Production note

In this quickstart, you started and resumed the run by running node manually. In production, those calls usually come from your own queue worker, webhook handler, job runner, or similar system.

Retry or resume with the same runId, and use storage that the resuming process can read. LocalStorage is fine for this local example; use shared storage when another machine or serverless invocation may resume the run.

kassette records and replays the workflow. It does not run a server or schedule retries for you. For deployment wiring, see Wiring the dispatcher. For a full example, see Cloudflare Queues + R2.

What just happened

  • classify ran once. On resume, kassette read its result from the journal instead of running it again.
  • The pause survived process exit. start.ts stopped as suspended; resume.ts continued after ctx.suspend() with the review result.
  • The journal is readable JSONL. You can inspect it with cat, jq, or store it elsewhere.
  • kassette did not start a server. Locally you invoked Node by hand; in production, your queue, webhook, or job runner invokes the workflow.

Next steps