Donor Reply Agent · AfifAI
Case study · AI engineering · 2026

From one hard-wired key to a Copilot agent any nonprofit can install

Fundraisers ask Copilot in Outlook or Teams to draft a reply to a donor and get a personal email built from that donor’s real gifts and notes in the CRM. I took the agent from a single-customer pilot to a multi-tenant design: one Marketplace package and one backend, with each organization connecting its own CRM key.

TypeScriptM365 Copilot declarative agentCustom engine agentEntra ID SSOAzure Table StorageAzure OpenAI gpt-4oZodVitest
Client
US nonprofit fundraising-CRM vendor; pilot with an international human-rights nonprofit (confidential)
My role
Team lead & architect (hands-on)
Stage
Pilot live; multi-tenant key work built (phases 0–4), mid-2026
Status
Live pilot
At a glance
N to 1deployments to serve N organizations: one shared backend and one Marketplace package
≈1 day to ≈5 minto onboard an organization, from a hand-built package and backend to an admin pasting a key (estimate)
96unit tests in 8 suites, run with lint, types, build and evals on every push
0donor facts written by the model. Templates insert placeholders, and the rewrite only changes tone and length
3Microsoft 365 surfaces on one agent core: Outlook Copilot, Teams / Copilot Chat, Outlook add-in
24–39engineer-days in my estimate for the multi-tenant work, delivered as 5 phases plus hardening
AES‑256GCM encryption for every stored key, which is decrypted only in memory and never logged

“Estimate” numbers are engineering estimates to confirm with the vendor. All others are counted from the code, tests and project documents.

The problem

Every customer would have needed its own copy

The first version served one pilot organization, and its CRM API key was a single backend setting read at startup. When the CRM vendor tested the install, they found the obvious consequence: one package meant one backend meant one key, shared by everyone who installed it.

A self-serve Marketplace listing therefore needed a separate build and hosted backend for each customer. That was not acceptable, and neither was one shared key across organizations.

What I owned

Design, estimate and build

  • Built the agent core and both Copilot surfaces with the team: donor lookup, engagement templates, grounded rewrite and output schemas.
  • Led the multi-tenant meeting with the vendor, then wrote the solution design, the onboarding policy options and the effort estimate (24–39 engineer-days, 5 phases).
  • Implemented the tenancy layer: identity extraction, encrypted per-tenant key store, resolver cache, admin key API, chat onboarding card, Entra SSO for the Outlook surface and the add-in “Connect” panel.
  • Set up the quality gate (lint, format, type-check, 96 tests, build and an eval harness) to run in CI on every push.
What I built

One agent core, three front ends, a key per tenant

Architecture

The key follows the identity, not the deployment

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

Two organizations mean two tenant ids, two rows and two keys. A request can only ever use the key that matches its verified identity claim. Admin routes and key entry are gated to org admins, and keys never appear in logs, where donor emails are redacted too.

Design decisions

Make the secret per tenant, and keep the model on a leash

Key per tenant, resolved per request

The CRM key moved from a deployment secret to runtime configuration keyed by the Entra tenant id. A Teams package is a static file everyone downloads, so it could never carry a customer secret. Trade-off: the Outlook surface had to move from no auth to Entra SSO so its calls identify the organization.

Templates first, model optional

Drafts start from deterministic templates that insert explicit placeholders when data is missing. The gpt-4o rewrite only adjusts tone and length, must keep placeholders verbatim, and falls back to the template if it fails.

One core, many front ends

Microsoft supports custom engine agents only in Teams and Copilot Chat, and declarative agents in Outlook and Office. Running both on one backend reaches users everywhere at no extra infrastructure cost. Trade-off: two Teams packages to maintain.

Open onboarding, gate kept in the schema

Any organization with a valid key can connect, because the key itself is the gate. An approval flag stays in the record so the vendor can turn on allow-listing later without a redesign.

Engineering log

What I had to solve to get from one customer to many

Problems that came up in testing with the vendor, and how they were closed:

Tenancy

The key could only be set once

The vendor’s own install test showed that the backend’s single key setting made every installer share one key, or forced a custom build per customer.

A per-tenant key store and resolver behind one package. Back-compat mode kept the live pilot untouched while the change rolled out.

Identity

Outlook calls carried no identity

The declarative agent’s plugin used no auth, so the backend could not tell which organization was calling and could not pick a key.

Moved the plugin to Entra SSO and added JWT validation middleware on the action routes, replacing the shared action key.

Onboarding

A wrong key would fail in the middle of a draft

With self-serve setup, a typo in a key would only show up later, as a failed draft for a fundraiser.

The key is validated at save time with one read-only probe and no retries. Invalid keys are rejected and nothing is stored.

Caching

Rotated keys must take effect at once

Agents are cached per tenant for speed, so a rotated key could have been ignored until a restart.

The cache entry remembers which key built the agent. A different key rebuilds it, and the admin API invalidates the entry on every change.

Security

Anyone in the org could set the key

The chat card would have let any member replace the organization’s key.

Admin-only gating through the Teams roster (owner or admin), plus encryption at rest and redaction of keys and donor emails in logs.

CRM API

Rate limits and transient errors

Bulk drafts hit 429s and occasional 5xx responses from the CRM.

Up to 3 retries with full-jitter exponential backoff, honouring Retry-After, plus one token refresh on an expired session.

Packaging

The Teams validator rejected the package

A 0.x version, a non-canonical schema URL, a non-GUID agent id, a remote spec URL and an unsupported confirmation flag were each rejected.

Fixed each one and added build scripts for the deploy zip and for per-tenant packages. Nine packaging rules are recorded in the docs.

Try it

What happens to one request, per tenant

An illustrative replay with synthetic tenants and donors. It contains no client data or code. Each scenario shows the checks the backend runs.

Outcome

Where it landed

MeasureBeforeAfterSource
Deployments for N organizationsN (one per customer)1 backend + 1 packagederived
Onboarding an organizationHand-built package + backend, ≈1 dayAdmin pastes a key, ≈5 minestimate
Key per organizationOne shared keyOne encrypted key per tenantcode
Outlook surface identityNoneEntra SSO, validated JWTcode
Bad key detectedAt first failed draftAt save, nothing storedcode
Automated checks per push—96 tests + lint, types, build, evalscode

Source: code = in the repository; derived = follows from the design; estimate = to confirm with the vendor.

  • Pilot live in Outlook Copilot and Teams for an international nonprofit, from one Azure backend.
  • Multi-tenant key handling built across both surfaces: store, resolver, admin API, chat onboarding, Entra SSO and the add-in panel.
  • An executive overview and a phased developer build plan delivered to the vendor.
  • A CI gate that runs offline against mock CRM fixtures, so no secrets are needed to test.
  • Next: a multi-key integration test with a second demo key, a security review, Marketplace hardening and the separate bot-to-API networking fix.
Stack

Stack

Agent
TypeScript, Node.js, Express, Bot Framework, Zod
Copilot
Declarative agent + API plugin (OpenAPI), custom engine agent, Office.js add-in
AI
Azure OpenAI gpt-4o: turn classifier, grounded rewrite
Tenancy & security
Entra ID SSO, JWT validation, Azure Table Storage, AES-256-GCM, Key Vault
Quality
Vitest (96 tests), ESLint, Prettier, eval harness, GitHub Actions
Hosting
Azure App Service, Azure Bot Service

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