Tool calls and answers
An agent meets JSON Schema in two places: a tool's parameters, which every call the model writes must fit before the tool runs, and the shape of a structured answer, which the program reads back. In both the schema comes at run time, the data comes from a model, and what the program does with a failure is tell the model what to fix.
A tool call, before the tool runs
Compile each tool's parameters when the tool is registered, and check every call against them. A call that does not fit is not run: its messages go back to the model as the call's result, and the model writes the call again.
use flowschema::Schema;
use serde_json::json;
let read = Schema::new(&json!({
"type": "object",
"required": ["path"],
"additionalProperties": false,
"properties": {
"path": {"type": "string"},
"limit": {"type": "integer", "minimum": 1}
}
}));
let call = json!({"path": "src/lib.rs", "limit": 0});
assert!(!read.matches(&call));
assert_eq!(read.mismatches(&call), ["data.limit must be >= 1 (is 0)"]);A structured answer
Ask the model for an answer in a shape, check what it returns, and send the messages back until it fits, a bounded number of times.
use flowschema::Schema;
use serde_json::json;
let verdict = Schema::new(&json!({
"type": "object",
"required": ["verdict", "summary"],
"properties": {
"verdict": {"enum": ["approve", "request_changes"]},
"summary": {"type": "string", "minLength": 1}
}
}));
let answer = json!({"verdict": "maybe", "summary": "Looks fine."});
let fix = verdict.mismatches(&answer).join("\n");
assert_eq!(fix, r#"data.verdict must be one of ["approve","request_changes"] (is "maybe")"#);
// Sent to the model: "Your answer does not match the schema: <fix>. Answer again."A schema that comes with the request
Where an agent serves many users, no schema is known ahead: each request brings its own tools and its own answer shape, and a tool server may change its tools as it runs. Compile the schema from the request, check, and drop it. Schema::new reads the document where it is and copies nothing, and a tool's parameters compile in well under a microsecond (the figures), so there is no cache to key, to bound or to clear.
use flowschema::Schema;
use serde_json::json;
// The tools as a request lists them, and the call the model wrote.
let tools = json!([{"name": "read", "input_schema": {"type": "object", "required": ["path"],
"properties": {"path": {"type": "string"}}}}]);
let (called, arguments) = ("read", json!({"path": 7}));
let tool = (tools.as_array().into_iter().flatten()).find(|tool| tool["name"] == called);
let said = tool.map(|tool| Schema::new(&tool["input_schema"]).mismatches(&arguments));
assert_eq!(said, Some(vec!["data.path must be string (is integer)".to_string()]));One compile for every request
Where the schemas are known ahead, compile each once. A compiled Schema is Send + Sync and does not change, so one compile serves every request, on any thread: keep it in an Arc beside the tool it belongs to.
What agents' schemas ask
The schemas agents send are small and plain, and a call is smaller still: a flat object of two or three fields, most of them strings. Nearly every one is an object with properties and required; additionalProperties, items, an enum of strings, anyOf, format and $ref come next, and the rest of JSON Schema is rare. flowschema is made fast where these are, checks the rest as the drafts say, and a schema pays nothing for what it does not use. The repository's guide has the counts, keyword by keyword.