SDKs & MCP Java SDK

Java SDK

A thin client for the Forgebench API that handles retries, typed errors and streaming for you.

Your code holds a Forgebench key (sk_...) instead of a provider key, and Forgebench forwards each request to the model you asked for. Every call is budget-checked before money is spent and recorded in the audit log. See Core concepts for terms such as Agent, Budget and Policy.

Install

JitPack builds the SDK from the public forgebench-sdk repository. Add the JitPack repository and the dependency.

Maven:

<repositories>
  <repository><id>jitpack.io</id><url>https://jitpack.io</url></repository>
</repositories>

<dependency>
  <groupId>com.github.seedlinglabs</groupId>
  <artifactId>forgebench-sdk</artifactId>
  <version>main-SNAPSHOT</version>
</dependency>

Gradle:

repositories { maven { url 'https://jitpack.io' } }
dependencies { implementation 'com.github.seedlinglabs:forgebench-sdk:main-SNAPSHOT' }

main-SNAPSHOT resolves to the latest stable release. For reproducible builds, pin a release tag such as java-v1.0.0; see Releases.

Requires Java 11+. The only runtime dependency is Jackson; HTTP uses the JDK's java.net.http.HttpClient. Packages: ai.forgebench (client and requests), ai.forgebench.types (responses) and ai.forgebench.errors.

JitPack builds a tag the first time anyone requests it. If Maven times out on a brand-new tag, try again a minute later.

Connect to the gateway

You never send a provider credential; Forgebench strips one if a client sends it.

import ai.forgebench.Forgebench;

Forgebench client = Forgebench.builder()
    .apiKey("sk_...")
    .baseUrl("https://api.forgebench.ai")
    .build();
Builder methodDefault
apiKey$FORGEBENCH_API_KEY
baseUrl$FORGEBENCH_BASE_URL, then https://api.forgebench.ai
timeoutDuration.ofSeconds(60): connect, wait for response headers, and each read of a stream
streamTimeoutDuration.ofMinutes(5): wall-clock cap on one whole streamed call
maxRetries2, honoring Retry-After. Writes retry only on a 429 or a failure to connect (refused, unknown host, connect timeout, TLS handshake). See Retries
defaultHeader(name, value)sent on every request
httpClientbring your own java.net.http.HttpClient (proxy, SSL context, executor)

With both environment variables set, Forgebench.builder().build() is enough. If self-hosted, set FORGEBENCH_BASE_URL or call .baseUrl(...). A wrong URL fails with connection refused or 404.

The client is thread-safe: build one and share it, for example as a Spring @Bean. All SDK errors are unchecked, so calls work inside lambdas.

The same builder makes an async client. Every call, including audio(), voice() and agents().runStream(...), returns a CompletableFuture. It runs on its own daemon thread pool (shut down by close()), or on yours with buildAsync(executorService):

import ai.forgebench.AsyncForgebench;
import ai.forgebench.ChatCompletionRequest;
import ai.forgebench.ChatMessage;
import ai.forgebench.Forgebench;

try (AsyncForgebench client = Forgebench.builder()
        .apiKey("sk_...")
        .baseUrl("https://api.forgebench.ai")
        .buildAsync()) {
    client.chat().completions().create(ChatCompletionRequest.builder()
            .model("mock-gpt")
            .addMessage(ChatMessage.user("hello"))
            .build())
        .thenAccept(resp -> System.out.println(resp.getContent()))
        .join();
}

A failed async call completes exceptionally with the same typed exception as the blocking client (wrapped in a CompletionException by join()).

whoami

Confirms the key is valid and shows the identity it resolves to. Call it at startup to catch a bad key early:

WhoAmI me = client.whoami();
System.out.println(me.getTenantId() + " " + me.getRoles() + " " + me.getScopes());

Chat completions

import ai.forgebench.ChatCompletionRequest;
import ai.forgebench.ChatMessage;
import ai.forgebench.types.ChatCompletion;

