Heads up: active development of this Spring Boot starter is moving under
jamjet-runtime-java/modules/spring-boot-starterin the next JVM runtime release. Maven coordinates remain unchanged:dev.jamjet:jamjet-spring-boot-starter. This repo will receive a redirect README and be archived after the next JVM release confirms a clean Maven publish from the new path.
Spring Boot starter for JamJet. Add one dependency and every call through a ChatClient built from the auto-configured ChatClient.Builder is recorded on the JamJet runtime as an execution with an event log, with optional human approval before the model is called.
Upgrading from 0.1.0: 0.1.0 called
/api/v1/...routes the runtime never served. The runtime answered them with its web UI (200 text/html), the advisors logged a warning and carried on, and nothing was recorded. 0.2.0 calls the runtime's real routes and refuses calls it cannot record (see When the runtime is unavailable). LangChain4j chat memory is now keyed by a namespace (see LangChain4j Integration), so memory written by a pre-release 0.2.0 build without one is not found and conversations start empty. Those executions stay on the runtime; read theirchat_memoryevents withGET /executions/{id}/eventsif you need to carry history over.
<dependency>
<groupId>dev.jamjet</groupId>
<artifactId>jamjet-spring-boot-starter</artifactId>
<version>0.2.0</version>
</dependency>docker run -p 7700:8080 -e JAMJET_DEV_MODE=1 jamjetdev/jamjet:0.5.0Dev mode turns off authentication. This image keeps state in memory (it sets STORAGE_BACKEND=memory); the jamjet-server binary defaults to a SQLite file in its working directory, which survives restarts. Outside dev mode, set spring.jamjet.api-token.
spring.jamjet.runtime-url=http://localhost:7700@SpringBootApplication
public class MyApp {
@Bean
ChatClient chatClient(ChatClient.Builder builder) {
return builder.build(); // JamJet advisors are added by auto-configuration
}
@Bean
CommandLineRunner demo(ChatClient chatClient) {
return args -> System.out.println(
chatClient.prompt("Summarize AI trends").call().content());
}
}The auto-configured ChatClientCustomizers add these advisors to every ChatClient built from the auto-configured ChatClient.Builder bean. A client built another way (ChatClient.create(model), ChatClient.builder(model)) gets none of them, so it is neither recorded nor gated; add the advisor beans to it yourself with .defaultAdvisors(...).
| Advisor | What it does on the runtime |
|---|---|
JamjetDurabilityAdvisor |
Registers the spring-ai-chat workflow once (POST /workflows), starts one execution per call (POST /executions) before the model is called, and appends a spring_ai.completed event with the outcome (completed or failed). |
JamjetAuditAdvisor |
Appends the prompt and the response as spring_ai.prompt and spring_ai.response events. Turn the text off with spring.jamjet.audit.include-prompts=false / include-responses=false. |
JamjetApprovalAdvisor |
Opt in with spring.jamjet.approval.enabled=true. Pauses calls that ask for approval until a reviewer decides (see below). |
Read a call's record back with GET /executions/{id}/events, or from Java:
List<ExecutionEvent> events = runtimeClient.listEvents(executionId);The model call runs in your JVM. The runtime holds the record: the workflow's single node is a wait on the spring_ai.completed event, which the advisor sends when the call ends. The runtime does not re-run or replay Spring AI calls.
With the default spring.jamjet.on-runtime-error=fail-closed, what happens depends on when the runtime fails:
| When | What fails to record | Result |
|---|---|---|
| Before the model is called | workflow registration, starting the execution, the spring_ai.prompt event, the approval request |
The call is refused with JamjetGovernanceException and the model is not called. |
| After the model returned | the spring_ai.response or spring_ai.completed event |
JamjetRecordingException: the model call completed (any effect has happened) and only its record is missing. The runtime's error is the cause. |
| After the model threw | the failed outcome |
The model's own exception, with a JamjetRecordingException attached as suppressed. |
A runtime-url that does not point at the runtime API counts as a failure: any non-JSON answer is an error, never data.
To let calls run unrecorded instead, opt in:
spring.jamjet.on-runtime-error=fail-openFail-open logs a warning for every call it lets through.
In fail-closed mode a streaming call (.stream()) is refused before the model runs unless you allow it:
spring.jamjet.allow-streaming=trueAn allowed stream records the whole streamed text as spring_ai.response and an outcome of completed, failed or cancelled. A stream's record can only be written after its output was delivered, so a fail-closed record failure on a stream arrives as an error at the end of the stream, after the chunks.
spring.jamjet.approval.enabled=true
spring.jamjet.approval.timeout=30mMark a call as needing approval:
chatClient.prompt("Send the wire")
.advisors(a -> a.param(JamjetApprovalAdvisor.REQUIRES_APPROVAL_KEY, true))
.call()
.content();The call records spring_ai.approval_requested on its execution and blocks until a reviewer decides through the approval endpoints:
# Calls waiting for a decision, each with its prompt and content_digest
curl -u reviewer:secret http://localhost:8080/jamjet/approvals/pending
# Decide
curl -u reviewer:secret -X POST http://localhost:8080/jamjet/approvals/{executionId} \
-H 'Content-Type: application/json' \
-d '{"decision":"approved","content_digest":"<from /pending>","comment":"checked"}'A decision is bound to the request that was reviewed. /pending shows the request (one line per message with its role, plus media, tool calls, tool responses and the tools offered; invisible and reordering characters escaped; cut at 2000 characters with a [truncated] marker) and a content_digest over the request the ChatClient hands the model: every message's role and text, media (MIME type and a hash of the bytes, or the URL), tool calls, tool responses, every readable property of the options object (so provider-specific options such as a tool choice or a seed are covered), the tool names and definitions, and the tool context. Not covered, because an advisor cannot see them: default options and tools configured on the ChatModel itself (for example spring.ai.openai.chat.options.*), which the model merges inside its own call. Set options that matter for approval on the ChatClient or the request, not on the model. A decision must echo that digest: without it the answer is 400, with a different one 409, and nothing is recorded. The approval advisor is ordered LOWEST_PRECEDENCE - 1, the last advisor slot in Spring AI 1.0.x: an advisor with the same order runs before it (so its changes are reviewed), and one ordered LOWEST_PRECEDENCE sorts after Spring AI's own model-call advisor and never runs. After approval, Spring AI 1.0.x's model-call advisor appends the output-format instructions (from .entity(...), or from a spring.ai.chat.client.output.format param) to the user message on the .call() path; the digest and preview already include that text. The .stream() path appends nothing.
What an approval does not cover. Spring AI runs the tool-calling loop inside the model call: after approval the model can call the offered tools, and those calls are not individually reviewed here. To gate the actions themselves, run them as runtime-governed tools with the runtime's own approval policy (require_approval_for), which holds each call until it is approved.
Only one decision applies to a call: a second or late one gets 409 and records nothing.
Reviewers must be authenticated. The approver recorded on the runtime is the authenticated principal of the request; the body cannot name it. This applies to GET /pending as well as to decisions, because the list shows prompts and initiators. A request with no principal, an anonymous authentication, or an authentication that is not authenticated gets 401 and releases nothing. A remember-me login is refused too unless spring.jamjet.approval.allow-remember-me=true, so a reviewer must have signed in this session. Separation of duties. The principal that triggered a call cannot approve it (403). Only JamJet sets it, from the security context; a caller-supplied jamjet.approval.initiator param is ignored and logged. It is captured before any thread hop: on the calling thread for .call(), at subscription for .stream() (and from the Reactor context on WebFlux). It is lost when the call runs on a thread that does not carry the caller's SecurityContext, for example an @Async method or a plain executor (use DelegatingSecurityContextExecutor or SecurityContextHolder propagation), or a blocking .call() inside a WebFlux handler. When the initiator is unknown, the request and decision events say so (initiator_known: false) and the call can be rejected but not approved, unless spring.jamjet.approval.allow-unknown-initiator=true. The check compares principal names exactly, so make sure one person has one name across your authentication sources. Reviewers need the approver authority. Being logged in is not enough: the controller itself requires ROLE_JAMJET_APPROVER (configurable with spring.jamjet.approval.required-authority) for both /pending and decisions, and answers 403 without it. A principal that is not a Spring Security Authentication has no authorities the controller can read and is refused. The endpoints are registered only when Spring Security is on the classpath. Restrict them in your filter chain as well, for example:
@Bean
SecurityFilterChain approvals(HttpSecurity http) throws Exception {
return http
.securityMatcher("/jamjet/approvals/**")
.authorizeHttpRequests(a -> a.anyRequest().hasRole("JAMJET_APPROVER"))
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()) // see the note below
.build();
}Turning CSRF off is acceptable here only because the decision endpoint accepts application/json bodies only, so a cross-site form post is refused (415) even though browsers resend cached Basic credentials. If reviewers sign in with a session or form login, keep CSRF on and send the token with each POST.
For local development, spring.jamjet.approval.controller.allow-unauthenticated=true registers the endpoints even without Spring Security and logs a warning at startup. It switches off every reviewer check, also when Spring Security is present: no principal is required, the authority check is skipped, and anonymous and remember-me principals are accepted. Decisions from anyone who did not really authenticate are recorded with approver unauthenticated and authenticated: false. Anyone who can reach the application can then approve its calls.
Approvals the runtime holds are not decided here. The runtime records the identity of the token that sends a decision and enforces separation of duties on it, so the application never forwards decisions with its own token. /pending lists any requests the runtime holds for a waiting call's execution, read-only, with their action_digest and the command to run with your own runtime token, for example jamjet approve exec_... --decision approved --node-id pay --action-digest <digest>.
A decision is appended to the call's execution as a spring_ai.approval_decision event (approver, whether authenticated, the initiator when known, comment) before the call is released. If it cannot be recorded, the endpoint answers with the runtime's status (or 502) and message, and the waiting call is released as rejected. Only "approved" lets a call through. A call that times out is always rejected (default-decision is deprecated; any value other than rejected fails at startup). If a decision is recorded but the call stopped waiting before it could be delivered, the endpoint answers 409 and appends a spring_ai.approval_decision_void event, so the record never claims a decision that did not apply.
On a ChatClient built from the auto-configured ChatClient.Builder, a call marked as requiring approval never runs ungated: JamjetApprovalGuard is auto-configured on its own, independently of durability-enabled and approval.enabled, and refuses the call before the model runs when no approval advisor is on the client. A client built any other way (ChatClient.create(model), ChatClient.builder(model), your own ChatClient.Builder bean, or with spring.ai.chat.client.enabled=false) gets no guard, and the marker does nothing there; add JamjetApprovalGuard and JamjetApprovalAdvisor to it yourself. A call whose execution could not be started (the runtime failed open) is rejected by the approval advisor.
| Property | Default | Description |
|---|---|---|
spring.jamjet.runtime-url |
http://localhost:7700 |
JamJet runtime URL |
spring.jamjet.api-token |
none | Bearer token for the runtime. The token decides the tenant; there is no separate tenant setting (spring.jamjet.tenant-id is ignored and logs a warning) |
spring.jamjet.durability-enabled |
true |
Enable the starter |
spring.jamjet.on-runtime-error |
fail-closed |
fail-closed refuses calls the runtime cannot record; fail-open runs them unrecorded |
spring.jamjet.workflow-id |
spring-ai-chat |
Workflow the durability advisor registers |
spring.jamjet.audit.enabled |
true |
Add the audit advisor |
spring.jamjet.audit.include-prompts |
true |
Record prompt text |
spring.jamjet.audit.include-responses |
true |
Record response text and token usage |
spring.jamjet.approval.enabled |
false |
Add the approval advisor and endpoints |
spring.jamjet.approval.timeout |
30m |
How long a call waits for a decision (500ms, 30s, 30m, 2h, ISO-8601; a bare number is minutes) |
spring.jamjet.approval.default-decision |
rejected |
Deprecated. A timed-out call is always rejected; any other value fails at startup |
spring.jamjet.approval.required-authority |
ROLE_JAMJET_APPROVER |
Authority a reviewer must hold to list or decide approvals |
spring.jamjet.approval.allow-unknown-initiator |
false |
Allow approving a call whose initiator could not be determined |
spring.jamjet.approval.allow-remember-me |
false |
Accept a remember-me login as a reviewer |
spring.jamjet.approval.controller.allow-unauthenticated |
false |
Local development only: register the approval endpoints without Spring Security and switch off every reviewer check (principal, authority, anonymous, remember-me) |
spring.jamjet.connect-timeout-seconds |
10 |
Connection timeout |
spring.jamjet.read-timeout-seconds |
120 |
Read timeout |
| Module | Description |
|---|---|
jamjet-spring-boot-autoconfigure |
Auto-configuration, advisors, approval endpoints and the runtime client |
jamjet-spring-boot-starter |
Starter POM (add this to your project) |
jamjet-spring-boot-starter-test |
JUnit 5 replay testing and a runtime Testcontainer |
langchain4j-jamjet |
LangChain4j durable agent and chat memory store |
jamjet-spring-boot-starter-example |
Example application |
- Java 21+
- Spring Boot 3.4+
- Spring AI 1.0+
- JamJet runtime 0.5.0+ (Docker image
jamjetdev/jamjetor thejamjet-serverbinary)
<dependency>
<groupId>dev.jamjet</groupId>
<artifactId>jamjet-spring-boot-starter-test</artifactId>
<version>0.2.0</version>
<scope>test</scope>
</dependency>Replay tests: load an execution recorded on a runtime you already run. Point the extension at it with the spring.jamjet.runtime-url system property (default http://localhost:7700):
@ExtendWith(JamjetReplayExtension.class)
class MyAgentTest {
@Test
@ReplayExecution("exec_abc123")
void agentProducesConsistentOutput(RecordedExecution execution) {
AgentAssertions.assertThat(execution)
.completedSuccessfully()
.hasEvent("node_completed")
.completedWithin(30, TimeUnit.SECONDS);
}
}mvn test -Dspring.jamjet.runtime-url=http://localhost:7700@WithJamjetRuntime starts a fresh, empty runtime container for the test class and sends no token, so it cannot replay an execution recorded elsewhere. Use it when the test creates its own executions.
Runtime container: JamjetTestContainer runs jamjetdev/jamjet:0.5.0 in dev mode.
Deterministic stubs: no LLM calls needed:
var stub = DeterministicModelStub.builder()
.onPromptContaining("weather", "Sunny, 72F")
.defaultResponse("I don't know")
.build();The autoconfiguration registers two helper beans. The advisors do not call them, so no metrics or spans appear until your own code does.
Micrometer: JamjetMicrometerBridge is registered when a MeterRegistry bean exists (on by default, set spring.jamjet.observability.micrometer=false to turn off). Metric names use spring.jamjet.observability.metric-prefix (default jamjet).
@Autowired JamjetMicrometerBridge metrics;
metrics.recordExecutionCompleted("completed", Duration.ofSeconds(4));
metrics.recordToolCall("search", Duration.ofMillis(120));
metrics.recordExecutionCost(0.012);Meters it records: jamjet.execution.duration, jamjet.execution.count, jamjet.node.duration, jamjet.node.retries, jamjet.tool.calls, jamjet.tool.duration, jamjet.execution.cost.usd, jamjet.audit.events.
OpenTelemetry: JamjetOtelBridge is registered only with spring.jamjet.observability.opentelemetry=true and an OpenTelemetry Tracer bean.
@Autowired JamjetOtelBridge tracing;
Span exec = tracing.startExecutionSpan("exec_1", "my-workflow");
Span tool = tracing.startToolSpan(exec, "search");
tracing.endSpan(tool, 0.0);
tracing.endSpan(exec, 0.012);Span names you build this way: jamjet.execution, jamjet.node.{id}, jamjet.tool.{name}, jamjet.model.call.
<dependency>
<groupId>dev.jamjet</groupId>
<artifactId>langchain4j-jamjet</artifactId>
<version>0.2.0</version>
</dependency>Recorded AI services: wrap any AiServices proxy. Each method call starts an execution of the langchain4j-durable-agent workflow and records a langchain4j.completed event with the result or the error:
var jamjet = new JamjetConfig().runtimeUrl("http://localhost:7700");
MyAssistant raw = AiServices.builder(MyAssistant.class)
.chatModel(model).tools(tools).build();
MyAssistant recorded = jamjet.wrap(raw, MyAssistant.class);Arguments and results are recorded by default. new JamjetConfig().recordContent(false) records only the method, the argument count, the outcome and, on failure, the exception type.
Chat memory on the runtime: each (namespace, memory id) is one execution of the langchain4j-chat-memory workflow; every update appends the full message list as a chat_memory event and reads take the latest one. Set the namespace to your application's name, so two applications on one runtime tenant do not read each other's memory under the same id (LangChain4j's default id is "default"):
var jamjet = new JamjetConfig()
.runtimeUrl("http://localhost:7700")
.memoryNamespace("support-bot");
var memory = MessageWindowChatMemory.builder()
.chatMemoryStore(jamjet.chatMemoryStore())
.id("session-1")
.maxMessages(20)
.build();The namespace separates applications; it is not access control. Anything holding a token for the tenant can read and append events on any execution.
The runtime has no lookup by input, so the first access to a memory id in a process scans GET /executions for it (linear in the tenant's execution count) and caches the result; a miss is cached for 30 seconds, so a new conversation scans once. Keep one writer per memory id: two replicas that both start the same new id each create an execution and the history splits, and after a restart the newest wins.
Both refuse to run unrecorded by default (JamjetGovernanceException); new JamjetConfig().onRuntimeError(RuntimeErrorMode.FAIL_OPEN) lets calls run and reads fall back to an empty memory instead.
mvn -Dgpg.skip verify # unit tests
mvn -Dgpg.skip -Pit verify # plus RuntimeContractIT against a real runtime (Docker)
JAMJET_IT_RUNTIME_URL=http://localhost:7700 mvn -Dgpg.skip -Pit verify # use a runtime you started (dev mode)Apache License 2.0