Skip to content

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.

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 ToolError instead 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 [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: DecisionKindApprove 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.ToClientEventNameConfirmAction, 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.

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.

  • 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 what ReadResult means 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. ToolEnvelope itself doesn’t enforce that convention mechanically — Markdown is a plain string — so it’s a formatting discipline for the plugin author, not something the type system checks.
  • Entities — an EntityRef[], 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.

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.

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 a ToolError a 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 — the tool_error.retryable tag ToolTracingFilter puts on its affiant.tool_error span event. The one retry the framework performs is on the ToolError ToolErrorFilter builds 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 while ToolInvocationContext.NextIsToolBody is true, which the Semantic Kernel completion-stage bridge sets false because its next() 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 leave NextIsToolBody at its default true, 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".

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.