Browse documentation

Types and schemas

Katari's type system — sums, unions, generics, privacy — and the JSON schemas it derives at every agent boundary.

Katari's types do double duty. Inside a program they are checked the usual way; at every agent boundary they are also schemas — JSON Schema derived from the declaration, read by the runtime to validate dynamic calls and by AI models to understand your agents as tools. You never write a schema by hand.

Scalars

TypeValues
integerwhole numbers (a subtype of number)
numberfloating-point numbers
stringtext
booleantrue / false
nullthe single value null
neverno values — the type of a call that does not return
unknownany value — the top type
filea blob handle whose bytes ride the runtime's blob store
"fast"a string literal is a type: exactly that string ("fast" <: string)

Containers

@"Containers: array (ordered), record (string-keyed map), object type (fixed labels)."
agent owner_of(
  tags: array[string],
  scores: record[integer],
  owner: { name: string, email: string },
) -> string {
  owner.name
}

array[T] is an ordered sequence, record[T] a string-keyed map with uniform values, and { label: T, ... } an object type with fixed labels (label?: T marks one optional — reading it types T | null). Agent types — agent (value: number) -> number with E — are ordinary types too; see Agents and delegation.

Sums: data, unions, match

@"A circle, by radius."
data circle(radius: number)
 
@"An axis-aligned rectangle."
data rect(width: number, height: number)
 
// One shape: a union synonym over the two constructors. (Type synonyms take no `@"..."` docs.)
type shape = circle | rect
 
@"Constructor dispatch with `match`; field patterns bind with `=>`."
agent area(value: shape) -> number {
  match (value) {
    case circle(radius => r) -> 3.14159 * r * r
    case rect(width => w, height => h) -> w * h
  }
}

data declares one constructor with named fields; a union of constructors is a sum type, and match is its dispatch — the compiler checks exhaustiveness, so adding a third shape breaks every match that forgot it. type names any type; it is a synonym, not a new type.

Unions work over any types, not just constructors, and matching narrows:

@"`array.get` types `string | null`; matching the `null` arm narrows the fallthrough to `string`."
agent first_or(values: array[string], fallback: string) -> string {
  match (array.get(target = values, index = 0)) {
    case null -> fallback
    case value -> value
  }
}

Absence is | null everywhere in the stdlib — there is no separate option type.

Generics

@"A bounded type generic: any T that has a `name` field can be labelled."
agent label[T extends { name: string }](value: T) -> string {
  value.name
}
 
@"A literal generic binds the argument at its singleton type:
`pick(value = \"fast\")` returns `\"fast\"`, not `string`."
agent pick[literal name extends string](value: name) -> name { value }
 
@"The singleton fits a literal union — and widens to plain `string`."
agent chosen() -> "fast" | "slow" {
  pick(value = "fast")
}
 
@"An effect generic: whatever `action` performs, this performs."
agent tap[R, effect E](action: agent (value: null) -> R with E) -> R with E {
  action(value = null)
}
 
@"An attribute generic ranges over public / private; the label flows through unchanged."
agent tag[attribute A](secret: string of A, label: string) -> string of A {
  prelude.concat(left = label, right = secret)
}

Generic parameters live in [...] before the parameter list and are inferred at the call site. Four kinds exist: plain type generics (optionally bounded with extends), literal generics (which bind a string-literal argument at its singleton type — how a scoped provider pins a resource name in the row), effect generics (rows as parameters — see Effects and handlers), and attribute generics (privacy labels as parameters). A type that appears only in the result cannot be inferred and is instantiated explicitly: json.parse_as[T](...).

Private values

@"A secret is a `string of private`: it may flow only toward a submission sink,
like the `Authorization` header `http.fetch` sends to the destination server."
agent authorized_headers() -> record[string of private] with prelude.throw[env.missing_secret] {
  record.set(
    target = record.empty(),
    key = "Authorization",
    value = prelude.concat(left = "Bearer ", right = env.get_secret(key = "api.token")),
  )
}

