A durable execution runtime for AI work on the JVM. You write an AI task as plain Java. Catalyst records every model call, tool call and side effect to an append-only log, so the task can:
- survive a crash. Kill the process mid-task, restart, and it continues from the exact step it reached, with zero duplicate model calls.
- be replayed exactly. Re-run a recorded execution with no external calls, and have Catalyst verify that your code still makes the same decisions.
- be branched and diffed. Fork a past run at any step, swap the model or a tool result, and compare the trajectories.
- be inspected. Timelines, token usage and cost are computed from the log, so there is nothing extra to instrument.
It runs embedded in your process. There's no server to operate; the durable log is a directory on disk. Think Temporal meets Git, for AI execution.
Status: pre-1.0, not yet published to a Maven repository; build it from source (below). The single-node runtime is complete and deployable. Multi-node failover on a FoundationDB-backed log works end to end and is new, so treat it as a preview (see docs/deployment.md). See ROADMAP.md.
1. Build and install (JDK 21 and Maven):
git clone https://github.com/CajunSystems/catalyst.git
cd catalyst
mvn install -DskipTests2. Add the dependency. catalyst-api brings the runtime, the durable log, the built-in tools and
the observability exporters:
<dependency>
<groupId>com.cajunsystems</groupId>
<artifactId>catalyst-api</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>Catalyst's log library, Gumbo (com.github.CajunSystems:gumbo), comes from JitPack. Step 1 already
put it in your local Maven repository, which is enough on the same machine. Anywhere else, for
example your CI, add the repository:
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>3. Write a task and run it:
import com.cajunsystems.catalyst.Context;
import com.cajunsystems.catalyst.ExecutionOptions;
import com.cajunsystems.catalyst.Task;
import com.cajunsystems.catalyst.api.Catalyst;
import com.cajunsystems.catalyst.engine.Timeline;
import com.cajunsystems.catalyst.mock.MockModel;
import com.cajunsystems.catalyst.model.CompletionRequest;
import com.cajunsystems.catalyst.model.Prompt;
import com.cajunsystems.catalyst.runtime.CatalystRuntime;
import com.cajunsystems.catalyst.runtime.ExecutionHandle;
import java.nio.file.Path;
import java.time.Instant;
public class Quickstart {
/** A task is a unit of AI work. Anything that talks to the outside world goes through ctx. */
static final class TriageTask implements Task<String> {
@Override
public String execute(Context ctx) {
String summary = ctx.model().complete(CompletionRequest.of(Prompt.builder()
.system("You triage support tickets.")
.user("Summarize: the printer on floor 3 is offline")
.build())).message();
String receivedAt = ctx.effect("received-at", () -> Instant.now().toString());
return summary + " (received " + receivedAt + ")";
}
}
public static void main(String[] args) {
try (CatalystRuntime runtime = Catalyst.builder(Path.of("catalyst-data"))
.model(MockModel.alwaysReturn("Printer offline, floor 3")) // a real model: see below
.task(new TriageTask())
.recoverOnStart(true)
.build()) {
ExecutionHandle<String> run =
runtime.execute(new TriageTask(), ExecutionOptions.withKey("ticket-1042"));
String result = run.result();
Timeline timeline = runtime.inspect(run.id()).timelineView();
System.out.println(result);
System.out.println(timeline.modelCalls() + " model call(s), " + timeline.totalTokens() + " tokens");
}
}
}Printer offline, floor 3 (received 2026-10-12T08:04:52.451055Z)
1 model call(s), 14 tokens
Run it again. You get the identical line, the same timestamp included, and the model is not
called. The key ticket-1042 names an execution that already exists in catalyst-data/, so Catalyst
hands back its recorded result instead of running it again. If the first run had crashed halfway, the
second would have resumed from the last recorded step.
To use a real model, add catalyst-langchain4j and wrap any LangChain4j ChatModel (OpenAI,
Anthropic, Gemini, Ollama, …):
.model(LangChain4jModel.of(OpenAiChatModel.builder().apiKey(key).modelName("gpt-4o").build()))The repo has runnable demos for every feature. This one records the first step of a two-step task,
kills its own process with the equivalent of kill -9, and then a second process finishes the job:
scripts/demo.sh /tmp/catalyst-demo record # step 1 recorded, then the process dies (exit 137)
scripts/demo.sh /tmp/catalyst-demo resume # a new process resumes it[resume] result: SUMMARY|FINAL
[resume] model calls THIS run: 1 (step 1 was substituted from the log; only step 2 ran live)
Other demos: scripts/demo.sh replay, branch, agent, eval, timer, child, supervise, fanout, group, calls, signal, retry, cancel, shutdown, streaming,
timeline, otel, autocapture, and more. The full list is at the top of scripts/demo.sh, and CI
runs every one of them as a gate (the two-node distributed-a/distributed-b demo in its
FoundationDB job).
- Task: your code,
Task<R>, a unit of AI work. Make it a named class if it may be resumed by another process. - Context (
ctx): the only way a task touches the outside world:ctx.model(),ctx.call(tool, input),ctx.effect(label, supplier),ctx.memory(),ctx.awaitSignal(name, type),ctx.sleep(d),ctx.child(task),ctx.spawn(task)/ctx.await(h),ctx.supervise(supervision, tasks),ctx.var(name). Each call is a boundary, and its result is recorded. - Execution: one run of a task, with an
ExecutionIdand an event log. Give it an idempotency key (ExecutionOptions.withKey(...)) and resubmitting that key never runs the work twice. - Resume, replay, branch: a task always re-runs from the top. Boundaries already in the log are substituted with their recorded results instead of executed, which is what makes all three work.
- The one rule: between boundaries, task code must be deterministic. Read the clock, generate ids
and call external systems through
ctx, or let the auto-capture agent rewrite the clock, random and UUID calls for you. A strict replay tells you if you broke the rule.
| I want to… | Use | Details |
|---|---|---|
| call a real LLM | LangChain4jModel.of(chatModel) |
Models |
| call an API or system with side effects | implement Tool, then ctx.call(tool, input) |
Tools |
| use the clock, random values or config safely | ctx.effect("label", () -> …) |
Effects |
| retry transient failures | .retryPolicy(RetryPolicy.exponential(…)) |
Failures |
| retry one flaky call, or give up on a hung one | .callPolicy(CallPolicy.retries(3, d).timeout(t)), ctx.call(tool, in, policy) |
Per-call |
| decide what happens to a call cut off by a crash | .inDoubtPolicy(…) |
Failures |
| resume crashed work after a restart | .task(...) + .recoverOnStart(true) |
Resuming |
| wait hours or days, then continue (durably) | ctx.sleep(Duration.ofDays(3)) |
Timers |
| hand work to a sub-task (a sub-agent) and wait for it | ctx.child(new SubTask()) |
Children |
| run several sub-tasks at once, then collect them | ctx.spawn(task) + ctx.awaitAll(handles) / awaitAny |
Fan-out |
| restart a group together when one fails | ctx.supervise(Supervision.restart(2).oneForAll(), tasks) |
Groups |
| restart a sub-task that fails | ctx.child(task, Supervision.restart(3).backoff(d)) |
Supervision |
| wait for a human's approval, then continue | ctx.awaitSignal(name, type) + runtime.signal(id, name, payload) |
Signals |
| cancel or pause an execution | runtime.cancel(id), pause(id), resume(id) |
Control |
| check that my task code is deterministic | runtime.replay(id, task) |
Replay |
| try a different model on a past run | runtime.branch(id, seq).withModel(m).run(task) |
Branch |
| build a tool-calling agent that survives crashes and replays | Agent.builder().tool(...).build().run(ctx, input) |
Agents |
| give an agent the tools of an MCP server | McpToolset.from(client).addTo(Agent.builder()) |
MCP |
| check a model or prompt change against real traffic | Eval.on(runtime).cases(ids).candidate(...).run() |
Evaluating |
| stream tokens to a UI | ctx.model().stream(request, sink) |
Streaming |
| see what an execution did, and what it cost | TimelineReport.writeTo(...), CatalystTracer (OTel) |
Observability |
docs/deployment.md covers:
- running a node on a persistent volume (and why not a shared filesystem)
- recovery on startup, graceful shutdown and drain timeouts
- choosing failure policies, and backups
- scaling out by sharding on idempotency key
- several nodes sharing one FoundationDB-backed log and taking over each other's work
(
.nodeId(...).distributed(), a preview)
Most applications need only catalyst-api, plus catalyst-langchain4j for real models and
catalyst-mcp for MCP tools.
| Module | What it is |
|---|---|
catalyst-api |
The Catalyst entry point; depends on everything below except catalyst-langchain4j |
catalyst-core |
Task, Context, the Model/Tool/Memory/EventLog interfaces, and the record/replay engine |
catalyst-runtime |
CatalystRuntime: scheduling, lifecycle, idempotency, recovery, shutdown; in-memory log |
catalyst-gumbo |
The durable, file-backed log, built on Gumbo |
catalyst-events |
The event schema and its serialization (kept stable; changes are additive) |
catalyst-tools |
Built-in tools: clock, calculator, HTTP, sandboxed filesystem |
catalyst-langchain4j |
Adapter for any LangChain4j chat model, streaming included |
catalyst-mcp |
An MCP server's tools as Catalyst tools, for agents (official MCP Java SDK; opt-in) |
catalyst-timeline |
Renders an execution as a self-contained HTML report |
catalyst-otel |
Exports an execution as an OpenTelemetry trace (API only; you bring the SDK) |
catalyst-eval |
Eval harness: replays recorded runs against candidate models and prompts, and scores them |
catalyst-agents |
A tool-calling agent loop built on Task (not to be confused with catalyst-agent, the auto-capture javaagent) |
catalyst-agent |
Optional Java agent that records clock, random and UUID calls automatically |
Java package root: com.cajunsystems.catalyst.
mvn install # everything, with tests
mvn -pl catalyst-core -am install # one module and what it depends onThe acceptance tests in catalyst-api (*AcceptanceTest) are each feature's end-to-end proof, and
each has a matching scripts/demo.sh demo that CI runs. On macOS, FilesystemToolTest,
ToolsAcceptanceTest and TimelineAcceptanceTest fail by design: FilesystemTool refuses to run
without the JDK's SecureDirectoryStream, which macOS lacks. They pass on Linux and in CI.
Working on Catalyst itself? CLAUDE.md is the contributor's map: architecture, the invariants the engine must keep, and the reasoning behind each feature.
- Developer guide: writing tasks, models and tools, failures, replay and branch, streaming, observability, auto-capture, how the log works
- Deployment: running and scaling Catalyst as a service
- Roadmap: what's done and what's next
- Schema evolution and Distribution design
MIT; see LICENSE.