Tool Envelopes
A tool method returns a string, and what goes inside that string is where Affiant’s contract
begins. The return type itself belongs to the backend: a Semantic Kernel [KernelFunction] read
tool returns Task<string>, while the Microsoft Agent Framework adapter takes a synchronous
string method as a tool shape too. What is the same on all three backends — Semantic Kernel, the
Microsoft Agent Framework and Microsoft.Extensions.AI, each with its own bridge over the
framework’s own interception seam (Interception Backends) — is
the envelope: ToolEnvelope is the type every Affiant tool returns, serialized to JSON with
.ToJsonString() before the string crosses back into whichever tool-invocation loop called it. 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:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")][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, IReadOnlyDictionary<string, object?>? Arguments = null, ProposedOperation? Operation = null) : 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 declared on the base record, so all three variants carry them
whichever one comes back.
The kind discriminator
Section titled “The kind discriminator”The [JsonDerivedType] attributes on the base record are what make polymorphic
deserialization possible. When a ToolEnvelope is serialized, System.Text.Json writes a
kind 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 kind value tells System.Text.Json which sealed subtype to construct,
without the caller needing to know in advance which variant is coming back.
The discriminator was originally spelled $type, inherited from Semantic Kernel’s own
KernelContent pattern rather than chosen deliberately — and a $-prefixed name is reserved
by JSON Schema, so a discriminator no schema could name was a discriminator nothing could
validate. It is kind as of 1.0.0-beta.3. A TypeScript or other non-.NET client doing
switch (result.$type) now reads undefined and silently falls through to its default arm;
change it to switch (result.kind). The three values themselves — "read", "write",
"error" — are unchanged, and a .NET consumer deserializing through ToolEnvelope needs no
code change beyond re-serializing any payload it had stored as raw text. The protocol’s own
tool-result.schema.json at v0.1.2 names those same three kinds on that same kind property,
but the member shapes it constrains are not this type’s — a write there is
{kind, entryId, status, card}, a read {kind, result}, an error {kind, code, message}, each
closed to any other property — so what the schema freezes for a .NET reader is the union and its
discriminator, not the fields laid out above.
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 tool returned 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. The listing below is
its shape, not code to paste — the body is omitted:
public static class ToolEnvelopeExtensions{ public static string ToJsonString(this ToolEnvelope envelope);}ToJsonString() serializes with AffiantJson.SerializerOptions — the framework’s one set of
JSON conventions, shared with the Evidence Card and the canonical form. That is camelCase
property names and no indentation, and also: nulls written rather than skipped
(DefaultIgnoreCondition = Never), instants through IsoInstantJsonConverter, ReviewStatus
camelCased, and every other enum as a string. Every property of a ToolEnvelope is written, null
or not; the one carve-out from that is per property rather than per options object — a property
the v0.1 schemas mark optional carries its own [JsonIgnore(Condition = WhenWritingNull)] and
is omitted when it has nothing to say, as six properties of the Evidence Card request and its
field-presentation hints do. Before 1.0.0-beta.3 the two paths were configured apart:
ToJsonString() set camelCase and nothing else, while the SignalR transport that carries an
Evidence Card added a string-enum converter of its own. Both now run through
AffiantJson.SerializerOptions, so a value is spelled the same way whichever path carries it — the
drift was in the options rather than in anything an envelope was observed to carry, since the only
enum in the envelope types themselves is ProvenanceSource, on a field’s provenance chain inside a
WriteProposal’s Affidavit, and it has carried its own type-level string converter since
1.0.0-beta.1. A tool method’s 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, kind as the discriminator key — looks like
this for a ReadResult. Note the property order: the discriminator comes first, then the variant’s
own properties, and the two the base record declares (toolName, timestamp) come last, which
is where System.Text.Json puts an inherited record’s positional members:
{ "kind": "read", "summary": "Found 2 customer(s)", "markdown": "| Name | Email |\n|---|---|\n| ...", "entities": [ { "entityType": "Customer", "entityId": "42", "displayName": "A. Rivera", "fields": { "email": "[email protected]" } } ], "toolName": "SearchCustomers", "timestamp": "2026-07-04T15:22:03.104Z"}(Indented here for reading; ToJsonString() emits it on one line. Nothing should depend on the
order — JSON objects are unordered — but a reader diffing against real output will see this one.)
Enums elsewhere in the framework cross as strings even though property names are camelCase, but
they do not all cross the same way: ProvenanceSource, ReviewRequirement and
ApprovalDecision are PascalCase; ReviewStatus, ExecutionOutcome and DecisionOutcome are
lowercase; CoverageCategory is kebab-case. For six of those seven, the spelling is the one that
type’s v0.1 schema freezes. ApprovalDecision has no schema at v0.1.2 — the protocol names
neither the type nor its members — and takes its PascalCase from the general string-enum converter
AffiantJson.Configure registers last, which is also what would spell any other enum the schemas
say nothing about. Those seven are not the whole set: DecisionKind — Approve or Reject, the
kind on a Docket row’s decision record — names no members of its own either, so through these
options it crosses PascalCase while docket-entry.schema.json freezes "approve" and "reject".
It reaches storage in the schema’s spelling only because the Entity Framework store’s
DocketRowSerialization writes the two strings by hand rather than letting the converter do it.
TransportEvent is not on that list at all: it never crosses as a serialized enum. The SignalR
transport maps every member to a client method name through
TransportEventExtensions.ToClientEventName — ConfirmAction, EvidenceCardResponse,
ReceiveToken, ContextUpdated, SystemNotification, DocketExpiring, DocketExpired,
GuideUI — so a client subscribes to the method name, and four of the eight are deliberately not
the member’s own name. ToolEnvelope itself carries no enum fields; 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. What the
framework enforces is narrower than the rule, and it is worth knowing in which directions.
ReviewGateFilter deserializes every tool result before it looks at any declaration, so a result
that comes back as a WriteProposal is carried to the review gate whether or not the registry
declares that tool write-capable. The registry is consulted only on the other branch: a result
that is not a proposal from a tool the registry does declare write-capable is refused there with
a wireup-invalid ToolError, and nothing is filed — with one hole, a null or empty result,
which the filter returns on before it reaches that check, so a declared write tool that hands back
nothing passes through unrefused. Every other return — a plain markdown string included — crosses
untouched.
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, written to be read directly by both audiences at once: the LLM reasoning over it and a human or a chat UI rendering it, which is whatReadResultmeans by markdown for dual-audience consumption. 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 no schema constrains them. (The protocol does ship an
entity-ref.schema.json at v0.1.2, but under that name it describes a different thing — the
entity a write names, entityType plus entityId and nothing else — not this read-side
record.)
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, which is where the
host does the work: the abstract base class is the post-invocation filter, and it deserializes the
result, keeps going only when the host’s MatchesTool claims the tool name and the ReadResult
carries at least one entity, then calls the host’s own ExtractAsync override. The override is
what picks entities out and calls the protected EmitEntity, and EmitEntity is the upsert into
the ContextFabric — nothing in the framework upserts a read’s entities on its own. What the host
does put there is available to a write tool’s field inference later in the same turn — without the
LLM having to restate it. The fabric is registered scoped, and nothing in the framework saves it:
a host that wants a read’s entities to survive the turn writes a ConversationContext itself
through IDocketStore.SaveContextAsync. Reading one back is not all the host’s work — on Semantic
Kernel, SessionRehydrator calls LoadContextAsync when a session reconnects and hands the stored
context back to the host beside the rehydrated history and Docket.
Write tools: WriteProposal
Section titled “Write tools: WriteProposal”public sealed record WriteProposal( string ToolName, DateTimeOffset Timestamp, object Envelope, IReadOnlyDictionary<string, object?>? Arguments = null, ProposedOperation? Operation = null) : ToolEnvelope(ToolName, Timestamp);
public sealed record ProposedOperation( string Kind, // the protocol's two-valued shape vocabulary: "create" or "update" string EntityType, string? EntityId, IReadOnlyList<string> Fields){ // Plus one static factory, elided here: `From(Affidavit)` derives the operation from a // sworn record — what ReviewGate falls back to when a proposal names none.}Envelope is typed object on WriteProposal — the abstract ToolEnvelope base declares only
ToolName and Timestamp — but in practice a write tool always constructs it as an Affidavit,
the sworn, per-field-provenance record covered in
Affidavits & Provenance:
var affidavit = Affidavit.Create( operationType: "WriteCreate", entityType: "LeaveRequest", entityId: null, fields: fields, warnings: warnings, requiresConfirmation: true);
return new WriteProposal(toolName, DateTimeOffset.UtcNow, affidavit).ToJsonString();operationType carries the protocol’s shape, not a host verb. Operation.IsUpdateShaped
recognizes only "WriteUpdate" and the bare "update", case-insensitively; everything else — a
host’s own "CreateLeaveRequest" included — is create-shaped. The shape decides real behavior:
the schema-driven projection throws if an entity id is passed with a create-shaped operation and
throws if one is missing on an update-shaped one, previous values are resolved on updates only,
and the canonical form writes "create" or "update" rather than the verb. A host verb travels
beside the shape — in the tool name, or in a field — never instead of it.
Arguments and Operation, both optional, are how a Docket entry’s id gets derived: the id is
the SHA-256 of the tenant, the conversation, the tool name, and the canonical form of the
proposed operation and its arguments — not of the Affidavit — with the digest’s first 128 bits
laid out as a version-8 UUID (Affiant.Core.Services.EntryIdDerivation), so two implementations
filing the same proposal agree on which row it is. A resubmission adds one more item to that
material — the id of the row it replaces — and a first filing carries none, so its id is what it
would have been before resubmission existed. Operation is the host’s own declaration of
the write’s shape (the entity it names and the fields it proposes, in the declared order); a
caller that supplies none leaves the gate to read it off the record instead, which is what a
resubmission does. ReviewGateFilter attaches the invocation’s own arguments to the proposal, but
only where the seam it runs at carried any — and that is where the backends part company. The
Microsoft Agent Framework middleware and the Microsoft.Extensions.AI function wrapper both hand the
neutral pipeline the arguments the model passed, so a proposal filed through either carries them.
Semantic Kernel does not: ReviewGateFilter runs at SK’s completion stage, and both SK seams —
AffiantAutoFunctionInvocationBridge and ManualToolInvoker — build that stage’s request with an
empty argument dictionary. The bridge does it deliberately and says why: a completion-stage filter
keys off the result, the function identity and termination, and
AutoFunctionInvocationContext.Arguments can throw when the loop supplied no KernelArguments.
ManualToolInvoker has the call’s real KernelArguments in hand at that point and passes an
empty dictionary only to mirror the bridge’s completion-stage shape. So on Semantic Kernel a
proposal reaches the gate with Arguments still null, and the entry-id material’s args is
written null. What a Semantic Kernel host must know: two calls of the same write tool in the
same conversation whose proposals differ only in a field’s value then derive the same entry id,
and the second replays the first’s row rather than filing a new one. Set Arguments on the
WriteProposal yourself — a proposal’s own arguments survive, the filter replaces them only when
the seam carried some — or supply your own ReviewContext.EntryId; a host calling
ReviewGate.FileForReviewAsync directly passes them the same way.
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 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. The attribute is
a declaration, not decoration: on Semantic Kernel every [KernelFunction] must be declared to the
framework’s tool registry, and AffiantStartupValidator throws an AffiantStartupException at
startup listing every function the registry has never heard of, because a function it holds no
declaration for is one it cannot tell writes. A declaration is either explicit —
services.AddAffiantTool<TStrategy>(...) for a write, or services.AddAffiantReadTool(...) for a
tool that genuinely does not write — or walked: kernelBuilder.AddAffiantPluginsFromType<T>() (and
AddAffiantPluginsFromAssembly(...)) registers a descriptor for every [KernelFunction] on the
type, reading a write’s operation kind, entity type and strategy off [AffiantWriteTool] and
giving every method without one a read descriptor. That walk is what turns the attribute into a
registration; the attribute alone declares nothing. On the Microsoft Agent Framework and
Microsoft.Extensions.AI the same walk happens inside the wiring itself:
AffiantToolCatalog.FromType<T>() reflects over the tool type’s public instance methods and builds
a descriptor the same way for each method it keeps — it skips generic method definitions, members
declared on object and special-name members (property accessors, event add/remove), and throws
rather than registering two tools that share a method name or an LLM-visible name.
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— a statement about the failure, not a switch a returned envelope can throw. On aToolErrora tool returns, nothing in the framework acts on the flag: the result is carried on as the tool’s own, and the value reaches observability alone — thetool_error.retryabletagToolTracingFilterputs on itsaffiant.tool_errorspan event. The one retry the framework performs is on theToolErrorToolErrorFilterbuilds itself out of a caught exception (below), and even there it is gated on the seam as well as on the flag: the filter re-runs the call only whileToolInvocationContext.NextIsToolBodyis true, which the Semantic Kernel completion-stage bridge sets false because itsnext()is SK’s own auto-invocation continuation rather than the tool body. A retryable mapped error raised there is surfaced without a retry, so the tool is not executed twice; MAF’s middleware onion and the M.E.AI wrapper leaveNextIsToolBodyat its defaulttrue, and the once-only retry does happen there.
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 ToolInvocationPipeline also wraps unhandled exceptions
into a ToolError automatically — ToolErrorFilter, which AddAffiantCore registers outermost
among the neutral filters every backend runs, maps common exception types like TimeoutException
to a retryable error and retries once where the seam above allows it — 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.