Tool Envelopes
Every [KernelFunction]-decorated method a host registers with Semantic Kernel must return
Task<string> — that is Semantic Kernel’s calling convention, not Affiant’s. What goes inside
that string is where Affiant’s contract begins. ToolEnvelope is the type every Affiant tool
returns, serialized to JSON before it crosses back into Semantic Kernel’s function-invocation
pipeline. It replaces an ad hoc mix of plain strings, raw query results, and hand-rolled JSON
with one discriminated union that the framework’s own filters — and any host UI — can parse
without guessing.
The three variants
Section titled “The three variants”ToolEnvelope is an abstract record in Affiant.Abstractions.Models with exactly three
sealed subtypes:
[JsonDerivedType(typeof(ReadResult), "read")][JsonDerivedType(typeof(WriteProposal), "write")][JsonDerivedType(typeof(ToolError), "error")]public abstract record ToolEnvelope(string ToolName, DateTimeOffset Timestamp);
public sealed record ReadResult( string ToolName, DateTimeOffset Timestamp, string Summary, string Markdown, EntityRef[] Entities) : ToolEnvelope(ToolName, Timestamp);
public sealed record WriteProposal( string ToolName, DateTimeOffset Timestamp, object Envelope) : ToolEnvelope(ToolName, Timestamp);
public sealed record ToolError( string ToolName, DateTimeOffset Timestamp, string Code, string Message, bool Retryable) : ToolEnvelope(ToolName, Timestamp);Every tool falls into one of three categories, and the type it returns says which:
- A read tool — one that fetches and presents existing state without side effects —
returns
ReadResult. - A write tool — one that proposes a mutation — returns
WriteProposal. It never executes the mutation itself; see Review Gate & Write Executors for what happens after. - Any tool, read or write, that fails returns
ToolErrorinstead of throwing. There is no fourth path where a plugin lets an exception escape to the LLM.
ToolName and Timestamp are common to all three variants because every downstream consumer —
context extractors, the review gate, observability — needs to know which tool produced a
result and when, regardless of which variant it turns out to be.
The $type discriminator
Section titled “The $type discriminator”The [JsonDerivedType] attributes on the base record are what make polymorphic
deserialization possible. When a ToolEnvelope is serialized, System.Text.Json writes a
$type field carrying the string given in the attribute — "read", "write", or "error" —
alongside the record’s own properties. When something later deserializes the string back into
a ToolEnvelope, that $type value tells System.Text.Json which sealed subtype to construct,
without the caller needing to know in advance which variant is coming back. This is the same
mechanism Semantic Kernel itself uses for its own KernelContent hierarchy, so it costs
nothing extra to teach — anyone who has deserialized SK’s own polymorphic content types has
already seen this pattern.
This is what lets a filter positioned after tool execution — a
ContextExtractor reading a ReadResult, or the review gate
detecting a WriteProposal — deserialize the same string the LLM just read and recover a
strongly-typed object from it, rather than re-parsing ad hoc JSON shapes per tool.
Serializing: ToolEnvelopeExtensions.ToJsonString()
Section titled “Serializing: ToolEnvelopeExtensions.ToJsonString()”Plugin authors do not call JsonSerializer.Serialize directly. Affiant.Abstractions.Models
ships an extension method that fixes the serialization contract in one place:
public static class ToolEnvelopeExtensions{ public static string ToJsonString(this ToolEnvelope envelope);}ToJsonString() serializes with JsonNamingPolicy.CamelCase and no indentation. A [KernelFunction]
method’s entire body, on any path, ends with a call like:
return new ReadResult(toolName, DateTimeOffset.UtcNow, summary, markdown, entities) .ToJsonString();The resulting wire shape — camelCase properties, $type as the discriminator key — looks like
this for a ReadResult:
{ "$type": "read", "toolName": "SearchCustomers", "timestamp": "2026-07-04T15:22:03.104Z", "summary": "Found 2 customer(s)", "markdown": "| Name | Email |\n|---|---|\n| ...", "entities": [ { "entityType": "Customer", "entityId": "42", "displayName": "A. Rivera", "fields": { "email": "[email protected]" } } ]}Enum values elsewhere in the framework (like ProvenanceSource) serialize as PascalCase
strings even though property names are camelCase — ToolEnvelope itself carries no enum
fields, but this mixed convention is consistent framework-wide; see
Transport & Wire Contract for the full wire-format
reference.
Rule 2: dual-audience returns
Section titled “Rule 2: dual-audience returns”This shape exists because of the second of the framework’s Seven Normative Rules: every tool return must be readable by both the LLM, for reasoning, and a UI, for rendering, from the same payload. Read tools do this with markdown plus structured entities; write tools do it with an Affidavit the LLM can summarize in prose and a UI can render as a form. Neither audience gets a lossy summary of what the other one sees — both read the identical envelope.
The anti-pattern this rule rules out is a tool that returns a raw SQL result set, an opaque
blob of JSON with no narrative structure, or a plain sentence with no way for a UI to recover
which entities were involved. Any of those forces a re-query somewhere downstream, or forces
the LLM to serialize state back out in a shape a UI then has to re-parse. ToolEnvelope closes
that gap by making the dual-audience shape the only shape a tool is allowed to return.
Read tools: ReadResult
Section titled “Read tools: ReadResult”Summary— a short, human-readable sentence for the LLM’s own reasoning, independent of the full markdown, e.g."Found 2 customer(s)".Markdown— the fuller result, formatted for a human or a chat UI to read directly. The framework specification’s convention is to embed entity references inline as[entity:id](link)-style markdown links wherever the text names something a later turn might reference ("See [entity:42](...) for the full record") — the point isn’t a fixed link target so much as giving the LLM a stable, quotable identifier for the entity it just read, one it can carry forward into a later tool call.ToolEnvelopeitself doesn’t enforce that convention mechanically —Markdownis a plainstring— so it’s a formatting discipline for the plugin author, not something the type system checks.Entities— anEntityRef[], the structured half of the dual-audience pair. This is what a UI (or a later filter) reads instead of re-parsing the markdown:
public sealed record EntityRef( string EntityType, string EntityId, string DisplayName, Dictionary<string, object> Fields);EntityType is a domain label ("Customer", "WorkOrder") chosen by the host — the framework
is domain-agnostic and never inspects it beyond string equality. Fields is a flat
Dictionary<string, object>, deliberately unstructured beyond that, because different domains
need different fields and EntityRef has no schema of its own to constrain them.
An empty EntityRef[] is a normal, valid ReadResult — a query that legitimately found
nothing returns zero entities, not a ToolError. Only reach for ToolError when the query
itself failed, not when it succeeded and found nothing.
Entities is what feeds a ContextExtractor: a post-invocation
filter reads ReadResult.Entities and upserts them into the ContextFabric, so the entities a
read tool surfaces this turn are available to a write tool’s field inference on a later turn —
without the LLM having to restate them.
Write tools: WriteProposal
Section titled “Write tools: WriteProposal”public sealed record WriteProposal( string ToolName, DateTimeOffset Timestamp, object Envelope) : ToolEnvelope(ToolName, Timestamp);Envelope is typed object at the ToolEnvelope level, but in practice a write tool always
constructs it as an Affidavit — the sworn, per-field-provenance record covered in
Affidavits & Provenance:
var affidavit = new Affidavit( OperationType: "CreateLeaveRequest", EntityType: "LeaveRequest", EntityId: null, Fields: fields, AggregateConfidence: aggregateConfidence, Warnings: warnings, RequiresConfirmation: true);
return new WriteProposal(toolName, DateTimeOffset.UtcNow, affidavit).ToJsonString();Why object and not Affidavit directly on the record: WriteProposal has to round-trip
through the same polymorphic ToolEnvelope deserialization every variant does — a filter
receiving a raw JSON string doesn’t know yet whether it’s about to unwrap a ReadResult,
WriteProposal, or ToolError. Once it is a WriteProposal, the framework’s own
IReviewContextProvider — a host-provided service — is responsible for extracting the
Affidavit from Envelope and building a ReviewContext around it. By the time the entry
reaches the Docket, it is narrowed back down properly:
DocketEntry.Envelope is typed Affidavit, not object. The looseness lives only at the
WriteProposal wire boundary, not in the durable review record.
The write side of Rule 2 plays out across that same boundary: the LLM sees a WriteProposal
and can summarize it in prose (“I’ve proposed a leave request from March 3–7, pending your
approval”), while a host UI renders the same Affidavit as an
Evidence Card — every field, its proposed value, and
its provenance, laid out for a reviewer. Same envelope, two readings.
A write tool is conventionally marked with the [AffiantWriteTool] method attribute (from
Affiant.Abstractions.Attributes), which names the tool’s operation kind, entity type, and the
ITaskInferenceStrategy type the framework should use to infer its fields — see
Context Fabric for how that inference actually runs.
Failures: ToolError
Section titled “Failures: ToolError”public sealed record ToolError( string ToolName, DateTimeOffset Timestamp, string Code, string Message, bool Retryable) : ToolEnvelope(ToolName, Timestamp);Code— a short, machine-readable label such as"CUSTOMER_NOT_FOUND"or"DB_TIMEOUT", stable enough for a UI or a test to branch on.Message— a human-readable explanation, written for the LLM and the end user, never a raw exception message or stack trace.Retryable— whether the framework should attempt the same call again once, automatically, before giving up.
Plugin authors are expected to catch known failure modes explicitly and return a ToolError
with an accurate Code — a database timeout and a not-found lookup should never share a code.
As a safety net, the framework’s own function-invocation pipeline also wraps unhandled
exceptions into a ToolError automatically (mapping common exception types like
TimeoutException to a retryable error and retrying exactly once), so a plugin that forgets to
catch something does not leak a raw exception string into the LLM’s context. That safety net is
a backstop, not a substitute for catching what a plugin author already knows can go wrong —
the framework’s automatic mapping only recognizes a handful of generic exception shapes, not
domain-specific failure conditions like "CUSTOMER_NOT_FOUND".
Where this fits
Section titled “Where this fits”ToolEnvelope is the seam between plugin code and everything downstream of it: the
Context Fabric reads ReadResult.Entities, the review gate reads
WriteProposal.Envelope, and a host UI can render either variant without the framework
understanding the domain those entities and fields belong to. See
Authoring Read Tools and
Authoring Write Tools for the full worked patterns, and
The Seven Normative Rules for Rule 2 and Rule 3 in full.