Visitar URL original
Inside-Azure-SQL-Hyperscale/examples/ch06-agentic-ai/README.md at main · Apress/Inside-Azure-SQL-Hyperscale · GitHub
Skip to content

Latest commit

 

History

History
962 lines (753 loc) · 46 KB

File metadata and controls

962 lines (753 loc) · 46 KB

Chapter 6 — Build the agentic Ward General workflow

Figures: this chapter's book figures are in figures/ch06-agentic-ai/, listed with their captions in the figures index.

Chapter 6 evolves the Ward General application from Chapter 4. It does not create a second application.

Where the application source lives

The cumulative Ward General solution remains in examples/ch04-first-app. Chapter 6 adds the WardGeneral.Agent project and its shift-handoff skill to that shared source tree. Keeping one application avoids copying the Chapter 4 web and data projects into each later chapter and lets the AiAgent feature flag expose the Chapter 6 behavior without creating a separate build.

This examples/ch06-agentic-ai folder owns the Chapter 6-specific SQL, DAB/MCP configuration, deployment and run scripts, and reader documentation. In short: application source is under ch04-first-app; Chapter 6 contracts and orchestration are here.

The completed workflow is a shift handoff, in two turns:

Show me the current patients that may need a shift handoff.

The agent returns at most three patients, each with the evidence behind it and a link to that patient's chart. Then:

Prepare patient X for shift handoff.

The agent reads that patient's chart, composes a structured SBAR handoff from it, shows it, and proposes to save it. The framework holds that write and the clinician approves or declines. Approving writes exactly one row to clinical.AgentRecommendation.

Two turns, because that is the shape of the work: a roster question and a document-preparation question are different tasks, and an agent that answers both at once has decided something the clinician did not ask it to decide.

The dock carries you between them. After a roster reply the suggestion chips become one per patient the reply named, so the second turn is a click rather than retyping an encounter id. Those chips are built from the ids the application parsed out of the reply, the same ids behind the chart links — the model does not get to author the button that calls it back. Once a handoff is saved the chips return to the roster question instead of offering the same patient again.

All patient data in this example is synthetic.

Why this is an agent and not a chatbot

The second turn is the one that earns the word. The agent has to carry the encounter id from its own previous reply, choose which tools to call and in what order, compose four prose sections from what came back, and then stop short of the write and hand the decision to a human. Nothing in that sequence is scripted by the application. The model chooses it, turn by turn, from a typed toolbox — including choosing what not to reach for.

The first turn earns it differently. get_handoff_candidates returns evidence, not a ranking — latest vitals against the earlier reading in the same window, medications started this shift, pending labs and how long they have been outstanding. Deciding which of those signals matters, for these particular patients, on this particular evening, is the judgment call. The database does the set-based work; the model does the reasoning; neither does the other's job.

What the reader can see

The assistant dock has a collapsible What the agent did panel under the transcript. It lists every tool the model called, the arguments it chose, elapsed milliseconds, and result size — failures included. This is the chapter's observability answer: the reader watches the control loop rather than taking the answer on faith. A production clinical app would not show this to a nurse, and that is fine; it exists so the mechanism is inspectable while it is being taught.

For the message-level view behind that trace, see payload.md. It follows the same handoff conversation through the system and user messages, advertised tool definitions, load_skill result, MCP tools/call requests, returned SQL evidence, and approval response. Its JSON is a normalized teaching representation rather than a captured provider-specific HTTP trace.

For a cross-chapter map from every database object through Chapter 6 to the application, SQL, or MCP surface that uses it, see OBJECT-CATALOG.md. It also calls out objects that are deliberately present without an application caller.

What exists now

  • the application-hosted Microsoft Agent Framework client, connecting to one MCP endpoint and marking save_handoff approval-required;
  • the runtime MAF Agent Skill for the handoff workflow (skills/shift-handoff/SKILL.md);
  • the tool-call trace panel in the assistant dock (AgentTrace.cs, TracedAIFunction.cs);
  • chart links in the dock, built by the application from the encounter ids the reply cited — never from a URL the model wrote;
  • follow-up suggestion chips built from those same parsed ids, so a roster reply offers the second turn for each patient it named (AssistantDock.razor);
  • clinical.GetHandoffCandidates, the one set-based query behind the roster (12-handoff-candidates.sql);
  • clinical.AgentRecommendation and clinical.SaveShiftHandoff, with request-key uniqueness (11-handoff-contract.sql);
  • the approval gate and its UI round trip;
  • the AiAgent feature flag.

What Chapter 6 deliberately does not build

There is no ordering system, and no ordering surface in the application. The assistant prepares a handoff and stops. If imaging looks warranted, that belongs in the handoff's Recommendation section, and the clinician acts from the chart where Chapter 4 already put them. There is no orders page, no worklist, no status transitions, no cancellation, no radiology results.

