Journal entry
Building a Production Custom Engine Agent for Microsoft 365 Copilot

Building a Production Custom Engine Agent for Microsoft 365 Copilot
An enterprise agent becomes useful when it can do more than produce a plausible answer.
The agent I built can search approved SharePoint locations with the signed-in user's permissions, inspect storage, collect structured information, generate reviewed documents, load domain skills on demand, remember presentation preferences across conversations, and complete long-running work from Microsoft 365 Copilot and Teams.
It also provides read-only portfolio intelligence: natural-language portfolio questions, deterministic metrics, comparisons, and traceable explanations for authorized users.
It does this without giving the model direct access to an API, a credential, or a database.
That distinction defines the product. The model proposes the next action. Typed tools, identity checks, policy, and ordinary application code decide whether the action can run.
The result is a Custom Engine Agent built with the Microsoft 365 Agents SDK and Microsoft Agent Framework. Azure Managed Redis provides exact same-conversation continuity. Azure Cosmos DB provides bounded preference memory across conversations. Microsoft Foundry supplies the model through a governed AI gateway. The full data path stays within approved European Azure regions.
This article explains what the agent can do, how the main components work, and what production taught me that the happy-path samples did not.
What the Agent Can Do

I designed the agent around a small set of complete user outcomes.
| User need | Agent capability | Deterministic boundary |
|---|---|---|
| Find a document or understand a folder | Browse approved SharePoint sites and drives | Microsoft Graph uses delegated identity and preserves source permissions |
| Find storage pressure | Rank large files and folders with bounded scanning | The service validates site, path, result count, and minimum size |
| Produce a standard document | Collect missing fields, validate them, and render an approved template | Application code owns required fields, language rules, and the final artifact |
| Answer a domain-specific question | Load a reviewed Agent Skill only when it is relevant | The deployed skill bundle is pinned, read-only, and contains no executable code |
| Keep the conversation coherent | Recall the last ten prompt-and-answer turns | Redis applies a seven-day sliding expiry per conversation |
| Respect working preferences | Recall language, verbosity, formatting, and accessibility preferences | Cosmos DB stores only low-risk preferences with user list and deletion controls |
| Complete slower work | Stream progress and continue after the initial channel turn | The adapter owns deadlines, deduplication, and proactive continuation |
This is a stronger product boundary than "chat with enterprise data." Each capability has a clear input, output, identity mode, failure contract, and audit event.
The agent can also combine capabilities. A user can ask it to inspect a folder, identify the relevant files, apply a loaded domain skill, and produce a structured result in one conversation. The model plans that sequence. The application still executes every step through a reviewed path.
Portfolio Intelligence Through Governed Tools
The portfolio-question layer is available to users who already have access to the underlying records.
The objective is not to place raw portfolio tables in the prompt. It is to let a user ask a direct question and receive a calculated, dated, and explainable answer.
Examples include:
- How is this portfolio allocated by asset class, currency, geography, or issuer?
- Which positions contribute most to concentration?
- What changed between two reporting dates?
- Which holdings drove the period's performance?
- How much cash and near-cash exposure is available?
- Which positions or categories explain the largest movement?
- How do two authorized portfolios differ on the same metric?
The metric catalog is deliberately finite:
| Metric family | Example outputs | Calculation owner |
|---|---|---|
| Allocation | Asset class, currency, geography, sector, and issuer weights | Deterministic portfolio service |
| Concentration | Top holdings, top issuers, and cumulative top-N exposure | Deterministic portfolio service |
| Performance | Period return, contribution, and top positive or negative drivers | Approved performance engine |
| Liquidity | Cash ratio, near-cash exposure, and bounded liquidity categories | Deterministic portfolio service |
| Change | Position, weight, and allocation differences between dated snapshots | Versioned comparison service |
| Data quality | Missing prices, stale values, incomplete classifications, and calculation warnings | Validation rules before model use |
The model chooses a reviewed metric tool and explains its result. It does not calculate source-of-record figures from raw text. Each response includes the portfolio scope, valuation date, currency, metric definition, warnings, and source timestamp needed to interpret the answer.
The security contract stays the same:
- the signed-in identity determines which portfolios are visible
- the application binds the requested identifier to that authorized set
- tools are read-only and expose no general query language
- aggregations happen before the result reaches the model
- tool results are bounded, typed, and labeled as untrusted context
- portfolio values never enter durable preference memory
- traces contain metric names and counts, not holdings or values
- every answer preserves data provenance and the calculation date
The same tool layer supports proactive insights. A monitoring workflow detects a material allocation change, stale data, or a concentration threshold and prepares an explanation for review. It uses deterministic calculations and explicit notification policy. The agent explains an event; it does not invent one.
The capability remains analysis-first. It does not include trade execution, permission changes, or model-generated source-of-record values. Those boundaries keep the feature useful without turning a conversational interface into an uncontrolled transaction surface.
Custom Engine Means Product Control
Microsoft describes a Custom Engine Agent as an agent where the developer controls orchestration, models, and integrations. That control is useful when the product must combine custom business logic, several systems, regional processing constraints, and a precise security model.
It does not mean rebuilding every part of the stack.
The Microsoft 365 Agents SDK owns the channel edge:
- Copilot and Teams activities
- conversation addressing
- single sign-on and token exchange
- channel response formats
- streaming and follow-up messages
- durable channel state
Microsoft Agent Framework owns the agent runtime:
- model-provider integration
- typed function tools
- agent sessions
- context providers and memory
- skills
- middleware and telemetry
- workflows when an explicit execution graph is better than open-ended planning
The application owns the product contract:
- which capabilities exist
- who can invoke each one
- which data can enter a model request
- which model deployment and region are allowed
- which results need approval or redaction
- which evidence is required before release
This division let me keep one agent brain behind several entry points. Copilot, Teams, and a private application route use the same prompt, tools, memory rules, and policy layer.
The Tool Catalog Is the Product Surface
The first useful version of the agent did not need dozens of tools. It needed a small catalog whose descriptions and schemas made the correct action obvious.
The catalog currently groups tools into four product areas:
- Content discovery. Browse approved SharePoint locations and find large storage items.
- Document workflows. Gather required fields, resolve missing information through an interaction, and generate a reviewed document.
- Memory control. List, remember, forget, or remove all durable preferences.
- Domain knowledge. Load a relevant skill and its references without placing the full knowledge base in every prompt.
Every tool is typed and bounded. There is no general SQL tool, unrestricted HTTP tool, arbitrary Graph request, or script runner.
Here is a reduced version of the file-search tool:
from typing import Any
from agent_framework import tool
@tool(
name="search_approved_files",
description="Search an approved document location as the signed-in user.",
max_invocations=2,
)
async def search_approved_files(
site_hint: str,
folder_path: str | None = None,
top: int = 20,
) -> dict[str, Any]:
query = validate_file_query(
site_hint=site_hint,
folder_path=folder_path,
top=min(top, 50),
)
return await capabilities.invoke(
capability_id="content.search.v1",
payload=query,
handler=lambda: graph.search(
query,
user_assertion=current_user_token(),
),
)
The model sees a precise function. It does not see the delegated token. It cannot choose another site after validation, raise the result limit, or replace the handler.
This follows Microsoft's current Agent Framework tool guidance: register only the tools the agent needs, describe them precisely, handle failures explicitly, and avoid broad operations such as arbitrary SQL.
Tool Selection Is Not Authorization
A valid function call is only a proposal. Before execution, a capability gate verifies:
- the authenticated user's application roles
- whether the capability is enabled in this environment
- whether the entry point may use it
- whether the payload can be processed or logged
- whether the result still belongs to the caller's authorized scope
- which metadata must be written to the audit trail
async def invoke_capability(
definition: CapabilityDefinition,
context: CapabilityContext,
payload: object,
handler: CapabilityHandler,
) -> object:
if definition.required_roles.isdisjoint(context.roles):
raise PermissionError("The caller cannot use this capability.")
if not definition.enabled:
raise RuntimeError("The capability is disabled.")
payload_policy.validate(definition, payload)
result = await handler()
await definition.post_check(result, context)
audit.completed(definition.id, context.entrypoint)
return result
The same gate protects calls from the agent, a REST endpoint, and a background workflow. This prevents policy drift between interfaces.
It also improves delivery speed. A new agent feature is usually a new capability definition, typed handler, and tool adapter. The channel integration does not change.
Skills Add Knowledge Without Adding Authority
Tools let the agent act. Skills help it understand how to approach a domain task.
I connected Microsoft Agent Framework's SkillsProvider to a separate repository of reviewed Agent Skills. A skill can contain instructions and reference files. The agent first sees only each skill's name and description. It loads the full SKILL.md and a specific reference only when the question matches.
This progressive disclosure has two benefits:
- the base prompt stays small as the knowledge catalog grows
- domain guidance can be owned, reviewed, and versioned independently from the agent service
The supply chain matters as much as retrieval. The service never downloads the latest branch at startup. CI materializes an exact bundle into the container and verifies three levels of identity:
{
"source_commit": "<reviewed-git-commit>",
"skills": [
{
"name": "domain-guidance",
"version": "2.1.0",
"files": {
"SKILL.md": "sha256:<digest>",
"references/rules.md": "sha256:<digest>"
}
}
]
}
The running container gets an immutable, read-only snapshot. It exposes only load_skill and read_skill_resource. It rejects executable files and does not register Agent Framework's skill script tool.
A skill can explain a process or help the model select a tool. It cannot grant a role, broaden a data scope, or create a new live integration. Any operation still passes through a typed capability.
This separation makes skills practical for enterprise use. They become reviewed knowledge packages instead of remote code execution with a friendly name.
Redis and Cosmos DB Solve Different Memory Problems