ChatCompletion resp = client.chat().completions().create(ChatCompletionRequest.builder()
    .model("mock-gpt")
    .addMessage(ChatMessage.user("Prove the governed path works."))
    .build());

System.out.println(resp.getChoices().get(0).getMessage().getContent());
System.out.println(resp.getUsage().getTotalTokens());
System.out.println(resp.getTraceId());
System.out.println(resp.getCallId());
  • resp.getContent() returns the first choice's content.
  • getTraceId() links the response to its audit entry, metering record and trace.
  • getCallId() identifies this call; see Call lineage.
  • resp.getExtra() holds response fields this SDK version doesn't model.

See Make your first call for the full response shape.

Build messages with ChatMessage.system(...), user(...), assistant(...) and tool(...), or pass a Map<String, Object> in the wire shape (role, content, tool_calls, tool_call_id, name). temperature(...) and maxTokens(...) have builder methods. toBuilder() copies a request so you can change one field for the next turn.

Streaming

import ai.forgebench.ChatCompletionStream;
import ai.forgebench.types.ChatCompletionChunk;

try (ChatCompletionStream stream = client.chat().completions().createStream(ChatCompletionRequest.builder()
        .model("mock-gpt")
        .addMessage(ChatMessage.user("Stream me a sentence."))
        .build())) {
    for (ChatCompletionChunk chunk : stream) {
        String delta = chunk.getChoices().get(0).getDelta().getContent();
        if (delta != null) System.out.print(delta);
    }
}

The budget check runs before the stream opens, so a 402 is thrown from createStream, never mid-stream. Before the stream opens, a 429 or failure to connect is retried; a 5xx isn't. Close the stream (try-with-resources) if you stop early. Read the call id with stream.getCallId() (from the X-Call-Id header).

Call lineage

Pass a response's call id as parentCallId on the calls it causes, such as a sub-agent's turn or the next step of a tool loop. Cost rolls up to the root call, and the console shows the run as a tree. It doesn't affect whether a call is allowed or what it costs.

ChatCompletion plan = client.chat().completions().create(request);

ChatCompletion step = client.chat().completions().create(request.toBuilder()
    .parentCallId(plan.getCallId())
    .build());

To group calls under one trace, generate an id per turn with Trace.newTraceId() and pass it as .traceId(...) on each call.

Tools

Which allowlist authorizes a tool call depends on the tool's name:

  • Names starting with mcp_ (such as mcp_time_get_current_time) are checked against the MCP allowlist. Your agent's MCP client calls the server directly.
  • Every other name (such as search or send_email) is checked against the Tool Registry. Your own code runs the tool.

Register each tool in the right place, or neither allowlist will authorize it.

Sending a tool schema

Pass the OpenAI-shaped tool list with .tools(...). tool_choice and other OpenAI body fields go in .extraBody(...). Below, search is a Tool Registry tool and mcp_time_get_current_time is an MCP tool:

ChatCompletion resp = client.chat().completions().create(ChatCompletionRequest.builder()
    .model("gpt-4o")
    .messages(messages)
    .tools(List.of(
        Map.of("type", "function", "function", Map.of(
            "name", "search",
            "description", "Search the KB",
            "parameters", Map.of("type", "object", "properties", Map.of("q", Map.of("type", "string"))))),
        Map.of("type", "function", "function", Map.of(
            "name", "mcp_time_get_current_time",
            "description", "Current time in a timezone",
            "parameters", Map.of("type", "object", "properties", Map.of("tz", Map.of("type", "string")))))))
    .extraBody(Map.of("tool_choice", "auto"))
    .build());

List<Map<String, Object>> calls = resp.getChoices().get(0).getMessage().getToolCalls();
for (Map<String, Object> call : calls == null ? List.<Map<String, Object>>of() : calls) {
    Map<?, ?> function = (Map<?, ?>) call.get("function");
    String name = (String) function.get("name");
    String arguments = (String) function.get("arguments");
    Object result = name.startsWith("mcp_")
        ? yourMcpClient.call(name, arguments)
        : yourToolDispatch(name, arguments);
}

