Volunteer Recognition Agent · AfifAI
Case study · AI engineering · 2026

A Copilot agent that briefs organizers on every volunteer before an event

Volunteer organizers at nonprofits ask in Teams or Microsoft 365 Copilot Chat and get a short, personalised dossier for each volunteer on a shift: what they have done, what they are signed up for and how to thank or re-engage them. Every fact comes from that organization’s own volunteer records, and organizations never see each other’s data.

.NET 8Microsoft 365 Agents SDKAzure AI Foundry AgentsAzure AI SearchAzure OpenAI gpt-4oContainer AppsKey Vault
Client
US volunteer-management SaaS for nonprofits (confidential)
My role
Team lead & architect (hands-on)
Stage
Phases 0–3 complete; live pilot in the launch tenant, mid-2026
Status
Live pilot
At a glance
≈30 to <1 minper-event volunteer prep, from manual record lookups to a generated dossier (estimate)
6 hautomatic re-sync of every customer’s data, instead of manual exports
0code changes to onboard a customer: one registry row, two vault secrets, one sync pass
1 to 0agent-provisioning calls per customer on each steady-state sync
0cross-tenant fallbacks. An unknown tenant is refused, locked down by 8 isolation tests
500documents per index upsert batch, streamed from a paged API
13deployment pitfalls found and written into the runbook and scripts

“Estimate” numbers are engineering estimates to confirm with pilot data. All others are counted from the code and configuration.

The problem

The data existed, but nobody had time to read it before an event

The volunteer platform already holds every registration, shift and logged hour. Before an event, though, an organizer who wants to thank a long-standing volunteer or spot a likely future lead has to click through records one person at a time. Most don’t, so recognition stays generic.

The product was going to be sold to many nonprofits from one shared deployment. That made tenant isolation the first requirement, ahead of anything the model does: one organization must never be able to retrieve another organization’s volunteers, even by accident.

What I owned

Architecture, code and delivery, end to end

  • Designed the two-service architecture and the per-customer isolation model, and planned the phased delivery (Phases 0–3).
  • Wrote most of the .NET 8 code: the ingestion worker, the conversational agent, the shared contracts and the isolation tests.
  • Scripted provisioning and deployment (Azure resources, Container App, App Service, Bot Service, Teams package) and wrote the operations runbook.
  • Ran the first end-to-end deployment, then fixed each auth, RBAC and packaging failure and turned it into a runbook step.
What I built

Two services, one shared contract

Architecture

Isolated per customer at the data plane

The diagram is wider than the screen: swipe sideways to see all of it →

Each customer has its own index and its own Foundry agent, and the agent’s search tool also carries a customer filter as defence in depth. Both services use managed identities, so no secret appears in configuration or in the repository.

Design decisions

Isolation first, model second

Physical isolation, not just a filter

One index and one Foundry agent per customer. A filter bug cannot leak data, because the other customers’ data is not in that index. Trade-off: one more provisioning step per customer, so the worker does it once and records the agent id.

Tenant from the token, never from text

The customer is derived from the authenticated Entra tenant on the turn. A tenant that is present but unknown is refused with no email-domain fallback, and a customer whose data is not ready is refused too. Trade-off: anonymous test chats always get the refusal, by design.

Two services that share only a contract

Ingestion and conversation deploy, scale and fail separately. Their only coupling is the index schema and the registry. Trade-off: the registry has to be kept in sync between them, which is the next thing to move to a durable store.

A model where it helps, rules where it must hold

gpt-4o classifies intent, with a deterministic keyword fallback so the bot keeps working during an outage. “Refine” rewrites the cached dossier with zero new retrieval. All prompts forbid invented names, hours or history.

Engineering log

What broke on the way to the first live answer

The first end-to-end deployment surfaced most of the real work. A selection:

Isolation

An email fallback that could cross tenants

