Skip to content

Quickstart

This walkthrough wires one write-intent tool into a Semantic Kernel host, end to end: the tool proposes a write instead of making one, a human reviews it on an Evidence Card, and only after approval does an IWriteExecutor touch the database. Every type and registration method below is copied from Affiant.Abstractions, Affiant.Core, and their adapter packages — where the framework has no convenience method for a step, this page shows the real wiring instead of inventing one. It builds toward the flow in Why Affiant and Affidavits & Provenance: a RequestLeave tool that proposes a LeaveRequest creation, each field individually tagged with where it came from.

You’ll need the .NET 10 SDK and a Semantic Kernel host project — see Installation for the package list. This walkthrough uses five packages: Affiant.Core, Affiant.SemanticKernel, Affiant.EntityFramework, Affiant.Docket, and Affiant.Transport.SignalR.

AddAffiantCore wires the tool descriptor registry, the ContextFabric, the policy evaluator, and the deterministic pre-tool filters. AddAffiantSemanticKernel adds the Semantic Kernel adapter — the startup validator and the post-tool filter pair (TaskInferenceMergeFilter, ReviewGateFilter) that intercept a write tool’s result:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpContextAccessor();
builder.Services.AddAffiantCore();
builder.Services.AddAffiantSemanticKernel();

Neither call registers a database, a review transport, or an approval policy — separate adapter packages, added next. Note what AddAffiantCore does not register: no IApprovalPolicy. ApprovalPolicyEvaluator’s built-in fallback returns ReviewRequirement.ReviewerConfirmation whenever no policy answers, which is the always-ask-a-human default this walkthrough relies on. You don’t need Affiant.Policies/AddAffiantPolicies unless you want Standing Orders (auto-approval) or Referrals (escalation) — see Packages.

This walkthrough also skips AddAffiantSemanticKernel()’s companion call, AddAffiantInferenceOrchestration(). That call wires the pre-tool filters that let a write tool’s Affidavit honestly carry ProvenanceSource.Inferred for a field the tool call didn’t receive directly — powering the Inferred row of the determinism hierarchy in Affidavits & Provenance. It’s safe to skip only because every field on the write tool below arrives straight off the tool call’s own parameters (ProvenanceTag.FromUser, step 4) — nothing is left for the model to infer. Almost every real host needs at least one inferred field somewhere and should add the call; skipping it is silent, not enforced — no validator catches a missing registration the way AddAffiantSemanticKernel() itself is checked for.

2. Register persistence and the review transport

Section titled “2. Register persistence and the review transport”

ReviewGate — the service that files a review and waits for a reviewer’s decision — takes an IStreamingTransport, an IDocketStore, and an IApprovalPolicyEvaluator in its constructor. The Semantic Kernel adapter registers those dependencies but not ReviewGate itself, since its lifetime depends on host-scoped choices; the host registers it directly.

builder.Services.AddAffiantEntityFramework(o =>
o.UseSqlite("Data Source=affiant-quickstart.db"));
builder.Services.AddAffiantDocket(o => o.UseInMemory());
builder.Services.AddAffiantSignalR<ChatHub>();
builder.Services.AddScoped<ReviewGate>();

These are two separate packages, and as of 1.0.0-beta.1 they’re peers, not a chain — read this carefully, because the order of these two calls matters here in a way it wouldn’t if they were unrelated. AddAffiantEntityFramework(o => o.UseSqlite(...)) gives you AffiantDbContext and IChatSessionStore (the SignalR hub base class below needs the latter even if your hub never rehydrates a session) — and, on this SQLite branch, it also registers a SqliteDocketStore as IDocketStore, since SQL-backed Docket storage lives in Affiant.EntityFramework now, not Affiant.Docket. The very next line, AddAffiantDocket(o => o.UseInMemory()), registers a second, competing IDocketStoreInMemoryDocketStore — and because it’s registered after the SQLite one, it’s the one every GetRequiredService<IDocketStore>() call in this walkthrough actually resolves (.NET’s container returns the last registration for a single-instance resolution). AddAffiantDocket is also required regardless of which IDocketStore wins, because it’s what registers the expiry sweep (DocketExpiryService) unconditionally.

