Skip to content

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.

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 ToolError instead 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 [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.

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.

  • 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. 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 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.

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.

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".

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.