HomeJournalThis post

GraphQL vs REST for Agent Tool APIs

Compare GraphQL and REST agent-tool contracts through bounded operations, context cost, caching, pagination, errors, and recovery.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

GraphQL vs REST for agent tools becomes useful only after the callable operation set and recovery rules are bounded. This comparison runs six synthetic tasks through both contract shapes and records schema load, calls, bytes, cache identity, pagination, and retry ambiguity.

GraphQL vs REST for agent tools needs a budget

GraphQL vs REST for agent tools is not a contest between introspection and endpoints. The first decision is which tasks the agent may perform, with which inputs, output limits, side effects, and recovery rules. Freeze six representative tasks before choosing the interface: fetch one record, search a catalog, traverse a relation, page results, update an idempotent preference, and create a non-idempotent order.

Give each task an operation budget. Record allowed fields, maximum page size, maximum calls, response byte ceiling, authorization scope, timeout, and retry class. The agent receives only this bounded surface, not every operation the backend happens to expose. This is core agent tool API design: the contract is shaped for reliable invocation and review rather than for raw platform coverage.

Begin with a bounded agent-readable operation contract so both candidates answer the same workload. The synthetic lab estimates schema characters using a declared conversion, counts fixture payload bytes, and labels every unknown. It does not call a server, benchmark throughput, or establish security. GraphQL vs REST for agent tools becomes useful when the two adapters can be evaluated from one receipt instead of from two unrelated demos.

One task through two bounded operation treesAn agent task branches into an allowlisted GraphQL document and a selected OpenAPI operation, each with finite input and output surfaces.task: catalogsearch pageSEARCH_CATALOGfirst ≤ 20getProductslimit ≤ 20bounded resultsbounded results
One task through two bounded operation trees
An agent task branches into an allowlisted GraphQL document and a selected OpenAPI operation, each with finite input and output surfaces.
Bounded surfaces
AdapterApproved identityList bound
GraphQLNamed allowlisted documentfirst plus cursor
RESTSelected OpenAPI operationIdlimit plus cursor
Figure 1: Protocol shape changes, but the approved task and finite surface stay matched.

Bound GraphQL with named documents

GraphQL can let a client select precisely the fields required for a task, traverse related objects in one operation, and receive data plus structured errors. Those capabilities become agent-friendly only when the callable documents are constrained. Provide named persisted or allowlisted operations with typed variables, depth and complexity limits, bounded list arguments, and an explicit output schema. Do not hand an agent arbitrary introspection plus permission to synthesize unreviewed documents.

The official GraphQL Specification September 2025 defines documents, operations, selection sets, execution, response data, and errors. It does not prescribe an agent allowlist or guarantee that a selected field is cheap. Those are application policies. A GraphQL operation allowlist makes cost and authorization review possible because each document has a stable identity.

For the fixture, GET_PRODUCT, SEARCH_CATALOG, and CREATE_ORDER are frozen documents. Variables have finite schemas, list fields require first plus cursor, and mutation retries require an application idempotency key where the product supports one. The lab deliberately rejects an unbounded nested search document. GraphQL vs REST for agent tools should compare reviewed operations, not an idealized query language against a deliberately poor endpoint design.

Bound REST with selected OpenAPI operations

REST exposes resources through HTTP methods and representations, but an agent should still receive a curated subset. Select operationId values, keep request and response schemas small, define path and query parameter bounds, describe pagination, and attach concrete error shapes. An enormous OpenAPI document can consume context and expose irrelevant administrative routes just as easily as an unrestricted GraphQL schema.

The OpenAPI Specification 3.2.0 defines operation, parameter, response, link, and schema description contracts. The agent adapter can project six selected operations into a compact tool schema, preserving method semantics and response variants. This OpenAPI tool schema is generated from the approved subset rather than from the whole service.

Keep event-driven work separate. If the task is subscription or durable event delivery, separate request-response APIs from event contracts before comparing GraphQL vs REST for agent tools. This article covers bounded request-response operations. The REST fixture uses GET /products/{id}, GET /products with cursor, PATCH /preferences/{id}, and POST /orders. It records cache identity and retry policy per operation, making resource conventions observable rather than assuming HTTP alone makes the surface safe.

