Documentation
Table of Contents

Prompting and schemas

Code: src/IntentSharp/Prompting/PromptBuilder.cs, InputSerializer.cs, JsonSchemaBuilder.cs.

What every request contains

PromptBuilder.Build produces one system prompt and one user message.

The system prompt is PromptBuilder.SystemPreamble, which tells the model to carry out the intent on the input exactly, use only the input and intent, and answer with no preamble or markdown fences. AIRequestOptions.SystemInstructions is appended after a blank line.

The user message has these sections, in order:

## Intent
<the trimmed intent>

## Input (<type label>)
<serialized input>

## Output requirements
<output contract>
Additional output requirements: <ExpectedOutputDescription, when set>

## Examples                               (only when AIRequestOptions.Examples has entries)
Example 1 input:
<example input, trimmed>
Example 1 output:
<example output, trimmed>

Example 2 input:
...
Apply the same judgement and format to the input above.

## Previous attempt                       (retries only)
<previous raw output, cut at 6000 chars>

## Problems with the previous attempt     (retries only)
- <one line per issue>
Produce a corrected response that fixes every listed problem while still satisfying the intent.

Worked examples

AIRequestOptions.Examples is a list of IntentExample(Input, Output). PromptBuilder.Plan copies the list into PromptPlan.Examples, and Build renders every example, numbered from 1 in list order, after the output requirements and before any retry sections. Write each output exactly as the model is to answer: plain text for a string target, the envelope {"value": ...} for every other target.

Examples steer format and judgement; they never replace verification. A reply that copies an example and breaks a rule still fails. They are not counted toward IntentClientOptions.MaxInputChars, which limits the serialized input only. The intent catalog fills Examples from a definition's examples (intent-catalog.md).

Input serialization

InputSerializer.Serialize(input, maxChars):

  • null becomes null; a string passes through unchanged.
  • Dates and times are written in ISO 8601, losing nothing: DateTime and DateTimeOffset with the round-trip format "O" (2026-10-11T14:05:06.7890000Z), DateOnly as yyyy-MM-dd (2026-03-04), TimeOnly as HH:mm:ss with any fraction of a second (09:15:30.25). These are the same forms a date nested in an object gets from the JSON serializer.
  • Other simple types (primitives, enums, decimal, TimeSpan, Guid, Uri) that implement IFormattable use the invariant culture.
  • A DataTable becomes a JSON array of row objects; a DataRow becomes one JSON object.
  • A JsonNode, JsonElement or JsonDocument is written as its JSON text.
  • Everything else is compact JSON with web defaults (camelCase) and enums as strings.
  • Text longer than maxChars throws ArgumentException. Nothing is truncated.

InputSerializer.Describe(Type) renders a C#-like description, such as Order\n where Order { string Id; List<Line> Lines }, used for the input label and the output contract. Date types carry their format in the label, matching what Serialize writes: DateTime (ISO 8601), DateTimeOffset (ISO 8601), DateOnly (yyyy-MM-dd), TimeOnly (HH:mm:ss).

InputSerializer.DescribeInputType(input) is the label in ## Input (...). It is Describe of the input's type, except for inputs that are sent as JSON rather than as their members: a DataTable is labelled JSON array of row objects, a DataRow JSON object, and a JsonNode, JsonElement or JsonDocument JSON. The label is not counted toward MaxInputChars.

The envelope contract

For T == string the contract is "plain text only", the request format is ResponseFormat.Text, and no schema is sent.

For every other T, the model must reply with one JSON object {"value": <result>}, where the result is described with InputSerializer.Describe(T). The property name is JsonSchemaBuilder.EnvelopeProperty. The envelope lets primitives and arrays work with providers that require an object at the root.

For a runtime shape (T is JsonNode; see Runtime schemas), the result is described as "a JSON value that conforms to the provided JSON schema" when AIRequestOptions.OutputSchema is set, and as "a JSON value" when it is not.

Strict schemas