The word "memory" hides several different requirements. I split the implementation by contract rather than asking one database to do everything.
Redis Keeps the Current Conversation Exact
Azure Managed Redis stores the last ten exact prompt-and-final-answer pairs for a conversation. Each entry has a seven-day sliding expiry.
This layer answers: What did we just discuss in this thread?
Redis is a good fit because the data is small, ordered, frequently accessed, and temporary. It also supports channel deduplication and other short-lived coordination state. The service connects through Microsoft Entra ID and a private endpoint instead of a static access key. Microsoft documents this Entra-authenticated Python connection pattern and recommends Private Link for network isolation.
The history store is intentionally boring. It does not summarize or reinterpret old turns. It gives the model a bounded exact transcript and then expires it.
Cosmos DB Remembers Preferences Across Conversations
Cosmos DB answers a different question: How has this user asked the agent to work?
The durable layer can remember low-risk preferences such as:
- preferred language
- concise or detailed answers
- headings, tables, or bullet formatting
- accessibility and presentation needs
- stable interaction style
It must not remember identity, permissions, approvals, transactions, internal records, or facts extracted from tool results.
The implementation uses the preview Azure Cosmos DB Agent Memory Toolkit through an Agent Framework context provider. The preview status is an operational constraint. Package versions are pinned exactly, the provider sits behind an application interface, and a kill switch can disable durable recall without disabling the Redis-backed agent.
The production policy is narrower than the toolkit defaults:
- raw extraction staging expires after 24 hours
- durable facts expire after 365 days
- each user has a steady-state target of 100 facts
- retrieval injects at most the top three facts
- each recalled fact needs confidence of at least 0.9
- summaries, episodic memory, procedural memory, and automatic profiles are disabled
from dataclasses import dataclass
@dataclass(frozen=True)
class MemoryPolicy:
staging_ttl_seconds: int = 86_400
fact_ttl_seconds: int = 31_536_000
max_facts_per_user: int = 100
max_recalled_facts: int = 3
minimum_confidence: float = 0.9
MEMORY_POLICY = MemoryPolicy()
The model can propose a memory. Application code verifies that it is grounded in the newest user message, removes sensitive or identifier-shaped content, binds it to a pseudonymous authenticated user scope, and applies retention. The generated answer and tool results never become durable-memory sources.
Users also retain control. They can list memories, ask the agent to remember one preference, delete one visible memory, or delete everything. A full deletion removes both Redis history and Cosmos records, then queries again to verify that no scoped records remain.
This is the key memory lesson: useful personalization does not require an unrestricted user profile.
One Agent Brain, Several Boundaries
The private gateway assembles the Agent Framework runtime for each invocation. It selects the approved Foundry deployment, attaches only the reviewed tools, loads the scoped context providers, adds the read-only skills provider, and applies hard tool-loop limits.
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from azure.identity import ManagedIdentityCredential
model = FoundryChatClient(
project_endpoint=approved_project_endpoint(),
model=approved_model_deployment(),
credential=ManagedIdentityCredential(),
)
agent = Agent(
client=model,
name="enterprise-assistant",
instructions=reviewed_instructions(),
tools=build_tools(current_user),
context_providers=[conversation_history, preference_memory, skills],
)
The Microsoft 365 adapter remains thin. It validates the activity, performs on-behalf-of token exchange, forwards the authenticated request, and translates streamed output back into the channel protocol. It contains no prompt orchestration and no business tools.
This gives every entry point the same behavior. It also keeps framework changes isolated. The Agents SDK can change its channel lifecycle without changing the tool system. Agent Framework can change its session API without changing the Microsoft 365 contract.
Keep the Full Data Path in Europe
Data location was an architecture input, not a deployment detail added at the end.
The model route, application services, Redis, Cosmos DB, secrets, and operational telemetry use approved European Azure regions. The AI gateway accepts only reviewed model deployments. A configuration check rejects unknown regions instead of falling back to a convenient global endpoint.
The control set includes:
- regional deployment allowlists in application configuration
- private endpoints for application data stores
- managed identity for Foundry, Redis, and Cosmos DB
- disabled public data-plane access where the service supports it
- separate identities for the channel adapter, agent gateway, and model route
- metadata-only telemetry with no prompt bodies, tokens, memory text, or tool payloads
- release probes that run from inside the private environment
Microsoft's current Agent Framework guidance makes the developer responsible for reviewing third-party data flow and geographic boundaries. I apply the same rule to every provider behind the framework: changing a model connection must not silently change the data boundary.
This is also why the model route goes through API Management and an AI gateway. Policy, quotas, model allowlists, and trace propagation live at one reviewed ingress. Workload identity replaces static model keys.
Route Each Workload to the Smallest Suitable Model
The regional boundary is part of the capability contract. The browser or channel does not choose a model. The server resolves one approved route before the first inference call.
The policy uses two lanes:
| Workload | Route | Reason |
|---|---|---|
| General conversation | Swiss-hosted GPT-4.1 | Keep the default conversation path in Switzerland with predictable latency and cost |
| Standard tool selection and explanation | Swiss-hosted GPT-4.1 | Keep governed read-only tool use, including portfolio questions, on the same Swiss path |
| Difficult structured or multimodal extraction | GPT-5.6 in the EU Data Zone | Use the stronger model only when a measured quality threshold requires it |
| Complex computer-use workflows | GPT-5.6 in the EU Data Zone | Reserve the exception for tasks that need stronger visual reasoning and multi-step control |
This is not a simple prompt router. Every model-calling capability declares its processing lane in the capability catalog. A missing value, an unknown value, or disagreement between the catalog, runtime, and gateway disables the capability.
The default path also fails closed. A Swiss deployment failure does not trigger an automatic move to another region. A classifier can block a request or recommend the complex lane, but it cannot authorize a regional downgrade. The complex lane needs an explicit capability allowlist and evidence that the Swiss model did not meet the workload's evaluation threshold.
Conversation history follows the selected boundary. Swiss chat stays in the approved Swiss Redis path across retries, summaries, tool calls, and resumed turns. Durable memory remains disabled for that lane until each extraction and embedding stage can meet the same Swiss-processing rule.
The routing evidence is metadata only. It records the policy decision, capability, deployment, region, latency, and outcome. It does not record prompts, completions, portfolio rows, file contents, or tool payloads.
This design controls three things at once: data location, model cost, and output quality. Most work stays on the smaller regional model. The stronger model remains available for tasks that can justify it through tests instead of preference.
Streaming Turns Long Work into a Usable Product
Some tool loops complete in seconds. Others need document discovery, several typed calls, or a generated artifact. A useful Copilot experience must show progress without coupling the authentication request to the full job.
The adapter treats streaming as a lifecycle:
- Open one ordered response stream for the turn.
- Coalesce model deltas instead of sending an update for each token.
- Send meaningful progress while tools run.
- Finalize before the channel deadline.
- Continue slower work in a separately tracked turn when needed.
- Deduplicate retries so one user action creates one execution.
This is where the product becomes visible. Users can ask for a complete outcome instead of waiting on a blank chat or learning which backend API to call.
Production Found Two SDK Boundary Defects
The upstream fixes remain an important part of the story, but they are supporting work. They made the product reliable after its main architecture was already in place.
A Microsoft 365 Copilot activity can carry a composite channel identifier such as msteams:COPILOT. Delivery needs that full value. The Bot Framework token service partitions tokens by the base channel, msteams.
Different user-token operations normalized that value differently. One message could therefore trigger repeated token exchange until the channel returned HTTP 429.

