Draft technical appendix. This document is the implementation-facing companion to the Copilot Concierge scope study. Signatures, scopes, and schemas shown here are the design contract for Phase 1 build starting the week of April 27, 2026. The surface-facing narrative is in the scope study.
Daniel Lepel | M365 & Azure Architect

Copilot Concierge: Technical Appendix

The implementation contract behind the scope study: scopes, tools, schemas, prompts.
The scope study is the product. This document is the build. Every tool signature, every Graph permission, every Dataverse table, every JSON schema the Phase 1 implementation needs to stand up a working single-tenant demo. Written for the technical interviewer, the tenant admin reviewing consent, and the developer on the other side of the handoff. No marketing narrative. No em-dashes. Just the contract.
Companion document Phase 1 implementation scope Runtime: Azure AI Foundry | Python 3.11 Surface: Copilot Studio agent
Contents
Appendix A | Graph API scopes

Least-privilege permission set, staged by phase.

Phase 1 requests the minimum. Each later phase adds only what it needs.

Every scope below is a delegated permission on the signed-in user. No application-level Graph permissions are requested in the single-tenant demo. Delegated scopes are easier for a tenant admin to reason about during consent, and they keep the blast radius bounded to the operator's own mailbox and chats. Application scopes become relevant in the multi-tenant packaging work, which is out of scope for this document.

Scope
Phase
Purpose
Mail.ReadWrite
Phase 1
Read mail for classification. Stage drafts. Move to Noise folder on auto-archive. The minimum required scope for the Outlook triage pipeline.
Mail.Send
Phase 1
Auto-send for categories where the user opted in. Gated by the per-category toggle plus sender override plus confidence threshold. Never invoked when the toggle is off.
MailboxSettings.Read
Phase 1
Read the user's timezone, working hours, and automatic-reply state. Feeds the calendar-adjacency classifier feature and suppresses drafts when the user is out of office.
Calendars.Read
Phase 1
Read the next 48 hours of calendar events. Used as a feature in the classifier: a sender named in a meeting starting within two hours escalates the category.
User.Read
Phase 1
Read the signed-in user's basic profile. Populates the settings page header and ties audit rows to the correct user identity.
Chat.ReadWrite
Phase 2
Read Teams one-on-one chats and at-mentions for the unified triage queue. Stage draft replies in the same chat. No channel write access under this scope.
ChannelMessage.Read.All
Phase 3
Read-only across followed channels for the morning digest and on-demand summaries. Intentionally scoped without a matching write permission. Channels stay read-only by design.
Tasks.ReadWrite
Phase 4
Create and update Microsoft To-Do tasks extracted from Outlook mail, Teams chats, and at-mentions. Write-back is required; no broader task-list permission is requested.
Files.ReadWrite.AppFolder
Phase 1
Read and write the voice profile JSON in the agent's app folder in the user's OneDrive. Limited to the app folder only; no broader OneDrive access requested.
Consent posture
All scopes above are delegated and request admin consent at tenant enrollment. Mail.Send is flagged for the admin review because it permits programmatic sending; the agent never sends without the per-category toggle explicitly on. ChannelMessage.Read.All is the broadest scope and is deferred to Phase 3 so Phase 1 and Phase 2 can ship with a narrower permission set.
Appendix B | Agent tool definitions

The tools the Foundry agent can call.

Every tool is a Python function. Every argument is typed. No string passing from the model into Graph.

The Foundry agent runs Claude as the reasoning model and exposes a typed tool surface to it. The model picks which tool to call; the Python layer validates arguments, calls Graph, handles retries, and returns structured responses. The model never writes Graph URLs directly. This split is the same discipline as the production Agent Architecture system: probabilistic picks the tool, deterministic runs the tool.