The first resolver fell back to email-domain matching whenever the tenant id didn’t match. A caller from an unrelated tenant with an address at a customer’s domain would have been served that customer’s data.

Tenant present but unknown now means refuse, with no fallback. Rows with no index or agent are refused as well. Eight xUnit tests lock the rules in.

Ingestion

Every sync depended on slow RBAC

Agent provisioning needs Foundry data-plane rights, which took 5–15 minutes to propagate. A list-or-create call on every pass made each sync fail until then.

The worker provisions once, writes the agent id to the registry and skips the call afterwards. Steady-state syncs no longer depend on Foundry RBAC, and one failing customer no longer stops the others.

Ingestion

Partial records from the source API

Some registrations arrived without one of the four identifiers needed for a stable document key.

Mapping is null-safe. Incomplete records are skipped and counted in the sync log, so they don’t crash the pass or produce duplicates.

Auth

Bot replies rejected with 401

The new SDK ignored the old Bot Framework settings, and the host failed at startup until the MSAL provider package was referenced. Once it started, outbound tokens were issued but every reply failed with 401.

Moved to the SDK’s connection configuration, named the vault secret so it lands on the right config key, and pointed the authority at the bot’s home tenant, as single-tenant bots require.

Deployment

Every path returned 404

The agent’s App Service had been created on the wrong runtime stack, so the .NET app never started.

Fixed the stack and added a health-endpoint check to the runbook, so this is caught before the bot is wired up.

Packaging

Teams rejected the package

Custom-engine agents need manifest schema 1.20 or later, and the Copilot scope needs 1.21. A zip built with the wrong tool also produced paths the validator refused.

Moved to schema 1.21 and wrote a build script that puts the three files at the zip root. This and 12 other findings are in the runbook.

Try it

How a turn is resolved

An illustrative replay with synthetic organizations and volunteers. It contains no client data or code. Each scenario shows the checks the agent runs before it answers.

Outcome

Where it landed

MeasureBeforeAfterSource
Prep per eventManual record lookups, ≈30 minDossier in under 1 minestimate
Data freshnessAd-hoc exportsAutomatic sync every 6 hconfig
Onboarding a customerNew product, no processConfig only: 1 row + 2 secretsderived
Provisioning calls per sync1 per customer0 after the first passderived
Cross-tenant fallback paths1 (email domain)0, covered by 8 testscode
Refine a dossierRe-query and re-draftRewrite cached text, 0 retrievalscode

Source: code = counted in the repository; config = default setting; derived = follows from the code change; estimate = to confirm with pilot data.

  • Phases 0–3 delivered: shared contracts, ingestion, agent and Teams package, each with deploy scripts.
  • Live in the launch tenant, with grounded dossiers in Teams and Microsoft 365 Copilot Chat from one package.
  • Suspending a customer is one status change, effective within the registry’s 30-second cache.
  • A runbook covering provisioning, RBAC, deployment and onboarding, including the 13 pitfalls from the first rollout.
  • Next steps: a durable shared registry instead of the file copy, persistent session state for scale-out, and a production rollout to more customers.
Stack

Stack

Agent
ASP.NET Core (.NET 8), Microsoft 365 Agents SDK, Azure Bot Service
AI
Azure AI Foundry Agents, Azure OpenAI gpt-4o
Retrieval
Azure AI Search, one index per customer
Ingestion
.NET 8 Worker Service; Azure Container Apps or App Service
Security
Entra ID, managed identities, Key Vault, RBAC
Ops
Bash deploy scripts, ACR builds, Application Insights, xUnit

Free scoping call · 30 minutes · No commitment

Bring one process. Leave with a plan and a number to beat.

Write a few lines: the process or system, what hurts today (time, errors, cost or an AI pilot that stalled) and who uses it. After a short call you get a written plan and a fixed quote within ≈3 days.

Get a free scoping callafif.mansour@gmail.com

Opens a pre-filled email · reply within one business day

The model reads. The code counts.

All projects