The fix was to normalize at the token-service boundary without changing the activity:
from microsoft_agents.activity import ChannelId
def token_channel_id(activity_channel_id: str) -> str:
return ChannelId(activity_channel_id).channel
I contributed that fix in Agents-for-python pull request #457. Microsoft merged it after wire-level tests and full repository validation.
The next defect kept the token-exchange invoke open while replaying the original user message. The tested lifecycle was to save authentication state, return HTTP 200, and replay the message as a separate tracked continuation turn. I proposed and validated that invariant in pull request #464. The maintainers continued the implementation in pull request #469 and merged the final SDK change.
The general lesson is useful beyond authentication: preserve the richer value for the boundary that needs it, normalize only where the narrower contract requires it, and do slow work outside a protocol response that has a short deadline.
Observability Connects the Whole Turn
One trace follows the request across:
- the inbound Microsoft activity
- token exchange and delegated identity
- the private gateway
- the Agent Framework run
- the Foundry model request
- every capability decision and tool call
- Redis and Cosmos memory operations
- the final channel response
Spans contain operation names, status, duration, model deployment, capability identifier, and aggregate result counts. They exclude prompts, access tokens, raw memory, document contents, and complete tool payloads.
That balance supports both debugging and privacy. It also provides product metrics: which capabilities users select, where time is spent, whether memory recall helps, and which dependencies approach their limits.
The authentication issue was easy to prove because the trace changed from ten exchanges and one rate limit to one exchange, one continuation, one agent run, and one answer.
DevSecOps Includes Prompts, Skills, and Policy
The deployable system contains more than Python source. It includes:
- Microsoft 365 manifests
- prompts and evaluation cases
- Agent Skills and their lock file
- tool definitions and capability policy
- API Management policy
- model and regional configuration
- container images
- database and private-network infrastructure
The pipeline scans secrets, runs formatting, static types, and tests, verifies every skill digest, generates OpenAPI artifacts, builds the image by digest, produces an SBOM and scan evidence, deploys to a validation environment, applies the gateway policy, and runs identity and readiness probes before promotion.
Prompt changes run tool-selection evaluations. Skill changes must match the reviewed commit and file hashes. The same immutable image digest moves between environments.
This makes the release evidence match the agent that users receive. A green Python test suite cannot prove that production has the reviewed prompt, tool catalog, memory mode, model route, or regional policy.
The Architecture I Would Reuse
The finished agent follows a small set of reusable rules:
- start with complete user outcomes, not a long list of APIs
- keep a small, precise tool catalog
- treat every model-selected action as an untrusted proposal
- keep authorization and data scope in application code
- package stable domain knowledge as pinned, read-only skills
- use Redis for bounded exact conversation history
- use Cosmos DB only for constrained cross-conversation preferences
- give users direct memory inspection and deletion controls
- keep one agent brain behind every channel
- make region, identity, and telemetry policy part of configuration validation
- trace one logical turn across authentication, model, tools, memory, and delivery
- fix framework defects upstream when the invariant belongs in the SDK
The model is the planner, not the product boundary. The product is the governed set of things the agent can do for a user.
That is what made this Custom Engine Agent useful: it can find information, apply reviewed knowledge, create structured outputs, remember how a user prefers to work, and remain understandable when a production turn crosses several services. Microsoft Agent Framework provided the runtime. The engineering work turned that runtime into an agent people can trust with real work.