The database plumbing for orders is real, and it is deliberately unreachable. 14-imaging-order-contract.sql deploys a complete write path — an idempotency key, a filtered unique index, and clinical.PlaceImagingOrder. Nothing calls it. There is no DAB entity, no MCP tool, and no application code that reaches it, so the agent cannot invoke it no matter what it is asked. Run every demo in this chapter and that procedure executes zero times.

That gap is the chapter's point. A capability existing in the database and an agent being permitted to invoke it are two separate decisions, made in two separate places. The tool inventory is the hard boundary: save_handoff is on the other side of it and place_order is not. Approval then governs the write that is offered.

The two writes are also different in kind, which is why only one is exposed. save_handoff records a document about care. place_order would issue an instruction to deliver care. Same approval mechanism, categorically different consequence.

What Chapter 6 changes in the Chapter 4 application

Chapter 6 is an evolution of one cumulative app, so it is worth being precise about what it touches. Every change is additive and inert while AiAgent is off.

A new project, src/WardGeneral.Agent/ — the whole agent lives here, so Chapter 4's web and data projects are almost untouched:

file what it is
WardGeneralAgent.cs the MAF client: connects to MCP, wraps tools, runs the turn, handles the approval round trip
AgentOptions.cs endpoint, deployment, MCP url, skill path, which tools require approval, and the always-on instructions
AgentTrace.cs the per-conversation record of tool calls behind the What the agent did panel
TracedAIFunction.cs the DelegatingAIFunction wrapper that captures each call
skills/shift-handoff/SKILL.md the runtime Agent Skill — the workflow the model loads on demand

In src/WardGeneral.Web/ — three small hooks and one new component:

  • Program.cs binds AgentOptions and registers WardGeneralAgent as scoped — one per user's Blazor session;
  • Options/FeatureFlags.cs gains AiAgent;
  • appsettings.json gains the Agent section, shipped with an empty endpoint;
  • Components/Layout/AssistantDock.razor is the dock itself — transcript, approval card, and trace panel.

Nothing is eagerly constructed. WardGeneralAgent is a lazily resolved scoped service, created only by the dock, and the dock renders only under @if (Features.Value.AiAgent). With the flag off, no agent is built, no MCP connection is opened, and no skill is loaded — which is what lets you drop back to Chapter 4 without deleting code.

In src/WardGeneral.Data/ — nothing. ChartRepository, AccessContext, RlsOptions, and the connection factory are exactly as Chapter 4 and Chapter 5 left them. The agent does not go through the data-access layer; it goes through MCP.

Components/Pages/Home.razor — nothing. This is deliberate, and the next section explains why.

Identity: why the system signs the handoff

Chapter 6 has no authentication. Anyone who can reach the application can use it, and every patient is reachable, because the row filtering that changes it is Chapter 8's. That is the honest state of the app at this point in the book.

But clinical.AgentRecommendation.ProposedById is NOT NULL with a foreign key to ops.Provider, because a clinical handoff with no author is not a handoff. So the write needs an identity even though no human has supplied one.

Chapter 6 resolves that with a system provider row, seeded idempotently by 11-handoff-contract.sql:

INSERT INTO ops.Provider (FullName, DepartmentId, Role, EntraObjectId)
SELECT N'Ward General Assistant (system)', MIN(d.DepartmentId), N'System', NULL
FROM ops.Department AS d;

clinical.SaveShiftHandoff resolves that row internally. Provider identity is not an MCP argument, does not appear in the model prompt, and is not accepted from the application. What lands in the table is true: the Chapter 6 system proposed the handoff during an unauthenticated session. Naming a real clinician would forge authorship, and a nullable column would let the record claim nothing at all.

The row is seeded by the Chapter 6 script rather than Chapter 2's, so Chapter 2's seed and its published row counts are unchanged. It is never the attending provider on an encounter, so Chapter 8's predicate — which compares clinical.Encounter.AttendingProviderId with the ProviderId in SESSION_CONTEXT — has no rows to grant it, and the "Viewing as" list — which loads attendings with active patients — never shows it.

The Chapter 8 boundary

Chapter 6 does not preview authentication by passing an untrusted provider id through the model. It runs DAB under one application identity and allows every synthetic patient to be read. Approval controls whether a proposed clinical write executes; it does not authenticate a clinician or authorize a row.

Chapter 8 owns the replacement architecture. Its example builds DAB's session-context integration and Row-Level Security, and simulates the authenticated provider with the "Viewing as" selector. Entra authentication and a trusted identity-to-provider mapping are discussed in the chapter as the production design, not built in the example. That is more than changing one argument, and Chapter 6 does not claim otherwise.

What this means when you run it

You do not need to pick a provider, and you do not need Chapter 8's RowLevelSecurity flag to save a handoff. Run with AiAgent alone and the write works. The "Viewing as" selector stays exactly where Chapter 8 wants it — Home.razor renders it only under RowLevelSecurity, so a Chapter 4 or Chapter 6 reader never sees a control that filters nothing.