Contract budget across six synthetic tasksSchema context, calls, payload bytes, cache identity, and page count appear as paired bars with unknown evidence hatched instead of scored as zero.schema contextpayload bytescalls + pagescache identityGraphQL solid accent · REST outlined · hatch unknown
Contract budget across six synthetic tasks
Schema context, calls, payload bytes, cache identity, and page count appear as paired bars with unknown evidence hatched instead of scored as zero.
  • Schema bars count projected contract characters, not a complete platform schema.
  • Payload bars count matched synthetic JSON bytes.
  • Calls and pages use the same six task fixtures.
  • Cache identity is recorded as present, absent, or unknown.
Figure 2: A matched workload replaces abstract claims about overfetching or call count.

Compare context, calls, bytes, and caching

Schema context is the portion of the operation contract placed before the model. Count it with a declared estimator and report raw characters. A selected GraphQL document plus variables can be compact; a full schema is not. A selected REST operation can be compact; a full generated SDK description is not. GraphQL vs REST for agent tools should compare the actual projected surfaces the agent receives.

Calls and payload bytes depend on the task and server design. A relation traversal may fit one GraphQL selection while REST uses linked requests, but a purpose-built REST representation may also fit one call. Conversely, an overbroad GraphQL selection can return more than needed. The fixture freezes response objects and counts UTF-8 bytes; it does not extrapolate network speed.

Cache identity must be explicit. REST can use method, URI, headers, validators, and intermediary behavior. GraphQL deployments may use persisted-document identity, variables, application caches, or HTTP semantics depending on the transport design. The HTTP Semantics standard defines safety, idempotency, cache, and method meanings, but it does not automatically supply correct application keys. The budget bars show evidence and unknowns separately, avoiding a universal winner.

Model pagination and partial-result recovery

Every list must be bounded. GraphQL connections commonly expose edges or nodes with page information and a cursor. REST collections commonly expose a cursor or link to the next page. The agent contract should name maximum page size, stable sort key, cursor lifetime, filter identity, and termination rule. Page receipts include the query variables or URI, returned count, next cursor, and accumulated item identities.

Partial results differ visibly. A GraphQL response can contain data and errors together, so the adapter needs field-path policy: which missing field makes the task fail, which can be reported as partial, and whether a narrower retry is valid. REST commonly communicates a response status and body per request; multi-call aggregation can still become partial at the adapter layer. An API recovery contract names that aggregation rather than hiding it.

Runtime validation belongs at both boundaries. Use a reviewed schema and validate tool input at the runtime boundary before sending either operation. Then validate the response variant actually received. GraphQL vs REST for agent tools should not award points for a schema that exists but is not enforced. The lab injects a field error and a failed second page so both adapters must produce an explicit partial-state receipt.

Separate safe retry from ambiguous mutation

A timeout does not reveal whether a write committed. Retrying a safe read is different from replaying an order creation. HTTP method semantics help classify the request, but application behavior and idempotency still matter. GraphQL mutations do not communicate retry safety through the word mutation; the operation contract must state its side effect, idempotency key, deduplication window, and lookup path.

For REST, machine-readable problem details or another stable error envelope helps the agent distinguish invalid input, conflict, rate limit, transient failure, and ambiguous outcome. The pattern in making REST failure recovery machine-readable can carry a correlation identifier and recovery hint without letting free-form prose drive retries.

The lab rejects an unsafe replay of CREATE_ORDER after an unknown outcome. It accepts a preference update only because the fixture declares idempotent replacement semantics and a stable resource identity. The GraphQL adapter applies the same policy to its named mutation. GraphQL vs REST for agent tools should converge on the same safety decision even when wire shapes differ. If they do not, the recovery contract—not the protocol brand—is inconsistent.