getToolCalls() returns OpenAI-shaped maps ({"id", "type", "function": {"name", "arguments"}}), or null if the model called no tool. arguments is a JSON string, not parsed. Echo the turn with ChatMessage.assistant(null, calls) and answer each call with ChatMessage.tool(callId, result) before the next turn.

If the response names a tool this agent isn't bound to, the whole response is refused with 403 tool_not_authorized (or the MCP equivalent); see Errors. Bind the tool on the Tool Registry or MCP page first.

Other fields only reachable through extraBody

These fields go in extraBody. A field set with a builder method wins over the same field in extraBody.

FieldWhat it's for
tool_choice"auto", "none", or a specific function.
response_formatJSON mode, for example {"type": "json_object"} for structured output.
metadataThe dimensions tagging; see Metadata.
modalities["text", "image"], for image-generating models.
reasoning_effortA hint for reasoning-capable models (low/medium/high).
request_idYour own idempotency/correlation id.
sourceA free-text tag for where the call came from.

Any other field in extraBody is silently dropped.

The agentTools() helper (agent credentials only)

With an agent key, client.agentTools() gives you the agent's current MCP allowlist and a dispatch loop. A binding revoked in the console drops out on the next turn, with no redeploy.

  • openaiSchema() builds the model's tool list from the allowlist (GET /v1/agent-tools).
  • dispatch runs each allowed tool call, reports the outcome, and returns the role: "tool" messages to append before the next turn.
List<Map<String, Object>> tools = client.agentTools().openaiSchema();

ChatCompletion turn = client.chat().completions().create(ChatCompletionRequest.builder()
    .model("gpt-4o")
    .messages(messages)
    .tools(tools)
    .build());

List<Map<String, Object>> toolMessages = client.agentTools().dispatch(
    turn.getChoices().get(0).getMessage().getToolCalls(),
    (name, args) -> yourMcpClient.call(name, args),
    turn.getCallId());

An exception from your executor is reported as that tool's error and returned to the model as {"error": ...}. Forgebench stores only a tool's name and description, so pass argument schemas with openaiSchema(Map.of("search", jsonSchema)).

agentTools().report(callId, toolName, result, error, latencyMs) is what dispatch calls internally. Use it directly only if you run tools outside an executor callback. Reports (success, error, latency) go to the audit log and traces; the model never sees them.

Agents

Agent runs are governed like any other call: budget-checked at each hop, metered and audited.

import ai.forgebench.AgentCreateParams;
import ai.forgebench.RunParams;
import ai.forgebench.types.Agent;
import ai.forgebench.types.Run;

for (Agent a : client.agents().list()) {
    System.out.println(a.getId() + " " + a.getName() + " " + a.getModel());
}

Agent agent = client.agents().create(AgentCreateParams.builder()
    .name("Support bot")
    .ownerIdentityId(client.whoami().getIdentityId()) // the accountable owner: required
    .model("mock-gpt")
    .systemPrompt("You are a helpful support agent.")
    .build());

Run run = client.agents().run(agent.getId(), RunParams.builder()
    .input(Map.of("messages", List.of(
        Map.of("role", "user", "content", "Help me reset my password."))))
    .waitForCompletion(true)
    .timeout(Duration.ofSeconds(30))
    .build());
System.out.println(run.getStatus() + " " + run.getOutput());

ownerIdentityId is required; the builder won't build without it.

Agent → agent (A2A tasks)

An agent bound to another agent can open a task on it. The callee's governed calls are recorded under the task:

import ai.forgebench.CallParams;
import ai.forgebench.types.Task;

Task task = client.agents().call("qp-research", CallParams.text("10 MCQs on photosynthesis")
    .data(Map.of("grade", 9))
    .parentCallId(plan.getCallId())
    .build());
if (task.getState().equals("input_required")) {
    task = client.agents().call("qp-research", CallParams.text("CBSE")
        .contextId(task.getContextId())
        .build());
}
String notes = task.artifactText();

