spacestr

🔔 This profile hasn't been claimed yet. If this is your Nostr profile, you can claim it.

Edit
Geert Baeke (Unofficial)
Member since: 2026-10-28
Geert Baeke (Unofficial)
Geert Baeke (Unofficial) 1h

Deep Agents from zero: seven small examples A basic agent is a model calling tools in a loop. That’s enough for simple tool calls, but it falls apart on longer work: the agent loses track of what it was doing, its context fills up with tool output, and it has nowhere to put intermediate results. 🤷 LangChain’s Deep Agents library adds the harness that tools like Claude Code have around that loop: a filesystem, subagents, skills, planning and context management. I build most of my agents with Deep Agents these days, and a few people asked how to get started with it. So this post is the introduction I would have liked: seven small examples that build on each other, each one small enough to read in one go. The diagram shows what we’ll cover. The numbers are the examples below. What are Deep Agents? deepagents is a Python library (there’s a TypeScript version too) with one main function: create_deep_agent. It builds a LangChain agent with a set of middleware already wired in, and returns a compiled LangGraph graph. So everything you know from LangGraph still works: invoke, stream, checkpointers, LangSmith tracing. What you get out of the box: A virtual filesystem: ls, read_file, write_file, edit_file, glob and grep. Virtual is the important word: by default the files live in the agent’s state, not on your disk. When the agent writes a file, nothing shows up in your folder. To get real files on disk, you configure the agent with a different backend, such as FilesystemBackend(root_dir=...). We’ll do that in example 5. More in the Backends docs. The task tool: the agent can hand work to a subagent that starts with a clean context, and only gets the result back. See Subagents. Context management: long conversations get summarized, and large tool results are moved to files instead of filling the context window. See Context engineering. Other features are off until you ask for them. Each one is a single argument to create_deep_agent: Planning: middleware=[TodoListMiddleware()] adds the write_todos tool, so the agent writes a plan and ticks items off as it works. We do that in example 4. Skills: skills=["/skills/"] points the agent to folders with a SKILL.md. It only sees each skill’s name and description, and reads the full instructions when it needs them. That’s example 5. See Skills. Memory: memory=["/AGENTS.md"] loads AGENTS.md files into the system prompt at the start of every run. The agent can update them with edit_file when it learns something, and with a backend that persists files, it remembers that in the next conversation. See Memory. Human approval: interrupt_on={"write_file": True} pauses the agent before that tool runs, so you can approve, edit or reject the call. It needs a checkpointer, more on that near the end. See Human-in-the-loop. A shell: pass a backend that can run commands as backend=, such as LocalShellBackend (your own machine) or a sandbox, and the agent gets an execute tool. See Sandboxes. ⚠️ Important: Deep Agents moves fast. Everything below was tested with deepagents 0.7.32, langchain 1.4.7, langchain-openai 1.7.3, langchain-mcp-adapters 0.3.2, langchain-azure-ai 1.2.12 and langgraph-checkpoint-postgres 3.2.0 (October 2026). Since 0.7, the planning tool is no longer included by default. Older blog posts and tutorials assume it is. Setup You need Python 3.11 or later, uv and a model. Let’s create a project: uv init deepagents-intro && cd deepagents-intro uv add deepagents langchain-openai langchain-mcp-adapters The examples use model="openai:gpt-5.4-mini". That string goes to LangChain’s init_chat_model, so any provider works: anthropic:claude-sonnet-4-6, google_genai:..., ollama:.... Install the matching langchain- package and set its key. Pass a model explicitly: relying on the default model is deprecated. I ran everything against a gpt-5.4-mini deployment in Azure AI Foundry. Its OpenAI v1 endpoint works with the plain OpenAI client, so two environment variables are enough: export OPENAI_BASE_URL=https://.openai.azure.com/openai/v1/ export OPENAI_API_KEY= # no keys? an Entra token works too (it expires after about an hour) export OPENAI_API_KEY=$(az account get-access-token --resource https://cognitiveservices.azure.com --query accessToken -o tsv) The deployment name is the model name, so with a deployment called gpt-5.4-mini the model string stays the same. With plain OpenAI, set only OPENAI_API_KEY. ⚠️ For openai: models, Deep Agents uses the Responses API by default. The Foundry v1 endpoint supports it. If your gateway only does chat completions, pass init_chat_model("openai:...", use_responses_api=False) as the model instead of the string. There’s also a class made for Foundry: AzureAIOpenAIApiChatModel in the langchain-azure-ai package. It takes your Foundry project endpoint and an Azure credential, so there’s no key or expiring token to put in an environment variable. Install it with uv add langchain-azure-ai azure-identity and pass the model object instead of the string: from azure.identity import DefaultAzureCredential from langchain_azure_ai.chat_models import AzureAIOpenAIApiChatModel # reads AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ model = AzureAIOpenAIApiChatModel(model="gpt-5.4-mini", credential=DefaultAzureCredential()) agent = create_deep_agent(model=model, system_prompt="...") I kept the openai: string in the examples so they work with any OpenAI-compatible endpoint, but if you build on Foundry, this is the cleaner option. See Microsoft Foundry in the LangChain docs. Tip: set LANGSMITH_TRACING=true and LANGSMITH_API_KEY to see every model call and tool call in LangSmith. It’s optional, but it makes the examples a lot easier to follow. 😊 1. A minimal deep agent Let’s start with the smallest possible agent: a model and a system prompt, no tools of our own. from deepagents import create_deep_agent agent = create_deep_agent( model="openai:gpt-5.4-mini", system_prompt="You are a concise assistant. Save anything longer than a few lines to a file.", ) result = agent.invoke( {"messages": [{"role": "user", "content": "Write three tips for naming Azure resources to /tips.md, then summarize them in one line."}]} ) print(result["messages"][-1].text) print("\nFiles in the agent's state:", list(result["files"])) print(result["files"]["/tips.md"]["content"]) Save it as 01_minimal.py in the project and run it with uv run python 01_minimal.py: Saved to `/tips.md`. Summary: Use a consistent, concise Azure naming convention that encodes type/environment/workload, avoids special characters, and standardizes casing/separators. Files in the agent's state: ['/tips.md'] 1. Use a consistent naming convention that includes the resource type, environment, and workload. 2. Keep names short but descriptive, and avoid spaces or special characters. ... What happened here: We gave the agent no tools, yet it wrote a file. That’s the built-in write_file tool. The file is not on your disk. The default backend (StateBackend) keeps files in the graph state, so result["files"] holds them. Add a checkpointer and they survive between turns of the same thread. result["messages"] is the full conversation, including the tool calls. The last message is the answer. 2. A custom Python tool A tool is a plain Python function. The type hints become the schema and the docstring becomes the description, so write the docstring for the model. This one calls the free Frankfurter API for exchange rates (no key needed): import httpx from deepagents import create_deep_agent def get_exchange_rate(base: str, target: str) -> str: """Get today's exchange rate between two currencies, using ISO codes like EUR or USD.""" r = httpx.get("https://api.frankfurter.dev/v1/latest", params={"base": base, "symbols": target}, timeout=10) r.raise_for_status() data = r.json() return f"1 {base} = {data['rates'][target]} {target} (ECB rate of {data['date']})" agent = create_deep_agent( model="openai:gpt-5.4-mini", tools=[get_exchange_rate], system_prompt="You help with travel budgets. Always use get_exchange_rate for rates, never guess.", ) result = agent.invoke( {"messages": [{"role": "user", "content": "I have 1200 EUR for a week in Tokyo. What is that in yen, and per day?"}]} ) for message in result["messages"]: for call in getattr(message, "tool_calls", []): print(f"-> {call['name']}({call['args']})") print(result["messages"][-1].text) The loop at the end prints every tool call, so we can see what the agent did: -> get_exchange_rate({'base': 'EUR', 'target': 'JPY'}) Using today's rate: - 1 EUR = 175.92 JPY - 1200 EUR = 211,104 JPY ... So your budget is about ¥211,104 total, or ¥30,159 per day. tools= is additive: your tools are added to the built-in ones, never replace them. httpx comes with the OpenAI client, so there’s nothing extra to install. 3. Tools from an MCP server Most tools I use these days come from MCP servers. langchain-mcp-adapters turns them into LangChain tools, and those go into tools= like any other. Let’s use the Microsoft Learn MCP server, which is public and needs no sign-in: import asyncio from deepagents import create_deep_agent from langchain_mcp_adapters.client import MultiServerMCPClient async def main(): client = MultiServerMCPClient( { "learn": { "transport": "streamable_http", "url": "https://learn.microsoft.com/api/mcp", } } ) tools = await client.get_tools() print("MCP tools:", [t.name for t in tools]) agent = create_deep_agent( model="openai:gpt-5.4-mini", tools=tools, system_prompt="Answer Azure questions from Microsoft Learn only. Cite the URLs you used.", ) result = await agent.ainvoke( {"messages": [{"role": "user", "content": "What is the maximum number of replicas for an Azure Container App?"}]} ) # ... print the tool calls and the answer, as in example 2 ... asyncio.run(main()) The output: MCP tools: ['microsoft_docs_search', 'microsoft_code_sample_search', 'microsoft_docs_fetch'] -> microsoft_docs_search({'query': 'Azure Container Apps maximum number of replicas'}) -> microsoft_docs_search({'query': 'Azure Container Apps scaling replicas maximum Microsoft Learn'}) The maximum configurable number of replicas for an Azure Container App is **1,000**. ... Sources: - https://learn.microsoft.com/azure/container-apps/scale-app#scale-definition A few things to know: MCP tools are async, so we use ainvoke (or astream) instead of invoke. MultiServerMCPClient takes several servers at once. Use "transport": "stdio" with a command and args for local servers. get_tools() opens a new MCP session for every tool call. That’s fine here. For servers with state, open one session with client.session("learn") and load the tools from it. ⚠️ On Foundry? A Foundry toolbox bundles the tools you configure in your project (MCP servers among them) behind one MCP endpoint. langchain-azure-ai can load it: AzureAIProjectToolbox(toolbox_name="my-toolbox") from langchain_azure_ai.tools returns the toolbox tools as LangChain tools that you pass to tools=, with Entra ID authentication handled for you. Its get_skills() even loads the toolbox skills as files for create_deep_agent. It’s in preview, so expect changes. See Use Foundry Toolbox with LangChain. 4. Planning with write_todos For longer tasks it helps when the agent writes a plan first and ticks items off as it goes. In Deep Agents that’s the write_todos tool, from LangChain’s TodoListMiddleware. Since 0.7 you add it yourself: from deepagents import create_deep_agent from langchain.agents.middleware import TodoListMiddleware agent = create_deep_agent( model="openai:gpt-5.4-mini", middleware=[TodoListMiddleware()], # adds the write_todos tool (opt-in since deepagents 0.7) system_prompt="You are a technical writer. Plan with write_todos first, one item per section, and keep the list up to date.", ) task = ( "Write a short checklist for moving a Flask app to Azure Container Apps to /checklist.md. " "Cover the container image, secrets, scaling and monitoring, one section each." ) todos = [] for state in agent.stream({"messages": [{"role": "user", "content": task}]}, stream_mode="values"): if state.get("todos", []) != todos: todos = state["todos"] print("\n".join(f" [{t['status']:>11}] {t['content']}" for t in todos), end="\n\n") print(state["files"]["/checklist.md"]["content"]) This time we stream with stream_mode="values", which gives us the full state after every step. The todo list lives in the state under todos, so we print it whenever it changes: [in_progress] Draft checklist sections for container image, secrets, scaling, and monitoring [ pending] Write the checklist to /checklist.md [ pending] Verify the file content is correct [ completed] Draft checklist sections for container image, secrets, scaling, and monitoring [ completed] Write the checklist to /checklist.md [in_progress] Verify the file content is correct ... The tool itself does nothing clever: it stores a list of items with a status (pending, in_progress, completed). The value is mostly that your UI can show progress. Note that the model didn’t follow “one item per section”. It’s a hint, not a contract. 😊 ⚠️ Why is it opt-in now? LangChain benchmarked the todo list on their Deep Agents evals with GPT-5.6 Terra, Claude Opus 4.8 and GLM 5.2. It gave no significant accuracy gain on any of them, and it cost more tokens on two of the three (GPT-5.6 Terra even scored a bit better without it). Turn it on when you want to show progress in a UI, or when your own evals say it helps. 5. Skills with SKILL.md A skill is a folder with a SKILL.md file: YAML frontmatter with a name and a description, then instructions in Markdown. Deep Agents puts only the names and descriptions in the system prompt. The agent reads the full file when a task fits the description. That’s called progressive disclosure, and it means you can give an agent dozens of skills without a giant prompt. Here’s skills/release-notes/SKILL.md: --- name: release-notes description: Turn a list of commits or changes into release notes for end users. Use when asked for release notes, a changelog or a "what's new" summary. --- # Release notes 1. Group the changes under these headings, in this order, and leave out empty ones: `### ✨ New`, `### 🛠 Improved`, `### 🐛 Fixed`. 2. Write one bullet per change, in plain language for users, not developers. No commit hashes, no file names. 3. Merge changes that are really one feature into one bullet. # ... The name must match the folder name. Skills are read through the agent’s backend, so to load them from disk we switch to a FilesystemBackend: from pathlib import Path from deepagents import create_deep_agent from deepagents.backends import FilesystemBackend here = Path(__file__).parent agent = create_deep_agent( model="openai:gpt-5.4-mini", backend=FilesystemBackend(root_dir=here, virtual_mode=True), # the agent sees this folder as / skills=["/skills/"], system_prompt="You are a helpful assistant for a small product team.", ) commits = """ a1f3c2e feat: export reports to PDF 9b8d7e1 fix: dark mode toggle not saved after restart 4c2a9f0 chore: bump fastapi to 0.142 e7d1b33 feat: PDF export now includes charts 1f0e8a2 perf: dashboard loads 2x faster """ result = agent.invoke( {"messages": [{"role": "user", "content": f"Release notes for version 2.3, please:\n{commits}"}]} ) # ... print the tool calls and the answer ... The prompt doesn’t mention the skill, yet the first thing the agent does is read it: -> read_file({'file_path': '/skills/release-notes/SKILL.md', 'offset': 0, 'limit': 1000}) Version 2.3 brings faster dashboards, improved PDF exports, and a fix for dark mode settings. ### ✨ New - Export reports to PDF, including charts. ### 🛠 Improved - Dashboard pages now load much faster. ### 🐛 Fixed - Dark mode preferences are now saved correctly after restart. The two PDF commits became one bullet and the dependency bump is gone, just like the skill says. ⚠️ With a FilesystemBackend, write_file and edit_file change real files in root_dir. virtual_mode=True (the default) keeps the agent inside that folder. Don’t point it at your home directory. Without the backend, skill files have to be passed in the state with invoke(files={...}). Skills don’t have to live on disk. A StoreBackend keeps files in a LangGraph store, and with PostgresStore (from https://baeke.info/2026/10/30/deep-agents-from-zero-seven-small-examples/ #deepagents #sevensmall #smallexamples #context #see #skills #claudecode #memory #planning #important #rss #feeds #linksharing #qwibbit