Turning on Chapter 8's RowLevelSecurity flag is outside this workflow. Chapter 6 neither requires nor simulates a clinician selection.

That is also why the approval card reads "Attributed to nobody — signed in as Admin" (as in the book's Figure 6-9): there is no acting clinician yet. It is not an error. When you approve, clinical.SaveShiftHandoff records the handoff under the Ward General Assistant (system) provider.

One cumulative application

Application source remains here:

examples/ch04-first-app/

Chapter 6 assets remain here:

examples/ch06-agentic-ai/

The Chapter 6 folder owns SQL procedures, DAB configuration, probes, and orchestration. It must not contain a forked copy of the Ward General solution.

Prerequisites

Complete Chapters 2 through 5 first. In particular, the reader needs:

  • the wardgeneral Hyperscale database and Chapter 2 schema and data;
  • the Chapter 4 application configured through its existing environment file;
  • the Chapter 5 AI resources and database objects, including the note embeddings and the vector index;
  • the gpt-5 chat deployment Chapter 5 already created on that Foundry resource — the agent reuses it; there is nothing new to deploy;
  • Cognitive Services OpenAI User for your identity on that Foundry resource, because the agent calls the model as you — grant-model-access.ps1 checks and assigns it (see Configure);
  • .NET 10 and the DAB CLI, version 2.0.0 or later.
  • go-sqlcmd (sqlcmd v1.x) if you want setup-dab.ps1 to deploy the SQL for you. Running the scripts yourself in the MSSQL extension or SSMS is equally fine — every file carries its own SET-options preamble.

What Chapter 6 calls but does not deploy

Chapters 4 and 5 are prerequisites, not suggestions, and one tool depends on them directly. search_clinical_context calls clinical.SearchSimilarNotes, which is Chapter 5's 09-research-vector-search.sql. Chapter 6 does not re-deploy it.

That is deliberate. The procedure is useless on its own: it embeds its query through the external model created by 06-ai-embeddings.sql and searches the vector index built by 07-vector-index.sql. Re-running 09 from here would refresh one link of a four-link chain and protect nothing — while quietly overwriting a Chapter 5 object from a Chapter 6 script.

If you skipped Chapter 5, the other six tools still work. search_clinical_context fails at call time with "Could not find stored procedure 'clinical.SearchSimilarNotes'", which names the real cause.

"Prerequisite" means state, not a step you repeat per machine. Everything Chapters 2, 4 and 5 build lives in two shared places — the wardgeneral database and your Azure resources — so it is already there when you sit down at a second computer, and none of it needs running again. What does not travel is purely local:

Travels (do once) Does not travel (per machine)
Schema, seed data, procedures, external model, embeddings, vector index ch04.env and appsettings.Development.json — both git-ignored
Foundry resource and model deployments The built application (build.ps1)
The trusted HTTPS dev certificate
A SQL firewall rule for that machine's egress IP

So on a fresh clone you recreate two config files, build, and go — you do not re-run Chapter 5's SQL. If you are unsure what is already deployed, ask the database rather than guessing:

SELECT CASE WHEN OBJECT_ID('clinical.SearchSimilarNotes','P') IS NULL
            THEN 'MISSING - run Chapter 5' ELSE 'present' END;

go-sqlcmd, not the ODBC sqlcmd

setup-dab.ps1 deploys three SQL scripts with sqlcmd -G. That flag means two different things depending on which sqlcmd you have, which is why the script checks rather than trusting the name:

Build What bare -G does
go-sqlcmd (v1.x) Uses DefaultAzureCredential — the same passwordless identity the app and DAB already use
ODBC sqlcmd Attempts Entra interactive auth, and needs ODBC 17.2 or later

Both install as sqlcmd, and on Windows the ODBC build lands on the machine PATH, which is searched before the user PATH. So the newer tool can be installed and still lose. setup-dab.ps1 resolves in this order, and skips any candidate whose --version fails — a check that succeeds only on go-sqlcmd:

Order Location
1 -SqlCmd <path> on the command line
2 SQLCMD environment variable
3 %LOCALAPPDATA%\Programs\sqlcmd\sqlcmd.exe
4 C:\Program Files\sqlcmd\sqlcmd.exe
5 sqlcmd on PATH

If none resolve, setup-dab.ps1 fails — it does not skip the deploy. A skipped deploy is the worst outcome available here: setup reports success, none of the Chapter 6 contracts exist, and the fault resurfaces later as an agent whose tools are missing, which looks like a model problem rather than a setup one. To run the scripts yourself instead, say so with -SkipSql.

winget install --id Microsoft.Sqlcmd

# no admin? unzip a release and point at it
[Environment]::SetEnvironmentVariable('SQLCMD', "$env:LOCALAPPDATA\Programs\sqlcmd\sqlcmd.exe", 'User')

The DAB version is not a detail

runtime.mcp, and the dml-tools switch inside it, do not exist before DAB 2.0 — and dml-tools: false is the single line that bounds the agent's tool surface to the seven entities in this configuration. On a 1.x CLI the config still passes schema validation, then the engine refuses to start with:

Audience and Issuer must be set when using a JWT identity Provider.

against a configuration whose authentication provider plainly reads Unauthenticated. The message points nowhere near the real cause, so both setup-dab.ps1 and run-dab.ps1 check the version and say so directly. To fix it by hand:

dotnet tool update --global Microsoft.DataApiBuilder
dab --version    # expect 2.0.0 or later

The wardgeneral-research named replica is a Chapter 5 prerequisite, not a Chapter 6 one. Chapter 6 does not connect to it.

run-dab.ps1 and setup-dab.ps1 read WardGeneral__Server from examples/ch04-first-app/ch04.env and accept -Server. Neither has a built-in server name; both stop rather than guess one.

Configure — two files, and neither one travels

Chapter 6 needs one setting Chapters 4 and 5 never did: an endpoint for the chat model the agent reasons with. Chapter 5 called its model from inside the engine, so the application never needed to know where it lived. The Chapter 6 agent runs in the application, which means the application has to be told.

Both configuration files are git-ignored. That is deliberate — your server name never reaches a commit, and a git pull can never collide with your edits — but it has a consequence worth knowing before it surprises you: a fresh clone, or a second machine, has neither file. Nothing is broken. Create them again.

1. The database, in examples/ch04-first-app/ch04.env. Running ./examples/ch04-first-app/load-env.ps1 creates this from the template and stops so you can fill it in:

WardGeneral__Server=<your-sql-server>.database.windows.net
WardGeneral__Database=wardgeneral

This one wants the FQDN, because it goes into a connection string rather than an az command. A value still in <angle brackets> counts as unfilled and the scripts stop rather than run against the wrong server.

2. The model, in examples/ch04-first-app/src/WardGeneral.Web/appsettings.Development.json. appsettings.json ships Agent:Endpoint empty on purpose — a default resource name is always somebody else's environment. Create the Development file alongside it:

{
  "Agent": {
    "Endpoint": "https://<your-foundry>.openai.azure.com/",
    "Deployment": "gpt-5",
    "McpUrl": "http://localhost:5000/mcp"
  }
}

Use the same Foundry resource Chapter 5 deployed, and a reasoning chat deployment on it — the handoff asks the model to weigh several signals against each other, which is not a completion task. McpUrl already points at 5000 in appsettings.json; repeat it here only if you moved the port.

There is no key in either file. The application authenticates to both SQL and the model endpoint as you, through DefaultAzureCredential, so your identity needs a Foundry data-plane role such as Cognitive Services OpenAI User on that resource. Owner and Contributor don't include one, and Chapter 5 granted it only to the SQL server's and gateway's managed identities — so most readers need to add it. Check, then grant:

./examples/ch06-agentic-ai/grant-model-access.ps1          # read-only report
./examples/ch06-agentic-ai/grant-model-access.ps1 -Apply   # assign it to you

-Apply assigns the role to the signed-in user on that one Foundry account. It needs Owner, User Access Administrator, or Role Based Access Control Administrator — the same rights Chapter 5's deploy needed — and can take a few minutes to take effect. An endpoint that resolves but returns 401 is nearly always this missing role rather than a wrong URL.

If you would rather not create the Development file, Agent__Endpoint as an environment variable works too — environment variables outrank both JSON files.

Target architecture

One DAB configuration carries exactly one connection string, so one DAB process reaches one database. Chapter 6 uses one, over the primary.

Ward General application-hosted MAF agent
  |
  +-- MCP endpoint  (localhost:5000, connection: wardgeneral)
        reads   get_patient_chart, get_patient_chart_by_bed,
                search_encounters, get_handoff_candidates,
                search_clinical_context, clinical_assistance
        writes  save_handoff      [approval-gated]

The Chapter 5 Research page is a separate path entirely. It opens its own ADO.NET connection to the wardgeneral-research named replica through WardGeneralConnectionFactory.CreateResearch(), tagged ApplicationName=wardgeneral-research so you can see it in sys.dm_exec_sessions. Chapter 6 changed nothing about it.

The runtime Agent Skill tells the model how to perform the handoff workflow. It does not enforce authorization, approval, or idempotency — those live in the application, the framework, and the database.

Supplement: why Ward General has both a data layer and DAB

This section is a repository supplement, not text from the book. Data API builder facts come from What is Data API builder? (accessed 2026-10-20).

Ward General reaches the same database through two paths:

Path Code What it calls
Blazor UI Chapter 4's WardGeneral.Data (ADO.NET, Microsoft.Data.SqlClient) Stored procedures such as clinical.GetPatientChart, clinical.SearchEncounters, and clinical.GenerateClinicalAssistance, plus read-only queries over views such as ops.vBedCensus
Chapter 6 agent DAB, through dab-config.primary.json Seven stored procedures published as custom MCP tools. dml-tools is off, so the model gets no generic table read/write tools.

Five of the agent's seven tools call procedures the UI also calls, so those business rules live once, in SQL.

Why not use DAB for everything? You could. DAB is a configuration-based engine that, per Learn, "replaces most custom data APIs that perform generic CRUD" operations. From one JSON file it serves REST and GraphQL (and MCP from version 1.7) over tables, views, and stored procedures, with paging, filtering, and sorting. It supports Microsoft Entra ID, JWT, and App Service authentication, and it passes claims to the SQL session context, which is exactly how Chapter 8's secured DAB path feeds Row-Level Security.

A hand-built data layer still makes sense when the application talks to SQL directly rather than through an HTTP API, and you want that connection code in your own process. Ward General keeps WardGeneral.Data for that reason, and later chapters build on it:

  • Chapter 8 sets SESSION_CONTEXT on each connection the UI opens.
  • Chapter 9's failover probe reuses its Microsoft.Data.SqlClient retry policy.

A reasonable rule of thumb: use DAB when the consumer is another service, an agent, or a front end that wants REST, GraphQL, or MCP. Keep your own data access when the app needs direct control over its SQL connections.

The ordering plumbing — what exposing it would take

This section is a thought experiment. Nothing in it is built, and none of it is required to run Chapter 6. The point is to show how short the distance is between a procedure sitting unused in the database and an agent that can act on the world — and how deliberate each step across that distance has to be.

clinical.PlaceImagingOrder is already deployed by 14-imaging-order-contract.sql. Three things stand between it and the model.

1. A DAB entity. The MCP endpoint publishes what the configuration names and nothing else, so the procedure would need an eighth entry in dab-config.primary.json alongside the seven that exist:

"place_order": {
  "description": "Place an imaging order for the encounter under review. ...",
  "source": {
    "type": "stored-procedure",
    "object": "clinical.PlaceImagingOrder",
    "parameters": {
      "EncounterId": 0, "Modality": "", "BodySite": "",
      "OrderedById": 0, "RequestKey": ""
    }
  },
  "mcp": { "custom-tool": true },
  "permissions": [ { "role": "anonymous", "actions": [ "execute" ] } ]
}

The description is not documentation — the model routes on it. A vague one is a defect with no compiler to catch it.

2. Registration as an approval-required tool. Adding the entity alone would let the agent call it unattended. AgentOptions.ApprovalRequiredToolFragments is what makes Microsoft Agent Framework hold the call and surface the card, the same mechanism save_handoff already uses. This is the step whose omission is silent and expensive.

3. A runtime Agent Skill for ordering. The handoff skill would be the wrong home — a handoff describes a patient, an order changes their care, and merging the two procedures would blur exactly the line the chapter draws. A separate place-order skill would carry the clinical procedure: what evidence justifies imaging, which modality suits which question, what to do when the indication is ambiguous, and when to recommend in prose instead of ordering. It would load only when the conversation is actually about imaging.

What none of that gives you. Even with all three in place, an inserted row is a record of an intent. No X-ray happens. Nothing downstream is listening, there is no worklist, no scheduling, no radiology result coming back, and no status beyond Ordered. The database has recorded that someone approved a request — which is a genuinely useful thing, and also nowhere near a working ordering system.

Chapter 11 picks up that other end, and shows how new rows in clinical.ImagingOrder could be consumed with Azure SQL change event streaming to feed a hospital imaging system. That system is discussed there, not built either. The seam is the interesting part: the database contract here and the consumer there are designed independently, and neither one has to know how the other is implemented.

Evolution sequence

The Chapter 6 code already ships in examples/ch04-first-app/src/. Steps 2 and 5 describe how it was built — read them to understand the design; there is nothing to write. As a reader you build (step 1), deploy the SQL and start DAB (steps 3–4), build again (step 6), and run (step 7). The book's prompt "build the Chapter 6 agent" does exactly that.

1. Prove the shared-app baseline

From the repository root:

./examples/ch04-first-app/build.ps1

Capture a baseline failure before changing source. Do not attribute an existing build error to Chapter 6.

2. Add the runtime shift-handoff skill

Place the workflow in a file-based MAF Agent Skill and wire it through the current .NET AgentSkillsProvider context-provider API. Keep the core system instructions short and always active — the skill body is not in context until the model has already decided to load it, so anything that must be true on turn one belongs in the instructions instead.

The skill separates the two questions and tells the model to answer only one per reply. For the roster it requires, per patient, the field that drove the ranking with its value — "SpO2 91%, down from 96% earlier in the window", not "vitals are concerning". A ranking a clinician cannot audit is a ranking they cannot use.

Build immediately after this application change:

./examples/ch04-first-app/build.ps1

3. Add the SQL contracts

setup-dab.ps1 deploys these — it's what the skill runs for "build the Chapter 6 agent", and you can run it yourself (see Publish the MCP endpoint). To run the T-SQL yourself instead, use setup-dab.ps1 -SkipSql and run them in this order, so the deployed contracts match the DAB configuration.

script adds
11-handoff-contract.sql clinical.AgentRecommendation, the Ward General Assistant (system) provider row, clinical.SaveShiftHandoff; removes obsolete Chapter 6 wrapper procedures on upgrade
12-handoff-candidates.sql clinical.GetHandoffCandidates
14-imaging-order-contract.sql clinical.ImagingOrder.RequestKey, the filtered UQ_Imaging_RequestKey index, and clinical.PlaceImagingOrder — deployed but never called. No DAB entity, no MCP tool, no application caller. See The ordering plumbing below
13-refresh-shift-window.sql re-anchors the demo's shift window to now; adds genuinely outstanding labs; reshapes in-window vitals so they trend. Rerunnable and cheap on a rerun — every part is absolute rather than incremental, and the vitals rewrite touches only rows whose value would change

Run 14 before 13. The refresh script is last on purpose because it re-anchors the demo window to the moment it runs, so anything that writes clinical rows has to precede it.

The request key is what makes the handoff write retry-safe. Without database-enforced uniqueness, the workflow must not claim exactly-once behavior — an application check alone loses the race.

Validate directly that:

  • valid input inserts one row;
  • invalid input inserts zero rows;
  • retrying the same request key cannot insert a second row.

12-handoff-candidates.sql has a documented limitation worth knowing before you read its output: clinical.Diagnosis carries no timestamp, so "added recently" is approximated by identity order. The script says so rather than implying a precision it does not have.

13-refresh-shift-window.sql is the one script here that is not a contract, and it exists because of how the Chapter 2 seed works. The seed places each active admission a few hours before the moment it runs — correct on the day, stale a week later. clinical.GetHandoffCandidates looks back twelve hours, so on any day but seeding day it correctly returns an empty roster, and the chapter's first turn has nothing to reason about. The script shifts activity on active encounters forward by the measured gap, in one transaction, and leaves discharged encounters alone — those are the historical note corpus the Research page searches, and they are supposed to be old.

It also fixes a subtler gap. PendingLabCount counts labs in Ordered or Collected status, and every seeded lab is Resulted, so that column could never be anything but zero no matter what the date was. The script creates outstanding labs for roughly forty percent of active encounters, so the signal the tool advertises is a signal that exists.

The third part is about vitals, and it is the one worth understanding, because without it the chapter's first turn produces an answer that is well-formed and meaningless. Chapter 2 generates vitals as a counter: heart rate climbs 117, 118, 119, then wraps to 100 and climbs again. clinical.GetHandoffCandidates reports each vital as a pair — the latest reading and the earliest one still inside the twelve-hour window — so on that data "up from" is an artifact of where the window happens to start, and when the wrap falls inside the window the pair inverts: a patient whose numbers never changed appears to have dramatically improved. The agent is not wrong when it reports this. The tool is not wrong either, and neither is the SQL. The data is what is wrong, so the data is what the script fixes.

It rewrites the readings inside the window as a line from a per-encounter baseline, with a small jitter so no series is suspiciously smooth. Every encounter gets one of three courses: about twenty percent deteriorating, twenty percent improving, the rest stable. Values are absolute rather than incremental, so a rerun recomputes the same line instead of compounding drift.

Two details in that assignment are deliberate. The course is keyed off (EncounterId / 5) % 5 while the outstanding labs use EncounterId % 5, so the deteriorating patients are not the same patients who have a lab pending — two signals that always agree would teach you nothing about weighing evidence. And a fifth is a large share to move, but clinical.GetHandoffCandidates does not rank: it returns the ten most recently active encounters, not the ten sickest. Shape too few patients and the roster the model actually receives contains none of them.

The script's verification block prints one encounter per course with the earlier and latest values for heart rate, SpO2 and temperature — the same pair the tool reports — so a sign error shows up in the script's own output rather than inside a model's answer.

Rerun it whenever the roster comes back empty, or whenever the vitals stop trending. It is idempotent: it measures the gap each time, prints Window already fresh and changes nothing when there is nothing to do.

4. Publish the MCP endpoint

setup-dab.ps1 installs the DAB CLI, deploys the scripts above, and validates the config. Then:

./examples/ch06-agentic-ai/run-dab.ps1

It resolves the server from WardGeneral__Server or -Server, connects passwordless with Authentication=Active Directory Default, validates the config before starting, and sets the port through ASPNETCORE_URLS. Note that dab start has no --port switch — the port is an ASP.NET Core host setting.

Inspect the tool inventory before connecting the model:

./examples/ch06-agentic-ai/probe-mcp.ps1

The tool list is the agent's actual security surface: whatever is on it, the model may call; whatever is not on it cannot be reached no matter what the prompt says. Entities are named in literal snake_case so the emitted MCP tool name is exactly what the chapter and the skill call it, independent of DAB naming conventions. There is no generic SQL execution tool anywhere in the config.

The file declares exactly seven entities, and all seven become named MCP tools. Six are ungated operations and save_handoff is the one approval-gated write. Procedures the agent does not use are not declared.

Seven tools is the whole inventory, and it is seven only because dab-config.primary.json sets runtime.mcp.dml-tools to false. DAB publishes its seven generic tools on by default, and one of them, execute_entity, can call a stored procedure by name — including the procedure the application approval-gates. That is a path around the gate, so the chapter turns the generic set off and the seven named tools become the entire surface. If the probe ever reports more than seven, that is the setting to check first.

5. Add the MAF approval round trip

Mark save_handoff approval-required using the current Microsoft Agent Framework mechanism. The UI must display the exact pending action — the tool name and every argument the model proposed, verbatim.

Approval and rejection must answer that exact framework request in the same agent session. A conversational yes is not an approval response. Rejecting must execute no write. Approving must execute once and report the returned RecommendationId.

The model supplies no provider identity. clinical.SaveShiftHandoff derives PatientId from the encounter and attributes the Chapter 6 handoff to the system provider inside SQL. Chapter 8 replaces this unauthenticated contract with trusted identity and row authorization.

6. Clean build

run.ps1 uses dotnet run, so restarting already compiles Chapter 6 source edits. Build explicitly anyway so compiler errors appear on their own rather than inside startup output.

Stop the app first — Windows holds the running app's assemblies open:

./examples/ch04-first-app/shutdown.ps1
./examples/ch04-first-app/build.ps1 -Clean

7. Run

For a durable local demo, use the Chapter 4 launcher. It starts and probes DAB when AiAgent is enabled, then starts the app; both processes survive terminal and VS Code closure and write logs under %LOCALAPPDATA%\WardGeneral\logs. The book runs Chapter 6 with both Chapter 5's AiAssistance and AiAgent:

./examples/ch04-first-app/start-demo.ps1 -Enable AiAssistance,AiAgent
./examples/ch04-first-app/start-demo.ps1 -Enable AiAgent     # agent only

Use the separate foreground commands below when debugging DAB startup:

./examples/ch06-agentic-ai/run-dab.ps1        # terminal 1, port 5000
./examples/ch06-agentic-ai/probe-mcp.ps1      # terminal 2
./examples/ch04-first-app/restart.ps1 -Enable AiAssistance,AiAgent

Coming back on a later day? The shift window will have aged out, and the first turn will correctly report that nobody needs a handoff. Use the wrapper instead of the bare restart — it re-anchors the window, then starts the app. One command, with DAB and the app in the background:

./examples/ch06-agentic-ai/refresh-and-run.ps1 -Detached

Or in two terminals, with the app in the foreground:

./examples/ch06-agentic-ai/run-dab.ps1              # terminal 1, still required
./examples/ch06-agentic-ai/refresh-and-run.ps1     # terminal 2

refresh-and-run.ps1 runs 13-refresh-shift-window.sql and then hands off to restart.ps1 (or, with -Detached, to start-demo.ps1, which also starts DAB if it isn't running). It defaults to -Enable AiAssistance,AiAgent, the whole application; pass -Enable AiAgent for the agent alone. -SkipRefresh restarts without touching data when the window was already refreshed today. Without -Detached it deliberately does not start DAB — the pre-flight below already catches that and names the script to run.

It can stop and wait for you. restart.ps1 checks the server firewall rule for this client's IP and prompts for approval before adding or updating it. That is a real prompt on standard input: answer it, or pass -SkipFirewall when the rule is already in place. -SkipPreflight passes through too. This matters most when the script is launched somewhere the prompt is easy to miss — a background terminal looks idle rather than blocked.

DAB must be running before the app starts with AiAgent on, and the app now enforces that rather than trusting it. With the flag set, Chapter 4's preflight-run.ps1 probes Agent:McpUrl and refuses to start when nothing answers, naming run-dab.ps1 in the error. An agent that launches with no tools is the worse outcome: the dock opens, the model answers from nothing, and you debug a prompt when the real problem is a process that was never started.

The check runs only when AiAgent is on, so Chapter 4 and Chapter 5 readers never see it. It also warns when the endpoint answers with a tool count other than seven.

Give DAB time to bind before starting the app. It acquires an Entra token and reads schema metadata for every entity at startup, so on a slow link the first bind can take a couple of minutes. Wait for its listening line, or for probe-mcp.ps1 to return, rather than assuming it failed.

The restart script prints the local application URL.

8. Run Chapter 5 and Chapter 6 together

Nothing forces you to pick one chapter's features at a time. -Enable takes a list, so one restart brings up the whole application — Chapter 5's AI assistance card and Research page alongside Chapter 6's assistant dock:

./examples/ch04-first-app/restart.ps1 -Enable AiAssistance,AiAgent

-Enable is the complete set of flags to force on, and it is not sticky. Naming only AiAgent on the next restart turns the Chapter 5 surface back off, which is the behavior that lets you return to any chapter's state without editing code.

Running both at once also puts the chapter's architecture on screen at the same time, which is the real reason to do it. The two AI features reach the database by different routes:

surface route database
Ch 5 — Research page its own ADO.NET connection, WardGeneralConnectionFactory.CreateResearch() wardgeneral-research named replica
Ch 5 — AI assistance card the ordinary application connection wardgeneral primary
Ch 6 — assistant dock every tool through the one MCP endpoint wardgeneral primary

The agent never reaches the named replica, and the Research page never goes through DAB. Watch it in sys.dm_exec_sessions while both are running: the research connection is tagged ApplicationName=wardgeneral-research, so the vector search separates from the agent's traffic by name.

This needs both chapters' database objects installed — Chapter 5's 06 through 09, and Chapter 6's 11 through 13. A missing Chapter 5 object shows up as a failing assistance card while the agent keeps working, because the two paths share nothing but the server.

End-to-end proof

No provider selection and no Chapter 8 flag — AiAgent alone is enough. The handoff is attributed to the system provider, which is what an unauthenticated session honestly is.

  1. Ask Show me the current patients that may need a shift handoff.
  2. Open the trace panel. Confirm get_handoff_candidates ran once. If the model instead called get_patient_chart per patient, the skill is not being loaded — fix that rather than accepting the answer.
  3. Confirm each ranked patient carries a named field and its value.
  4. Name one of them: Prepare patient X for shift handoff.
  5. Confirm the trace shows get_patient_chart for that encounter, and that search_clinical_context was not called — a handoff is built from this patient's chart alone.
  6. Confirm the four SBAR sections appear before the approval card.
  7. Decline, then query clinical.AgentRecommendation by request key: the inserted-row delta must be zero.
  8. Repeat with a new key, approve, and query again: the delta must be exactly one.
  9. Approve again with the same key: the delta must still be one.

A plausible answer without the expected tool trace fails the test. An approved conversation without exactly one durable row also fails.

Rollback — going back to Chapter 4 or Chapter 5

Three layers behave differently.

The application — just drop the flag. -Enable is the complete list of flags to force on, and it is not sticky:

./examples/ch04-first-app/restart.ps1                        # back to Chapter 4
./examples/ch04-first-app/restart.ps1 -Enable AiAssistance   # back to Chapter 5

The Chapter 6 code is still compiled in, but none of it runs. The agent is resolved only by the assistant dock, and the dock renders only when AiAgent is on — so with the flag off there is no agent, no MCP connection, and no runtime skill. Stop DAB too; nothing calls it now.

The SQL — leave it. Everything Chapter 6 adds is backward compatible. Chapters 4 and 5 behave exactly as before with it installed. Remove it only for a clean schema, in dependency order:

DROP PROCEDURE IF EXISTS clinical.GetHandoffCandidates;
DROP PROCEDURE IF EXISTS clinical.SaveShiftHandoff;
DROP TABLE     IF EXISTS clinical.AgentRecommendation;
DELETE FROM ops.Provider WHERE FullName = N'Ward General Assistant (system)';

Delete the system provider row after the recommendation table, not before — the foreign key holds it in place while any handoff still references it.

The data. Handoffs are self-contained: clinical.AgentRecommendation holds nothing but agent-created rows, so dropping the table removes them all and touches no Chapter 2 seed data.

If you want the pre-Chapter-6 source back, that is a Git operation in your own clone. Commit or stash your Chapter 6 work first so you do not lose it.

Agent-driven workflow

Ask Copilot to build the Chapter 6 agent (the book's prompt) — it configures what's missing, runs setup-dab.ps1 after telling you what it will do, builds, and starts DAB and the app with AiAssistance and AiAgent. You can also ask it to set up the Chapter 6 SQL MCP tools or run the app with AiAgent. The repository skill is:

.github/skills/wardgeneral-agent/SKILL.md

It follows this same sequence and must stop at any missing completion gate rather than treating the chat icon as proof.

Stop

./examples/ch04-first-app/shutdown.ps1

It stops the app and DAB, including the background processes start-demo.ps1 and refresh-and-run.ps1 -Detached start. If you ran DAB in its own terminal, you can also Ctrl+C it there.

Links from the book

Every web link printed in Chapter 6, in the order it appears, so you can click instead of typing it. Cross-references to other chapters and links to this repository are left out.