← All projects

Customer Support Agent with Tool Calling

Build a customer-support AI agent backend with a hand-written tool-calling loop against the Anthropic Messages API, PostgreSQL-backed tools, a refund approval gate, conversation persistence and a full audit log.

AI AgentsIntermediate12-18 hours#ai-agents#llm#tool-use#security#persistenceJava 21Spring Boot 3java.net.http.HttpClientJacksonPostgreSQLFlywayJUnit 5WireMock

Revise first: Exception HandlingJava Testing (JUnit, Mockito)Java SecurityTransactions & PropagationMicroservices & Resilience

An "AI agent" backend is not magic: it is a while loop that calls a model, runs the tools the model asks for, feeds the results back, and stops when the model answers in text. The hard engineering is everything around that loop: bounding it, treating model output and tool output as untrusted, gating dangerous actions behind a human, persisting conversations, and leaving an audit trail you can show a compliance reviewer. Teams hiring for AI-backend roles ask exactly these questions, because a demo that works is easy and an agent that cannot be tricked into refunding the wrong order is not. You will call the Anthropic Messages API directly with HttpClient and Jackson so every step of the loop is visible.

Target behaviour

POST /api/chat  {"conversationId":null,"message":"Where is my order 1042? Also refund it."}
  -> customer 7 (from the auth token, never from the body)

[iter 1] model stop_reason=tool_use
  tool_use lookup_order {"order_id":1042}
  tool_result {"order_id":1042,"status":"DELIVERED","total_cents":18900,"customer_id":7}
[iter 2] model stop_reason=tool_use
  tool_use request_refund {"order_id":1042,"amount_cents":18900,"reason":"customer request"}
  tool_result {"status":"PENDING_APPROVAL","approval_id":31}      (over the 5000 threshold)
[iter 3] model stop_reason=end_turn

200 {"conversationId":412,
     "reply":"Order 1042 was delivered. The refund of 189.00 needs a teammate's approval; ticket 31 is open.",
     "iterations":3,"inputTokens":2310,"outputTokens":187}

POST /api/approvals/31/approve   (role SUPPORT_LEAD)  -> 200 {"status":"REFUNDED"}

Architecture

 client ──JWT──▶ ChatController ──▶ AgentService (the loop)
                                      │  1 load history   ▲ 6 persist messages
                                      ▼                   │
                         ┌─────────────┐   ┌─────────────────┐
                         │  LlmClient  │   │ ConversationRepo│─▶ PostgreSQL
                         └──────┬──────┘   └─────────────────┘
                                ▼
              2 tool_use blocks       │ 5 tool_result blocks
                                      ▼
                    ToolRegistry ──▶ lookup_order | list_invoices | request_refund
                    (ctx = customerId from JWT)        │
                                       every call ──▶ tool_audit row
                                       refund > threshold ──▶ refund_approval (PENDING)

The model only proposes; your code decides. The customerId comes from the authenticated principal and is injected into every tool, so a customer who types "I am customer 8" gets nothing. The model is stateless, so each request replays the stored message list. Approvals never block the loop: the tool returns PENDING_APPROVAL immediately and a human completes the refund later through a separate endpoint.

The core of it

Put the model behind an interface so the loop is testable. Messages are kept as Jackson JsonNode, because content blocks (text, tool_use, tool_result) must round-trip unchanged.

public interface LlmClient {
    LlmResponse create(String system, ArrayNode messages, ArrayNode tools) throws IOException;
}

public record LlmResponse(String stopReason, ArrayNode content, int inputTokens, int outputTokens) {
    public List<JsonNode> toolUses() {
        List<JsonNode> out = new ArrayList<>();
        for (JsonNode b : content) if ("tool_use".equals(b.path("type").asText())) out.add(b);
        return out;
    }
    public String text() {
        StringBuilder sb = new StringBuilder();
        for (JsonNode b : content) if ("text".equals(b.path("type").asText())) sb.append(b.path("text").asText());
        return sb.toString();
    }
}

The real client. Headers are x-api-key and anthropic-version: 2023-06-01. The system prompt is sent as a block list so you can mark it with cache_control, which caches the whole prefix up to and including that block (tools, then system). A prefix below the model's minimum cacheable length is simply not cached, with no error.