#deepagents #sevensmall #smallexamples #context #see
Geert Baeke (Unofficial)
Geert Baeke (Unofficial) 1d

Agent 365: who's keeping an eye on your agents? Agents are popping up everywhere. Makers build them in Copilot Studio, developers ship them on Foundry, SharePoint sites come with a ready-made one, and partners sell them in the store. That’s great, until someone asks a simple question: how many agents do we have, who owns them, and what are they allowed to touch? In my test tenant, with seven users, the Microsoft 365 admin center counts 357 agents, and tells me that 345 of them don’t have an owner. I certainly didn’t build 357 agents. 🤷 That’s the problem Agent 365 is meant to solve. In my autopilots post I built DocHelper, a digital coworker with its own mailbox and Teams account. In this post we’ll look at the other side: what an admin sees and controls once agents like DocHelper show up. Not the code this time, but the control plane. 😊 What is Agent 365? Agent 365 is Microsoft’s control plane for agents. It’s not where agents run, and it’s not where they’re built. It’s where admins find them, approve them, lock them down and watch what they do, whatever platform they come from. It helps to see it as two products stacked on top of each other: Microsoft Entra Agent ID is the identity layer. Agents built on supported platforms (Foundry, Copilot Studio, the Agent 365 SDK) get their own identity, created from a blueprint that holds the credentials and declares the permissions the agent asks for. An admin still has to consent to those. Agents that act like a colleague, such as autopilots, also get an agent user with a mailbox and a Teams account. Every agent identity and blueprint needs a sponsor: a person or group who answers for it. Agent 365 is the management layer on top. It brings the registry, the approval flow and the admin pages, and it extends Entra, Defender, Purview and Intune to agents, so they get the same treatment as users. Agent 365 itself lives in the Microsoft 365 admin center under Agents. Identities and Conditional Access are still managed in the Entra admin center, and Defender and Purview keep their own portals. ⚠️ Important: Agent 365 has been generally available since May 1, 2026, licensed per user: it’s part of Microsoft 365 E7 or an add-on to E5, A5 or Business Premium, and agents owned or managed by a licensed user are covered. Agents with their own user account (autopilots, AI teammates) are still a Frontier preview, and the Frontier page asks for at least one E7 license in the tenant. In my tenant, only E7 did the trick. That last bit cost me a few evenings. 🤷 The registry: your agents in one list Agents > All agents is the inventory. The Registry tab lists the agents available in your tenant, each with a publisher type: Microsoft, external partners, published by your org, and shared by their creator. You can filter by platform. Foundry agents show up on their own, and once you connect a platform under Connected platforms (registry sync, in preview), agents from Amazon Bedrock, Google Vertex AI and others land in the same list. Templates for agents with their own account carry an AI teammate tag and show how many instances were hired. ⚠️ Important: if you remember the Agent registry blade in the Entra admin center: it was retired on May 1, 2026. The admin center list is the inventory now. Open an agent and you get one page with everything about it. Here’s DocHelper’s template, coworker, built on Foundry: Notice the tabs: Users, Data & tools, Security, Permissions, Activity and Licenses. And the Block button, top right. We’ll get to those. Approving an agent Not every agent needs an admin’s blessing. It depends on how far it’s shared: Shared by its creator. A maker shares a Copilot Studio, Agent Builder or SharePoint agent with a few colleagues, or a Foundry developer publishes for Just you and sends the link. No approval needed. These agents still show up in the registry as Shared by creator, so admins can see and block them. Admins can also limit sharing: for Agent Builder in the Microsoft 365 admin center, for Copilot Studio in the Power Platform admin center. Published to the organization. When a maker or developer wants the agent in the org catalog (Built by your org), it lands under Agents > All agents > Requests as Pending review, and nobody gets it until an admin publishes it. Autopilots. Agents with their own account come in as Pending activate. Nobody can hire an instance before an admin activates it. A Global Administrator or AI Administrator handles these requests. Readers can look, but not approve. Approval is a short wizard, and it’s where most of the governance gets decided: Who gets it. Everyone, or specific users and groups. For DocHelper, that’s who may hire an instance. A policy template. A bundle of controls applied to the agent at activation, which every instance inherits: SharePoint access control, Purview audit, Defender protection and, for autopilots, the license each instance gets. For other agents you can make your own templates with Conditional Access policies, access packages and custom security attributes (templates for autopilots don’t support custom policies yet). A template change only affects new activations, not agents that were already approved, so pick carefully. Permissions. The admin reviews what the blueprint asks for and grants admin consent. My first attempt stopped at the template. The default one refused to continue: The per-user Agent 365 license doesn’t count here. Autopilots need the Frontier agent licenses (25 free seats, one per instance), and those only showed up after I assigned an E7. After that, the wizard went through. After consent, you can see exactly what was granted on the blueprint in Entra. Here’s DocHelper’s: Mail.Read and Files.Read.All on Graph, the Agent 365 MCP servers for Mail, Teams, Word and friends, and the permission to send telemetry to Agent 365: ⚠️ Important: consent decides which calls the agent may make. It doesn’t give it anyone’s data. DocHelper acts as its own user account, so it reads its own mailbox and only the files, sites and groups shared with that account, usually by its manager. Who’s responsible? Agents get the same kind of accountability as people, split over three roles on purpose: RoleWhoCanOwnerthe technical adminchange the configuration and credentials, disable, re-enable, deleteSponsorthe accountable business person, requireddisable the agent, change sponsors, request access on its behalfManagerthe person the agent reports to, by default whoever hired itrequest access, sees it in their org chart When a sponsor leaves the company, a Lifecycle Workflows leaver workflow can move the sponsorship to their manager. You do have to set that up, and the sponsor needs a manager in Entra. No orphaned agents. Well, apart from the 345 in my tenant. 😎 Locking it down Because every agent has a real identity, the security tools you use for people work for agents too. A few things I found useful: Conditional Access. You can target All agent users or specific agents, and targeting a blueprint covers every agent identity created from it, including future ones (but not their agent users). Two gotchas: a policy for All users does not include agent users, and a policy on the agent identity doesn’t cover its agent user. An autopilot needs both. No god mode. Entra simply refuses high-privilege roles for agents (Global Administrator, Privileged Role Administrator, User Administrator) and permissions like Application.ReadWrite.All or RoleManagement.ReadWrite.All. Not even a Global Admin can override that. Access that expires. With access packages, an agent (or its sponsor) requests a bundle of groups and permissions, someone approves it, and it ends on a date. The sponsor gets a heads-up before it expires. Nobody acts, access is gone. The Block button. Block a single instance, or block the agent and all its instances stop until an admin unblocks it. Tools. Agents > Tools lists the plugins, skills, MCP servers and connectors available to agents in your tenant. For supported MCP servers, admins can allow or block individual tools (still rolling out), enforced at runtime by the Agent 365 Tooling Gateway. Seeing what agents do The Activity tab shows who used an agent, and the Agent map also shows which tools it called. Here’s the map for coworker: the two users who worked with DocHelper on the right, and the two tools it called on the left: Defender’s advanced hunting and Purview audit use the same data. That data is OpenTelemetry spans: one invoke_agent per turn, plus chat for model calls and execute_tool for tool calls. For hosted agents, Foundry sends the turn itself (unless Agent 365 data collection is turned off on the Foundry resource), but the tool calls have to come from the agent. In DocHelper I had to get the attributes exactly right before the tools showed up on the map, which is in the autopilots post. ⚠️ Important: at least one user in the tenant needs an E7 or Agent 365 license assigned, or the spans are dropped, even though the export returns a 200. Silent failures are the best failures. 🤷 Shadow AI: the agents nobody published Not every agent comes through the front door. People install AI apps on their laptops too. The Shadow AI page (Frontier) uses Defender for Endpoint to find them on managed devices: My test tenant has no managed devices, so all counts are zero. The list is short and fixed, and on this page only OpenClaw can actually be blocked, through an Intune policy on managed Windows devices. Learn also describes a newer Local agents page that finds (and can block some) coding agents like Claude Code, Codex and GitHub Copilot CLI. It’s a start, but it won’t find the Python agent someone wrote on Tuesday. 😊 What you don’t get Agent 365 brings a lot together, but be realistic: Licensing is a project of its own. Per-user Agent 365, E7, Frontier seats per instance, E5 plus Defender for Endpoint and Intune for Shadow AI. Figure this out before anything else. Lots of preview. Agent users, Conditional Access for agent users, Shadow AI and parts of the observability story are preview, and the docs don’t always agree with each other yet. Telemetry is your job. For your own agents, the activity and tool data is only as good as the spans you send. Owners don’t appear by magic. The registry shows you the 345 agents without an owner. Assigning them is still work for a human. Wrapping up Agent 365 is the control plane for agents in Microsoft 365. Entra Agent ID gives agents an identity with a sponsor, and Agent 365 puts all of them in one registry, runs every new agent through an approval with a policy template, and extends Conditional Access, Defender, Purview and Intune to them. With DocHelper, the whole loop worked: publish, approve, hire, watch, block. The idea is simple: treat agents like employees. Give them an identity, a boss, limited access and someone who keeps an eye on them. The platform does a lot of that for you, as long as you get the licensing sorted first. If you want to build an agent that shows up here, Microsoft’s autopilot sample is a good start: https://github.com/microsoft-foundry/foundry-samples/tree/main/samples/python/foundry-autopilot-agent If you spot any errors or have questions, feel free to reach out! https://baeke.info/2026/10/29/agent-365-whos-keeping-an-eye-on-your-agents/ #agents #microsoft #copilotstudio #entra #foundry #teams #defender #purview #frontier #rss #feeds #linksharing #qwibbit