call blocks up to 30 seconds (.wait(Duration)) for an answer: completed, failed, or a question (input_required, read it with task.getQuestion()). A task still working after that is returned as-is; poll it with client.agents().task("qp-research", task.getId(), Duration.ofSeconds(20)) or stop it with cancel(...).

To receive tasks without an inbound port, call client.agents().serve(handler). It blocks and long-polls for this agent's tasks. Pass parentCallId(task.getCallId()) on calls made inside the handler so they nest under the task:

client.agents().serve(task -> {
    ChatCompletion answer = client.chat().completions().create(ChatCompletionRequest.builder()
        .model("mock-gpt")
        .addMessage(ChatMessage.user(task.getText()))
        .parentCallId(task.getCallId())
        .build());
    return task.done(answer.getContent());
});

The handler may return a TaskReply (task.done(...), task.ask(...), task.fail(...)), or a String, Map or List of artifacts, which becomes a completed reply. An exception fails that task without ending the loop. ServeOptions sets the long-poll window, a task limit and a stop condition; interrupting the thread also ends the loop. In a server app, run serve on a background thread.

Runs

Run run = client.runs().create(Map.of("prompt", "hello"));   // mock-gpt
run = client.runs().get(run.getId());
run = client.runs().waitFor(run.getId(), Duration.ofSeconds(30), Duration.ofMillis(500));
System.out.println(run.getStatus());

waitFor polls until the run reaches succeeded or failed, and throws a ForgebenchException if it is still running after the timeout. runs().create(agentId, input, model) runs an agent directly.

Workflows

A workflow is a named group of agents (steps) that one app runs in order, one agent and one key per step. Records are plain maps.

WhoAmI me = client.whoami();
Map<String, Object> created = client.workflows().register(Map.of(
    "name", "Question paper",
    "owner_identity_id", me.getIdentityId(),
    "steps", List.of(Map.of("name", "grading", "model", "mock-gpt"),
                     Map.of("name", "retry", "model", "mock-gpt")),
    "edges", List.of(Map.of("from", "grading", "to", "retry", "kind", "sequence"))));
// each step's key is in created.credentials[].credential.api_key, shown once

Workflow.Run run = client.workflow("question-paper", Map.of("grading", keyA, "retry", keyB)).run();
run.step("grading").chat(request);
run.step("retry").chat(request);   // parent = grading's call
run.rootCallId();

client.workflows().checks("question-paper");   // wired? threaded? seeing traffic?

Each step call uses that step's own key and sends the previous call of the run as its parent (X-Parent-Call); the first call is a root. The workflow helper is blocking and covers chat completions only, not streaming. AsyncForgebench.workflows() has async twins of the registry calls, and AsyncForgebench.workflow(...) returns the same blocking helper.

Tracing

Mark your agent's steps and each run becomes one nested trace: the agent, its functions and tools, and each model call with its tokens and cost. Steps are sent in the background with the same API key. Read traces in the console. See Tracing your agent.

Steps with try-with-resources

startActiveObservation opens a step that is current until it is closed. Steps and model calls made on the same thread inside it nest under it, and it ends even when an exception is thrown.

import ai.forgebench.tracing.Observation;
import ai.forgebench.tracing.ObservationType;
import ai.forgebench.tracing.TraceAttributes;
import ai.forgebench.tracing.Tracing;

Tracing tracing = client.tracing();

try (Observation agent = tracing.startActiveObservation(ObservationType.AGENT, "support-agent")) {
    tracing.updateCurrentTrace(TraceAttributes.builder()
            .userId("customer-42").sessionId("chat-7").tags("support").build());
    agent.input(question);
    ChatCompletion reply = client.chat().completions().create(request);   // nested
    agent.output(reply.getContent());
}

ObservationType is SPAN, AGENT, TOOL, CHAIN, RETRIEVER, EMBEDDING, EVALUATOR, GUARDRAIL or EVENT; names are free text.

One call as a step

tracing.observe runs a call as a step: the return value is its output, and an exception is recorded as an error and re-thrown unchanged (checked exceptions too). tracing.run is the same for a call with no result.

