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.
1. Register the framework
Section titled “1. Register the framework”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 IDocketStore — InMemoryDocketStore — 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.
3. Declare the field schema
Section titled “3. Declare the field schema”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), };}4. Write the tool
Section titled “4. Write the tool”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.
5. Register the tool and the plugin
Section titled “5. Register the tool and the plugin”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.
6. Provide review context
Section titled “6. Provide review context”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>();.
7. What happens when the tool runs
Section titled “7. What happens when the tool runs”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.
8. Execute the write
Section titled “8. Execute the write”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.
9. Build and run
Section titled “9. Build and run”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.
Where to go next
Section titled “Where to go next”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.