Schema

akurai-schema is a lightweight JSON-shape validator — the zero-dependency substitute for Zod. It matches AI tool payloads to UI components by letting you declare what shape a value must have, then validating any akurai_json::Value against that declaration.


The schema model

A Schema describes an object (the root of every AI tool payload) as a list of named fields. Each field has:

Validation is exhaustive: every violation is reported, not just the first one. Paths use dot-notation for nested objects (user.address.city) and bracket-notation for array indices (tags[2]).

FieldType variants

| Variant | Matches | |---------|---------| | FieldType::String | Value::Str(_) | | FieldType::Int | Value::Int(_) | | FieldType::Float | Value::Float(_) or Value::Int(_) | | FieldType::Bool | Value::Bool(_) | | FieldType::Array(Box<FieldType>) | Value::Array(_) where every element matches the inner type | | FieldType::Object(Box<Schema>) | Value::Object(_) validated by a nested schema | | FieldType::Enum(Vec<String>) | Value::Str(s) where s is one of the listed strings | | FieldType::Any | any Value, including Null |


Building a schema

Use Schema::object with a slice of (name, FieldType, required) triples:

use akurai_schema::{Schema, FieldType};

let schema = Schema::object(&[
    ("id",     FieldType::Int,    true),
    ("name",   FieldType::String, true),
    ("score",  FieldType::Float,  false),  // optional
    ("status", FieldType::Enum(vec!["active".into(), "inactive".into()]), true),
    ("tags",   FieldType::Array(Box::new(FieldType::String)), false),
]);

Nested objects use FieldType::Object:

let address_schema = Schema::object(&[
    ("street", FieldType::String, true),
    ("city",   FieldType::String, true),
]);

let schema = Schema::object(&[
    ("name",    FieldType::String,                      true),
    ("address", FieldType::Object(Box::new(address_schema)), true),
]);

validate and matches

Schema::validate

pub fn validate(&self, value: &Value) -> Result<(), Vec<SchemaError>>

Returns Ok(()) on success. On failure, returns Err(errors) where each SchemaError has:

All violations are collected in a single call — you get the complete picture.

use akurai_json::parse;

let schema = Schema::object(&[
    ("x", FieldType::Int,    true),
    ("y", FieldType::String, true),
]);

let value = parse(r#"{"x":"bad","y":42}"#).unwrap();
let errors = schema.validate(&value).unwrap_err();
// errors[0].path = "x", errors[0].message = "expected integer"
// errors[1].path = "y", errors[1].message = "expected string"

Schema::matches

pub fn matches(&self, value: &Value) -> bool

Convenience wrapper — true iff validate returns Ok. Use this when you only need a yes/no answer (e.g. component selection in the tool-UI runtime).


JSON schema description format

Schemas can be described as JSON so the same definition can be handed to a client without recompilation. Use Schema::from_json:

pub fn from_json(value: &Value) -> Result<Schema, ParseError>

The JSON schema is an object whose keys are field names and values are field descriptors:

{
  "name":   { "type": "string",  "required": true  },
  "count":  { "type": "int",     "required": true  },
  "score":  { "type": "float",   "required": false },
  "active": { "type": "bool",    "required": false },
  "tags":   { "type": "array",   "items": "string", "required": true  },
  "status": { "type": "enum",    "values": ["on", "off"], "required": true },
  "addr":   {
    "type": "object",
    "required": false,
    "schema": {
      "city": { "type": "string", "required": true }
    }
  },
  "meta":   { "type": "any",     "required": false }
}

Descriptor keys

| Key | Required | Meaning | |-----|----------|---------| | type | yes | One of string, int, float, bool, array, object, enum, any | | required | no (default false) | Whether the field must be present | | items | for array | Element type — a type string ("string", "int", …) or a full descriptor object | | values | for enum | Array of allowed string literals | | schema | for object | Nested JSON schema object |

Unknown keys in a descriptor are silently ignored — future extensions are backward-compatible.

Example — parse and validate

use akurai_json::parse;
use akurai_schema::Schema;

let schema_json = parse(r#"{
    "id":   {"type":"int",    "required":true},
    "name": {"type":"string", "required":true},
    "tags": {"type":"array",  "items":"string", "required":false}
}"#).unwrap();

let schema = Schema::from_json(&schema_json).unwrap();

let payload = parse(r#"{"id":1,"name":"button"}"#).unwrap();
assert!(schema.matches(&payload));

Matching tool payloads to UI components

The tool-UI runtime uses Schema::matches to select the right component for an AI tool's output. Each registered component carries a schema; the runtime walks the registry and returns the first component whose schema matches the incoming payload:

// Pseudo-code — actual wiring lives in the runtime layer.
fn find_component<'a>(registry: &'a [Component], payload: &Value) -> Option<&'a Component> {
    registry.iter().find(|c| c.schema.matches(payload))
}

Because matches collects no allocations on the happy path (it short-circuits via validate_into returning early once any error is found), selection over a registry of dozens of components is negligible.


Error path format

| Situation | Example path | |-----------|-------------| | Root value wrong type | "" (empty string) | | Top-level field wrong | "name" | | Nested object field wrong | "address.city" | | Three levels deep | "order.shipping.zip" | | Array element wrong | "tags[2]" | | Array-of-objects element field wrong | "items[1].price" |