Order order = tracing.observe(ObservationType.TOOL, "lookup_order", "A-1001",
        () -> orders.get("A-1001"));

tracing.run(ObservationType.TOOL, "send_receipt", () -> mailer.send(order));

Values are captured when they are set, serialised with Jackson (getters are called), and cut at 64,000 characters.

Steps that start and end in different places

tracing.startObservation(...) creates a step that isn't current, for a callback or background job. step.startObservation(...) adds a child. Call end() when done; a step that never ends is never sent.

Observation job = tracing.startObservation(ObservationType.CHAIN, "nightly-sync");
Observation page = job.startObservation(ObservationType.RETRIEVER, "fetch-page");
page.output(Map.of("rows", 120));
page.end();
job.end();

Threads and executors

The current step belongs to the thread. Hand work to an executor with Tracing.wrap(...); AsyncForgebench does this for every call it makes.

Future<Order> order = executor.submit(Tracing.wrap(() -> loadOrder("A-1001")));
CompletableFuture<Order> later = CompletableFuture.supplyAsync(
        Tracing.wrapSupplier(() -> loadOrder("A-1002")), executor);

Spring AI

Spring AI runs a model's tool loop on the calling thread, so its model calls nest by themselves. Wrap each ToolCallback so every tool run is a step, and open one step per request:

class TracedTool implements ToolCallback {
    private final ToolCallback delegate;
    private final Tracing tracing;

    TracedTool(ToolCallback delegate, Tracing tracing) {
        this.delegate = delegate;
        this.tracing = tracing;
    }

    @Override public ToolDefinition getToolDefinition() { return delegate.getToolDefinition(); }
    @Override public ToolMetadata getToolMetadata() { return delegate.getToolMetadata(); }
    @Override public String call(String input) { return call(input, null); }

    @Override
    public String call(String input, ToolContext context) {
        return tracing.observe(ObservationType.TOOL, delegate.getToolDefinition().name(), input,
                () -> delegate.call(input, context));
    }
}

Configuration

Forgebench client = Forgebench.builder()
        .tracing(TracingConfig.builder()
                .sampleRate(0.2)            // keep 20% of traces (whole traces, never half)
                .environment("production")
                .release("2.3.0")
                .mask(myMask)               // UnaryOperator<JsonNode>, run on every value and error message
                .build())
        .build();

Without .tracing(...), the client reads FORGEBENCH_TRACING_ENABLED, FORGEBENCH_SAMPLE_RATE, FORGEBENCH_ENVIRONMENT and FORGEBENCH_RELEASE; a value that does not parse is ignored with a warning. enabled(false) turns tracing into a no-op without touching your code.

Sending before exit

Steps are sent about every second from a daemon thread. In a short program, call client.flush() before exit; client.close() sends what's left and stops, and a JVM shutdown hook also flushes. A failed send is logged (System.Logger "forgebench.tracing") and dropped, never thrown.

APIWhat it does
tracing.startActiveObservation(type, name)A step that is current until closed.
tracing.observe(type, name, [input,] call), tracing.run(...)Run a call as a step.
tracing.startObservation(type, name), step.end()A step that is not current.
step.input(..), .output(..), .metadata(k, v), .level(..), .statusMessage(..), .recordError(e)Change a step before it ends.
tracing.updateCurrentTrace(TraceAttributes)Trace fields; model calls in the trace inherit the user and session.
Tracing.currentTraceId(), currentObservationId(), createTraceId(seed)Ids for logs and linking; a seed always gives the same id.
Tracing.wrap(..), Tracing.wrapSupplier(..)Carry the current step into another thread.
client.flush(), client.close()Send now / send and stop.

Errors

Every SDK-raised error is an unchecked ai.forgebench.errors.ForgebenchException. Network failures throw ForgebenchConnectionException directly; anything that got an HTTP response throws a subclass of ApiException, one per status code, each carrying getStatusCode(), getCode(), getDetail(), getBody() and getRequestId():

