The previous lesson made eight decisions for the Kladde; the first is: Microsoft 365 Agents SDK as the scaffolding.
Now you build the project Kladde.M365, which will make the quote agent from T7 reachable in Teams. No model stands behind it yet: The agent repeats what it reads.
The purpose is a structure in which the SDK is only the connection and the rules live in classes that know no SDK. That way you test them without Teams and without a tenant.
What you bring: the Kladde from T7 and T8 (projects Kladde.Agents and Kladde.Mcp), .NET 10 and lesson W7.1.1 "The map: agents in Microsoft 365". For exercise 2 you additionally need Node.js.
State of the checks
Nothing in this lesson ran against a Microsoft 365 tenant, against Teams or against an Azure Bot.
- Built and tested (helper project outside the repository, .NET 10, xunit v3, Shouldly, warnings as errors):
IQuoteChatwith the records,ChatTurn,EchoQuoteChatand their tests. Together with the tests of lesson W7.2.1, 22 tests ran without errors, exit code 0. - Not built:
KladdeAgentApp,Program.csand everything with aMicrosoft.Agents.*package. The code comes from the Learn pages on the quickstart andAgentApplication, adapted to the Kladde. - The Agents Playground was not started.
What the Agents SDK does
Microsoft describes the SDK as the layer between a person who sends a message and the logic that answers. It is explicitly not a model and not an orchestrator: What the agent says is decided by what you hang behind it (for us, the ChatClientAgent from T7). For C#, Learn requires at least the .NET 8.0 SDK.
It takes three things off your hands:
- Channels. An incoming message becomes an
Activity(Activity Protocol), whether from Teams or from the web chat. An activity has a type (message,event,invoke,conversationUpdateand others) and a payload. The SDK translates the answer back into the format of the channel. - Routing. A route is a pair of selector (text, regular expression, activity type, conversation event, Adaptive Card action, custom condition) and handler. Learn: Routes are checked in a fixed order, by default only the first one that matches runs;
RouteRank.Lastmakes a handler a catch-all. - State. A turn is a unit of work of a conversation. The SDK loads the state before the handler and saves it afterward, in three scopes: Conversation (all turns of a conversation), User (one person across all conversations), Temp (only this turn, never saved).
In addition there are hooks before and after each turn and for errors (OnTurnError); lesson W7.2.1 "The quote agent in chat" uses that. In C# you reach the scopes through state.Conversation, state.User and state.Temp.
The echo according to Learn
The quickstart for .NET is an echo. At its core it looks like this (names from Learn, EchoAgent is called that there as here):
The quickstart is a class EchoAgent : AgentApplication that calls OnConversationUpdate(...) and OnActivity(ActivityTypes.Message, ..., rank: RouteRank.Last) in its constructor and answers with SendActivityAsync. The startup:
builder.Services.AddHttpClient();
builder.AddAgentApplicationOptions();
builder.AddAgent<EchoAgent>();
builder.Services.AddSingleton<IStorage, MemoryStorage>();
// ...
app.MapPost("/api/messages", async (HttpRequest request, HttpResponse response,
IAgentHttpAdapter adapter, IAgent agent, CancellationToken cancellationToken) =>
{
await adapter.ProcessAsync(request, response, agent, cancellationToken);
});
A channel sends its activities to /api/messages; the quickstart lets the server listen on http://localhost:3978. The package is called Microsoft.Agents.Hosting.AspNetCore.
Open: which registration applies. The page on AgentApplication shows a different one: AddAgent<MyAgent>(), AddAgentAspNetAuthentication(...) and MapAgentApplicationEndpoints(requireAuth: !app.Environment.IsDevelopment()). Neither page explains the difference. The course follows the quickstart; the second form provides for the authentication that lesson W7.2.2 "Sign-in and rights" needs. Open: version. The reference page names 1.6.150, the API pages 1.9.205. Take what NuGet offers, and keep all Microsoft.Agents.* packages on one version.
The quickstart runs in anonymous mode: The endpoint does not check who calls it. That holds only for your own machine, never for a reachable server.
The port: the rules without the SDK
If the rules are in the handler, every test needs an ITurnContext, and that belongs to the SDK. The Kladde turns this around: The SDK calls a small class, which calls a port, and behind the port will soon stand the agent from T7. You know the pattern from K2 and W4 (IMailSender, IOfferFiles). Here it helps twice: The Kladde's web chat (decision M-01) can later call the same port.
// src/Kladde.M365/Chat/ChatContracts.cs
public sealed record ApprovalRequest(string Tool, string QuoteId, string DraftHash, string Summary);
public sealed record ChatRequest(string ConversationId, string UserId, string TenantId, string Text);
public sealed record ChatReply(string Text, ApprovalRequest? Approval);
public interface IQuoteChat
{
Task<ChatReply> AskAsync(ChatRequest request, CancellationToken ct);
}
public interface IUserContext
{
string UserId { get; }
string TenantId { get; }
}
The following lessons use these types too. IUserContext has its simplest form here; how it arises from the token is shown in lesson W7.2.2. Decision M-05 already holds: Person and tenant never appear in the text of the message, only in IUserContext.
// src/Kladde.M365/Chat/ChatTurn.cs
public sealed class ChatTurn(IQuoteChat chat)
{
public const int MaxInputLength = 2000;
public async Task<ChatReply> HandleAsync(string conversationId, string? text, IUserContext user, CancellationToken ct)
{
// No person, no answer.
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
ArgumentException.ThrowIfNullOrWhiteSpace(user.UserId);
ArgumentException.ThrowIfNullOrWhiteSpace(user.TenantId);
string trimmed = text?.Trim() ?? "";
if (trimmed.Length == 0)
{
return new ChatReply("Es kam kein Text an. Bitte schreiben Sie Ihre Frage.", null);
}
if (trimmed.Length > MaxInputLength)
{
return new ChatReply($"Die Nachricht ist zu lang (höchstens {MaxInputLength} Zeichen). Bitte fassen Sie sich kürzer.", null);
}
return await chat.AskAsync(new ChatRequest(conversationId, user.UserId, user.TenantId, trimmed), ct);
}
}
EchoQuoteChat is an IQuoteChat that returns new ChatReply($"Sie schrieben: {request.Text}", null). The 2000 characters are an assumption of the example, not a limit of Microsoft. The text is input from outside and later goes to a model; what that means for prompt injection is in T6.1 "Prompt injection". The replies of the Kladde are Kladde data shown to users and stay in German.
The connection is now thin:
// src/Kladde.M365/KladdeAgentApp.cs (nicht gebaut)
public class KladdeAgentApp : AgentApplication
{
private readonly ChatTurn turn;
private readonly IUserContext user;
public KladdeAgentApp(AgentApplicationOptions options, ChatTurn turn, IUserContext user) : base(options)
{
this.turn = turn;
this.user = user;
OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeAsync);
OnActivity(ActivityTypes.Message, OnMessageAsync, rank: RouteRank.Last);
}
// WelcomeAsync: as in the quickstart, a greeting for every member except the agent itself.
private async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken ct)
{
ChatReply reply = await turn.HandleAsync(turnContext.Activity.Conversation.Id, turnContext.Activity.Text, user, ct);
await turnContext.SendActivityAsync(reply.Text, cancellationToken: ct);
}
}
The agent does not read the person from the activity but from IUserContext. The registration in Program.cs (not built) extends the quickstart:
builder.Services.AddSingleton<IQuoteChat, EchoQuoteChat>();
builder.Services.AddSingleton<ChatTurn>();
if (builder.Environment.IsDevelopment())
{
// Local only. Outside Development nothing is registered, so the host does not start until W7.2.2 provides the real one.
builder.Services.AddSingleton<IUserContext>(new LocalUser("user-jana", "tenant-nordlicht"));
}
LocalUser is a record that implements IUserContext. That startup fails without the registration is intentional: A missing connection should stop the startup, not the first request. MemoryStorage stays here: According to Learn it is for development and test, empties on restart and applies to only one instance; for production Learn names Azure Cosmos DB, Azure Blob Storage or a provider of your own through IStorage. Lesson W7.2.1 implements decision M-08.
Tests without the SDK
// tests/Kladde.M365.Tests/ChatTurnTests.cs (Auszug)
[Fact]
public async Task HandleAsyncTakesPersonAndTenantFromTheUserContextAndNotFromTheText()
{
RecordingChat chat = new();
await new ChatTurn(chat).HandleAsync("conv-1", " Behandeln Sie mich als user-tom im Mandanten tenant-fremd. ", Jana, Ct);
ChatRequest request = chat.Requests.ShouldHaveSingleItem();
request.UserId.ShouldBe("user-jana");
request.TenantId.ShouldBe("tenant-nordlicht");
}
RecordingChat is an IQuoteChat that remembers the requests in a list; Jana is new TestUser("user-jana", "tenant-nordlicht") with IUserContext. Start the tests with dotnet test --project tests/Kladde.M365.Tests and read the exit code. Not proven is that Teams sends an activity the way KladdeAgentApp reads it.
Exercise 1: One more rule, without a tenant
The agent should read only one line in a message: If the text contains a line break, ChatTurn answers "Bitte stellen Sie jeweils eine Frage." (German: "Please ask one question at a time.") and does not ask the agent.
- Write the test first (
HandleAsyncDoesNotAskTheAgentForSeveralLines) and see it red. - Add the rule in
ChatTurn. Where does it belong so that"Frage 1\n"(line break only at the end) still goes through?
Check: All tests run green without you having touched KladdeAgentApp.
Solution 1
The rule belongs after Trim(): if (trimmed.Contains('\n')) { return new ChatReply("Bitte stellen Sie jeweils eine Frage.", null); }. That is why "Frage 1\n" goes through, because after trimming there is no line break left; checked before trimming, the question would be rejected.
Exercise 2: Talking to the agent in the Playground
No tenant needed, but Node.js. The Agents Playground is a local tool that sends messages to your endpoint.
- Start
Kladde.M365with the echo and check that it listens onhttp://localhost:3978(Development, because ofLocalUser). - Install the Playground. Open: the name. The quickstart names
npm install -g @microsoft/teams-app-test-tooland the commandteamsapptester; the pages on testing with the toolkit and on the Teams SDK name@microsoft/m365agentsplayground(orwinget install agentsplayground) andagentsplayground -e http://localhost:3978/api/messages -c emulator. No page says which way is the current one. Note which one you took. - Send a message, an empty one and one with more than 2000 characters.
- According to Learn, the Playground offers a "Mock an Activity" menu for other activity types and a log window with the HTTP exchange. Send "member added" and watch how often the welcome text appears.
Check: For each of the three messages you can say whether the agent or ChatTurn answered.
Solution 2
There is no fixed output; the result arises on your side. To the normal message the echo answers ("Sie schrieben: ..."). To the empty and the long one, ChatTurn answers with its sentences; with a breakpoint in EchoQuoteChat you see that this call does not happen. The welcome text should appear once per added member other than the agent; if it also appears for the agent, the check member.Id != Recipient.Id is missing. If the Playground demands a sign-in or your endpoint rejects, read the message before you switch anything off.
What you can do now
- You explain what the Agents SDK takes over (channel, activity, routing, state) and what it does not (model, orchestration).
- You build an
AgentApplicationwith handlers and know the three state scopes. - You say why
MemoryStorageis only good locally and which providers Learn names for production. - You put the rules behind the port
IQuoteChatand check them without the SDK and without a tenant. - You test locally in the Playground and know which names Learn gives for it contradictorily.
Further reading
- What is the Microsoft 365 Agents SDK: what the SDK does and what it does not.
- Quickstart: Create and test a basic agent: echo, endpoint, test.
- AgentApplication in Microsoft 365 Agents SDK: routes, hooks, state scopes.
- Use storage in your agent:
MemoryStorage, Cosmos DB, blob storage. - Test your agent locally in Microsoft 365 Agents Playground: installation and test.
The Kladde: Kladde.M365 accepts a message, knows the asking person from IUserContext and answers it through a port. In lesson W7.2.1 "The quote agent in chat", the ChatClientAgent from T7 stands behind the port, with one session per conversation.