I was reading back through the logs of a run that had gone sideways when I noticed that an agent talking to a local Ollama endpoint had accepted a number where a file path belonged, and had carried on without hesitating. My tool definition declared the type. Nothing had rejected the value anywhere along the way.
The cause was a single word in the parameter definition. I had written any_of. The spelling is anyOf.
What makes this awkward is that nobody warns you. Write a type as String with a capital letter and it fails immediately. Misspell a combiner and everything proceeds as though the constraint were there. I'd have preferred the loud failure, and I only understood that after tracing the whole thing back.
The Antigravity changelog notes that in 2.12.2, custom Python tools now emit OpenAPI / JSON Schema compliant parameter definitions — lowercase types, camelCase combiners — which removes schema errors when you connect to OpenAI-compatible local engines such as Ollama, LM Studio, or vLLM. That's a welcome fix. But a fix here also means constraints that weren't doing anything are about to start doing something.
The numbers below come from running JSON Schema Draft 2020-12 locally as the judge (Python's jsonschema 4.26.0). I measured against the spec the changelog names as its target, not against any one engine's implementation.
Capitalized types fail. Snake_case combiners don't
I built ten variants that all express the same intent, and checked two things for each: whether the schema itself passes metaschema validation, and whether an obviously invalid argument gets flagged.
| Spelling | Metaschema check | Invalid argument | Verdict |
|---|---|---|---|
type: "String" | 1 error | UnknownType at runtime | You'll notice |
type: "Integer" | 1 error | UnknownType at runtime | You'll notice |
required given a string | 1 error | 1 violation | You'll notice |
any_of | 0 errors | 0 violations | Passes in silence |
one_of | 0 errors | 0 violations | Passes in silence |
all_of | 0 errors | 0 violations | Passes in silence |
enum_values | 0 errors | 0 violations | Passes in silence |
min_items | 0 errors | 0 violations | Passes in silence |
exclusive_minimum | 0 errors | 0 violations | Passes in silence |
additional_properties | 0 errors | 0 violations | Passes in silence |
Seven of the ten sailed past both checks. That's the specification working as designed: JSON Schema says unknown keywords are ignored. min_items isn't an error — it's an annotation sitting outside the vocabulary. It never registers as a constraint, so it never produces a violation either.
Types are the exception because type closes its set of allowed values. That's the one place the spec refuses to guess, so String is rejected outright.
I stopped filing "breaks loudly" and "breaks quietly" under the same heading. The first kind surfaces on its own. The second only surfaces if I go looking.
Once normalization lands, arguments that used to pass get rejected
Next I wanted to see what actually changes when the spelling is corrected. I kept every type lowercase and misspelled only the combiners and constraint keywords, then ran identical arguments through the schema before and after normalization.
| Argument | Before | After |
|---|---|---|
| The expected shape | Passes | Passes |
mode given a number | Passes | Rejected (1 violation) |
tags as an empty array | Passes | Rejected (1 violation) |
An undeclared depth key | Passes | Rejected (1 violation) |
Metaschema validation returned zero errors on both versions. As JSON Schema documents, both are perfectly valid. And yet all three malformed arguments passed the first and were stopped by the second.
That's the part I'd want to know in advance. Normalization is announced as a change that removes errors, but from your tool's side it's equally a change that starts refusing arguments it used to accept. Agents don't send the same shapes every time, so the day something stops going through is rarely a quiet one.
The normalization itself is the right call. The problem is the ordering — you find out how loose your definitions were only after the fix arrives.
Count your definitions before you upgrade
What you need isn't a spell check. It's a count of how many keywords in your parameter definitions are being ignored. Sixty lines will do it.
# schema_lint.py — count keywords that are never evaluated as constraints
import json, sys
from jsonschema import Draft202012Validator as V
VALID_TYPES = {"string", "number", "integer", "boolean", "object", "array", "null"}
def collect_keywords():
"""Harvest the Draft 2020-12 vocabulary from the metaschema itself."""
ks = set()
def walk(node):
if isinstance(node, dict):
for k, v in node.items():
if k == "properties" and isinstance(v, dict):
ks.update(v.keys())
walk(v)
elif isinstance(node, list):
for x in node:
walk(x)
walk(V.META_SCHEMA)
return ks
KEYWORDS = collect_keywords() | {"nullable", "examples", "title", "description", "default"}
def lint(schema, path="$"):
out = []
if not isinstance(schema, dict):
return out
parent = path.split(".")[-1]
for k, v in schema.items():
here = f"{path}.{k}"
if k not in KEYWORDS and parent not in ("properties", "$defs", "patternProperties"):
out.append((here, f"unknown keyword '{k}' — never evaluated as a constraint"))
if k == "type":
for t in (v if isinstance(v, list) else [v]):
if isinstance(t, str) and t not in VALID_TYPES:
out.append((here, f"invalid type '{t}' (lowercase only)"))
if k == "properties" and isinstance(v, dict):
for pn, ps in v.items():
out += lint(ps, f"{path}.properties.{pn}")
elif isinstance(v, dict):
out += lint(v, here)
elif isinstance(v, list):
for i, x in enumerate(v):
out += lint(x, f"{here}[{i}]")
return out
if __name__ == "__main__":
findings = lint(json.load(open(sys.argv[1])))
for p, m in findings:
print(f" {p}: {m}")
print(f"{len(findings)} finding(s)")
sys.exit(1 if findings else 0)The check is skipped when the parent is properties because what sits there are your own parameter names, not vocabulary. Forget that guard and path and limit both get reported as unknown keywords, which drowns the output. I did exactly that on my first pass, produced close to a hundred false positives, and threw the report away.
Point it at a broken definition and you get:
$ python3 schema_lint.py tool.json
$.properties.path.type: invalid type 'String' (lowercase only)
$.properties.limit.min_items: unknown keyword 'min_items' — never evaluated as a constraint
$.properties.mode.any_of: unknown keyword 'any_of' — never evaluated as a constraint
$.additional_properties: unknown keyword 'additional_properties' — never evaluated as a constraint
4 finding(s)
It returns an exit code, so it drops straight into CI. I've written up the same shape of problem for configuration files in How Silently Ignored Config Keys Slip Through CI, and Where to Block Them — the idea is identical: when a system stays quiet about what it ignored, you have to add the part that speaks up.
One thing worth checking after you fix the spelling
Correcting the keywords is mechanical. The order is what I'd be careful about.
- Run
schema_lint.pyacross every tool definition and write down the finding count before you change anything. It matters later. - Fix the spellings:
any_of→anyOf,min_items→minItems,additional_properties→additionalProperties, types to lowercase. - Replay arguments that actually flowed through in production against the corrected schema. Twenty or thirty pulled from your logs is plenty. Anything rejected here is an invalid argument that had been passing unnoticed.
- For each rejection, decide whether to loosen the schema or fix the caller.
Please don't skip step three. Constraints coming alive means some of what your agent has been sending will now stop. Learning that from your own logs beats learning it in production, and the difference shows up in how the next morning goes.
If you're still stuck on the connection itself, sort that out before the schema. I've covered the wiring in Calling Local LLMs from Antigravity — Ollama and LM Studio Integration in Practice. And when a tool can't read a file it clearly should be able to read, the cause is often an exclusion rule rather than a schema; that separation is in Git Tracks the File, but Your Agent Can't See It. Here's Where It Drops Out.
If you do one thing today
Pick the custom tool with the widest argument surface and run schema_lint.py against it. Zero findings tells you that's a place you don't have to think about. Even one finding tells you that constraint has never once done its job.
I've stopped treating "no errors" as evidence that something works. When a system stays quiet, I give it a separate part whose only job is to report the quiet — a small habit, though the detour it saved me from is one I won't forget for a while.
Thank you for reading this far. I hope it shortens the path for someone stuck at the same place.