get_inbound_conversation
def get_inbound_conversation(message_id: str, include_thread: bool = True) -> ConversationContext
Fetches the inbound message, its thread history, and sender metadata from Outlook or Teams. Returns a unified ConversationContext object so the model sees mail and chat in the same shape. Paginates the thread if longer than 20 messages.
Graph/me/messages, /me/chats, /me/chats/{id}/messagesPhase1 (mail), 2 (chat)
get_calendar_adjacency
def get_calendar_adjacency(sender: str, window_hours: int = 2) -> CalendarAdjacency
Returns any calendar events in the next N hours that include the sender as an attendee or named in the body. Feeds the classifier as a feature for escalating priority when the sender is on an imminent meeting.
Graph/me/calendar/calendarViewPhase1
get_sender_rules
def get_sender_rules(sender_email: str, tenant_id: str) -> SenderRules
Reads the Dataverse VIP and blacklist tables for the signed-in user. Returns whether the sender is VIP, blacklisted, or neither, plus the category floor if the sender is VIP.
SourceDataverse: concierge_sender_overridePhase1
classify_conversation
def classify_conversation(ctx: ConversationContext, adjacency: CalendarAdjacency, rules: SenderRules) -> Classification
Calls Claude via Foundry with the classification prompt. Returns category, confidence score (0 to 1), and a one-line reason string for the audit log. The model sees the full thread context, calendar adjacency, and sender rules packed into a single system-plus-user prompt.
ModelClaude via Azure AI FoundryPhase1
evaluate_action
def evaluate_action(classification: Classification, user_settings: UserSettings) -> ActionDecision
Deterministic rule engine. Combines the per-category toggle, sender override, and confidence threshold into a single action decision: stage_draft, auto_send, mark_read, archive, or no_action. Never called by the model directly; the orchestrator calls it after classify_conversation returns.
SourcePython rule_engine.pyPhase1
load_voice_profile
def load_voice_profile(user_upn: str) -> VoiceProfile | None
Reads the voice_profile.json document from the user's OneDrive app folder. Returns None on first run; the agent falls back to the universal filter alone until profile_trainer.py generates the profile from Sent Items.
Graph/me/drive/special/approot:/voice_profile.jsonPhase1
generate_draft
def generate_draft(ctx: ConversationContext, classification: Classification, profile: VoiceProfile | None) -> DraftReply
Calls Claude via Foundry with the drafting prompt, the full thread context, the user's voice profile, and a hard exclusion list of AI-tell phrases. Returns a draft reply body plus a subject line suggestion (for new threads) plus a list of any voice-profile rules the draft intentionally bent with the reason logged.
ModelClaude via Azure AI FoundryPhase1
lint_draft
def lint_draft(draft: DraftReply, universal_filter: list[str], profile_rules: list[Rule]) -> LintResult
Post-generation linter. Deterministic. Rejects drafts that contain any universal-filter phrase (em-dashes, banned words, negation-contrast patterns). Returns pass, flag (warning for user review), or block (draft is never staged and the event writes a classification_error row in the audit log).
SourcePython voice_lint.pyPhase1
stage_draft
def stage_draft(conversation_id: str, draft: DraftReply, channel: str) -> DraftReference
Creates a draft reply in the appropriate surface. Outlook uses createReplyDraft; Teams uses the chat draft pattern. Returns the Graph reference so the user can open and edit from the client.
Graph/me/messages/{id}/createReplyDraft, /me/chats/{id}/messagesPhase1 (mail), 2 (chat)
send_reply
def send_reply(conversation_id: str, draft: DraftReply, channel: str) -> SendResult
Sends the reply directly. Guarded by a pre-flight check that re-reads the user's auto-reply toggle for the category; a flipped toggle between classification and send cancels the send and stages instead. Writes to the audit log with both the decision path and the pre-flight result.
Graph/me/sendMail, /me/chats/{id}/messagesPhase1 (mail), 2 (chat)
write_audit_row
def write_audit_row(event: AuditEvent) -> None
Emits one custom event to Application Insights. Every tool call produces an audit row. The user-facing audit view in the settings page reads from the same Application Insights workspace via a Kusto query.
SourceApplication Insights custom eventsPhase1
summarize_channel
def summarize_channel(channel_id: str, since: datetime, cluster: bool = True) -> ChannelSummary
Reads messages from the channel since the given timestamp. Calls Claude via Foundry to cluster by topic and summarize each cluster. Used by the morning digest and by on-demand prompts in M365 Copilot chat.
Graph/teams/{id}/channels/{id}/messagesPhase3
extract_action_items
def extract_action_items(ctx: ConversationContext, classification: Classification) -> list[ActionItem]
Calls Claude via Foundry to find commitments, requests, and implicit tasks in the conversation. Returns zero or more ActionItem records. Each item carries a priority mapped from the classifier category and a back-link to the source message.
ModelClaude via Azure AI FoundryPhase4
create_todo_task
def create_todo_task(item: ActionItem, list_id: str) -> TodoReference
Creates the To-Do task with the back-link stored in the body as a deep link to the source message. Sets the task priority, due date (when the classifier identified one), and a tag matching the concierge category.
Graph/me/todo/lists/{id}/tasksPhase4
Appendix C | Dataverse data model

The four tables the settings page reads and writes.

Dataverse is the config store. Copilot Studio uses it natively; the Foundry agent reads it via Dataverse connector.

Four tables cover Phase 1. Two more are added for Phase 3 (channel subscriptions) and Phase 4 (To-Do list binding). All tables carry a composite key of tenant_id plus user_upn so the schema is ready for multi-tenant packaging without a migration.

