How to create your own Cowork agent in Microsoft Teams

Anthropic's Claude Cowork gave everyone a working mental model for chat-driven agents. Here is how to build that same shape yourself, inside a Teams chat, on infrastructure you control.

Person on a sofa with a coffee mug, reading a chat conversation on a laptop.

TL;DR

Put an agent in a Teams chat and it works the way your team already works. Someone types, the agent does the job in its own isolated microVM, and the answer arrives in the same thread. Two pieces get you there. The zwrm MCP gateway provides a purpose-built Microsoft connector, which gives the agent Microsoft 365 tools including the one it uses to reply, and the MS Teams trigger turns every incoming chat message into an agent run. Set the trigger’s session key path to chat_id and the whole conversation shares one workspace, so the fifth message can build on the second without anyone repeating themselves. Two things to get right before you start: grant Chat.ReadWrite rather than Chat.Read, because being able to read a chat does not let you answer in it, and set a budget first, because an agent on a chat trigger runs when other people type.


Claude Cowork made a shape legible for a lot of people. You talk to an agent in a chat, it does real work, and the session remembers what came before. We are not shipping Cowork, we are not compatible with it, and we have nothing to do with Anthropic’s product. We are building the same shape on our own runtime, because the shape is a good one and most teams already have the chat window open.

The interesting property of a chat-driven agent is not the chat. It is that a conversation is a natural boundary for a piece of work. One thread, one task, one workspace that remembers. Everything below is how you get that in Teams, in an afternoon.

What you get

An agent that reads messages in a Teams chat, does the work in an isolated microVM, and answers in the same chat. Two pieces:

  • The Microsoft 365 connector gives the agent Microsoft 365 tools, including the one it uses to send the reply.
  • The MS Teams trigger watches a connected account’s chat messages and starts a run for each one.

In practice that looks like someone dropping “pull last week’s failed payments and put them in a spreadsheet” into a chat, and the file arriving in that same thread a few minutes later. A trigger without the connector gives you an agent that hears everything and cannot say anything.

Connect Microsoft 365

You bring your own Entra ID app, so the agent’s access is minted against your tenant and limited to the permissions you granted. Register a single-tenant app with the redirect URI https://mcp.zwrm.io/oauth/callback and add the delegated permissions you need.

Write the client secret’s expiry date somewhere you will see it. Nothing reminds you on the day it lapses, and the failure mode is an agent that quietly stops answering. Rotating it later means re-entering the client ID, the scope and the authorization server alongside the new secret. Users do not have to consent again.

Then add the connector from Dashboard, MCP, Add integration, Microsoft 365, or from the CLI:

zwrm mcp upstream add ms365 https://mcp-ms365.zwrm.io/mcp \
  --oauth \
  --oauth-client-id <client-id> \
  --oauth-client-secret <client-secret> \
  --oauth-scope "User.Read Chat.ReadWrite offline_access" \
  --oauth-issuer "https://login.microsoftonline.com/<tenant-id>/v2.0"

Pin the authorization server to your tenant ID. common and organizations fail issuer verification, so the tenant-pinned form is the only one worth trying.

Now the scope that costs people an afternoon. Grant Chat.ReadWrite, not Chat.Read. Read does not imply send, so an agent holding Chat.Read receives every message and owns no tool that can answer one. The symptom looks like a broken agent rather than a missing permission. For Teams work you also want ChannelMessage.Read.All, ChannelMessage.Send and Team.ReadBasic.All, plus offline_access. Add Presence.ReadWrite only if you want the presence option later.

Give the agent its own account

Consent is granted per user or per agent. Run zwrm mcp connect ms365, or use the Connect button on the connection row. Agents authorize from their own agent page, and an agent without its own grant is denied rather than silently mapped onto a human’s account. Inheriting a colleague’s token is the kind of convenience that reads fine in a demo and terribly in an audit log.

So create a dedicated Microsoft account for the agent. Whatever that account can see in Teams is what the agent can reach, and no amount of prompt wording narrows it.

The connector covers Microsoft 365 broadly: Teams, mail, calendar and files. Your Entra app sets the ceiling, since an “all tools” attachment only ever exposes tools inside the scopes you configured. From there you pick what the agent may touch. If you want a tight list rather than a wide one, build a virtual MCP server holding the handful of tools the job actually needs.

