Skip to content

Schemas (IO)

The problem: a static type across a runtime boundary

Section titled “The problem: a static type across a runtime boundary”

A tool’s input and output need two things at once: a static TypeScript type the compiler can check against, and — eventually — a runtime validator. The validator needs to coerce whatever an LLM actually hands back (malformed JSON, markdown fences, a trailing comma) into that type. loopy solves this with a single carrier type, IO, shaped like the Standard Schema spec. Any validator library that implements it — Zod, Valibot, ArkType, and others — can be dropped in without loopy depending on any of them.

export interface IO<In, Out = In> {
readonly "~standard": {
readonly version: 1;
readonly vendor: string;
readonly validate: (
value: unknown,
) => { readonly value: Out } | { readonly issues: readonly { readonly message: string }[] };
readonly types?: { readonly input: In; readonly output: Out };
};
}
export type InferIn<S extends IO<any, any>> = NonNullable<S["~standard"]["types"]>["input"];
export type InferOut<S extends IO<any, any>> = NonNullable<S["~standard"]["types"]>["output"];

The static In/Out types live in a phantom property (~standard.types). It’s never actually populated at runtime — it exists purely so InferIn<S> / InferOut<S> can pull the type back out with an indexed access. Every place in loopy that needs “the actual TypeScript type this schema describes” — a tool’s run parameter, a workflow node’s return type — goes through InferOut<...>, never the schema object itself.

io<Out, In>() — the built-in minimal constructor

Section titled “io<Out, In>() — the built-in minimal constructor”

loopy ships a minimal constructor so you can use the type surface fully without pulling in a real validator dependency:

export function io<Out, In = Out>(vendor: string = "loopy"): IO<In, Out> {
return {
"~standard": {
version: 1,
vendor,
validate: (value: unknown): { readonly value: Out } => ({ value: value as Out }),
},
};
}

io<{ path: string; patch: string }>() gives you a schema whose static output type is { path: string; patch: string }. At runtime, io()’s own validate is an identity cast, not real validation — that’s the seam where a real validator (Zod, Valibot, ArkType) plugs in for real coercion of LLM output, with typed parse errors instead of silent fail-open, without changing InferOut<S> or anything downstream of it.

This is a different concern from Schema-Aligned Parsing (SAP), which the agent driver already runs on every structured-output turn: it robustly extracts a JSON block from a model’s raw text — stripping code fences, skipping stray prose, backtracking over unbalanced brackets — before handing the result to your schema’s validate. SAP fixes “the model wrapped its JSON in markdown”; a real schema’s validate fixes “the JSON doesn’t actually match the shape I promised.”

import { io } from "@loopyjs/core";
const input = io<{ path: string; find: string; replace: string }>();
const output = io<{ applied: boolean }>();

Every tool(), agent(), and workflow node takes an input and output schema built with io<...>() — or, once a real validator is wired in, a Zod/Valibot/ArkType schema implementing the same ~standard shape.