Table | concierge_user_settings
CREATE TABLE concierge_user_settings ( tenant_id string -- composite key, tenant identifier user_upn string -- composite key, user principal name cat_critical_toggle enum -- draft_only | auto_on cat_urgent_toggle enum -- draft_only | auto_on cat_important_toggle enum -- draft_only | auto_on cat_normal_toggle enum -- draft_only | auto_on cat_fyi_toggle enum -- draft_only | auto_on (mark_read is auto_on) cat_noise_toggle enum -- archive_on | archive_off cat_threshold json -- {category: float 0..1} confidence floor per category digest_time_utc time -- morning digest delivery time teams_channel_digest_on bool todo_integration_on bool voice_profile_active bool created_at datetime updated_at datetime );
Table | concierge_sender_override
CREATE TABLE concierge_sender_override ( tenant_id string -- composite key user_upn string -- composite key sender_pattern string -- exact email, or domain suffix (@contoso.com) rule_type enum -- vip | blacklist category_floor enum | null -- vip only; min category to classify at force_draft_only bool -- if true, never auto-send regardless of toggle note string -- user-entered justification, shown in settings created_at datetime );
Table | concierge_channel_subscription (Phase 3)
CREATE TABLE concierge_channel_subscription ( tenant_id string user_upn string team_id string channel_id string include_in_digestbool last_summarized datetime );
Table | concierge_todo_binding (Phase 4)
CREATE TABLE concierge_todo_binding ( tenant_id string user_upn string todo_list_id string -- bound To-Do list; defaults to "Tasks" tag_prefix string -- default "concierge:"; user can override );
Why Dataverse, not Azure SQL
Copilot Studio already reads and writes Dataverse natively; adding a separate SQL backing store would pull the tenant admin into a second data residency review for no added capability. Dataverse also handles the row-level security by default: the signed-in user only ever reads their own rows without extra filter logic in the agent. The tradeoff is query flexibility; for the four tables above, Dataverse filtering is sufficient.
Appendix D | Voice profile schema

The per-user profile, as a single JSON document.

Lives in OneDrive. Editable by the user. Regenerated on demand from the 15 most recent Sent Items.

The voice profile is one JSON document per user, stored in the agent's OneDrive app folder at /approot/voice_profile.json. The user can open it, read it, and hand-edit it. Every field has a default so a missing or unparseable profile falls back gracefully to the universal filter alone.

