Custodia Custodia
Microsoft Copilot Studio · Purview eDiscovery (Premium)

Ask for a FOIA case. Get a case, a search, and a real number back.

Custodia is a Copilot Studio agent that turns an incoming public-records request into a properly named eDiscovery case, a bounded KQL search, and an item-count estimate — in one conversation, restated and confirmed before anything is created, and never exceeding the reviewer's own Purview role.

Delegated on-behalf-of auth No application permissions Read + create only Nothing destructive
Custodia agent avatar
Custodia
Public-records & eDiscovery agent
25Graph ops exposed
0Delete / purge ops
Confirm before create
ℹ️ What this is

Custodia is a delegated-auth connector and agent template, not a standing service. Every call it makes runs as the signed-in reviewer, through their own Purview eDiscovery role — the agent can never see or do more than the person driving it already could in the Purview portal. There is no service identity, no app-only token, and no shared credential anywhere in this design.

⚠️ AI disclosure & disclaimer

Custodia uses generative AI to construct search queries and summarize results. It can make mistakes — verify case setup, query logic, and item counts independently before relying on them for a statutory response. It never decides whether a record is responsive, exempt, or privileged, and it has no tool that can apply that determination even if asked. This site is a demonstration of the repository's design; deploy it in your own tenant and test it against synthetic data first. Not affiliated with Microsoft. Microsoft, Microsoft Purview, Microsoft Graph, and Copilot Studio are trademarks of Microsoft Corporation.

What you get

The routine 80% of case setup, handled conversationally

Custodia doesn't replace the FOIA team's judgment — it removes the tedious, error-prone setup that happens before judgment is even possible: naming the case correctly, writing a bounded query, and knowing roughly how big the request is before you commit to it.

Consistent case naming

Every case follows the same pattern — request number, year, short subject — instead of whatever a reviewer typed under deadline pressure.

Query built from plain language

Describe the request in your own words; Custodia writes the bounded KQL, always anchored to the requested date range.

Σ

Estimate before you commit

Item count and mailbox/site hits come back before anything is collected, so scope decisions are made against a known size, not a guess.

Restate, then confirm

Case name, query, date range, and scope are read back in full before every create. One confirmation authorizes exactly one action — never a chain.

Nothing destructive is reachable

Purge, delete, legal-hold changes, and document tagging aren't just discouraged — they're not defined in the connector. The agent has no tool that could do them.

Legal calls stay human

Responsiveness, exemptions, and privilege are never decided by Custodia — and it can't apply a document tag even if you ask it to.

See it in action

A FOIA request comes in by email. Here's the whole conversation.

This is a real transcript shape — the operations, confirmations, and async estimate behavior all match how the connector and agent instructions actually work. Case, search, and counts below are illustrative, not from a real tenant.

Custodia · Microsoft Teams
JR

New request came in — #00187, Riverside Ave rezoning. Set up the case for the correspondence January through June 2026.

Before I create anything, here's exactly what I'll set up:

  • Case name: FOIA-2026-00187-riverside-rezoning
  • Description: Public-records request 00187. Requested date range 2026-01-01 to 2026-06-30.

Nothing is created until you confirm.

⏸ Waiting on your confirmation
JR

Confirmed.

POST /security/cases/ediscoveryCases → 201 Created

Case created — FOIA-2026-00187-riverside-rezoning.

Now the search. This is a separate action from creating the case, so I need a separate confirmation:

  • Search name: FOIA-2026-00187-riverside-rezoning-search-01
  • Query: ("Riverside Ave" OR "rezoning" OR "zoning variance") AND (received>=2026-01-01 AND received<=2026-06-30)
  • Scope: none — I'll attach the mailboxes you name rather than searching the whole tenant.
⏸ Waiting on your confirmation
JR

Confirmed — add the Planning Dept shared mailbox and my own.

POST /security/cases/ediscoveryCases/{caseId}/searches → 201 Created

Search created. Running an estimate before we talk about collecting anything:

POST .../searches/{searchId}/estimateStatistics → 202 Accepted
GET .../operations → running…
GET .../lastEstimateStatisticsOperation → 200 OK

Estimate complete:

1,842Indexed items
2Mailboxes with hits
0Unindexed items

That's an estimate, not a collection — nothing has been pulled into a review set, and nothing has left Purview. Say the word if you want to collect these into a review set next; that's a separate, more heavily guarded confirmation.

Every create above required its own explicit confirmation. Estimating is asynchronous — Custodia polls until it finishes rather than guessing at a result.
Architecture

Direct to Microsoft Graph. No service to secure.

v1 is deliberately the simplest thing that works: a Copilot Studio custom connector calling Microsoft Graph directly, with on-behalf-of delegated auth doing all the access-control work. No Azure Function, no hosting, no compute in the middle.