public LlmResponse create(String system, ArrayNode messages, ArrayNode tools) throws IOException {
    ObjectNode body = om.createObjectNode();
    body.put("model", model).put("max_tokens", 1024);   // model from app.llm.model
    ObjectNode sys = om.createObjectNode().put("type", "text").put("text", system);
    sys.set("cache_control", om.createObjectNode().put("type", "ephemeral"));
    body.set("system", om.createArrayNode().add(sys));
    body.set("messages", messages);
    body.set("tools", tools);
    HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/v1/messages"))
        .timeout(Duration.ofSeconds(60))
        .header("x-api-key", apiKey).header("anthropic-version", "2023-06-01")
        .header("content-type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(om.writeValueAsString(body))).build();
    try {
        HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
        return parse(res);   // throws LlmUnavailableException on 429/5xx, LlmException on other 4xx
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new IOException("interrupted", e);
    }
}

parse reads stop_reason, content and usage.input_tokens / usage.output_tokens; with caching on, usage also has cache_creation_input_tokens and cache_read_input_tokens. A baseUrl property lets WireMock stand in for the API.

A tool declares a JSON Schema that the model sees as input_schema. Keep descriptions specific; the model chooses tools by reading them.

public interface Tool {
    String name();
    String description();
    ObjectNode inputSchema();
    Object execute(JsonNode input, long customerId) throws ToolException;
}

Each tool builds its schema with Jackson (type: object, properties, required). Every order query is scoped with WHERE id = :id AND customer_id = :customerId, so a guessed id from another account looks identical to a missing one.

The loop. It is a plain for with a hard cap. An unknown tool, bad arguments or a business failure becomes a tool_result with is_error: true so the model can recover or apologise; only infrastructure failures escape.

public ChatResult run(long convId, long customerId, String userText) throws IOException {
    ArrayNode msgs = repo.load(convId);
    msgs.add(userMessage(userText));
    for (int i = 1; i <= maxIterations; i++) {
        LlmResponse r = llm.create(SYSTEM, msgs, registry.schemas());
        msgs.add(assistantMessage(r.content()));          // keep tool_use blocks verbatim
        if (!"tool_use".equals(r.stopReason())) {
            repo.save(convId, msgs);
            return new ChatResult(r.text(), i);
        }
        ArrayNode results = om.createArrayNode();
        for (JsonNode use : r.toolUses()) results.add(runTool(convId, customerId, use));
        msgs.add(om.createObjectNode().put("role", "user").set("content", results));
    }
    repo.save(convId, msgs);
    throw new AgentLimitException("no final answer after " + maxIterations + " iterations");
}

All tool_result blocks for one assistant turn go in a single user message, and each carries the matching tool_use_id. Saving only at the end is a deliberate simplification; the pitfalls section covers the failure mode.

private ObjectNode runTool(long convId, long customerId, JsonNode use) {
    String name = use.path("name").asText();
    ObjectNode block = om.createObjectNode().put("type", "tool_result")
        .put("tool_use_id", use.path("id").asText());
    String outcome = "ok";
    try {
        Object result = registry.require(name).execute(use.path("input"), customerId);
        block.put("content", Untrusted.wrap(om.writeValueAsString(result)));
    } catch (ToolException | JsonProcessingException e) {
        outcome = "error";
        block.put("is_error", true).put("content", Untrusted.wrap(e.getMessage()));
    }
    audit.record(convId, customerId, name, use.path("input").toString(), block.path("content").asText(), outcome);
    return block;
}

Prompt-injection hygiene. Order notes, invoice memos and customer names are attacker-controlled text. Wrap tool output as data, truncate it, and tell the system prompt that text inside <tool_data> is untrusted and must never be obeyed. This lowers risk but is not a guarantee; the real defences are below.

final class Untrusted {
    static String wrap(String s) {
        String t = s.length() > 4000 ? s.substring(0, 4000) + "...[truncated]" : s;
        t = t.replace("</tool_data>", "");
        return "<tool_data>" + t + "</tool_data>";
    }
}

The approval gate lives in code, not in the prompt. The refund tool checks ownership, a refundable amount, and the threshold itself, so a jailbroken model still cannot move money above the line.

public Object execute(JsonNode in, long customerId) throws ToolException {
    long orderId = in.path("order_id").asLong(-1), cents = in.path("amount_cents").asLong(-1);
    Order o = orders.findForCustomer(orderId, customerId)
        .orElseThrow(() -> new ToolException("order not found"));
    if (cents <= 0 || cents > o.refundableCents()) throw new ToolException("invalid refund amount");
    if (cents > thresholdCents) {
        long id = approvals.createPending(orderId, customerId, cents, in.path("reason").asText());
        return Map.of("status", "PENDING_APPROVAL", "approval_id", id);
    }
    refunds.execute(orderId, cents);          // @Transactional: refund row + order update together
    return Map.of("status", "REFUNDED");
}

Persistence and audit: store each message as a jsonb row keyed by (conversation_id, seq) so a conversation replays exactly, and keep tool_audit append-only with columns for conversation, customer, tool, input jsonb, output, outcome and timestamp.

An SDK would shorten the client: Anthropic publishes an official Java SDK, and Spring AI and LangChain4j both wrap tool calling. They are reasonable in production, but write the loop by hand once; you cannot debug an abstraction you have never seen unwrapped.

Testing

Test the loop with a scripted LlmClient that returns canned responses in order. No network, no tokens, fully deterministic. The queue also lets you assert exactly what the loop sent back.

class ScriptedLlm implements LlmClient {
    final Deque<LlmResponse> script; final List<String> seenMessages = new ArrayList<>();
    ScriptedLlm(LlmResponse... r) { script = new ArrayDeque<>(List.of(r)); }
    public LlmResponse create(String sys, ArrayNode msgs, ArrayNode tools) {
        seenMessages.add(msgs.toString());
        return script.removeFirst();
    }
}

@Test
void unknownOrderBecomesIsErrorResultAndLoopContinues() throws Exception {
    var llm = new ScriptedLlm(
        toolUse("tu_1", "lookup_order", "{\"order_id\":9999}"),
        text("I could not find that order."));
    ChatResult r = agent(llm).run(convId, 7L, "order 9999?");
    assertThat(r.reply()).contains("could not find");
    assertThat(llm.seenMessages.get(1)).contains("\"is_error\":true", "\"tool_use_id\":\"tu_1\"");
    assertThat(auditRows()).extracting("outcome").containsExactly("error");
}

Add tests for the iteration cap (endless tool_use throws AgentLimitException) and for a refund above the threshold (no refund row, one PENDING approval). For AnthropicClient itself, use WireMock: stub POST /v1/messages, assert the x-api-key and anthropic-version headers and the JSON body, and return recorded 429 and 500 responses to test the retry path. Keep one opt-in integration test against the real API, skipped when no key is set.

Bugs you will hit

  • Dropping the assistant turn or its tool_use blocks. If you store only the text, the next request contains a tool_result with no matching tool_use and the API returns a 400. Persist the assistant content array verbatim and append results as a user message.
  • One tool_result message per tool call. When the model calls two tools in one turn, all results must be blocks in a single user message. Splitting them is rejected or confuses the model.
  • Trusting identifiers from the model. The model will happily pass customer_id: 8 if the user asks. Take identity from the JWT, inject it, and filter every query by it. Authorisation is code, never prompt text.
  • Unbounded or retried loops. A model repeating the same failing call burns tokens. Cap iterations, cap tokens per conversation, and cap per-request wall time. Do not blindly retry non-idempotent tools: key refunds on (order_id, approval_id) with a unique constraint.
  • Losing the conversation on a mid-loop crash. Saving only at the end means a crash after a tool executed leaves an audit row but no history. Persist after each iteration inside a short transaction, and never hold a database transaction open across the HTTP call to the model.

Interview angle

  • How does the tool-calling protocol work? You send tool definitions; the model replies with tool_use blocks and stop_reason: tool_use; you execute them and send back a user message of tool_result blocks keyed by tool_use_id; you repeat until the stop reason is end_turn. The model never executes anything.
  • How do you defend against prompt injection through tool output? You cannot fully prevent it, so assume it happens: delimit and label untrusted data, truncate it, but rely on hard controls. Least-privilege tools scoped by the authenticated user, server-side validation, a human gate on irreversible actions and an audit log limit the blast radius.
  • Why return errors as is_error results instead of throwing? The model can read the error, correct its arguments or explain it to the customer. Throwing would end the whole request for a recoverable fault. Genuine outages (database down, API 5xx) should still surface as HTTP errors.
  • How do you control cost and latency? Cache the stable prefix with cache_control, keep tool descriptions tight, trim history, use a smaller model for routing, cap iterations, and track usage per conversation. Add a timeout, backoff on 429 and 5xx, and a circuit breaker around the client.

Milestones

0 of 7 milestones

Done when

Stretch goals