Schema | voice_profile.json
{ "schema_version": "1.0", "user_upn": "jane.doe@contoso.example", "generated_at": "2026-04-28T09:14:00Z", "sample_size": 15, // Sent Items count sampled "tone": { "register": "plain-direct", // plain-direct | warm-collegial | formal-executive "avg_sentence_length_words": 14, "hedging_frequency": "low", // low | medium | high "contraction_usage": "natural", // avoid | natural | heavy "humor": "dry" // none | dry | warm }, "structure": { "opens_with_greeting": true, "typical_greeting": "Hi {firstname},", "typical_signoff": "Daniel", "uses_bullet_lists": "when-useful", // rarely | when-useful | often "paragraph_density": "short" // short | medium | dense }, "vocabulary": { "preferred_phrases": [ "happy to", "quick note", "let me know", "checking on" ], "banned_phrases": [ "seamless", "leverage", "robust", "ensure", "utilize", "spearhead", "innovative", "transformative" ], "punctuation_rules": { "no_em_dashes": true, "no_double_hyphens": true, "serial_comma": true } }, "forbidden_patterns": [ "X isn't just about Y", // negation-contrast "From X to Y", // scope framing "Whether X or Y" // parallel padding ], "examples": [ { "context": "short_ack", "sample": "Got it, will circle back by EOD Thursday." }, { "context": "meeting_confirm", "sample": "Works for me. I'll send an invite for 2:00." }, { "context": "polite_decline", "sample": "Thanks for thinking of me. Not a fit right now, but please keep me on the list." } ], "overrides": { "user_edited": false, // true once the user hand-edits any field "last_edited_at": null } }

The profile_trainer.py service regenerates the profile on demand. Regeneration reads the 15 most recent items from Sent Items, strips quoted reply content and signatures, and calls Claude via Foundry with a dedicated profile-extraction prompt that returns this JSON shape. If the user has manually edited the profile (overrides.user_edited = true), regeneration preserves the edited fields and only refreshes the computed fields the user hasn't touched.

Appendix E | Audit log schema

One custom event per tool call.

Application Insights is the store. Kusto is the query surface. The settings page reads from the same workspace.

Every tool call in the agent pipeline emits one custom event to the concierge Application Insights workspace. Events carry a correlation ID so all tool calls for one inbound conversation thread under the same trace. The user-facing audit view runs a Kusto query against the same workspace; there is no separate audit table.

Field
Type
Description
event_name
string
Tool call name (e.g. classify_conversation, stage_draft). Always present.
correlation_id
string
UUID issued at the webhook arrival step. All tool calls for one inbound message share the same correlation ID.
tenant_id
string
Tenant identifier from the signed-in user context.
user_upn
string
User principal name of the signed-in user. Audit view filters on this field.
conversation_id
string
Graph message ID or chat ID for the inbound item under triage.
category
string | null
One of Critical, Urgent, Important, Normal, FYI, Noise. Null for events that run before classification.
confidence
float | null
0 to 1. Present on classify_conversation events and on any downstream event that consumed the score.
reason
string
One-line model-generated reason. Shown in the user audit view.
action_decision
string
stage_draft, auto_send, mark_read, archive, no_action, blocked. Logged by the rule engine.
action_path
string
How the decision was reached: category_toggle, sender_override, confidence_floor, lint_block, or preflight_cancel.
revocable
bool
True if the user can revoke the action from the audit view. False for actions the agent cannot undo (e.g. sends delivered to external recipients).
draft_hash
string | null
SHA-256 of the draft body. Lets the audit view detect drafts the user manually edited before sending.
latency_ms
int
Elapsed time for the tool call, in milliseconds. Populates the performance dashboard.
error_code
string | null
Populated on failure paths only. See the full error taxonomy in rule_engine.py.
ts_utc
datetime
Event timestamp in UTC. Audit view converts to the user's timezone from mailbox settings.
Sample Kusto | recent actions for one user
customEvents | where name in ("stage_draft", "send_reply", "archive") | where customDimensions.user_upn == "jane.doe@contoso.example" | where timestamp > ago(24h) | project ts = timestamp, event = name, category = customDimensions.category, conf = customDimensions.confidence, path = customDimensions.action_path, rev = customDimensions.revocable, reason = customDimensions.reason | order by ts desc
Appendix F | Classification prompt structure

The template behind classify_conversation.

One system prompt, one user prompt, one strict JSON response format.

The classification call to Claude follows a fixed template. The system prompt pins the six categories, their definitions, and the output format. The user prompt packs the conversation context, calendar adjacency, and sender rules. The model returns strict JSON only. Any non-JSON response is a hard error and the conversation falls back to a manual-review category with the user notified.

System prompt | classify_conversation
You are Copilot Concierge, a triage classifier for inbound M365 messages. Your only output is one JSON object matching the schema below. No prose. Categories, in priority order: - CRITICAL: Explicit deadline today, escalation language, blocking someone, or named in a meeting starting within 2 hours. - URGENT: Deadline this week, repeated follow-ups, or strong role signals. - IMPORTANT: Substantive request needing thought, no immediate pressure. - NORMAL: Standard correspondence. Answer when convenient. - FYI: Informational. No response expected. - NOISE: Newsletters, promos, automated. No action. Signals to weight: 1. Explicit deadlines in the thread body. 2. Sender role, history, and whether the sender is in the user's VIP list. 3. Calendar adjacency: sender named in a meeting within 2 hours escalates. 4. Thread position: reply to a long thread escalates when the user is the last non-sender party. 5. Language features: escalation words, blocking phrasing, at-mentions. Respond with: { "category": "CRITICAL|URGENT|IMPORTANT|NORMAL|FYI|NOISE", "confidence": 0.0-1.0, "reason": "<= 140 chars, one sentence, user-facing" }
User prompt | classify_conversation (template)
CONVERSATION From: {sender_name} <{sender_email}> Subject: {subject} Arrived: {arrived_utc} Thread length: {thread_count} {body_truncated_to_4000_chars} SENDER CONTEXT VIP list: {vip_membership} Blacklist: {blacklist_membership} Recent threads with user: {recent_thread_count} Last user reply: {last_user_reply_utc | "never"} CALENDAR ADJACENCY Next 2h events with sender: {adjacent_event_count} Adjacent titles: {adjacent_titles[:3]} USER STATE Timezone: {tz} Working hours: {working_hours} Out of office: {oof_state} Classify.
Why strict JSON
The model output feeds a deterministic rule engine. Prose responses break the pipeline. Strict JSON with a fixed schema means every tool downstream has a typed contract, and any drift from the schema surfaces as a hard error rather than a silent misread. Claude on Foundry supports structured output natively; the prompt above pairs with the structured-output API to enforce the shape at the model boundary.
Appendix G | Security posture

What tenant admins and security reviewers should ask.

The answers are written for the security review, not the marketing deck.

Every Phase 1 design decision was made with a tenant admin's security review in mind. The posture below is the set of answers the review will want, stated plainly. Any item a reviewer flags as insufficient is a Phase 1 build gate.

Data residency
All customer data stays inside the tenant: Graph calls hit the user's mailbox, Dataverse is the tenant's Dataverse, Application Insights is the customer's workspace, OneDrive profile is the user's OneDrive. The Foundry agent runtime runs in an Azure subscription the customer owns.
Model provider isolation
Claude runs via Azure AI Foundry's model catalog. Requests stay inside the Azure boundary. No data leaves the customer's Azure subscription for model inference. This is the deployment option the security review needs to see for Anthropic models inside enterprise tenants.
Scope minimization
Delegated scopes only. No application permissions in the single-tenant demo. Each phase requests the minimum permissions it requires. The two scopes a reviewer will focus on (Mail.Send and ChannelMessage.Read.All) are gated behind opt-in toggles.
Write-action gating
Every write action (send, archive, To-Do create) passes through the rule engine with the per-category toggle, sender override, and confidence threshold. Default is draft-only. The user controls every auto-action and can revoke any of them from the audit view.
Audit completeness
Every tool call emits one event. Correlation IDs thread events per conversation. The audit view reads the same workspace the pipeline writes. No parallel audit table; no risk of drift between what the agent did and what the audit shows.
Prompt injection exposure
Classifier and drafter run on user-supplied text. Untrusted input is marked as such in the user prompt; the system prompt restates that tool outputs (not conversation body) are the source of truth for actions. The rule engine runs downstream of the model and will not execute instructions embedded in message bodies.
Revocability
Draft creation and archive actions are fully revocable from the audit view. Send actions to external recipients are not revocable after delivery; the audit view marks them as such. Revocability is written into the audit event, not inferred.
Data retention
Application Insights retention matches the customer's existing workspace policy. Voice profile JSON is editable and deletable by the user. Dataverse rows follow standard Dataverse retention. No secondary copies of customer data are kept by the agent.
Appendix H | Dependencies and handoff

What a developer needs to pick this up.

Runtime versions, repos, libraries, and the handoff checklist.
Component
Version
Notes
Azure AI Foundry
GA channel
Agent service. Hosts the Python orchestration code and brokers Claude calls.
Anthropic Claude
Sonnet 4.6 (Foundry catalog)
Reasoning model. Model choice is configurable in settings; Sonnet is the default for Phase 1 on cost-quality balance.
Python
3.11
Runtime in Foundry. Pinned to 3.11 for stability; 3.12 upgrade tracked as Phase 1.5.
Microsoft Graph SDK
msgraph-sdk-python 1.x
Official SDK. Covers mail, calendar, chat, drive, todo endpoints in a single client.
Copilot Studio
Power Platform current
Surface layer. Publishes the Concierge agent into M365 Copilot chat. Dataverse is the backing store Studio uses natively.
Application Insights
Log Analytics workspace
Audit log target. Customer's existing workspace or a dedicated one per the reviewer's preference.
Dataverse
Tenant default
Config store. Four tables in Phase 1, six by Phase 4. All composite-keyed on tenant plus user.
Handoff checklist
At Phase 1 ship, the handoff artifact is: one GitHub repo with the Python orchestration, one Bicep template for the Foundry workspace and App Insights, one Dataverse solution file for the tables, one Copilot Studio solution file for the agent, one admin-consent URL for the delegated scopes, and this technical appendix versioned alongside the code. The scope study ships as the user-facing README; this appendix ships as the developer-facing one.

Both documents are versioned and updated as the build advances. The scope study version tracks user-facing capability; the appendix version tracks the implementation contract. A reader comparing the two at any point can see exactly which promises have been built and which are still ahead.

The short version of the appendix.

Phase 1 needs five delegated Graph scopes, fourteen typed tool functions, four Dataverse tables, one JSON voice profile, one Application Insights workspace, and one Foundry agent with Claude as the reasoning model. The scope study is the why and the shape. This document is the contract.

If you're reviewing this for a technical interview, a security assessment, or a developer handoff, the fields and signatures above are the design surface. Any question the document does not answer is a gap worth calling out; I'd rather find it now than during the build.

Daniel Lepel
Principal Microsoft Cloud Architect
daniel@lepel.us
212-252-9200
Albany, NY Capital District
Copilot Concierge Technical Appendix (Draft) | daniellepel.com | Drafted April 2026 | Companion: Scope Study | See also: Agent Architecture