Java SDK for Clavenar. Inspect the tool calls a model emits against your policies before your agent runs them.
Part of the by-language agent-wrapper SDK family alongside
@clavenar/agent-sdk
(TypeScript) and
clavenar-agent-sdk
(Python) — all speak the same wire contract.
The Maven registry uses GitHub Packages authentication. Add
https://maven.pkg.github.com/clavenar/clavenar-java-sdk as repository
github and put a GitHub token with read:packages in the matching
~/.m2/settings.xml server entry. The same JAR is attached anonymously to the
versioned GitHub release.
For an anonymous, byte-exact download of the current package and publication evidence:
base=https://github.com/clavenar/clavenar-java-sdk/releases/download/v1.5.3
curl -fsSLO "$base/agent-sdk-1.5.3.jar"
curl -fsSLO "$base/pom.xml"
curl -fsSLO "$base/bom.json"
curl -fsSLO "$base/bom.xml"
jar tf agent-sdk-1.5.3.jar >/dev/nullMaven dependency:
<dependency>
<groupId>com.clavenar</groupId>
<artifactId>agent-sdk</artifactId>
<version>1.5.3</version>
</dependency>Gradle:
implementation("com.clavenar:agent-sdk:1.5.3")Requires Java 17+. The only runtime dependency is Jackson; the SDK takes no dependency on the Anthropic or OpenAI SDKs — it duck-types their responses.
Spring AI and LangChain4j own the model call, so gate the tool before it executes. This is the primary surface:
var inspector = new ClavenarInspector(
ClavenarOptions.builder("http://localhost:8088").token(token).build());
// Spring AI ToolCallback / LangChain4j ToolExecutor, inside your tool body:
inspector.enforce(toolName, toolCallId, argumentsJson); // throws ClavenarDenied on a policy block
// ... reached only when the call cleared policy — run the toolenforce throws ClavenarDenied / ClavenarPending in enforce mode; in
observe mode it never throws and fires your callbacks instead.
import com.anthropic.client.AnthropicClient;
AnthropicClient client = Clavenar.wrap(
rawAnthropicClient,
ClavenarOptions.builder("http://localhost:8088").build());
// Use the client exactly as before; every tool_use is inspected first.
var message = client.messages().create(params); // throws ClavenarDenied on a blockThe same Clavenar.wrap detects an OpenAI client
(chat().completions().create()) structurally. It returns a dynamic
proxy of the same interface type and passes every non-create method
through unchanged.
ClavenarInspector.inspect returns a Verdict (ALLOW / DENY /
PENDING / RATE_LIMITED). Every call explicitly selects the side-effect-free
clavenar.decision/v1 contract with a UUID allocated before the first attempt;
multi-tool turns use one ordered atomic decision. Proxy 0.5.0 and Lite 0.9.0
reject unselected tool calls with HTTP 426; upgrade this SDK before the gateway
by following https://clavenar.com/docs/sdk-migration/. inspectAll, enforce, and
the wrap facade translate, in enforce mode, to unchecked exceptions rooted at
ClavenarException:
| Exception | Meaning |
|---|---|
ClavenarDenied |
policy rejected the call — toolName, reasons, reviewReasons, intentCategory, layer, correlationId |
ClavenarPending |
parked for human review — call resolve() to block until decided |
ClavenarRateLimited |
429 before evaluation — code() (rate_limited velocity gate / quota_exceeded spend gate), retryAfterSecs(); never auto-retried |
ClavenarTransportException |
clavenar unreachable / unexpected response — status() (0 = network) |
ClavenarConfigException |
bad options, or a model tool call with unparseable arguments |
ClavenarDenied carries reasons(), layer(), and correlationId(). To
see which detector fired, run the gateway with
CLAVENAR_PROXY_VERBOSE_VERDICTS=true (Lite: --verbose-verdicts) — the
deny then carries a per-detector detail() breakdown, and the SDK renders
it to stderr when you set devMode(true):
var opts = ClavenarOptions.builder("https://clavenar.internal")
.devMode(true) // dev/staging only — detailed denials are an attacker oracle
.build();
// On a deny, the SDK prints a panel to stderr:
// ━━ clavenar denied: send_email ━━
// layer=brain intent=Exfiltration correlation=abc-123
// detectors:
// persona_drift 0.12
// injection 0.91 ⚠ flagged
// degraded: injectionProgrammatic access (no devMode needed):
catch (ClavenarDenied e) {
if (e.detail() != null) {
e.detail().detectors().stream()
.filter(d -> d.flagged() || d.score() >= 0.5)
.forEach(d -> System.out.println("fired: " + d.detector()));
}
}detail() is null unless the gateway opts in; without it the panel prints
a hint to enable verbose verdicts.
var opts = ClavenarOptions.builder(endpoint)
.observe()
.onVerdict((verdict, ctx) -> log.info("{} -> {}", ctx.toolName(), verdict.kind()))
.build();Observe never blocks: verdicts surface via onVerdict, transport
failures via onPolicyError, and every call passes through — the
rollout knob for tuning policies against live traffic.
try {
inspector.enforce(toolName, id, argsJson);
} catch (ClavenarPending pending) {
pending.resolve(); // blocks; returns on approve, throws ClavenarDenied on deny
}Pending polling treats only network failures and 5xx responses as transient. Malformed success bodies, correlation mismatches, and every other HTTP status are terminal transport errors.
Use GovernedExecutionClient when policy authorization and the provider effect
must form a recoverable workflow. Supply an application-owned
DurableExecutionStore, a cryptographic AuthorizationVerifier, a receipt
signer, and an executor that forwards the supplied idempotency ID to the
provider. The client verifies all authorization bindings before committing an
intent or releasing an effect.
On restart, a stored completion is integrity-checked and returned. A stored
intent is passed to the optional EffectRecoverer; if it cannot conclusively
find the provider effect, the client throws ClavenarRecoveryRequired instead
of replaying it. Completion plus receipt-outbox persistence is bounded by the
configured finalization deadline.
SecureTransportProfile reuses its connection pool. Call reload() after
rotating credential files and close the profile during application shutdown.
StreamGate holds a tool call's closing event until clavenar returns a
verdict, so a denied call never reaches your loop as actionable. Drive it
from your stream-reading loop with start / update / close (Anthropic
block index) or closeByPrefix (OpenAI per-choice drain). See
docs/SEQUENCES.md.
Verdict v = Realtime.inspect(
new Realtime.FunctionCallDone(callId, name, argumentsJson), opts);Matches the TypeScript reference 1:1 on the wire — see
docs/PARITY.md for the map and the additive Java-idiom
differences.