Architecture

One declaration,
three consumers.

A signature drives the prompt, the JSON Schema sent to the provider, and the zod schema every reply is checked against. Everything below follows from that single source.

@ts-dspy/core 0.5.0 zod ^4.1.13 node >=22 ESM + CJS 4 packages

01

One request, end to end

A signature class records its fields on the constructor at decoration time, so getInputFields() and getOutputFields() work without ever instantiating it. Predict.forward() reads those fields three separate times: once to write the prompt, once to build a JSON Schema for providers that can constrain decoding, and once to build the zod schema that every reply must pass.

The fork in the middle is the only place the two paths differ. They rejoin at the same gate, so a model that answers in prose where you declared a number fails identically whichever route it took.

declared field types class AnswerQuestion extends Signature @InputField / @OutputField → static field maps inputs buildPrompt() description + labelled inputs + field list prompt lm.getCapabilities() .supportsStructuredOutput true false generateStructured() buildOutputJsonSchema(signature) provider constrains decoding generate() parseOutput() scrapes "field: value" BaseLM fallback asks for JSON in prose candidate object buildOutputSchema().safeParse() one zod schema, built from the declared types success issues[] Prediction<T> direct field access ValidationError issues + rawOutput
Fig. 1 — The request path. The dashed rail is the point of the library: one declaration feeds the prompt, the JSON Schema, and the validator. Both branches end at the same safeParse, so nothing reaches your code untyped. All three shipped providers report supportsStructuredOutput: true, so the right-hand branch is what a custom ILanguageModel gets — and what RespAct always uses.

02

Packages and the provider seam

@ts-dspy/core depends on zod and nothing else — no vendor SDK. Each provider package pulls in its own official SDK and extends BaseLM, which already owns usage accounting, generate() delegation, and a prompt-based generateStructured() fallback. A provider implements two methods: chat() and getCapabilities().

Modules never name a vendor. They hold an ILanguageModel, handed in at construction or read from the configure() singleton.

your application configure({ lm }) · new Predict(Sig) declares signatures installs one provider @ts-dspy/core — zod only, no vendor SDK Signature decorators → static field maps Modules Predict · ChainOf- Thought · RespAct parsing + schema (zod, JSON Schema) config singleton holding the default LM calls through interface ILanguageModel generate · generateStructured · chat · getCapabilities extends BaseLM @ts-dspy/openai OpenAILM · openai @ts-dspy/gemini GeminiLM · @google/genai @ts-dspy/anthropic AnthropicLM · @anthropic-ai/sdk
Fig. 2 — Dependency inversion at ILanguageModel. Core calls down to the interface; providers implement up to it. That is why installing @ts-dspy/core alone pulls in no vendor SDK, and why a fourth provider is a new package rather than a change to core.

03

What each module adds

All three extend Module, which supplies the language model and the forward() contract. call() and __call__() are aliases for it.

ModuleLM callsAdds to the outputWhat it is for
Predict1 The baseline. Prompt, one completion, validate.
ChainOfThought2reasoning: string Extends Predict. Step one reasons in free text via plain generate(); step two re-asks with that reasoning in context and validates normally.
RespAct1…maxStepssteps: number Extends Module directly. A reason–act–observe loop over tools you supply, with an onEvent hook for tracing.

04

The RespAct loop

RespAct is the only module whose control flow is a cycle rather than a line. The transcript grows in place: each thought, each observation, and each correction is appended to the same conversation string and re-sent.

Two edges matter more than the happy path. A tool call identical to one already made gets an observation telling the model to move on, instead of being executed again. And a malformed final answer is not fatal — the loop names the fields that failed and gives the model another step, provided one is left.

execute tool tools[name](input) Action: Observation: loop ≤ maxSteps (6) repeats are skipped transcript task + tool list lm.generate() → Thought Final Answer: parseOutput() zod, per signature ok ValidationError → "provide every required field" Prediction fields + steps
Fig. 3 — Reason, act, observe. Tool use wins over a final answer in the same reply, so a model that hedges still advances the loop. Running out of steps throws rather than returning a half-formed answer.

05

Where things live

PathHolds
core/src/core/signature.tsDecorators and the static field maps.
core/src/core/module.tsThe forward() contract and LM resolution.
core/src/core/base-lm.tsUsage accounting, the structured-output fallback, and fence-tolerant JSON extraction.
core/src/core/config.tsThe configure() singleton: default LM, cache flag, tracing flag.
core/src/utils/schema.tsType string → zod schema, and → JSON Schema. Lenient coercion lives here.
core/src/utils/parsing.tsPrompt construction and the labelled-text extractor.
core/src/core/errors.tsTsDspyErrorValidationError, LMError.
{openai,gemini,anthropic}/srcOne BaseLM subclass each, plus its native structured-output override.
Design constraint

Coercion is deliberately lenient and validation is deliberately loud. "42" satisfies a number field, and so does "1,200" and "87%" — commas and a trailing percent are stripped before the check. But "about forty" throws a ValidationError naming the field rather than handing back a string where the type says number. Versions before 0.5.0 returned the raw string on coercion failure, which is exactly the lie the runtime check exists to prevent.