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):
nullbecomesnull; astringpasses through unchanged.- Dates and times are written in ISO 8601, losing nothing:
DateTimeandDateTimeOffsetwith the round-trip format"O"(2026-10-11T14:05:06.7890000Z),DateOnlyasyyyy-MM-dd(2026-03-04),TimeOnlyasHH:mm:sswith 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 implementIFormattableuse the invariant culture. - A
DataTablebecomes a JSON array of row objects; aDataRowbecomes one JSON object. - A
JsonNode,JsonElementorJsonDocumentis written as its JSON text. - Everything else is compact JSON with web defaults (camelCase) and enums as strings.
- Text longer than
maxCharsthrowsArgumentException. 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):
IsStrictCompatible(type)must be true (see below), otherwisenull.GetJsonSchemaAsNode(type, ...)exports the schema with web naming andTreatNullObliviousAsNonNullable = true, from a copy ofSerializerOptionswith strict number handling.SerializerOptionsreads numbers from strings, and exporting with it would type every number as["string", "integer"](or"number") with apatternthat step 3 removes, so a provider could answer any string; the copy keeps a number a number (intis{"type": "integer"}).MakeStrictwalks the schema. It removes keywords the providers reject (minimum,maximum,exclusiveMinimum,exclusiveMaximum,multipleOf,minLength,maxLength,pattern,minItems,maxItems,uniqueItems,default,$schema). For every object withproperties(a schema whosetypeis or includes"object", or that haspropertiesand notype) it setsadditionalProperties: falseand lists every property as required. It recurses intoproperties,items,anyOf,allOfandoneOf.$defsanddefinitionsare 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:
$schema,$idand$anchorare 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.The root's
$defsanddefinitionsare moved up to the root of the envelope, besideproperties.Root-relative
$refpointers are rewritten so they reach the same schema from the envelope root:$refin the caller's schemaIn 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/totalbecomes#/properties/value/properties/totalan external reference (not starting with #)unchanged The copy becomes the envelope's
valueproperty, andMakeStrictruns 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<,>orDictionary<,>; - a non-generic
IEnumerablewith 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.