Create the agent

zwrm agent create teams-assistant --runtime zwrm

The runtime can be zwrm, claude or codex. Agents carry a persistent identity across runs: instructions, memory, skills, secrets, connectors. The VM each run executes in is destroyed afterwards. The identity is not.

Standing behaviour, the things that should be true on every run regardless of what anyone typed, goes in the instructions:

zwrm agent update teams-assistant --instructions "..."

Turn Teams messages into runs

In the dashboard trigger editor, choose the provider “MS Teams — chat messages from a connected Microsoft account” and the MS365 connector it rides on. Name it, write the Instruction that applies to every accepted delivery, and select the agent.

From there the connection maintains itself. zwrm keeps the Teams subscription alive for as long as the trigger is active, suspends it when you pause the trigger, and restores it if Microsoft drops it. There is nothing on your side to renew.

Two options are worth knowing about:

  • Include my own messages fires when the connected account sends, not only when it receives.
  • Show as available in Teams publishes an Available presence while the trigger is active and lapses to offline when it is paused. Needs Presence.ReadWrite.

Every incoming message arrives as a small JSON event with the chat, the sender, the body and a timestamp. Match conditions decide which messages get through, routes send different messages to different agents, and message templates format the event under your instruction using {dot.path} tokens like {body.content} and {from.display_name}.

The part that feels like a conversation

Set the trigger’s session key path to chat_id.

That is one field in a form, and it changes what the thing is. Same chat, same workspace. The agent keeps its files and its context across every message in that conversation, so the fifth message can build on what happened in the second without anyone restating it. Ask it to check something, then say “now do the same for last quarter,” and it knows what “the same” refers to.

Leave the session key empty and every message gets a fresh one-shot workspace that remembers nothing. That is the right choice for a notification-shaped trigger and the wrong one for a conversation. One field is the difference between a bot that answers questions and something that works with you over an afternoon.

The reply lands in the right chat

zwrm has no messaging capability of its own. The agent replies by calling a Microsoft 365 tool that sends to the chat the message came from. That is why the connector is load-bearing.

You get two guarantees out of that. The agent always knows which chat to answer in, because the platform hands it the chat as part of the run. And if it has no way to send, it says so plainly in its result instead of reporting a reply that nobody received. A visible failure beats a success you cannot verify.

Chat.ReadWrite is what removes the second case entirely.

Staying in control

An agent wired to a chat runs when other people type, not when you decide. Set the limits before you connect it:

zwrm agent budget teams-assistant --daily-usd 20 --max-runs 50

Triggers carry a daily rate limit of their own on top of the agent budget. Every delivery is logged, so when a run surprises you, read what actually arrived rather than guessing at it:

zwrm triggers deliveries <trigger-id>

Most surprises turn out to be a match condition that was wider than you thought.

Before you wire it up

Four things worth knowing in advance.

  • Work and school accounts only. Personal Microsoft accounts do not work here, and that is a hard boundary rather than a backlog item.
  • The provider and connector are fixed once the trigger exists. If you picked the wrong connector, create a new trigger. Everything else about the trigger stays editable.
  • Each agent needs its own connected account. It cannot borrow a colleague’s, and it will be denied rather than quietly falling back to one.
  • The agent’s reach is whatever that account can see in Teams. Pick the account with that in mind, because it is the real permission boundary and everything else is a preference.

Planning the setup with Vonk

Vonk, the assistant on this site, will plan the setup with you: which scopes your case actually needs, what the agent’s instructions should say, how to shape the trigger for the way your team uses Teams. It will not click through the Entra portal for you. It is also, usefully, the thing you are about to build. An agent in a VM, answering in a chat.

Getting started

Start narrow. One chat, one dedicated account, a scope list with nothing speculative in it, and a budget you would not mind spending twice. Set the session key to chat_id on day one, because the difference shows up in the first ten minutes. Read the delivery log after a day of real conversation, then widen from evidence.


Want to run agents on infrastructure you control, in Europe? Start a free 14-day trial at zwrm.eu.