---
title: "Give scripts tools from a JSON Schema"
url: https://recursiveai.org/oneecma/cookbook/json-schema-tools/
description: "Register host functions whose argument is checked against a JSON Schema before your C# handler runs, the way an agent host exposes its tools to…"
version: "1.0.0"
---

# Give scripts tools from a JSON Schema

Register host functions whose argument is checked against a JSON Schema before your C# handler runs, the way an agent host exposes its tools to model-written JavaScript. A bad call fails with a report that names the path, the rule and the value.

```csharp
using System.Text.Json;
using RecursiveAI.OneEcma;
using RecursiveAI.OneEcma.Diagnostics;

const string createOrderSchema = """
{
  "type": "object",
  "required": ["customer", "items"],
  "additionalProperties": false,
  "properties": {
    "customer": { "type": "string", "minLength": 1 },
    "items": {
      "type": "array", "minItems": 1,
      "items": {
        "type": "object", "required": ["sku", "qty"],
        "properties": { "sku": { "type": "string", "pattern": "^[a-z]+$" }, "qty": { "type": "integer", "minimum": 1 } }
      }
    }
  }
}
""";

const string lookupSchema = """{ "type": "object", "required": ["sku"], "properties": { "sku": { "type": "string" } } }""";

var engine = new ScriptEngine(new ScriptEngineOptions
{
    Configure = e =>
    {
        JsRealm realm = e.MainRealm;

        JsObject createOrder = JsonSchemaTools.Define(realm, "createOrder", createOrderSchema, args =>
        {
            int lines = args.GetProperty("items").GetArrayLength();
            return JsonSerializer.SerializeToElement(new { id = "ord_1042", lines });
        });
        realm.DefineData(realm.Global, "createOrder", JsValue.FromObject(createOrder), JsAttributes.Method);

        JsObject lookup = JsonSchemaTools.DefineAsync(realm, "lookup", lookupSchema, async args =>
        {
            string sku = args.GetProperty("sku").GetString()!;          // read the argument before the first await
            await Task.Delay(10);                                        // a real service call
            return JsonSerializer.SerializeToElement(new { sku, price = 1.5 });
        });
        realm.DefineData(realm.Global, "lookup", JsValue.FromObject(lookup), JsAttributes.Method);
    },
});

JsValue ok = engine.EvaluateAsyncBody("""
    const { price } = await lookup({ sku: "pen" });
    const order = createOrder({ customer: "Harbor Street Books", items: [{ sku: "pen", qty: 3 }] });
    return `${order.id}: ${order.lines} line, pen at ${price}`;
    """, "agent.js");
Console.WriteLine(engine.ToDisplayString(await engine.AwaitAsync(ok)));

string[] attempts =
[
    """createOrder({ customer: "Harbor Street Books", items: [{ sku: "pen", qty: "3" }] });""",
    """createOrder({ customer: "Harbor Street Books", items: [{ sku: "Pen!", qty: 3 }] });""",
    """createOrder({ customer: "Harbor Street Books", item: [{ sku: "pen", qty: 3 }] });""",
];
foreach (string source in attempts)
{
    try
    {
        engine.Evaluate(source, "agent.js");
    }
    catch (JsException e)
    {
        Console.WriteLine(DiagnosticRenderer.Render(e.Report!));
    }
}
```

```text
ord_1042: 1 line, pen at 1.5
error[TypeError]: createOrder: argument 'args.items[0].qty' must be an integer, got string "3"
 --> agent.js:1:1
  |
1 | createOrder({ customer: "Harbor Street Books", items: [{ sku: "pen", qty: "3" }] });
  | ^^^^^^^^^^^ `args.items[0].qty` is string "3", expected an integer
  |
  = help: convert the string first: Number("3")
  = note: host function: createOrder(args) with a JSON Schema input
  = stack: createOrder [.NET] ← <top-level> (agent.js:1)

error[TypeError]: createOrder: argument 'args.items[0].sku' must be a string matching /^[a-z]+$/, got string "Pen!"
 --> agent.js:1:1
  |
1 | createOrder({ customer: "Harbor Street Books", items: [{ sku: "Pen!", qty: 3 }] });
  | ^^^^^^^^^^^ `args.items[0].sku` is string "Pen!", expected a string matching /^[a-z]+$/
  |
  = note: host function: createOrder(args) with a JSON Schema input
  = stack: createOrder [.NET] ← <top-level> (agent.js:1)

error[TypeError]: createOrder: argument 'args.items' is missing; expected an array (required property)
 --> agent.js:1:1
  |
1 | createOrder({ customer: "Harbor Street Books", item: [{ sku: "pen", qty: 3 }] });
  | ^^^^^^^^^^^ `args.items` is missing
  |
  = help: pass an array (required property) as 'args.items'
  = note: host function: createOrder(args) with a JSON Schema input
  = stack: createOrder [.NET] ← <top-level> (agent.js:1)
```

The schema you already give the model for a tool is the one that checks the call. The handler only ever sees arguments that passed.

## The four ways to define a tool

- `JsonSchemaTools.Define(realm, name, schema, Func<JsonElement, JsonElement>)`: JSON in, JSON out, as above.
- `JsonSchemaTools.DefineAsync(…, Func<JsonElement, Task<JsonElement>>)`: the script gets a promise.
- Typed overloads take `JsonTypeInfo<TArgs>` and `JsonTypeInfo<TResult>` from a `System.Text.Json` source-generated context, so they stay NativeAOT-safe. Use a camel-case naming policy to match the schema.
- `JsonSchemaTools.Validated(realm, name, schema, existingFunction, thisValue)` puts a schema check in front of a function you already have, such as an exported `[JsExport]` method.

The script value becomes JSON from its own enumerable properties: functions, symbols and `undefined` properties are left out, and non-finite numbers become `null`.

## Supported keywords

`type` (also a list), `enum`, `const`, `properties`, `required`, `additionalProperties` (false or a schema), `items`, `minItems`, `maxItems`, `minLength`, `maxLength`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, `pattern`, `allOf`, `anyOf`, `oneOf`, `not` and local `$ref`. Other keywords are ignored. `pattern` is a .NET regular expression with a 200 ms limit, and a timeout counts as no match.

## Good to know

- In 1.0.0 the `JsonElement` a `DefineAsync` handler receives is valid only until the handler first awaits. Read what you need from it, or keep `args.Clone()`, before the first `await`, as `lookup` does above. Reading it afterward throws `ObjectDisposedException`.
- `EvaluateAsyncBody` runs code as the body of an async function, so `await` and `return` work at the top level: see [Run code a model wrote](https://recursiveai.org/oneecma/cookbook/run-model-code/).
- The helper and its messages are documented in [[JsExport] bindings](https://recursiveai.org/oneecma/docs/js-export/#json-schema-tools).
