Skip to content

Latest commit

 

History

History
135 lines (84 loc) · 10.9 KB

File metadata and controls

135 lines (84 loc) · 10.9 KB

Giving an AI agent write access to a content graph, without letting it invent facts

Disclosure: I am writing this as a paid contributor to the Hygraph Creator Program. Hygraph did not review the numbers before I published them, and the criticisms in the last section are mine.

I run an overnight animal-welfare monitoring pipeline called ZooVision (source). Fixed cameras produce video, a TwelveLabs Pegasus 1.5 vision call turns chunks of that video into timestamped behaviour descriptions, a deterministic Python rule engine decides whether any of it matters, and a human reads a brief in the morning.

The pipeline has two invariants that took me a while to arrive at:

  1. The model may only claim what it actually read in the scene.
  2. Only the deterministic rules may claim severity.

There is a third rule that follows from the first two: when a provider call fails, that stretch of the night is recorded as an explicit gap and is never backfilled.

This article is about moving that morning brief into a Hygraph content graph and letting an agent do the writing through Hygraph's MCP server, without weakening any of those three rules. Everything below was measured on a free Hobby project. The code is at github.com/rishabhcli/hygraph-agent-content-graph.

1. Model the difference between a description and a judgement

The tempting schema is one NightEvent model with a description field and a severity field. It is also the schema that quietly destroys the thing I care about, because once a model can write to severity, nothing in the system distinguishes "the bear has not moved in thirty minutes" from "the bear is in distress."

So the graph has three models instead of one:

  • Observation carries what the model described: behavior, sourceQuote, modelName, modelConfidence, chunkUri, and the unedited rawResponse. It has no severity field at all. There is nowhere for a judgement to go.
  • Assessment carries what the rules decided: ruleFired, severity, ruleVersion, rationale, reviewDecision. It holds a required relation back to the Observation it was derived from.
  • DataGap carries what nobody knows: reason, attempts, detail, and a backfilled boolean that is always false. The field exists specifically so that a true value is a visible bug rather than an invisible assumption.

ruleVersion matters more than it looks. Six months from now, somebody will ask why an escalation fired on a specific night, and "because the rules said so" is only an answer if you can say which rules.

2. Wire the agent up

The MCP server is in Early Access and is available on every plan, including the free one. Create a token under Project Settings → Access → Permanent Auth Tokens → Generate MCP PAT, pick a scope, and grab the endpoint from Access → Endpoints:

https://mcp-{REGION}.hygraph.com/{PROJECT_ID}/{ENVIRONMENT}/mcp

For Claude Code that is one line:

export HYGRAPH_TOKEN="..."
claude mcp add hygraph https://mcp-us-west-2.hygraph.com/$PROJECT_ID/master/mcp \
  --transport http --header "Authorization: Bearer ${HYGRAPH_TOKEN}"

I wrote a small client instead, because I wanted timings (src/hygraph_mcp.py). Two things cost me time there and are worth knowing.

The session header is not optional and the error does not say so. initialize returns an Mcp-Session-Id header that you must echo on every later call. Miss it and you get 404 Invalid session ID, which reads like a wrong URL. Worse, the casing differs by protocol version: Mcp-Session-Id over HTTP/1.1, mcp-session-id over HTTP/2. Match it case-insensitively.

The token never expires. Decoding the PAT shows iss, aud, iat, sub, jti and no exp claim. There is no rotation story here except the one you build.

Once connected, the server reports itself as Autobahn MCP Server 0.0.1 and exposes 15 tools, namespaced by prefix in their descriptions: [CONTENT], [SCHEMA MANAGEMENT], [AI GUIDELINES], [AGENTS].

The content tools enforce a sequence, and say so in their own descriptions: list_entity_types, then get_entity_schema, then create_entry. The description for create_entry literally reads "PREREQUISITES (will fail if skipped)". I found this genuinely useful. It is the API telling the agent to go read the schema before writing, which is exactly the failure mode you want closed.

3. Build the schema through the agent, not by hand

submit_batch_migration applies schema changes atomically, all or nothing. I built the whole graph this way: 4 enumerations, 4 models, 32 fields and 3 relations, as 42 operations across 7 batches.

{"operation_name": "createSimpleField", "params": {
    "apiId": "backfilled", "parentApiId": "DataGap", "type": "BOOLEAN",
    "displayName": "Backfilled", "isRequired": True,
    "description": "Invariant: always false. Present so any true value is a visible bug."}}

Two practical notes. The tool warns you to keep batches under 10 operations because larger ones degrade into a generic internal error, and that warning is worth obeying. And migration names must be unique per environment, so on a retry you need a new name, not the same one.

Measured, on the free plan:

Step Batches p50 Total
dry_run: true 7 228 ms 2.2 s
dry_run: false 7 1.7 s 17.1 s

Dry runs are cheap enough to always run first. They are also not sufficient: the docs are explicit that some checks only happen on real execution, and I found that to be true.

4. The guardrail question, tested rather than assumed

The claim I wanted to make was: a required relation means an agent cannot write an Assessment that no Observation supports. The awkward part is that isRequired is not in the documented parameter list for createRelationalField. Only apiId, parentApiId, type, displayName, description, isList and reverseField are.

I sent it anyway, and then checked instead of trusting the absence of an error. get_entity_schema reports it back:

{ "name": "observation", "type": "RELATION", "relatedTypename": "Observation",
  "isList": false, "isRequired": true }

Then the actual test. Create an Assessment with a severity of ESCALATE and no observation attached:

create failed: content API call failed:
- variable 'data.observation' must be defined

The guardrail holds. An agent with full content and schema permissions cannot write a severity claim that is not anchored to something a model actually described. That is the single most valuable thing I got out of this exercise, and it is enforced by the schema rather than by a prompt.

Two more constraints are structural rather than mine. There is no delete tool. Not for content, not for schema. All 42 schema operations are create* or update*. Deletes and unpublishes have to happen by hand in Studio. If you have ever watched an agent enthusiastically clean something up, you will recognise this as the correct default.

5. MCP versus writing the mutations myself

I wrote the same 23 entries both ways, in the same process, on the same network, twice: once through create_entry, once through hand-written GraphQL against the Content API. The result was not what I expected.

via MCP hand-written GraphQL
Calls 30 23
Failures 0 0
p50 per call 281 ms 495 ms
p95 per call 360 ms 678 ms
Slowest call, 60 vs 46 samples 388 ms 15,429 ms
Request bytes 14.6 KB 14.3 KB
Response bytes 54.2 KB 4.3 KB
Tool definitions to load 44,438 chars 0

MCP was about 1.75x faster at p50 and dramatically tighter in the tail. Across 60 MCP calls nothing exceeded 388 ms. Across 46 hand-written calls, one took 15.4 seconds, and I could not reproduce it on demand.

My hypothesis is boring and I want to label it as a hypothesis: the MCP server sits inside Hygraph's infrastructure and reaches the content API over a shorter path than my laptop does. If you are writing from your own server in the same region, I would expect this gap to narrow or invert. Do not take my p50 as yours.

The cost shows up somewhere else. The 15 tool definitions are 44,438 characters, roughly 11,000 tokens, that an agent loads before doing anything at all. MCP responses were 13.6x larger than the GraphQL equivalents, because they are wrapped and annotated for a model to read. On a small job the latency win is real and the context cost is worse. publish_entry was equally consistent: 92 entries, zero failures, p50 284 ms.

6. Render the gaps

The last invariant is a rendering problem. A monitoring UI that draws a smooth line through missing data is lying, and it is lying in the most expensive direction, because absence of evidence renders identically to evidence of calm.

So the front end (web/index.html, one file, no build step, public Content API, no token) draws three layers. The observation layer is neutral grey and never coloured by severity. The assessment layer sits underneath and is the only thing allowed to carry colour. Gaps are drawn as diagonal hatching labelled "no answer", with the reason and retry count in the tooltip.

Coverage is stated as a number next to the summary, not buried: 300 minutes covered, 45 minutes with no answer, 87% coverage, 0 backfilled gaps. Severity is carried by label as well as colour, because the person reading this is doing it at 6am and may be colourblind.

What I would not use this for

The Generate MCP PAT flow with the General MCP Server scope produced a token far broader than content authoring needs. Reading its permission list, it can create new permanent auth tokens, invite users to the project, assign roles, and read audit logs. None of that is required to write an Observation. If you are handing a token to an agent, use the Content MCP Server scope and add schema rights only for the migration, then drop back down. The convenience default is not the safe default.

I would also not use MCP for the schema work on an established project. Being able to say "add a required SEO title to every page model" is genuinely useful on an empty project like mine. On a project with live entries, isRequired needs a migrationValue, and the blast radius of a wrong guess is larger than the typing it saved.

Where it clearly won: the discovery loop. get_entity_schema returning field names, types, allowedValues for enums, and the exact {"connect": {"id": ...}} shape for relations meant the agent stopped guessing field names. That is the class of error that used to cost me the most time, and it is mostly gone.

Reproduce it: clone the repo, create a Hobby project, generate an MCP PAT, put the endpoint and token in .env, then python3 src/build_schema.py --apply and python3 src/benchmark_writes.py. You will get your own numbers, which is the point.