T of private attaches the privacy attribute: the value is a secret. Privacy is information flow, not encryption — public <: private, so a public value fits anywhere a private one is expected, but a private value may leave the program only through a deliberate submission sink typed to absorb it: http.fetch's header values and body (which go to the one server the program named), never its url (which leaks into logs and proxies — a private value there is a K3001 type error). Everything derived from a secret is tainted private, and a private value is redacted at every user-facing boundary — run results, the trace, escalation answers. The values behind env.get_secret and oauth.token are private by declaration; a private agent's handle and result are the same machinery applied to callables. Schemas ignore the attribute — privacy has no JSON counterpart.

The JSON boundary

@"Read an AI reply through the typed text boundary: parsed and validated against
the type's derived schema in one step."
agent read_pick(reply: string) -> string with prelude.throw[json.parse_error | json.validation_error] {
  let picked = json.parse_as[{ tool: string, arguments: record[unknown] }](text = reply)
  picked.tool
}

json.parse_as[T] parses a document and validates it against T's schema in one step — sugar for json.parse then json.validate[T]. Malformed text throws json.parse_error; a value that does not conform throws json.validation_error, naming the offending path. It is the right tool when you know the shape you expect — an AI reply, a webhook body you re-read. json.validate[T] is that check on its own: it takes an unknown, returns it unchanged when it conforms to T (otherwise json.validation_error), and rewrites nothing. Validation is one of only two places a type is enforced at runtime — the other is reflection.call_agent, which checks a delegation's arguments against the callee's schema the same way; everywhere else your types are settled at compile time. Reserved wire keys all live in the $katari_ namespace, disjoint from anything a real document carries, so a $-prefixed key like $ref stays an ordinary field.

For a document of unknown or irregular shape, json.parse yields a plain value (unknown) — a record / array / string / integer / number / boolean / null (or a file, where the text carried a $katari_ref handle) — already an ordinary Katari value with no dedicated JSON tree type. That marker interpretation is unconditional: parse reads a $katari_ key the same way whether or not a schema is in play. You traverse it with shape filters (case record(r), case array(a)) and the total readers (json.field, json.element, json.text, json.entries, json.items), each of which returns a harmless default rather than throwing when a key is missing or a shape is wrong — so probes chain freely and absence is checked once, at the end, where it matters.

Going the other way, json.stringify is the one writer — total and canonical, one value to one text, so it never throws. A document round-trips (json.stringify(json.parse(s)) is s up to whitespace); a file renders as its { "$katari_ref": … } handle object and a data value nests its fields under $katari_value as { "$katari_constructor": name, "$katari_value": { … } }. Nothing is escaped, which is why stringify doubles as the read channel that shows a model a handle or a data value verbatim. See the reference for the surface and its laws.

You build a document with ordinary record and array literals — there is no constructor to call. A record key is normally a bare identifier, but any key can be quoted: { "type" = "message", "$ref" = id } is how you write a key that is a reserved word (type), $-prefixed, or otherwise not an identifier (a hyphen, a space). Quoted or bare, the key reaches the wire exactly as written.

Schemas from types

@"Send a calendar invitation to one attendee."
agent invite(
  @"The attendee's email address." email: string,
  @"The meeting length, in minutes." minutes: integer,
) -> null {
  null
}
 
@"Read a callable's derived schemas back at runtime."
agent inspect() -> unknown {
  reflection.get_metadata(value = invite).input
}

Every agent's input, output, and request schemas are derived from its declaration. The @"..." annotation on the agent becomes its description; a @"..." before a parameter becomes that property's description in the input schema — which is exactly what an AI model reads when it decides how to call your tool, so write them for that reader. On the wire, a data value carries its constructor under a $katari_constructor marker with its fields nested under $katari_value, and a file under a $katari_ref handle, so unions of data types survive the round trip unambiguously. Turning those marked forms back into values is unconditional — the same codec json.parse runs, with or without a schema: a { "$katari_ref": … } becomes a file every time. So when an AI replays a { "$katari_ref": … } into a tool argument, it is already a file by the time reflection.call_agent sees it — call_agent's job is to check that value against the callee's input schema and throw call_error on a mismatch, never to lift a bare record into a handle. reflection.get_metadata hands you the derived schemas as unknown values at runtime — the building block of a tool list — and dynamic dispatch validates arguments against the same schemas (Giving the model tools puts the loop together).

Where to go next