graph LR R["Reviewer in Teams /
Microsoft 365 Copilot"] --> A["Copilot Studio agent
Custodia"] A -->|"OAuth 2.0 + on-behalf-of"| C["Custom connector"] C -->|"delegated token,
reviewer's own scopes"| G["Microsoft Graph v1.0
eDiscovery API"] G --> P[("Microsoft Purview
eDiscovery Premium")]

Delegated, always

Graph scopes: eDiscovery.ReadWrite.All and User.Read, both delegated. No application (app-only) permission exists anywhere in the app registration.

On-behalf-of, not shared

Each reviewer authenticates with their own credentials. There is no maker-provided connection — get that wrong and the whole permission model silently collapses onto one identity.

The connector is the boundary

Purge, delete, legal-hold changes, and document tagging aren't blocked by a prompt — they simply don't exist in apiDefinition.swagger.json.

Export gives you a link, not an attachment

Custodia never downloads, stores, or forwards the exported file. Once the export operation succeeds, Microsoft Graph attaches a downloadUrl to that operation, and Custodia surfaces it in the reply — the reviewer clicks it and downloads the package directly from Purview, authenticated as themselves. The link is time-limited and specific to that one export.

Phase 2, on paper only

An Azure Function backend is documented as a future option for code-enforced approval workflows on export — not built, because it isn't needed for read + create.

Why it asks "Allow this agent to proceed?"

Each distinct tool (list cases, create a search, etc.) asks for a one-time permission the first time a reviewer uses it in a session. That's the on-behalf-of consent model working as intended — the agent is asking to borrow the reviewer's own Graph token, not a blanket grant. It won't ask again for that tool once granted. Verified directly: in a fresh session, a never-used tool prompts once, and a previously-used tool runs silently — including after the token would otherwise have expired, once the connector's scope includes offline_access.

Allow vs. Deny

Allow grants the connector a token scoped to that one tool, for that reviewer, for that session — not a standing grant to every tool or every case. Deny stops that specific call; Custodia reports that it couldn't complete the action rather than retrying silently or substituting another identity. Denying one tool doesn't block the others — a reviewer who denies CreateSearch can still use ListCases normally.

Not the same as the write confirmations

This is separate from the "restate and confirm" prompts before a create/update — those come from agent/instructions.md and repeat every time by design. The one-time "Allow" card is Microsoft Entra ID granting the connector a token; it has nothing to do with whether the action is safe to repeat.

If it keeps re-prompting for the same tool

That's not expected — check the connector's Security tab. The most common cause is a missing offline_access scope (no refresh token is issued, so the short-lived access token expires between calls) or a comma-separated Scope field instead of space-separated. Both are documented in connector/apiProperties.json and agent/greeting-and-prompts.md.

Safety & guardrails

The primary control is omission, not a prompt

Guardrails in agent/instructions.md are the second layer. The connector surface — what operations even exist — is the first, and it's the one that actually holds under a jailbreak attempt or a confused-deputy prompt.

✓ What Custodia can do

  • List & read cases, searches, custodiansScoped to whatever the signed-in reviewer's Purview role already permits.
  • Create a case, a search, a custodianAlways restated in full and confirmed before it happens — one action per confirmation.
  • Estimate search statisticsItem counts and mailbox/site hits, polled asynchronously to completion.
  • Create and list case tagsThe labels themselves, as workflow scaffolding — never applied to a document.
  • Read legal holdsSo the agent can warn a reviewer a custodian is on hold — never change one.

✕ What's deliberately absent

  • purgeDataDestroys source content in mailboxes and Teams. Irreversible — not in the connector.
  • Any deleteCases, searches, review sets, custodians — destroying case state destroys the audit trail.
  • Legal hold changesCreating, updating, or releasing a hold under an open request risks spoliation.
  • Applying a tag to a documentResponsive / non-responsive / exempt is a legal determination the agent will not make.
  • Any application (app-only) permissionThe agent can never exceed the signed-in reviewer's own role — by design, not by policy.
Get started

One script does most of the setup for you

Hand the repository to whoever administers your tenant and Power Platform environment. They don't need to be a developer to run it.

Run the installer

Creates the Entra app registration, delegated Graph permissions, admin consent, and the Power Platform custom connector — no portal clicking required.

Create the agent in Copilot Studio

Paste in agent/instructions.md, add the connector as an action, and require each user to sign in with their own credentials.

Grant reviewer access

Assign the Purview eDiscovery Manager role deliberately, per reviewer — then test the permission boundary before pointing it at a real request.

PS> ./scripts/0-install-everything.ps1
 
Checking prerequisites …
Creating the Entra app registration …
Creating the Power Platform custom connector …
Running the health check … all checks passed
Summary written to SETUP-SUMMARY.md

Read the repository first

Full setup guide, safety scope, evaluation sets, and the Phase 2 Azure Function option — all documented in the README.

View on GitHub →