From APIs to Actions: Rethinking Back-End Design for Agents
Last year, our team was building a customer support agent on top of GPT-4. The idea was simple enough: give the agent access to our existing backend services, let it handle tier-1 tickets, escalate the weird stuff to humans. We had a pretty clean REST API already. We figured we'd just point the agent at our OpenAPI spec and call it a day. That did not go well. The agent kept calling endpoints in the wrong order, passing incomplete parameters, and occasionally doing things like trying to cancel an order it had just created because it misread the confirmation response. Not great and the frustrating part wasn't that the AI was "dumb." It was that our API was designed for a completely different consumer. It was designed for code that knows what it wants . That experience sent me down a rabbit hole I haven't fully climbed out of yet. The short version: back-end design for AI agents is a genuinely different problem than back-end design for human-driven clients, and I think most teams are underestimating how different. I've seen this same problem show up in a pretty specific domain lately: Applicant Tracking Systems. ATS platforms are a genuinely interesting stress test for agent-facing APIs because the workflows are stateful, the consequences of a wrong action are real (you can accidentally reject a candidate, trigger an offer letter, or move someone's application to the wrong stage), and the underlying data models are messy in ways that make simple REST calls go sideways fast. So I'll use that context throughout, alongside the billing examples I started with. The Assumption Baked Into Every API You've Ever Built REST, GraphQL, gRPC; all of it was built on one assumption so obvious nobody ever says it out loud. The client knows what it wants. A user clicks "submit order," your frontend calls POST /orders , done. The intent is clear before the API call even happens. The API just fulfills it. Agents don't work that way. An agent starts with a goal ("screen the inbound applications for the senior engineer role and move qualified candidates to the phone screen stage") and then has to figure out which sequence of calls achieves that goal. It's reasoning at call time. It might explore. It might backtrack. It might interpret an error response as useful information and adjust course entirely.This is a fundamentally different access pattern, and most ATS APIs weren't designed for it. Greenhouse, Lever, Workday; they all expose clean REST surfaces that make perfect sense when a recruiter is clicking through a UI or a developer is writing a deterministic integration. They're much harder for an agent to use correctly without guardrails. The typical REST API has a few properties that are totally fine for human-driven clients but quietly terrible for agents: Endpoints are atomic and context-free. GET /applications/{id} gives you an application. It doesn't tell you what stage the candidate is in, which stages are valid next steps, or whether this candidate has already been rejected and re-applied. A human recruiter knows the workflow. An agent has to infer it from the response shape plus whatever's in its system prompt. Errors are designed to be read by developers. A 422 with a body like {"error": "invalid_stage_transition", "current": "offer_extended", "attempted": "phone_screen"} makes perfect sense to a person staring at a terminal. An agent has to parse that, understand what it means in context, and decide whether to stop, retry, or try a different path. Side effects are implicit. Nothing in a typical ATS response tells you "by the way, advancing this candidate just triggered an automated email to the hiring manager and created a calendar invite." An agent operating autonomously can set off a chain of real-world events it didn't anticipate. I'm not saying REST is broken. I'm saying it was designed for deterministic clients, and agents are not deterministic clients. What "Agent-Friendly" Actually Means When I started thinking about what we'd need to change, the concept that kept coming up was discoverability at runtime . Not discoverability of the API surface (OpenAPI handles that reasonably well), but discoverability of valid actions given the current state . Here's a concrete ATS example. A typical application resource exposes stage transitions like this: GET /applications/{id} POST /applications/{id}/advance POST /applications/{id}/reject POST /applications/{id}/move-to-offer POST /applications/{id}/withdraw From a REST design standpoint, perfectly clean. But when an agent is working a pipeline, it will occasionally try to advance a candidate who is already in the final stage, or move someone to offer when an offer is already extended. Which throws a 409. Which the agent sometimes retries. Which is not great, especially when retrying a stage transition in an ATS can trigger duplicate notifications. The fix we landed on was embarrassingly simple: add a _links section to the application response that lists which transitions are currently valid , based on actual workflow state. Hypermedia, basically, which is ironic because HATEOAS has been mocked for years as over-engineering. Turns out it's genuinely useful when your client is an LLM that can't read your hiring workflow diagram. { "id": "app_7741", "candidate": "Daniel Pacheco", "role": "Senior Software Engineer", "current_stage": "phone_screen", "status": "active", "_links": { "advance": { "href": "/applications/app_7741/advance", "method": "POST", "next_stage": "technical_interview" }, "reject": { "href": "/applications/app_7741/reject", "method": "POST" } } } No move-to-offer in _links because the candidate hasn't cleared the technical interview yet. The agent doesn't need to know the hiring workflow rules. It just needs to know what's on the menu right now. The Tool Definition Layer If you're using Spring AI, you're already working with its @Tool annotation model, which lets you expose Java methods directly as callable tools. I've seen teams treat this as a thin adapter, basically just wrapping existing ATS service methods and shipping the defaults. That works, kind of. It misses most of the benefit, though. The tool description isn't documentation. It's a prompt. And it should be written like one. Here's the difference between a lazy tool definition and one that's actually written for an agent, using an ATS context: // Version 1: minimal, basically useless for agent reasoning @Component public class ApplicationTools { @Tool(description = "Advances a candidate to the next stage.") public String advanceCandidate(String applicationId) { return applicationService.advance(applicationId); } } // Version 2: written for an agent @Component public class ApplicationTools { private final ApplicationService applicationService; public ApplicationTools(ApplicationService applicationService) { this.applicationService = applicationService; } @Tool(description = """ Advances an active application to the next hiring stage. Only call this if the candidate meets the criteria for the current stage and the hiring manager has confirmed they should move forward. Do not call this to reject or withdraw a candidate; use rejectCandidate or withdrawApplication for those cases. Check _links in the application response to confirm advancement is a valid action before calling. Returns a confirmation with the new stage and any triggered notifications. """) public StageTransitionResult advanceCandidate( @ToolParam(description = "The application ID (format: app_XXXX). " + "Call listApplications first if you don't already have this.") String applicationId) { return applicationService.advance(applicationId); } } The second version tells the agent when to use this tool, when not to, and what t...