The usual pitch for GraphQL and agents is that the model requests exactly the fields it needs, so you stop over-fetching. That's a bandwidth argument. Bandwidth stopped being the bottleneck in an agent loop roughly the moment agent loops existed — the model is about to spend four seconds and real money thinking about whatever comes back, so 8KB on the wire is not what's hurting you.
Nearly every GraphQL-for-agents post opens this way, which is why anyone who has shipped one of these stops reading at paragraph two. There's a real argument underneath. It's less flattering to everyone involved, GraphQL included.
You are maintaining two schemas
You have Postgres, and Postgres has a schema. It's authoritative, machine-readable, and updated every time you migrate, because that's what migrating means.
You also have an agent with forty tools, each carrying a name, a description, a JSON Schema for its arguments, and a response shape that lives in someone's head. All of it written by hand, deployed on a cadence that has nothing to do with the database.
That second pile is also a schema. It's lossy, and it has no mechanism for noticing when the first one moves. Nobody sat down and decided to maintain two schemas — you decided to write forty tools, and this is what forty tools turns out to mean eight months later.
The complaint you usually hear about this is verbosity, which is the wrong complaint. The real problem is that the drift is silent:
| Change in Postgres | Hand-written REST tool | Generated GraphQL schema |
|---|---|---|
| Column added | Invisible until a human edits the tool | Queryable immediately |
| Column renamed | 500, or silently drops the field | Validation error, by name, before execution |
| Table added | New endpoint, new tool, new description, new deploy | Queryable |
| Permission tightened | Tool still advertises it; fails at runtime if you're lucky | Field vanishes from that role's schema |
Column two is where it hurts, because those failures land at runtime, inside a loop, as a strange tool result the model then reasons its way around. Agents are unreasonably good at reasoning around broken tools. It retries, rephrases, tries a neighbouring tool, and eventually hands you a confident summary built on a field that stopped existing in March. You find out when a customer asks why the numbers moved.
Of those four rows, the renamed column is the one I'd bet on causing your next incident. A rename is the change least likely to trigger anyone's instinct to go check the agent.
Introspection, and why "just introspect" is wrong
Introspection is the fix for drift — the agent asks what exists instead of being told in advance. The problem is that introspection is exhaustive by design, and generated schemas are enormous.
One table in a generated schema doesn't produce one type. It produces the table type, then _bool_exp, _order_by, _insert_input, _set_input, _inc_input if anything is numeric, _on_conflict, _constraint, _update_column, _select_column, _mutation_response, _aggregate, _aggregate_fields, and a statistical family of _avg_fields, _max_fields, _min_fields, _sum_fields, _stddev_fields, _stddev_pop_fields, _var_samp_fields and friends. Every table you own carries a type for computing population variance, in case that ever comes up.
That's north of twenty generated types per table, before any business logic exists. I won't quote a figure from my own project, because the only figure that matters is yours. It's two commands away:
nhost schema dump --role admin -o schema.admin.graphqls
nhost schema dump --role user -o schema.user.graphqls
wc -c schema.*.graphqls
Run those, then put both files through a tokenizer. The admin number is usually an unpleasant surprise. What matters more is the gap between the two — that gap is this entire post, expressed in bytes.
Piping raw introspection into a context window converts a schema advantage into a token bill. Anyone telling you otherwise is demoing four tables.
The schema is the permission boundary
The property that makes any of this worth the trouble rarely comes up in these posts: roles don't only filter rows, they filter the schema. A role without select permission on internal_notes doesn't get an authorization error when it asks — that field isn't in its version of the type. No permission on a table, and the role doesn't see the table at all.
Introspecting as agent therefore returns a genuinely different, much smaller document than introspecting as admin. The defaults help too. In a zero-trust setup a new role starts with access to nothing and you grant your way up, which compares well against adding a fortieth endpoint to a REST service where the default is whatever the surrounding handlers happened to do.
Narrowing the role collapses two problems into one, because the context bloat and the blast radius shrink together. You stop hand-curating a tool list and start writing a permission set, and the tool list falls out of it.
A select permission on invoices for a role called agent looks roughly like this:
role: agent
table: invoices
permission:
columns: # internal_notes is not here. for this role it does not exist.
- id
- amount
- status
- customer_id
- created_at
filter:
organization:
members:
user_id:
_eq: X-Hasura-User-Id
limit: 100
Tenant scoping there is a row filter evaluated in the data layer, keyed off a session variable that arrived in a signed JWT. It isn't a WHERE clause the agent was trusted to remember. And internal_notes isn't hidden so much as absent — there's no prompt injection that extracts a field the type system doesn't contain. The limit is doing more work than it looks like, and I'll come back to it.
The common alternative is a line in a system prompt reading "Only query data belonging to the current user's organization." That's a request, not a control. It sits in the same context window as untrusted user text, with no enforcement and no audit trail, and its failure rate against adversarial input is well documented by now.
Hand-rolled REST can enforce isolation properly, obviously, and the question was never whether it can. It's whether it does so in one declarative place every query passes through, or across forty handlers where one is quietly missing a WHERE clause. Usually it's the latter, and usually the missing one is an endpoint someone added for a quick admin report that was never meant to be permanent.
None of this comes free either. Deep permission filters compile into the generated SQL, where they can get slow in ways that don't surface until they do. The tell is a query that runs fast as admin and slow as user against identical data. Nhost documents this one and points at Postgres JIT compilation as the usual culprit. Worth knowing before you conclude that GraphQL is slow, because it isn't — your permission tree is.
Things that break
Agents write queries no human would type, which produces failure modes GraphQL alone mostly doesn't have. Unbounded lists are the worst of them, because the model has no intuition for cardinality — users is a word to it, not four hundred thousand rows. That's what the limit in the permission was for, and it belongs on every select permission a non-human role has, not just the ones you got around to.
Nesting is close behind, since it costs the agent nothing to write and so it writes it. The containment is a depth limit: on Nhost that's maxDepthQueries under [graphql.security], on a plan that includes it. Self-hosted Hasura puts it behind a tier too, so if yours doesn't have it, you're building a gateway. Set it lower than feels comfortable. An agent has no legitimate reason to go four levels deep, and the queries where it wants to are exactly the ones you'd rather it didn't run.
The same config block has forbidAdminSecret, which rejects any request carrying an admin-secret header. Turn it on and "the agent accidentally ran as admin" becomes structurally impossible instead of something you audit for afterwards. The docs warn that enabling it can break deployments, which is a polite way of saying you probably have something running as admin right now that you've forgotten about.
Mutations are the uglier half, since GraphQL will happily let you insert into three tables in a single call. The partial-failure semantics of that are about as well specified as you'd expect from a spec that mostly declined to have opinions about errors. An agent handed a partial success will improvise.
I avoid finding out what it improvises: one mutation, one table, one call, an idempotency key. It's boring, and I'm aware it's avoidance rather than a solution. Give the agent a read-only role by default too, with writes behind a second role it has to explicitly assume — introspection makes exploration cheap, and models are curious.
The part I haven't solved
The thing I don't have a good answer for is role sprawl, and it follows directly from everything above. Narrow roles are how you control context and permissions at the same time. Follow that far enough and you get a role per agent task, then per-tenant variations, then one for the reporting agent that's almost the support agent's role. Now permission metadata is a thing you maintain by hand.
Which is, you'll notice, structurally the same problem as the hand-maintained tool list. It's smaller, declarative, and lives in one place, so it's a better version of the problem. But I'd be overselling to call it solved, and if you've found a clean pattern here, I'd like to hear it.
When to ignore all of this
If you have six endpoints, write six tools and skip everything above, because at that size this is over-engineering and you'll feel silly. The threshold isn't a table count but a rate of change. Once your schema moves faster than a human remembers to update tool descriptions, manual maintenance has already lost — you just haven't hit the bug yet.
Most of the argument evaporates if your data layer isn't relational, since the permission-filtered-schema property is doing the heavy lifting. That property comes from a mature permission layer over a relational store, not from GraphQL as a language. GraphQL over microservices with hand-written resolvers gives you the schema and none of the enforcement, which is the worst version of this trade available.
And if you're here because agents supposedly work better with GraphQL, they don't especially. Models write mediocre GraphQL alongside their mediocre SQL and their mediocre REST calls. The gain was never query quality — it's that the surface stays honest without anyone maintaining it.
Concretely
The setup I've been describing is Postgres with a permission-aware GraphQL engine on top, which is what Nhost packages — Postgres, GraphQL, auth, storage, functions, event triggers. The part that matters here is that auth and the permission layer are the same system. So x-hasura-user-id in that row filter is a claim from a signed token, not something an application layer asserted and hoped was right.
It doesn't solve the context problem, and it won't stop your agent writing a stupid query. You still have to design the roles, which is where the actual work lives. Most of the value in any of this is one afternoon spent deciding what the agent role is allowed to see, and no platform does that afternoon for you.
What you get is the boundary in one declarative place instead of forty handlers, and a schema that can't drift from its permissions because they're the same object.
Top comments (1)
The permission-filtered schema is the real win here, and the role-sprawl caveat is important. A pattern that has worked better for me than role-per-agent is to compile a small set of purpose roles from reusable policy modules.
For example:
support.read,billing.summary, andinventory.lookupeach compose column sets, row predicates, maximum cardinality/depth, and allowed operations. Tenant/user values remain runtime claims, not separate roles. The build emits both permission metadata and a role-specific schema digest, then CI checks that every exposed field traces back to an owned policy module.Schema changes should use an expand/contract window too. Add the new field, regenerate and diff each purpose schema, run representative queries as each role, then retire the old field only after clients have moved. A vanished field is safer than unauthorized access, but an unannounced disappearance can still trigger agent improvisation.
I’d also cache introspection by
role + policy version + database schema version, never just role. That keeps the surface small without letting a warm catalog outlive an authorization or migration change.