ExceptionHTTPMeaningRetry?
AuthenticationException401Missing, invalid or revoked API keyNo: fix the key
BudgetExceededException402Over budget; refused before any tokens were spentNo: queue, fall back, or report it
PermissionDeniedException403Authenticated but lacking the required role/scope, model allowlist, tool binding, or agent-to-agent bindingNo: fix config, don't retry
NotFoundException404Resource not found, or not visible to this workspaceNo
ConflictException409Conflicting state, for example deleting something that must be revoked firstNo: fix the resource's state first
ValidationException422Request body rejectedNo: fix the request
RateLimitException429Too many requests (Forgebench's own limit, or the provider's), or a tool call-volume ceiling / repeat-offender circuit breakerYes: auto-retried up to maxRetries
ServerException5xxForgebench server errorReads: 500, 502, 503 and 504 are auto-retried. Writes (a chat call): no, it may already have been billed
ForgebenchConnectionExceptionN/ANetwork failure reaching Forgebench, or a stalled streamReads: auto-retried. Writes: only when it could not connect at all

By the time you see a RateLimitException, the SDK has already retried it up to maxRetries times (default 2). The same applies to a ServerException or connection failure on a read.

A write (chat call, A2A task, tool report) is never replayed after a 5xx or a dropped connection, because the server may already have run and billed it. A ServerException from a chat call means it may or may not have run; check before repeating it.

Other errors will fail again if retried unchanged. Three unauthorized attempts at the same tool within ten minutes escalate to a 429 (tool_repeatedly_unauthorized).

getCode() names the cause when the server gives one (tool_not_authorized, not_permitted, agent_budget_exceeded, and others). When it's null, check getBody(). A 402's body includes limit_usd, spent_usd and attempted_cost_usd:

import ai.forgebench.errors.*;

try {
    ChatCompletion resp = client.chat().completions().create(request);
} catch (BudgetExceededException e) {
    log.warn("budget gate refused the call: {}", e.getBody());
} catch (PermissionDeniedException e) {
    log.error("not authorized ({}): {}", e.getCode(), e.getDetail());
} catch (AuthenticationException e) {
    log.error("auth failed: {}", e.getDetail());
} catch (ValidationException e) {
    log.error("request rejected: {}", e.getBody());
} catch (NotFoundException | ConflictException e) {
    log.error("{}", e.toString());
} catch (RateLimitException e) {
    log.warn("rate limited after retries: {}", e.getCode());
} catch (ServerException e) {
    log.error("control-plane error after retries (request {})", e.getRequestId());
} catch (ForgebenchConnectionException e) {
    log.error("could not reach the control plane: {}", e.getMessage());
}

Every 402, 403, 400 and 429 refusal is written to the Refusals log with its cause. See Errors and refusals for each body shape.

Retries

The Python, TypeScript and Java SDKs share one retry rule: a call the server may already have run and billed is never sent twice.

RequestRetried on statusRetried on network error
Reads and other repeatable calls (GET, PUT, DELETE), and trace batches408, 425, 429, 500, 502, 503, 504Failure to connect, read timeout, dropped connection
Other writes (POST, PATCH): a chat call, an A2A task, a tool report429 onlyFailure to connect only
A stream, before it opensAs for its methodAs for its method
  • A stream that is already delivering events is never retried.
  • A read timeout on a write is not retried: the server may have run the call.
  • There are at most maxRetries retries (default 2). The wait starts at 0.5s and doubles up to 8s, with jitter. A Retry-After header in seconds replaces it, capped at 30s; an HTTP-date is ignored.

Metering and budget state

MeteringSummary s = client.meteringSummary();
System.out.printf("spent $%s / limit $%s (remaining $%s)%n",
    s.getSpentUsd(), s.getMonthlyLimitUsd(), s.getRemainingUsd());
System.out.println(s.getByModel());

Reading workspace spend needs a key with the admin role or the billing:read permission. An agent key gets a PermissionDeniedException.

Next

Cookie settings

Our Cookie Policy lists every cookie and what it does.