#agents #microsoft #copilotstudio #entra #foundry
Geert Baeke (Unofficial)
Geert Baeke (Unofficial) 2d

From WordPress.com to a static site on Azure baeke.info ran on WordPress.com since 2011. That worked fine for a long time, but lately I write every post in Markdown, in a git repo, with Claude Code next to me. Getting that Markdown into WordPress meant converting it to block markup, uploading images through the admin page and checking that every block was valid. Lots of moving parts for what is, in the end, text and images. 🤷 So I moved the blog to a static site: the posts are Markdown files in git, Astro turns them into HTML, and Azure Static Web Apps serves the result. The migration took about an hour one evening before bed. The redesign happened the next day, from my phone, on the train, while my machine at home did the work. 😎 Here is the before and after: In this post I’ll give an overview of how it fits together and the key points: getting the content out of WordPress, keeping the old URLs working, hosting on the Free plan, switching DNS without (much) drama, and the new design. The repo is private for now because it also holds my drafts, but the snippets below are the parts that matter. Why move at all? A few reasons, in order of importance: The writing workflow. Markdown in git is the source of truth. No conversion to blocks, no media library, no “this block contains unexpected or invalid content”. Speed. Plain HTML from a CDN is fast. Cloudflare Web Analytics reports a median Largest Contentful Paint of about 250 ms. Control. The whole site, design included, is code I can change, review and roll back. Cost. The site runs on the Free plan of Static Web Apps. The big picture The setup has four parts and one step that ran only once: Markdown in git. I write posts in posts//post.md. A small script, md2site.py, copies a post into the Astro project with its images and frontmatter. GitHub Actions builds the site on every push to main that touches the site, and deploys it. Pull requests get their own preview URL. Azure Static Web Apps serves the HTML, images and search index, with free certificates for baeke.info and www.baeke.info. Cloudflare keeps doing DNS, plus a small analytics script. The site itself is Astro with content collections. Search is Pagefind, which builds a search index at build time, so there is no search server. Code highlighting is Shiki, and there is a full-text RSS feed and a sitemap. Getting the content out of WordPress WordPress.com has a public REST API. For a public blog it needs no login and no export file: curl "https://public-api.wordpress.com/wp/v2/sites/baeke.info/posts?per_page=100&page=1" A one-time import script walked through all 225 posts. For each post it converted the HTML to Markdown with turndown, downloaded the images next to the post and wrote the metadata as frontmatter. Each post now looks like this: --- title: "End-to-end authorization with Entra ID and MCP" date: "2025-07-29T15:14:48Z" permalink: "/2025/07/29/end-to-end-authorization-with-entra-id-and-mcp/" categories: ["ai","api"] tags: ["agents","azure","entra","mcp"] cover: "./2026-10-card-end-to-end-authorization-with-entra-id-and-mcp.png" wpId: 6144 --- Fifteen years of posts means fifteen years of different editors, so the conversion had a few gotchas: Old code blocks from the SyntaxHighlighter plugin use
inside . Converted naively, all the code ends up on one line. Text like in a sentence is an unknown HTML tag to a converter, so it silently disappears. Those have to be escaped. Tables need the GitHub-flavored Markdown plugin, and a few links still pointed to an old WordPress staging host. The 118 comments on 35 posts are archived as JSON and shown read-only under their posts. The wpId links a post to its comments. Once the import was done, I deleted the import script. WordPress won’t be used anymore, so there is nothing to keep in sync. Keeping the old URLs working This was my main requirement: links to the blog from other sites and from search engines should keep working. The static site uses the same URL structure as WordPress: Posts: /2025/07/29/end-to-end-authorization-with-entra-id-and-mcp/ Lists and archives: /page/2/, /category/azure/, /tag/mcp/, /2025/ and /2025/07/ The feed at /feed/, and old shortlinks like /?p=6144 Most of that is just generating pages at the same paths. The rest is in staticwebapp.config.json: { "routes": [ { "route": "/feed/", "rewrite": "/feed.xml" }, { "route": "/author/*", "redirect": "/about/", "statusCode": 301 }, { "route": "/_astro/*", "headers": { "Cache-Control": "public, max-age=31536000, immutable" } } ], "globalHeaders": { "Referrer-Policy": "strict-origin-when-cross-origin" } # ... } Static Web Apps routes can’t match on query strings, so /?p=6144 is handled on the home page itself: a few lines of JavaScript look up the id in a generated wp-ids.json and redirect to the post. /?s=term searches forward to the search page the same way. ⚠️ Important: that Referrer-Policy header matters. Static Web Apps sends same-origin by default, and with that policy embedded YouTube videos refuse to play (error 153). strict-origin-when-cross-origin fixes it. Hosting on the Free plan The Free plan of Static Web Apps is generous for a blog, but it has limits: 250 MB of storage per environment and 15,000 files 100 GB of bandwidth per month 2 custom domains and 3 preview environments no SLA With 225 posts and all their images, the site is about 107 MB in 2,269 files. The Standard plan is about $9 per month if I ever outgrow it. ⚠️ Important: watch the size of what you generate. My first build used Astro’s responsive image layout, which writes several sizes of every image. That build was 472 MB, far over the 250 MB limit. Without it the build is back to 107 MB. Switching DNS at Cloudflare My DNS lives at Cloudflare, and both baeke.info and www now point to the Static Web App. The root domain uses a CNAME, which Cloudflare allows at the apex thanks to CNAME flattening. Both records are set to DNS only, so Static Web Apps handles the certificates. baeke.info is the default domain of the app, so www redirects to it. ⚠️ Important: to validate a root domain, Static Web Apps wants the TXT record on @ itself. I first added it as _dnsauth, which is what you use for subdomains, and that gave about 15 minutes of certificate errors before the right record was in place. On a busier site, add the TXT record first and only then switch the CNAME. For analytics I use Cloudflare Web Analytics. Its automatic setup needs proxied records, so instead the site includes the small analytics script. It uses no cookies. A new design, from the train The first version of the static site copied the WordPress theme, so nobody would notice the move. Once it was live, I asked for a radically new design: fresh, clear and modern, and not the usual violet AI gradients. 😊 The result is an editorial look: warm paper, near-black ink and one red-orange accent. Headings are in Bricolage Grotesque, the text in Source Serif 4, and dates and labels in JetBrains Mono. The fonts are hosted by the site itself, so nothing loads from Google. The home page lists posts as text without thumbnails, and posts show their date and reading time in a column on the left. There is a dark mode too, with light as the default. I reviewed every step on my phone. The dev server was reachable over my Tailscale network, and a preview environment on Static Web Apps gave a real URL before anything went to production. When I was happy, it was one message to ship it. The title cards I use as social images got the same treatment: The cards are rendered from a small HTML template with headless Chromium. The favicon and the iOS home screen icon are now the “b” from the logo with the red-orange dot. Publishing a new post Publishing is now a few commands and a push: python3 tools/md2site.py posts/2026-10-static-site --categories azure # copy in as a draft cd site && npm run dev # preview, drafts included python3 tools/md2site.py posts/2026-10-static-site --publish # when it's ready git commit -am "New post" && git push # GitHub Actions deploys The workflow builds the site and uploads the result with the Static Web Apps action: on: push: branches: [main] paths: ['site/**'] pull_request: paths: ['site/**'] jobs: deploy: steps: # ... checkout, setup-node, npm ci - run: npm run build working-directory: site - uses: Azure/static-web-apps-deploy@v1 with: azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} action: upload app_location: site/dist skip_app_build: true Drafts are left out of the build, so pushing a draft is safe. What you don’t get A few honest caveats: No new comments. Old comments are shown read-only. New posts have none for now; I might add giscus later. WordPress followers don’t come along. People who followed the blog in the WordPress Reader or by email need to switch to the RSS feed. Hotlinked images break. Other sites that embed images from /wp-content/uploads/ get a 404, and WordPress attachment pages are gone. No SLA on the Free plan. For a personal blog I can live with that. Wrapping up A blog is text and images, and a static site treats it that way: Markdown in git, a build that turns it into HTML, and a CDN that serves it. The move kept every post, comment and URL that matters, the site is much faster, and it costs nothing to run. The best part is the workflow. The whole thing, from the import to the redesign, was done with Claude Code, some of it from my phone. Writing, previewing and publishing are now just files and a push. If you spot any errors or have questions, feel free to reach out! https://baeke.info/2026/10/28/from-wordpress-com-to-a-static-site-on-azure/ #staticweb #azurestatic #webapps #markdown #site #html #astro #free #search #code #rss #feeds #linksharing #qwibbit

#staticweb #azurestatic #webapps #markdown #site
Geert Baeke (Unofficial)
Geert Baeke (Unofficial) 3d

Always-on assistants that are asleep most of the day I wanted an assistant that is always there. I write to it at 7am and the answer is ready at 7:01. It remembers what I told it last week. I don’t have to start it, and I don’t have to explain myself again. The obvious way to build that is to keep a container running for every assistant. That works, but you pay for CPU and memory around the clock for something that waits for a message 99% of the time. 🤷 So I built Dottie, a small platform for personal agents I call dotties. A dottie feels like it is always on, but it is awake for only a few minutes a day. The rest of the time nothing runs for it. The trick is a new Azure service: Azure Container Apps Sandboxes. That’s my overview page: Ada, Rex and Grumpy (don’t ask 😊), 0 awake, 3 asleep. That’s how it looks nearly all of the time. In this post we will look at how that works: what a dottie is, how it wakes up, and how a sandbox gives it a computer that costs nothing while it sleeps. The code is on GitHub: https://github.com/gbaeke/dottie Two designs that don’t work There are two obvious designs, and neither is great: One container per assistant, always running. The agent loop, its memory and a shell all live in the container. Ten assistants means ten containers running 24/7, mostly to wait. No state at all. Every request starts a fresh container and throws it away afterwards. That’s cheap, but now the assistant has no computer. It can’t keep a cloned repo, an installed tool or a half-finished spreadsheet between conversations. What I wanted sits in between: the assistant keeps its things, but it only costs money while it works. What are Container Apps Sandboxes? Azure Container Apps Sandboxes (in preview) are small, isolated microVMs that you create and control from code. If you know Container Apps: apps are services that run all the time, jobs run to completion, and dynamic sessions are throwaway code interpreters. Sandboxes are the machine you can put to sleep. A few properties make them a good fit here: You control the lifecycle. You create a sandbox, run commands in it, upload and download files, and stop it when you decide to. Stop means suspend. When a sandbox stops, the platform keeps a snapshot of the disk, or of the disk and memory. When you start it again, it comes back as you left it: same files, same processes. A stopped sandbox costs no compute. You don’t pay CPU or memory while it is stopped. Each one is isolated. A sandbox has its own CPU, memory, disk and network boundary, and egress policies control what it can reach. Running code an LLM wrote is a lot less scary that way. Sandboxes come from prewarmed pools, so starting one is quick. In other words, a sandbox is exactly the missing middle: a persistent computer that costs nothing while it sleeps. ⚠️ Important: Sandboxes are a preview service, so the API and the details below may change. So what is a dottie? A dottie is a personal agent with four things: A personality: a short description of who it is and how it talks. Ada is my day-to-day assistant, and Rex does research. A wiki: its long-term memory, as markdown pages in PostgreSQL (people/anna.md, projects/website.md, …). The index.md page is always in its prompt. It opens the other pages when it needs them, using the normal file tools. Skills: instructions for specific jobs, such as “write the daily briefing” or “research a topic and report back”. A skill is loaded only when the dottie needs it. Tools: message another dottie, tell me something, schedule itself, fetch a web page, run shell commands, and call any MCP server I attach. You turn on tools per dottie as toolkits. In the screenshots below, the chips at the top right show Ada’s toolkits: computer · docker, messaging, schedule and web. The wiki is my favorite part. I can read and edit the same pages the dottie uses, so I can see what it knows and correct it when it gets something wrong. Ada wrote this index.md herself: The pages tagged dottie were written by Ada, and people/geert.md was written by me. She reads the index on every waking and opens user.md or log.md only when she needs them. Notice what is not in the list: a running process. Everything that makes Ada Ada is data in a database: personality, wiki, skills and conversation history. The agent loop is just code that runs on top of that data. I used LangChain’s Deep Agents for it. That’s the key idea. A dottie is not a process. It is a row, some pages and a history. A process exists only while it works. Waking up: everything is a message If dotties are asleep, something has to wake them up. There is one rule for that: the only way to wake a dottie is a message. All messages go into a single messages table in PostgreSQL, and three kinds of senders write to it: You, from the web UI, from Telegram, or from another agent over MCP The clock: a scheduler turns due schedules (“every weekday at 7”) into messages Other dotties: Ada can message Rex A dispatcher watches the table. When a dottie has mail, it claims the messages, wakes the dottie, runs the agent until it’s done, delivers the answer, and lets the dottie go back to sleep. The claim is a plain SELECT ... FOR UPDATE SKIP LOCKED, so two workers never take the same messages: def claim(session: Session, dottie_id: int) -> list[Message]: """Take the oldest conversation's pending messages for processing.""" # ... find the oldest conversation with pending mail ... ids = list( session.scalars( select(Message.id) .where(Message.recipient_id == dottie_id, Message.status == "pending", ...) .with_for_update(skip_locked=True) ) ) if ids: session.execute(update(Message).where(Message.id.in_(ids)).values(status="processing")) ... Because there is only one way in, everything else gets simple. A schedule is “post a message at a time”. Dottie-to-dottie is “post a message to someone else”. Adding Telegram was a webhook that posts a message. If the table ever needs to become Azure Service Bus, only post and claim change. Schedules are also just rows. The Morning briefing below is mine. The Say hello one-off was created by Ada herself after I asked her to remind me of something (note the set by the dottie tag): ⚠️ Dotties that talk to each other can loop forever. 😊 Every message carries a hop counter (MAX_MESSAGE_DEPTH, 5 by default), and a dottie can send only a limited number of messages per waking. A day with Ada Let’s see how this works in practice. 08:00. The clock sees that Ada’s Morning briefing is due and posts a message to her inbox. The dispatcher wakes Ada. She reads her wiki index, fetches the news, writes a short briefing and posts it in the conversation (or on Telegram). Then she goes back to sleep. Each run lands in the same conversation, so she can build on the previous ones: The activity feed shows what actually happened during that waking. She woke up for the scheduled task, made one fetch_url call, stayed awake for 120 seconds in case I replied, and went to sleep: Notice what is missing: she never needed a shell, so no sandbox was started. Most wakings look like this. Later that day. I ask Ada to write a Python script, run it, and tell me who and where she runs as. Now she needs her computer. The first execute call starts (or resumes) her sandbox, and the script ends up in /workspace: This is my local setup, so her “computer” is a Docker container and the host is my laptop 😎. On Azure it’s a Container Apps sandbox. Two minutes after the last message, the engine stops it. The compute cost goes to zero, and /workspace/fibonacci.py stays on the disk for next time. Dottie to dottie. Dotties also talk to each other, and every message wakes the recipient. Here Rex asked Ada to compute something with Python on her computer. She did and reported back, and then they sorted out a cancellation between themselves: Every bubble in that conversation is a separate waking. In between, nothing ran for either of them. The sandbox is the dottie’s computer, not the dottie This is the one rule that drives the design: Identity, wiki and history live in PostgreSQL. If a sandbox were lost tomorrow, the dottie would lose its scratch files but not its mind. The sandbox is lazy. Most wakings are just chat: read the wiki, think, answer. Starting a sandbox costs time and money, so it starts only when the agent runs its first command or touches /workspace. The engine stops the sandbox when the dottie goes back to sleep, after a short warm window (120 seconds) so a follow-up question skips the resume. The platform’s auto-suspend is only a backstop. The next waking resumes the same sandbox: same disk, same installed tools, same half-finished work. The lazy part is a small wrapper around the real sandbox. Nothing starts until the agent first calls execute or touches a file: class LazySandbox(BaseSandbox): """A sandbox that is only started when the agent first uses it.""" def _sandbox(self) -> SandboxBackendProtocol: with self._lock: if self._inner is None: inner, self.started = self.provider.wake(self.dottie) ... return self._inner def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: try: return self._sandbox().execute(command, timeout=timeout) except Exception as e: return ExecuteResponse(output=f"My computer did not start: {e}", exit_code=1) On Azure, wake either resumes the dottie’s existing sandbox or creates one. With the preview SDK that is only a few lines: def wake(self, dottie: Dottie) -> tuple[SandboxBackendProtocol, str]: if dottie.sandbox_ref: client = self.group.get_sandbox_client(dottie.sandbox_ref) client.ensure_running() # resumes a suspended sandbox with its disk (and memory) else: client = self.group.begin_create_sandbox( disk="ubuntu", auto_suspend_seconds=max(300, self.idle_seconds * 2), # a backstop labels={"app": "dottie", "dottie": dottie.slug, "user": dottie.owner_id}, ).result() return AcaSandbox(client), client.sandbox_id def sleep(self, ref: str) -> None: self.group.get_sandbox_client(ref).stop() Locally, the same interface is backed by a Docker container per dottie, so you can try all of this without an Azure subscription. Where does the agent loop run? There are two modes, set with AGENT_MODE: app: the agent thinks in the main app, and the sandbox only runs commands and holds files. It’s simple, but every active agent costs app CPU and memory. sandbox: the agent loop itself runs inside the dottie’s sandbox. The app becomes a control plane (messages, clock, dispatcher, UI), the thinking scales out on the platform, and the app mostly waits. The diagram below shows a single waking in sandbox mode: The second mode has a security property I really like: the sandbox gets a random token for that run and nothing else. It has no database password and no model key. When the agent in the sandbox needs the model or a tool, it calls back to the app over /internal, and the app adds the real credentials. The token stops working when the run ends. So even code the agent writes and runs in its sandbox can’t leak anything valuable. On Azure, that adds up to the architecture below. It uses one container image and two Container Apps: the main app and a small gateway that serves only the /internal API to the sandboxes. A user-assigned managed identity means there are no passwords or keys anywhere: What you don’t get A few honest caveats: ⚠️ Sandboxes are in preview, so expect changes. The first waking in sandbox mode installs the runtime in the sandbox. That takes about 30 seconds. After that it stays on the disk. An assistant that sleeps can’t watch for things on its own. If you want it to notice something, give it a schedule or point a webhook at it. Wrapping up Always on is a property of the experience, not of the infrastructure. The infrastructure can be asleep nearly all the time, as long as two things hold. First, what makes the assistant itself (memory, personality, history and skills) lives somewhere durable. Second, the thing that wakes it up is one boring, reliable message queue. Container Apps Sandboxes fill the last gap: a real computer per assistant that you suspend when it’s idle and resume in seconds with everything still on disk. You pay for waking time, not for waiting. Easy does it! 😎 The code is at https://github.com/gbaeke/dottie. It runs locally with Docker and on Azure with a few scripts. If you try it, spot any errors or have questions, feel free to reach out! https://baeke.info/2026/10/27/always-on-assistants-that-are-asleep-most-of-the-day/ #containerapps #appssandboxes #dottie #azurecontainer #cpu #running #rss #feeds #linksharing #qwibbit

#containerapps #appssandboxes #dottie #azurecontainer #cpu
Geert Baeke (Unofficial)
Geert Baeke (Unofficial) 3d

Autopilots: agents with their own mailbox, Teams account and boss Most agents live behind a chat box. You open a web app or Copilot, type a question, get an answer and close the tab. That’s fine for questions. It’s less fine for work that arrives by email at 7am, needs a look at a PDF, and should be answered in the same thread before anyone has had coffee. Microsoft’s answer to that is the autopilot: an agent that joins your organization like a new hire. It has a name, an email address, a Teams account, a manager and a spot in the org chart. You don’t open it. You mail it, chat with it or @mention it, just like a colleague. I wanted to know what it takes to build one, so I built DocHelper: a digital coworker that reads documents you send it, converts them, summarizes them and pulls out key fields. In this post we’ll look at what an autopilot is, why you’d want one, how to create one on Azure with Foundry hosted agents, and what the code has to do. Spoiler: more than you might expect. 😊 My code isn’t public (yet), but my host started from Microsoft’s Python autopilot sample, which is the best place to begin: https://github.com/microsoft-foundry/foundry-samples/tree/main/samples/python/foundry-autopilot-agent What is an autopilot? An autopilot is an agent with its own identity in Microsoft 365. When someone hires one, Entra creates an agent identity and an agent user for it. That agent user is a real account: A mailbox. People mail it, and it replies in the thread. Teams. You can chat with it 1:1, add it to a group chat or @mention it in a channel. Documents. @mention it in a Word, Excel or PowerPoint comment and it answers in the comment thread. A manager. Someone in the org is responsible for it, and it shows up in the org chart. Behind every instance sits an agent identity blueprint in Entra. Think of it as the job description for a kind of agent, not the agent itself. It holds: The credentials. The blueprint gets tokens on behalf of each instance. Instances have no secrets of their own. On Azure, that credential is a managed identity. The permissions it asks for. For example Mail.Read, so it can read the mail it’s sent. These are only a request. Nothing is granted until an admin consents. Inheritable permissions. Once an admin consents, every instance created from the blueprint gets the same baseline permissions. Who published it, plus any app roles. The code isn’t in the blueprint. It runs in the Foundry hosted agent, which is linked to its blueprint. One blueprint can be hired many times. The finance team hires one, HR hires another. Each hire gets its own agent identity and user account, with its own mailbox, manager and access to team resources. Because they all share one blueprint, an admin can block them all, apply one Conditional Access policy to all of them, or revoke a permission for all of them in one step. Roll out a new version of the agent behind the blueprint and every instance runs it. Admins see all of this in Agent 365, the control plane for agents in the Microsoft 365 admin center. Here’s my coworker blueprint, which the admin center shows as an agent template. The Instances tab lists DocHelper, the one instance I hired from it: ⚠️ Important: autopilots are a preview, available through the Microsoft 365 Frontier program. Expect things to move. Some of what I describe below will be out of date by the time you read it. 🤷 Why would you want one? Because most work doesn’t start in a chat box. It starts in a mailbox, in a Teams thread or in a comment on a document. An autopilot meets people there: No new app. Nobody has to learn a portal or remember a URL. You forward a mail to DocHelper and that’s it. It works when you don’t. Mail is pushed to the agent the moment it arrives, so the answer can be waiting for you. It has a real identity. It reads and writes with its own account and its own permissions, not with yours. You can see exactly what it can touch and take that away again. Admins stay in control. An admin approves the blueprint, decides who may hire it, and can block it in one place. Activity and tool calls show up in Agent 365. One build, many hires. You build a capability once and let teams hire their own instance. The trade-off: you’re now responsible for something that behaves like an employee. It needs clear instructions, limited access and a manager who keeps an eye on it. Just like a real new hire. 😎 Two ways to build one You can create an autopilot in two places: Copilot Studio, low-code. A maker builds the agent in Copilot Studio, turns on Agent account before the first publish, and publishes. Instructions, tools and triggers are configured in the designer, and it runs on Copilot Credits. This is the quick route if the job fits what Copilot Studio offers, but there are limits: internal users only, no documents with protected sensitivity labels, and one shared memory for everyone it works with. Microsoft Foundry, pro-code. You write the agent yourself, ship it as a container, run it as a hosted agent and publish it as an autopilot. You pick the framework, the model and the tools. I went with Foundry, because I wanted full control over the agent loop and the tools. The rest of this post is about that route. Creating an autopilot on Foundry The path from container to coworker has four steps, and three different people are involved: 1. Build. You build the agent as a container image, push it to a registry and create a hosted agent in a Foundry project. Foundry creates the Entra agent identity blueprint and an agent identity for you. You don’t need an Entra role or an app registration for any of this, only Azure RBAC on the project. Here’s the coworker hosted agent in the Foundry portal. Notice the Activity protocol endpoint (that’s how Microsoft 365 talks to it, more on that below) and the Entra agent identity and blueprint on the right: 2. Publish. From the portal (the Publish button above) or through the publish API, you publish the agent as an autopilot. That creates an approval request in Microsoft 365, listing the permissions the blueprint asks for, such as Mail.Read to read the mail it’s sent. 3. Approve. A Global Admin or AI Admin approves the request in the Microsoft 365 admin center under Agents > All agents > Requests, consents to the permissions and decides who may hire it. Consent decides which calls the agent may make at all. It doesn’t open up any team’s data: that happens later, when the manager gives the instance access to sites and groups, just like for a new colleague. 4. Hire. Now anyone who’s allowed can find it in Teams under Apps (it was listed under Agent templates in my tenant) and create an instance. You give it a name and an alias, and you become its manager: A few minutes later DocHelper has a mailbox and shows up in Teams. Time to send it some work. What you need On the Microsoft 365 side: The tenant in the Frontier program, with the Agent 365 terms accepted by a Global Admin. At least one Microsoft E7 license in the tenant, according to the Frontier docs. The Foundry quickstart is a bit looser (a Copilot or Agent 365 license), but in my test tenant nothing showed up until I assigned an E7: no Frontier banner, no seats. An agent seat per hired instance. Frontier gives the tenant 25 free Microsoft 365 Frontier for AI Teammates licenses. No free seat, no hire. An admin who approves the blueprint. On the Azure side: A Foundry project in a region that supports hosted agents, with a model deployment. A container registry and, ideally, Application Insights for traces. Azure RBAC for the developer: Owner (or Contributor plus RBAC Administrator) to create the resources, then Foundry User on the project and AcrPush on the registry to build and publish. ⚠️ Important: keep Foundry and Microsoft 365 in the same Entra tenant. The agent identities live in Entra, and Microsoft 365 has to find them. Microsoft doesn’t spell this out, but it’s the only setup I’d try. What’s inside the container? This is where it gets interesting. A hosted agent for Microsoft 365 is a container that speaks the Activity Protocol: the same protocol Teams bots have used for years. Microsoft 365 sends every event as an activity (a JSON document) with a POST to one URL, /activity/messages, and the agent answers with activities of its own. If all you need is a Teams chat, Foundry can bridge a normal Responses agent to Teams for you. For email and comment notifications I wanted the activities themselves, so my container hosts the Activity Protocol directly. You don’t parse those by hand. Two Microsoft SDKs do the plumbing: The Microsoft 365 Agents SDK receives the activities, checks the token on every call, routes each activity to a handler, gets tokens for the agent user and sends replies. The Agent 365 notifications package adds handlers for the Agent 365 events: email, and comments in Word, Excel and PowerPoint. It also has a ready-made email reply. Every event arrives on that same URL. They differ in only a few fields: EventchannelIdsub-channelwhat’s in itTeams chat or channelmsteamsthe message text, file linksEmailagentsemailan emailNotification: message id, conversation id, HTML bodyWord, Excel, PowerPoint commentagentsword, excel, powerpointa wpxcomment: document id, comment idLifecycleagentse.g. agenticUserIdentityCreated when someone hires the agent In Python, the host boils down to a few handlers. This is simplified from my code: app = AgentApplication[TurnState](storage=storage, adapter=CloudAdapter(...), authorization=auth, ...) notifications = AgentNotification(app) # email and Office comments: every Agent 365 notification @notifications.on_agent_notification(ChannelId(channel="agents", sub_channel="*"), auth_handlers=["AGENTIC"]) async def on_notification(context, state, n): await handle_turn(context) # Teams messages addressed to the agent user @app.activity("message", is_agentic=True, auth_handlers=["AGENTIC"]) async def on_teams_message(context, state): await handle_turn(context) handle_turn is where my own code starts. It turns the activity into a plain dict and hands it to a service with no SDK in it, which is much easier to test. The SDK only receives the event and sends the reply. How an email reaches the agent Let’s follow one email from start to finish: The important bit is push, then pull. Microsoft 365 pushes a notification to the agent the moment a mail lands in its mailbox, so the agent never polls. But that notification is thin on purpose. It has the message id and the HTML body. No subject, no attachments, no earlier messages in the thread. So the agent pulls the rest itself, from Microsoft Graph, as its own agent user. The SDK exchanges a token for the agent user (that’s the AGENTIC auth handler above), and my tools call Graph with it: async def provide(scopes: list[str]) -> str | None: result = await app.auth.exchange_token(context, scopes=scopes, auth_handler_id="AGENTIC") return result.token With that token, the get_email tool does three Graph calls: GET /me/messages/{id} for the subject, sender and the body. I use uniqueBody, which is only what this mail adds, so quoted replies don’t reach the model twice. GET /me/messages/{id}/attachments to list the attachments, and /attachments/{id}/$value to download one when a tool needs it. GET /me/messages?$filter=conversationId eq '...' for the earlier messages in the thread, across all folders, so the agent also sees its own previous replies in Sent Items. None of this comes for free. If you skip it, your agent answers a mail without knowing the subject, the attachment or what was said before. That’s a very confident colleague who never reads past the first line. 🤷 The reply is the easy part. You don’t need Mail.Send: you answer the activity with an EmailResponse and Microsoft 365 sends it as a reply in the thread, from DocHelper: await context.send_activity(EmailResponse.create_email_response_activity(html)) Here’s the result. I mailed DocHelper an invoice and asked for a summary and the key fields. A minute later the answer was in my inbox: The PDF was converted to Markdown with Azure AI Document Intelligence first, and the model worked from that. Document Intelligence is just another tool the agent can call. Teams files need code too Teams is similar. A message to the agent arrives with the text in the activity, so a plain chat works right away. Files are another story. A file sent in a Teams chat arrives as a link, and for an agent user that link can’t be downloaded directly. Before I wrote the code for it, this happened: At least it’s honest about it. 😊 The fix is to read the file through Graph’s /shares endpoint as the agent user. It’s not hard, but it’s code you have to write, test and secure. Only download from hosts you trust, check the type and size, and treat the content as untrusted input. The same goes for Word comments. The notification tells you which document and which comment, the comment text is in the activity, and a normal text reply ends up in the comment thread. Reading the document itself is a Graph call. Changing it is a whole other project, with more permissions. Sessions: every conversation gets its own sandbox Hosted agents don’t run as one long-lived container. Each session gets its own isolated sandbox, with its own CPU, memory and disk. When a session is idle (15 minutes by default) it goes to sleep and costs nothing. The next activity wakes it up, with its $HOME and /files still there. You pay for CPU and memory while sessions are active, plus model tokens. Which activity lands in which session is up to Microsoft 365. Here’s what I saw: Every email starts a new session, even a reply in the same thread. A Teams conversation keeps one session, a 1:1 chat or a channel thread, for days. That has consequences: Don’t keep state in the sandbox. My first version kept work items and approvals in SQLite under $HOME. That works for Teams, and does nothing for email. Real state belongs in a shared store, such as Azure Table Storage or Cosmos DB. Re-read the thread. Because every mail starts fresh, the mailbox is the memory. That’s why get_email reads the earlier messages in the thread. Handle every event once. A notification can be delivered again. I derive a work-item id from the email id or Teams activity id and claim it before doing anything, so a redelivery gets no second reply. Deploys don’t apply right away. A session is bound to the version that was current when its container started. A busy Teams chat can keep running the old version until it goes idle. 😎 Wait, isn’t this Dottie? If you read my post about Dottie, some of this will sound familiar. Some of the ideas are the same: Asleep by default. Nothing runs until an event arrives. A dottie wakes up for a message, and an autopilot wakes up for an email, a chat or a comment. A small VM that sleeps. A dottie gets an Azure Container Apps sandbox, and a hosted agent session gets its own VM-isolated sandbox. In both cases the files survive the nap and you don’t pay while it sleeps. The difference is what the agent is. A dottie is a personal assistant inside my own app, with no mailbox, no Teams account and no manager. I built the plumbing (messages, schedules, Telegram) myself. An autopilot is a teammate in Microsoft 365. Entra gives it an identity, people reach it in Outlook, Teams and Office, and admins govern it in Agent 365. Handling events with an agent So far, the SDK has received an event, and my code has checked it. Now something has to actually think. The nice thing about hosted agents is that this part is yours: any framework, any model you can reach. Before the agent sees anything, my handler does three checks, in this order: Is this from our tenant, and from someone who may use this agent? Check the Entra object id, never the display name. An email from outside is dropped silently, so outsiders don’t learn that an agent is listening. Have we seen this event before? Claim the work item, or stop. Build the prompt. A short header the sender didn’t write (channel, sender, message id), then the message itself, fenced as untrusted content. Then it hands over to the agent. In DocHelper, the loop is a small LangGraph graph: the model calls tools until it can answer, with a step limit so it can’t loop forever. But nothing ties you to that. Here’ https://baeke.info/2026/10/27/autopilots-agents-with-their-own-mailbox-teams-account-and-boss/ #agent #teamsaccount #own #microsoft #ownmailbox #azure #entra #rss #feeds #linksharing #qwibbit

#agent #teamsaccount #own #microsoft #ownmailbox

Welcome to Geert Baeke (Unofficial) spacestr profile!

About Me

Unofficial automated RSS/Atom links to Geert Baeke's work on Recent hands-on tutorials build agents with Azure Functions and Microsoft Foundry. Original work is credited at the source; follow the source links. Zaps support the independent feed operator, not the author.

Interests

  • No interests listed.

Videos

Music

My store is coming soon!

Friends