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.”
Using it
Section titled “Using it”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.