JsonSchemaBuilder.BuildEnvelope(type) wraps the inner schema in an envelope object with required: ["value"] and additionalProperties: false. The inner schema comes from ForType(type):

  1. IsStrictCompatible(type) must be true (see below), otherwise null.
  2. GetJsonSchemaAsNode(type, ...) exports the schema with web naming and TreatNullObliviousAsNonNullable = true, from a copy of SerializerOptions with strict number handling. SerializerOptions reads numbers from strings, and exporting with it would type every number as ["string", "integer"] (or "number") with a pattern that step 3 removes, so a provider could answer any string; the copy keeps a number a number (int is {"type": "integer"}).
  3. MakeStrict walks the schema. It removes keywords the providers reject (minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, minLength, maxLength, pattern, minItems, maxItems, uniqueItems, default, $schema). For every object with properties (a schema whose type is or includes "object", or that has properties and no type) it sets additionalProperties: false and lists every property as required. It recurses into properties, items, anyOf, allOf and oneOf. $defs and definitions are maps of named schemas: each named schema in them is made strict the same way.

Strict mode has no optional properties, and MakeStrict does not widen a property's type when it adds it to required. A property that a runtime schema leaves out of its own required list therefore still has to be filled with a value of its type. Declare it nullable ("type": ["string", "null"]) when the model should be able to answer that there is none.

JsonSchemaBuilder.SerializerOptions is read-only (MakeReadOnly at creation), so no code can change how every client reads replies.

If the exporter throws NotSupportedException or InvalidOperationException, ForType returns null.

Runtime schemas

IntentClient.TransformAsync(input, intent, JsonNode outputSchema, options) asks for a result shape given as a JSON schema while the program runs. It sets AIRequestOptions.OutputSchema on a copy of the options and calls TransformAsync<JsonNode>. PromptBuilder.Plan then builds the provider schema with JsonSchemaBuilder.BuildEnvelopeFromSchema(outputSchema). It works on a deep copy, so the caller's node is never modified:

  1. $schema, $id and $anchor are removed from the root of the copy. They describe the caller's document, and the copy is no longer a document root once it sits inside the envelope.

  2. The root's $defs and definitions are moved up to the root of the envelope, beside properties.

  3. Root-relative $ref pointers are rewritten so they reach the same schema from the envelope root:

    $ref in the caller's schema In the envelope
    # (the caller's root) #/properties/value
    #/$defs/... or #/definitions/... unchanged; the definitions are now at the envelope root
    any other #/... prefixed with #/properties/value: #/properties/total becomes #/properties/value/properties/total
    an external reference (not starting with #) unchanged
  4. The copy becomes the envelope's value property, and MakeStrict runs over the whole envelope, hoisted definitions included.

With no OutputSchema, TransformAsync<JsonNode> sends no schema and uses ResponseFormat.JsonObject.

The constraint keywords that MakeStrict removes never reach the provider, and the core does not check them: the verifier checks the JSON and the envelope, not the schema (verification.md). The intent catalog validates schema shapes against the full schema (intent-catalog.md); without the catalog, add validators.

Types that fall back to JSON-object mode

When BuildEnvelope returns null, PromptBuilder.Build uses ResponseFormat.JsonObject and adds "Use JSON types faithfully" to the contract. IsStrictCompatible returns false for:

  • object, JsonElement, JsonNode, JsonObject, JsonDocument;
  • any IDictionary, IDictionary<,>, IReadOnlyDictionary<,> or Dictionary<,>;
  • a non-generic IEnumerable with no element type, or a collection whose element type is incompatible;
  • recursive types, and any type with a public property that is one of the above (unless [JsonIgnore]).

A JsonNode target with an OutputSchema does not take this path: it is sent as a strict schema, as described under Runtime schemas.

How providers receive the format

ResponseFormat OpenAI (OpenAIProvider) Anthropic (AnthropicProvider)
Text no response_format no output_config
JsonObject response_format: {type: "json_object"} nothing; the prompt carries the contract
JsonSchema json_schema with strict: true, name AIRequest.SchemaName output_config.format of type json_schema

The default schema name is intentsharp_output; the judge uses judge_verdict.

How retries carry findings

The attempt loop passes the previous AttemptRecord to PromptBuilder.Build. When it has issues, the new request appends the previous raw output and one bullet per ResultIssue.Message. Every issue message is written so a model can act on it, for example "Return exactly 3 elements, one per input element, in the same order." Retries are never served from the cache.

Parsing tolerance on the way back

The verifier is lenient about form and strict about content. It strips a surrounding code fence, accepts a bare value without the envelope, reads property names case-insensitively, reads numbers from strings, skips comments and allows trailing commas (JsonSchemaBuilder.SerializerOptions). It reads enums by name only: a number, or a name combination that is no member of an enum without [Flags], is refused. It is strict about the envelope: an object with value and other keys is refused, unless the target is a dictionary or a type with its own member named value. See verification.md.