This walkthrough deliberately keeps it this way — SQLite session persistence, in-memory Docket — so that AffidavitField.Value stays the same CLR object you built rather than round-tripping through JSON (see the next paragraph), while step 9 still gets a real AffiantDbContext to migrate. A production host without that specific need should simplify: drop the AddAffiantDocket(o => o.UseInMemory()) call entirely and call bare AddAffiantDocket() (no store selection) instead, letting the SQLite registration from AddAffiantEntityFramework stand as the one and only IDocketStore — see Docket & Evidence Cards for that cleaner, single-store shape. Either way, an in-memory IDocketStore keeps the Affidavit you get back after approval as the same CLR object you built, not a value round-tripped through JSON; switch to a database-backed Docket once review state must survive a restart, but then AffidavitField.Value comes back as a JsonElement per field, covered in Authoring Write Tools.

AddAffiantSignalR<THub> requires a concrete hub deriving from AffiantHub — and it’s also where IWriteExecutor gets called after approval, so define it now:

using Affiant.Abstractions.Interfaces;
using Affiant.Abstractions.Models;
using Affiant.Abstractions.Transport;
using Affiant.Core.Services;
using Affiant.Transport.SignalR.Hubs;
public sealed class ChatHub(
IChatSessionStore chatSessionStore,
ReviewGate reviewGate,
IDocketStore docketStore,
IWriteExecutor writeExecutor) : AffiantHub(chatSessionStore)
{
// entryId is the DocketId carried on the EvidenceCardRequest the client received earlier.
public async Task ApproveEntry(Guid entryId, CancellationToken ct)
{
await reviewGate.HandleDecisionAsync(entryId, ApprovalDecision.Approved, ct);
// HandleDecisionAsync returns (null, null) when a live FileReviewAsync call owns the
// outcome — either way the DocketEntry's status is already Approved by now, so read it
// back for the affidavit and any reviewer amendments.
var entry = await docketStore.GetDocketEntryAsync(entryId, ct);
if (entry is { Status: ReviewStatus.Approved })
{
var amendments = entry.Amendments?.ToDictionary(kv => kv.Key, kv => kv.Value);
await writeExecutor.ExecuteAsync(entry.Envelope, amendments, ct);
}
}
public Task RejectEntry(Guid entryId, CancellationToken ct) =>
reviewGate.HandleDecisionAsync(entryId, ApprovalDecision.Rejected, ct);
}

Nothing in the framework calls IWriteExecutor for you — ReviewGateFilter (step 6) only files the review and logs the outcome. The hub method above is the host-owned integration point where an approved Affidavit actually reaches the database; see Review Gate & Write Executors for why that boundary is drawn there. builder.Build() and app.Run() come last, after steps 3–8 have added their own registrations — the full sequence is in step 9.

ITaskInferenceStrategy is the contract a write tool’s domain declares so the framework knows which fields exist. This walkthrough’s plugin builds its Affidavit by hand rather than relying on structured-output inference, but the strategy is still required: AddAffiantTool<TStrategy> (step 5) registers it, and the startup validator checks it resolves.

using Affiant.Abstractions.Interfaces;
public sealed class LeaveTaskInferenceStrategy : ITaskInferenceStrategy
{
public string EntityName => "LeaveRequest";
public double? MinimumConfidenceThreshold => 0.5;
public IReadOnlyList<TaskInferenceField> Fields { get; } = new List<TaskInferenceField>
{
new("StartDate", "string", "Start date (yyyy-MM-dd)",
Pattern: @"^\d{4}-\d{2}-\d{2}$", Required: true),
new("EndDate", "string", "End date (yyyy-MM-dd), inclusive",
Pattern: @"^\d{4}-\d{2}-\d{2}$", Required: true),
new("LeaveType", "string", "Type of leave",
Enum: new[] { "Annual", "Sick", "Personal" }, Required: true),
new("Reason", "string", "Reason for the request", MaxLength: 1000, Required: true),
};
}

A write-intent tool is a [KernelFunction] marked with [AffiantWriteTool], returning a WriteProposal that wraps an Affidavit. Every field gets its own ProvenanceChain — here, every value came straight from the user’s arguments, so every tag is ProvenanceTag.FromUser:

using System.ComponentModel;
using Affiant.Abstractions.Attributes;
using Affiant.Abstractions.Models;
using Microsoft.SemanticKernel;
public class RequestLeavePlugin
{
[KernelFunction("request_leave")]
[AffiantWriteTool("WriteCreate", "LeaveRequest", typeof(LeaveTaskInferenceStrategy))]
[Description("Propose a leave request. Returns a WriteProposal for review; never writes directly.")]
public Task<string> RequestLeaveAsync(
[Description("Start date (yyyy-MM-dd).")] DateOnly startDate,
[Description("End date (yyyy-MM-dd), inclusive.")] DateOnly endDate,
[Description("Annual, Sick, or Personal.")] string leaveType,
[Description("Reason for the request.")] string reason)
{
var fields = new AffidavitField[]
{
new("StartDate", startDate.ToString("yyyy-MM-dd"), null,
ProvenanceChain.From(ProvenanceTag.FromUser("StartDate"))),
new("EndDate", endDate.ToString("yyyy-MM-dd"), null,
ProvenanceChain.From(ProvenanceTag.FromUser("EndDate"))),
new("LeaveType", leaveType, null,
ProvenanceChain.From(ProvenanceTag.FromUser("LeaveType"))),
new("Reason", reason, null,
ProvenanceChain.From(ProvenanceTag.FromUser("Reason"))),
};
var affidavit = new Affidavit(
OperationType: "create",
EntityType: "LeaveRequest",
EntityId: null,
Fields: fields,
AggregateConfidence: 1.0f,
Warnings: [],
RequiresConfirmation: true);
return Task.FromResult(
new WriteProposal("RequestLeave", DateTimeOffset.UtcNow, affidavit).ToJsonString());
}
}

Notice RequestLeavePlugin has no database dependency: Rule 3 of the Seven Normative Rules — write tools never write — means it doesn’t need one. The only DbContext in this walkthrough is in the IWriteExecutor (step 8), which runs only after a human approves the proposal.

AddAffiantTool<TStrategy> registers the strategy in DI and a matching AffiantToolDescriptor in the registry atomically. Call it after AddAffiantCore:

using Affiant.Abstractions.Models;
builder.Services.AddAffiantTool<LeaveTaskInferenceStrategy>(
functionName: "request_leave", operation: Operation.WriteCreate, entityType: "LeaveRequest");

Registering the plugin type with the kernel is standard Semantic Kernel, not Affiant-specific: builder.Services.AddKernel().Plugins.AddFromType<RequestLeavePlugin>(); — your chat completion connector (OpenAI, Azure OpenAI, …) is added on the same builder, outside Affiant’s scope.

At boot, AffiantStartupValidator (registered by AddAffiantSemanticKernel) checks that every [KernelFunction] has a matching AffiantToolDescriptor and that every descriptor’s ITaskInferenceStrategy resolves from IServiceProvider; either failure throws AffiantStartupException naming the exact function or strategy at fault, so forgetting this step fails loudly at startup rather than silently at the first tool call.

ReviewGateFilter fires after every auto-invoked function. If the result deserializes as a WriteProposal, it asks a host-registered IReviewContextProvider to build a ReviewContext — session, tenant, user, reviewer, and the Affidavit itself — and only then calls ReviewGate.FileReviewAsync. Without this registration, ReviewGateFilter logs a debug message and skips the write silently, so this step is required, not optional:

using System.Text.Json;
using Affiant.Abstractions.Interfaces;
using Affiant.Abstractions.Models;
public sealed class HttpReviewContextProvider(IHttpContextAccessor httpContextAccessor)
: IReviewContextProvider
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public ReviewContext? BuildReviewContext(WriteProposal proposal)
{
var http = httpContextAccessor.HttpContext;
if (http?.User.Identity?.IsAuthenticated != true)
return null;
// WriteProposal.Envelope is declared `object`; by the time ReviewGateFilter has
// deserialized the tool's JSON result it's a JsonElement, not Affidavit — deserialize
// it explicitly, with the camelCase policy ToJsonString() serialized it with.
if (proposal.Envelope is not JsonElement envelopeJson)
return null;
var affidavit = envelopeJson.Deserialize<Affidavit>(JsonOptions);
if (affidavit is null)
return null;
var userId = http.User.FindFirst("sub")?.Value ?? "unknown";
return new ReviewContext(
SessionId: http.Request.Headers["X-Session-Id"].ToString(),
TenantId: "default",
UserId: userId,
ReviewerUserId: userId,
Affidavit: affidavit);
}
}

Register it with builder.Services.AddSingleton<IReviewContextProvider, HttpReviewContextProvider>();.

