Skip to content

Validation and schemas

Every place where data crosses from one part of the platform to another carries a Zod schema. Action input and output, provider config, entity data and searchInput, event payloads, item data — each is described by a schema, and each is checked where the data arrives. This is a deliberate, load-bearing choice, not boilerplate.

The boundaries are exactly the places where you cannot trust the shape of what you receive, because it came from a model, a network request, a config file, or another package:

Boundary Schema
Action call input / output
Provider registration config
Entity search and fetch searchInput / data
Event emission payload
Item emission data

At each of these, the platform parses before it proceeds. An action’s execute receives input only after it has passed the input schema, and its return value is checked against output before the caller sees it. A provider’s config is parsed before setup runs. An emitted item’s data is checked against its item type. The schema is the contract, and the contract is enforced automatically — you declare it, you do not hand-write the checks.

A single schema does four jobs at once:

  • Runtime safety. Malformed data is rejected at the edge with a specific error, so it never reaches code that assumed it was well-formed. An LLM that hallucinates a field, a config file with a typo, a provider returning the wrong shape — all fail loudly and early.
  • Static types. The schema is the type. z.infer derives the TypeScript type from the schema, so execute receives fully typed input and there is no second, hand-maintained type declaration to drift out of sync.
  • Generated definitions. The same schemas are converted into the tool definitions the agent gives a model, and into the OpenAPI documents the HTTP host serves. Describe a field once with Zod .describe() and that description flows into every generated surface.
  • Self-documenting contracts. A schema states precisely what a capability accepts and returns. It is the most reliable documentation a provider has, because it cannot lie — the runtime enforces it.

Because one declaration produces all four, the schema is the single source of truth for a boundary. There is nowhere else to look and nothing else to keep aligned.

The discipline is to validate once, at the boundary, and then trust the parsed value everywhere inside. Inside execute, input is already the right shape — there is no need to re-check it, and doing so would only add noise. The parse happened at the door; within the room, the data is known-good. This keeps validation concentrated where untrusted data actually enters, and keeps the interior code clean and typed.

The corollary is that you should push validation to the boundary rather than scattering ad hoc checks through your logic. If a value needs constraining, express the constraint in the schema — z.string().min(1), z.number().int().positive() — so the boundary rejects bad data before your code ever runs.

Import z from the core package, not from zod directly:

import { z } from '@grundlag/core';

Core re-exports Zod so every package uses one version and one import path, and the repo standardises on the zod v4 API. Using the re-export keeps providers consistent and avoids the subtle breakage of two Zod copies disagreeing about what a schema is.

When a boundary rejects data, the error is specific and inspectable. The validation errors — ActionInputValidationError, ActionOutputValidationError, and ProviderConfigValidationError — each carry the offending id and the Zod issues array, so a caller or a test can see exactly which field failed and why. All extend CoreError, so a consumer can catch platform failures as a group. See Define actions for these errors in the action flow and Providers and registries for config validation at registration.