Runnable artifact — No network, live server, library benchmark, security proof, or universal GraphQL/REST winner.

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
const canonical=value=>JSON.stringify(value,(_,item)=>item&&typeof item==="object"&&!Array.isArray(item)?Object.fromEntries(Object.entries(item).sort(([a],[b])=>a.localeCompare(b))):item);
const sha=value=>createHash("sha256").update(typeof value==="string"?value:canonical(value)).digest("hex");
const bytes=value=>Buffer.byteLength(JSON.stringify(value));
const catalog=Array.from({length:11},(_,i)=>({id:"p"+(i+1),name:"Product "+(i+1),price:20+i,tags:i%2?["studio"]:["field"],support:i===3?null:{sla:"48h"}}));
const tasks=[
  {id:"get-one",kind:"read",input:{id:"p2"}},
  {id:"search",kind:"list",input:{tag:"field",limit:3,cursor:0}},
  {id:"relation-partial",kind:"relation",input:{id:"p4"}},
  {id:"page-two-failure",kind:"list",input:{tag:"studio",limit:2,cursor:2,injectFailure:true}},
  {id:"update-preference",kind:"idempotent-write",input:{id:"u1",theme:"dark"}},
  {id:"create-order",kind:"non-idempotent-write",input:{product:"p2",outcome:"unknown"}}
];
const graphql={schema:"query GetProduct($id:ID!); query Search($tag:String!,$first:Int!,$after:Int); mutation SetPreference($id:ID!,$theme:String!); mutation CreateOrder($product:ID!,$key:ID)",operations:{"get-one":"GetProduct","search":"Search","relation-partial":"GetProductWithSupport","page-two-failure":"Search","update-preference":"SetPreference","create-order":"CreateOrder"}};
const rest={schema:"getProduct(id); searchProducts(tag,limit<=20,cursor); setPreference(id,theme); createOrder(product,idempotencyKey)",operations:{"get-one":"GET /products/{id}","search":"GET /products","relation-partial":"GET /products/{id}?include=support","page-two-failure":"GET /products","update-preference":"PUT /preferences/{id}","create-order":"POST /orders"}};
function data(task){if(task.kind==="read"||task.kind==="relation")return catalog.find(item=>item.id===task.input.id);if(task.kind==="list")return catalog.filter(item=>item.tags.includes(task.input.tag)).slice(task.input.cursor,task.input.cursor+task.input.limit);if(task.kind==="idempotent-write")return{ok:true,resource:"preference:"+task.input.id,theme:task.input.theme};return{accepted:"unknown",product:task.input.product}}
function execute(task,shape){if(task.kind==="list"&&(!Number.isInteger(task.input.limit)||task.input.limit<1||task.input.limit>20))throw new Error("unbounded-list:"+task.id);const result=data(task),partialErrors=task.id==="relation-partial"?[{path:"product.support",code:"UPSTREAM_UNAVAILABLE"}]:task.input.injectFailure?[{path:"page.next",code:"TRANSIENT_PAGE_FAILURE"}]:[];const retry=task.kind==="non-idempotent-write"&&task.input.outcome==="unknown"?"stop-inspect":task.kind==="idempotent-write"?"bounded-retry":task.kind==="read"||task.kind==="list"||task.kind==="relation"?"bounded-retry":"stop";return{task:task.id,operation:shape.operations[task.id],calls:1,pages:task.kind==="list"?1:0,payloadBytes:bytes(result),cacheIdentity:task.kind==="read"?"product:"+task.input.id:task.kind==="list"?"search:"+sha(task.input).slice(0,12):null,partialErrors,retry,result}}
const adapters=Object.fromEntries(Object.entries({graphql,rest}).map(([name,shape])=>[name,{schemaCharacters:shape.schema.length,declaredTokenEstimate:Math.ceil(shape.schema.length/4),rows:tasks.map(task=>execute(task,shape))}]));
const hostile={};try{execute({id:"bad",kind:"list",input:{tag:"field"}},graphql)}catch(error){hostile.unboundedGraphql=error.message}
const unsafe=adapters.rest.rows.find(row=>row.task==="create-order");assert.equal(unsafe.retry,"stop-inspect");hostile.unsafeRetryRejected=unsafe.retry==="stop-inspect";
for(const value of Object.values(adapters)){assert.equal(value.rows.length,6);assert.ok(value.rows.every(row=>row.calls===1&&Number.isInteger(row.payloadBytes)));assert.equal(value.rows.find(row=>row.task==="relation-partial").partialErrors.length,1)}
const core={schema:"agent-api-contract-receipt-v1",fixtureVersion:"2026-09-07",provenance:"Embedded synthetic catalog and support workloads; no network, private schema, vendor benchmark, or customer data.",claimBoundary:"Explicit contract-shape comparison only; not throughput, security posture, or a universal protocol winner.",estimator:"ceil(schema characters / 4), illustrative only",tasks,adapters,hostile,comparison:{graphqlCalls:adapters.graphql.rows.reduce((s,r)=>s+r.calls,0),restCalls:adapters.rest.rows.reduce((s,r)=>s+r.calls,0),graphqlPayloadBytes:adapters.graphql.rows.reduce((s,r)=>s+r.payloadBytes,0),restPayloadBytes:adapters.rest.rows.reduce((s,r)=>s+r.payloadBytes,0),decision:"prototype-on-operational-unknowns"}};
console.log(JSON.stringify({...core,receiptHash:sha(core)},null,2));console.log("PASS: matched GraphQL and REST task adapters preserve bounds, partial errors, and retry ambiguity");