With everything above registered, a chat turn that invokes request_leave flows like this: Semantic Kernel auto-invokes RequestLeaveAsync, which returns a WriteProposal as JSON. ReviewGateFilter deserializes it, calls HttpReviewContextProvider.BuildReviewContext, and passes the resulting ReviewContext to ReviewGate.FileReviewAsync. That method files a DocketEntry on the IDocketStore, evaluates the approval policy (falls through to ReviewerConfirmation, per step 1), and broadcasts an EvidenceCardRequest — carrying the entry’s DocketId and the Affidavit — to the session’s SignalR group, then blocks awaiting a reviewer’s response.

Your UI renders the Evidence Card from that payload and, when a human decides, calls back into ChatHub.ApproveEntry or ChatHub.RejectEntry with the same GUID (DocketId on EvidenceCardRequest, EntryId on ReviewGate/DocketEntry). ReviewGate.HandleDecisionAsync delivers the decision to the waiting FileReviewAsync call, which unblocks and returns a ReviewOutcome.

See Docket & Evidence Cards for the full DocketEntry lifecycle and Transport & Wire Contract for the wire shape of EvidenceCardRequest/EvidenceCardResponse.

ChatHub.ApproveEntry (step 2) is where the write actually happens: it reads the approved DocketEntry back and calls IWriteExecutor.ExecuteAsync with its Affidavit and any reviewer amendments. Here is a minimal executor for LeaveRequest:

using System.Globalization;
using Affiant.Abstractions.Interfaces;
using Affiant.Abstractions.Models;
using Microsoft.EntityFrameworkCore;
public sealed class LeaveWriteExecutor(HrDbContext db) : IWriteExecutor
{
public async Task<string?> ExecuteAsync(
Affidavit affidavit, Dictionary<string, object>? amendments, CancellationToken ct)
{
if (affidavit.EntityType != "LeaveRequest")
throw new NotImplementedException($"No executor for entity type '{affidavit.EntityType}'");
string Field(string name) =>
amendments?.TryGetValue(name, out var amended) == true
? amended.ToString()!
: affidavit.Fields.Single(f => f.Name == name).Value!.ToString()!;
var leaveRequest = new LeaveRequest
{
StartDate = DateOnly.Parse(Field("StartDate"), CultureInfo.InvariantCulture),
EndDate = DateOnly.Parse(Field("EndDate"), CultureInfo.InvariantCulture),
LeaveType = Field("LeaveType"),
Reason = Field("Reason"),
};
// SaveChanges happens ONLY here — never in the plugin, never in the field mapper.
db.LeaveRequests.Add(leaveRequest);
await db.SaveChangesAsync(ct);
return leaveRequest.Id.ToString();
}
}

HrDbContext here is your own domain DbContext — separate from Affiant’s AffiantDbContext, which persists chat sessions and (if you switch the Docket to a database-backed store) review entries. Register both:

builder.Services.AddDbContext<HrDbContext>(o => o.UseSqlite("Data Source=hr-quickstart.db"));
builder.Services.AddScoped<IWriteExecutor, LeaveWriteExecutor>();

The field-by-field extraction above works because this walkthrough’s Docket is UseInMemory() (step 2): affidavit.Fields[i].Value is still the literal string the plugin set, not a JsonElement. For more than one write-tool entity type, or routing multiple IFieldMapper<T> implementations into one executor, see Authoring Write Tools.

Every builder.Services call from steps 1–8 must run before builder.Build(). Apply the schema and map the hub after that, then run:

using Affiant.EntityFramework;
using Affiant.EntityFramework.Migrations;
using Affiant.Transport.SignalR.Extensions;
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AffiantDbContext>();
await db.MigrateAffiantSchemaAsync(app.Logger);
}
app.MapAffiantSignalR<ChatHub>();
app.Run();

On SQLite, MigrateAffiantSchemaAsync calls EnsureCreatedAsync (no migration history — fine for local development); on Postgres it runs the packaged EF migrations.

Affidavits & Provenance and Tool Envelopes cover the full type shapes this page only used. Review Gate & Write Executors and Docket & Evidence Cards go deeper on the state machine and DocketEntry lifecycle. Authoring Write Tools covers IFieldMapper<T>, multiple entity types, and error handling; The Compliance Harness covers proving a write strategy’s provenance is substantive, not just correctly shaped. Packages has the full dependency graph, including Affiant.Policies.