Run the shared synthetic task fixture

The Node artifact embeds a synthetic product catalog and support workload. Six tasks run through a GraphQL-style adapter and a REST/OpenAPI-style adapter. For each, the lab counts declared schema characters and token estimates, calls, UTF-8 payload bytes, cache identities, pages, partial errors, and retry ambiguity. It validates finite page sizes and rejects operations outside the allowlist.

Hostile controls matter. One GraphQL document omits a list bound and must fail before execution. One REST order creation is marked outcome-unknown and must not be automatically retried. A partial product relation and a failed second page remain in the receipt rather than being converted to empty success. Identical inputs produce one SHA-256 digest.

This fixture uses no network, private schema, customer data, vendor runtime, or generated production traffic. GraphQL vs REST for agent tools is evaluated only as two explicit contract shapes over the same objects. The numbers do not predict throughput, security posture, operational cost, or model accuracy. Run one shared task fixture before choosing the agent-facing API shape, then prototype any unknown that could reverse the decision.

Choose from receipts and prototype triggers

The decision record should list the approved tasks, projected schema, authorization boundary, page contract, cache identity, partial-error policy, retry classes, tool-result size limit, and operational owner. Attach the lab receipt and explain which criteria actually differentiated the candidates. GraphQL vs REST for agent tools can reasonably end in a tie when both adapters expose the same bounded operations.

Choose GraphQL when reviewed selection sets and relation traversal match the workload and the team can operate document allowlists, cost controls, caching, and partial errors. Choose REST when resource representations, HTTP semantics, selected OpenAPI operations, intermediaries, and existing operational controls make the contract clearer. A hybrid platform can expose either one at the agent boundary; the backend's internal style does not need to dictate the tool surface.

Prototype when schema projection, authorization, cache behavior, pagination stability, or ambiguous-write recovery is unknown. Revisit when the GraphQL or OpenAPI specification changes, model tool-schema constraints move, or the task set expands. The durable insight is that an agent-ready API is curated. Neither introspection nor a generated specification replaces product decisions about what can be called and how failure closes. The best interface is the one whose receipt lets the agent and operator recover without guessing.

Failure and retry matrixPartial query data, problem details, transient reads, idempotent writes, and ambiguous mutations route to retry, repair, inspect, or stop.outcomeretryrepairstop/inspectpartial query datainvalid inputtransient readambiguous write
Failure and retry matrix
Partial query data, problem details, transient reads, idempotent writes, and ambiguous mutations route to retry, repair, inspect, or stop.
Recovery rules
OutcomeAction
Partial dataInspect field path and task requirement
Invalid inputRepair once from typed error
Transient readRetry under bounded policy
Ambiguous writeStop unless idempotency and lookup close uncertainty
Figure 3: GraphQL and REST should converge on the same side-effect safety decision.