Codex Through the Glass: Microsoft Teams as a Codex Interface — The Complete Implementation Guide
Codex Through the Glass: Microsoft Teams as a Codex Interface
The Complete Implementation Guide
Series: Codex Through the Glass — Interface Patterns for Non-Developer Users (Part 1 of 8)
Microsoft Teams is where most enterprise knowledge workers already live. They chat, approve purchase orders, join standup calls, and file expense reports without ever leaving the purple sidebar. If you want non-developer users to interact with a Codex-powered agent, the shortest path to adoption is putting the agent where the users already are.
This article is a full implementation guide. It covers the architecture, the SDK choices, the Codex app-server protocol, complete code for every critical component, Adaptive Card templates for approval workflows, and production deployment patterns. By the end you will have everything you need to build a working Teams-to-Codex harness.
Part 1: The SDK Landscape in 2026
Before writing a line of code, you need to choose the right Microsoft SDK. The landscape has shifted significantly, and picking the wrong foundation will cost you weeks.
What happened to Bot Framework SDK v4?
Microsoft archived the Bot Framework SDK at the end of December 2025. Support tickets are no longer serviced. The repository remains on GitHub for reference, but no new features, security patches, or bug fixes will ship.1
If you are starting a new project in 2026, do not use Bot Framework SDK v4. If you have an existing Bot Framework bot, plan a migration.
The three current options
| SDK | Status | Best for | MCP support | Language |
|---|---|---|---|---|
| Teams SDK v2 (formerly Teams AI Library) | GA (JS/C#), Preview (Python) | New Teams bots and agents | Yes — built-in MCP client plugin | TypeScript, C#, Python |
| Microsoft 365 Agents SDK | GA | Multi-channel agents (Teams, Copilot, Web Chat) | Yes — via extensibility | C#, JavaScript, Python |
| Copilot Studio | GA | No-code/low-code agents | Yes — declarative agents with MCP | Visual designer |
For a Codex harness, the Teams SDK v2 is the right choice. It is the most lightweight, it handles authentication and event routing, and its MCP support means the bot itself can participate in the MCP ecosystem alongside Codex.23
How others have done it
Five distinct integration patterns exist in the wild as of June 2026:
- Microsoft Copilot Studio agents — no-code drag-and-drop. Microsoft’s own Copilot products use this. Great for simple Q&A, poor for custom agent backends.4
- Azure OpenAI + Bot Framework — the classic 2023-2024 pattern. An Azure App Service hosts a bot that calls Azure OpenAI. Still works but uses the archived SDK.5
- Teams SDK v2 + external agent — the pattern this article implements. A Teams SDK bot acts as a frontend, forwarding messages to a separate agent backend (Codex, LangChain, Azure AI Foundry, or any custom service).6
- NanoClaw messaging adapter — an open-source personal AI agent that connects to Teams (alongside Telegram, Slack, WhatsApp, Discord, and Gmail) using a skills-based architecture built on Anthropic’s Claude Agent SDK. NanoClaw demonstrates the “lightweight harness” pattern at production scale.7
- Power Automate + HTTP connector — a workflow-based approach where Power Automate receives Teams messages and calls an external API. Simple but limited for streaming or interactive approvals.8
This guide follows pattern 3, informed by the architectural lessons of pattern 4.
Part 2: Architecture
The architecture has three layers. The Teams SDK handles the messaging protocol. Your harness translates between Teams and Codex. The Codex app-server runs the agent.
flowchart TB
subgraph "Microsoft 365"
U["Teams User"] -->|message| TI["Teams Infrastructure<br/>(Azure Bot Service)"]
end
subgraph "Your Infrastructure"
TI -->|HTTPS POST /api/messages| TB["Teams SDK Bot<br/>(Node.js / Express)"]
TB -->|JSON-RPC 2.0| CB["Codex Bridge<br/>(harness logic)"]
CB -->|stdio or WebSocket| AS["Codex App Server<br/>(codex app-server)"]
CB -->|read/write| DB["SQLite<br/>(thread mapping)"]
end
subgraph "External Systems"
AS -->|MCP tools| ERP["ERP / SAP /<br/>Contract DB"]
AS -->|MCP tools| FS["File System /<br/>SharePoint"]
end
AS -->|approval request<br/>JSON-RPC notification| CB
CB -->|Adaptive Card| TB
TB -->|card render| TI
TI -->|interactive card| U
U -->|approve / reject| TI
TI -->|invoke activity| TB
TB -->|approval response| CB
CB -->|JSON-RPC response| AS
The four components
1. Teams SDK bot — A Node.js Express application using @microsoft/teams.apps. Receives messages from Teams, handles authentication, and renders Adaptive Cards. Approximately 80 lines of code.1
2. Codex bridge — Your harness logic. Manages the JSON-RPC 2.0 connection to the Codex app-server, translates between Teams activities and Codex protocol, and handles the approval flow. Approximately 200 lines of code.
3. Codex app-server — The same engine that powers the Codex TUI, VS Code extension, JetBrains plugin, and mobile app. Runs as a child process (stdio) or a WebSocket server. Manages threads, turns, sandbox execution, model selection, and MCP tool calls.910
4. SQLite database — Maps Teams conversation IDs to Codex thread IDs. Stores conversation references for proactive messaging. Persists approval state for crash recovery.
Why not call the OpenAI API directly?
You could skip the app-server and call the Chat Completions API from your bot. But you would lose:
- Sandbox execution — the app-server runs commands in an isolated environment with permission profiles
- MCP tool orchestration — the app-server manages MCP server lifecycles and tool routing
- Thread persistence — conversation history, session archival, and context compaction
- Approval flow — built-in human-in-the-loop with configurable policies
- Goal mode — multi-turn autonomous execution across hours or days
- Model discovery — automatic model selection and fallback
The app-server is the differentiator. Without it, you have a chatbot. With it, you have an agent.
Part 3: Prerequisites and Setup
What you need
| Requirement | Details |
|---|---|
| Node.js | v20+ (LTS recommended) |
| Microsoft 365 tenant | With permission to sideload custom apps |
| Azure subscription | For Azure Bot Service registration (free tier sufficient for development) |
| Codex CLI | v0.139.0+ installed globally: npm install -g @openai/codex |
| OpenAI account | API key or ChatGPT auth for the app-server |
| Teams Developer CLI | npm install -g @microsoft/teams.cli (preview) |
Step 1: Register your Teams bot
The Teams Developer CLI automates Azure AD app registration, client secret generation, manifest creation, and bot setup in a single command:
# Install the CLI
npm install -g @microsoft/teams.cli
# Register a new bot (creates Azure AD app + bot channel registration)
teams app create \
--name "Codex Invoice Agent" \
--endpoint "https://your-domain.com/api/messages"
This creates a .env file with CLIENT_ID, CLIENT_SECRET, and TENANT_ID.1
For development, use Dev Tunnels (built into VS Code) or ngrok to expose your local server:
# Using Dev Tunnels
devtunnel create --allow-anonymous
devtunnel port create -p 3978
# Or using ngrok
ngrok http 3978
Update your bot’s messaging endpoint to the tunnel URL + /api/messages.
Step 2: Scaffold the project
mkdir codex-teams-harness && cd codex-teams-harness
npm init -y
npm install @microsoft/teams.apps express better-sqlite3 ws dotenv
npm install -D typescript @types/node @types/express @types/better-sqlite3
Create the project structure:
codex-teams-harness/
src/
index.ts # Express server + Teams SDK setup
codex-bridge.ts # JSON-RPC client for Codex app-server
card-factory.ts # Adaptive Card templates
thread-store.ts # SQLite conversation mapping
types.ts # Shared type definitions
.env # Secrets (auto-generated by teams app create)
tsconfig.json
package.json
Part 4: The Teams SDK Bot
The message handler
The Teams SDK v2 reduces boilerplate by 70-90% compared to Bot Framework SDK v4. The core pattern is an event-driven message handler:26
// src/index.ts
import { App } from '@microsoft/teams.apps';
import { ExpressAdapter } from '@microsoft/teams.apps';
import express from 'express';
import dotenv from 'dotenv';
import { CodexBridge } from './codex-bridge';
import { CardFactory } from './card-factory';
import { ThreadStore } from './thread-store';
dotenv.config();
const expressApp = express();
const adapter = new ExpressAdapter(expressApp);
const app = new App({
httpServerAdapter: adapter,
clientId: process.env.CLIENT_ID!,
clientSecret: process.env.CLIENT_SECRET!,
tenantId: process.env.TENANT_ID!,
});
const bridge = new CodexBridge();
const cards = new CardFactory();
const store = new ThreadStore();
// Handle incoming messages from Teams users
app.on('message', async ({ reply, activity }) => {
const conversationId = activity.conversation.id;
const userText = activity.text?.trim();
const userName = activity.from.name;
if (!userText) return;
// Send a typing indicator while the agent works
await reply({ type: 'typing' });
// Look up or create a Codex thread for this conversation
let threadId = store.getThread(conversationId);
if (!threadId) {
// Start a new Codex thread
const thread = await bridge.startThread();
threadId = thread.id;
store.mapThread(conversationId, threadId, activity);
}
// Submit the user's message as a Codex turn
try {
const result = await bridge.startTurn(threadId, userText, userName);
// Format the result as an Adaptive Card
const card = cards.resultCard(result);
await reply({ attachments: [card] });
} catch (error) {
const errorCard = cards.errorCard(
'The agent encountered an error. Please try again.',
error instanceof Error ? error.message : 'Unknown error'
);
await reply({ attachments: [errorCard] });
}
});
// Handle Adaptive Card actions using verb-specific sub-routes
// Teams SDK v2 maps 'adaptiveCard/action' invokes to 'card.action' aliases
// and supports sub-routes per verb: 'card.action.{verb}'
app.on('card.action.approveAction', async ({ activity }) => {
const data = activity.value?.action?.data;
if (!data?.approvalId) return;
await bridge.approveAction(data.approvalId);
return {
statusCode: 200,
type: 'application/vnd.microsoft.adaptive.card',
value: cards.approvalConfirmationCard(data, 'approved').content,
};
});
app.on('card.action.rejectAction', async ({ activity }) => {
const data = activity.value?.action?.data;
if (!data?.approvalId) return;
await bridge.rejectAction(data.approvalId);
return {
statusCode: 200,
type: 'application/vnd.microsoft.adaptive.card',
value: cards.approvalConfirmationCard(data, 'rejected').content,
};
});
// Initialise and start the server
(async () => {
await app.initialize();
await bridge.initialize();
const port = process.env.PORT || 3978;
expressApp.listen(port, () => {
console.log(`Codex Teams harness running on port ${port}`);
});
})();
What the SDK handles for you
The Teams SDK automatically:6
- Verifies incoming requests are legitimately from Teams before processing
- Routes messages to the correct event handler
- Injects the
/api/messagesendpoint into your Express app - Handles OAuth and token refresh for Azure AD authentication
- Manages conversation references for proactive messaging
Your server remains yours. The adapter is a lightweight seam — not a framework takeover.
Part 5: The Codex Bridge
The bridge is the core of the harness. It manages a JSON-RPC 2.0 connection to the Codex app-server and translates between Teams events and the Codex protocol.
Understanding the Codex app-server protocol
The Codex app-server exposes a bidirectional JSON-RPC 2.0 API. Every surface that uses Codex — the TUI, VS Code extension, JetBrains plugin, iOS app, and now your Teams bot — speaks this same protocol. OpenAI designed it so that “internal teams and external partners could embed the same harness in their own products.”10
Working clients exist in Go, Python, TypeScript, Swift, and Kotlin.9
Supported transports:
| Transport | Use case | Configuration |
|---|---|---|
| stdio (default) | Local development, single-tenant | codex app-server (or --listen stdio://) |
| WebSocket (experimental) | Multi-tenant, remote servers | codex app-server --listen ws://127.0.0.1:8765 |
| Unix socket | Same-host container communication | codex app-server --listen unix:///path/to/socket |
Key JSON-RPC methods:9
| Method | Direction | Purpose |
|---|---|---|
initialize |
Client -> Server | Handshake with client metadata and capabilities |
thread/start |
Client -> Server | Create a new conversation thread |
thread/resume |
Client -> Server | Resume an existing thread by ID |
turn/start |
Client -> Server | Submit user input, begin agent execution |
turn/interrupt |
Client -> Server | Cancel an in-flight turn |
thread/list |
Client -> Server | Page through stored threads |
turn/started |
Server -> Client | Notification: turn has begun |
turn/completed |
Server -> Client | Notification: turn finished |
item/agentMessage/delta |
Server -> Client | Streaming agent text |
item/commandExecution |
Server -> Client | Agent wants to run a command |
item/commandExecution/outputDelta |
Server -> Client | Streaming command output |
turn/diff/updated |
Server -> Client | Unified diff of file changes |
The approval protocol
When the agent wants to execute a sensitive operation, the flow is:9
- App-server sends
approval/commandExecution(server-initiated JSON-RPC request) - Your client must respond with
{ "approved": true }or{ "approved": false } - If approved, the agent proceeds. If rejected, the agent adjusts its plan.
The approval payload includes the full command, working directory, and sandbox policy — everything your Adaptive Card needs to show the user.
Bridge implementation
// src/codex-bridge.ts
import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import { createInterface, Interface } from 'readline';
interface JsonRpcMessage {
jsonrpc: '2.0';
id?: number;
method?: string;
params?: any;
result?: any;
error?: { code: number; message: string };
}
interface TurnResult {
status: 'completed' | 'interrupted' | 'failed';
output: string;
diffs: string[];
approvals: ApprovalRequest[];
}
interface ApprovalRequest {
id: string;
command: string;
workingDir: string;
sandboxPolicy: string;
}
export class CodexBridge extends EventEmitter {
private process: ChildProcess | null = null;
private reader: Interface | null = null;
private requestId = 0;
private pending = new Map<number, {
resolve: (value: any) => void;
reject: (error: Error) => void;
}>();
private approvalCallbacks = new Map<string, {
resolve: (approved: boolean) => void;
}>();
private turnResults = new Map<string, TurnResult>();
async initialize(): Promise<void> {
// Spawn the Codex app-server as a child process (stdio transport)
this.process = spawn('codex', ['app-server'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
CODEX_API_KEY: process.env.OPENAI_API_KEY,
},
});
// Read newline-delimited JSON from stdout
this.reader = createInterface({ input: this.process.stdout! });
this.reader.on('line', (line) => this.handleMessage(line));
this.process.stderr?.on('data', (data) => {
console.error('[codex-stderr]', data.toString());
});
this.process.on('exit', (code) => {
console.error(`[codex] App-server exited with code ${code}`);
// Implement reconnection logic for production
});
// Send the initialize handshake
await this.sendRequest('initialize', {
clientInfo: {
name: 'teams-codex-harness',
title: 'Codex Teams Bot',
version: '1.0.0',
},
capabilities: {
experimentalApi: false,
},
});
console.log('[codex] App-server initialised');
}
async startThread(): Promise<{ id: string }> {
const result = await this.sendRequest('thread/start', {});
return { id: result.threadId };
}
async resumeThread(threadId: string): Promise<void> {
await this.sendRequest('thread/resume', { threadId });
}
async startTurn(
threadId: string,
userText: string,
userName: string
): Promise<TurnResult> {
// Initialise a result collector for this turn
const turnResult: TurnResult = {
status: 'completed',
output: '',
diffs: [],
approvals: [],
};
return new Promise(async (resolve, reject) => {
// Listen for turn completion
const onTurnCompleted = (params: any) => {
if (params.threadId === threadId) {
turnResult.status = params.status;
this.removeListener('turn/completed', onTurnCompleted);
resolve(turnResult);
}
};
this.on('turn/completed', onTurnCompleted);
// Listen for streaming agent text
const onDelta = (params: any) => {
if (params.threadId === threadId) {
turnResult.output += params.delta || '';
}
};
this.on('item/agentMessage/delta', onDelta);
// Listen for diffs
const onDiff = (params: any) => {
if (params.threadId === threadId) {
turnResult.diffs.push(params.diff);
}
};
this.on('turn/diff/updated', onDiff);
// Start the turn
try {
await this.sendRequest('turn/start', {
threadId,
input: [{ type: 'text', text: `[${userName}]: ${userText}` }],
});
} catch (error) {
this.removeListener('turn/completed', onTurnCompleted);
this.removeListener('item/agentMessage/delta', onDelta);
this.removeListener('turn/diff/updated', onDiff);
reject(error);
}
});
}
async approveAction(approvalId: string): Promise<void> {
const callback = this.approvalCallbacks.get(approvalId);
if (callback) {
callback.resolve(true);
this.approvalCallbacks.delete(approvalId);
}
}
async rejectAction(approvalId: string): Promise<void> {
const callback = this.approvalCallbacks.get(approvalId);
if (callback) {
callback.resolve(false);
this.approvalCallbacks.delete(approvalId);
}
}
private handleMessage(line: string): void {
let msg: JsonRpcMessage;
try {
msg = JSON.parse(line);
} catch {
return; // Skip non-JSON lines (e.g. startup logs)
}
// Handle responses to our requests
if (msg.id !== undefined && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id)!;
this.pending.delete(msg.id);
if (msg.error) {
reject(new Error(`${msg.error.code}: ${msg.error.message}`));
} else {
resolve(msg.result);
}
return;
}
// Handle server-initiated notifications
if (msg.method) {
this.emit(msg.method, msg.params);
// Handle approval requests — these require a response
if (msg.method.startsWith('approval/') && msg.id !== undefined) {
this.handleApprovalRequest(msg);
}
}
}
private async handleApprovalRequest(msg: JsonRpcMessage): Promise<void> {
const approvalId = `approval-${Date.now()}-${msg.id}`;
// Emit an event so the Teams bot can render an Adaptive Card
const approvalRequest: ApprovalRequest = {
id: approvalId,
command: msg.params?.command || 'Unknown command',
workingDir: msg.params?.workingDirectory || '.',
sandboxPolicy: msg.params?.sandboxPolicy || 'unknown',
};
// Store a callback that will be resolved when the user approves/rejects
const approvalPromise = new Promise<boolean>((resolve) => {
this.approvalCallbacks.set(approvalId, { resolve });
});
// Emit the approval request for the Teams handler
this.emit('approval:requested', approvalRequest);
// Wait for the user's decision (with timeout)
const timeoutMs = 5 * 60 * 1000; // 5 minutes
const approved = await Promise.race([
approvalPromise,
new Promise<boolean>((_, reject) =>
setTimeout(() => reject(new Error('Approval timeout')), timeoutMs)
),
]).catch(() => false); // Default to reject on timeout
// Respond to the app-server
this.sendResponse(msg.id!, { approved });
}
private sendRequest(method: string, params: any): Promise<any> {
return new Promise((resolve, reject) => {
const id = ++this.requestId;
this.pending.set(id, { resolve, reject });
const message = JSON.stringify({
jsonrpc: '2.0',
id,
method,
params,
});
this.process!.stdin!.write(message + '\n');
});
}
private sendResponse(id: number, result: any): void {
const message = JSON.stringify({
jsonrpc: '2.0',
id,
result,
});
this.process!.stdin!.write(message + '\n');
}
destroy(): void {
this.process?.kill();
this.reader?.close();
}
}
Connecting the approval bridge to Teams
Add this to your index.ts to wire approval requests to Adaptive Cards:
// Listen for approval requests from the Codex bridge
bridge.on('approval:requested', async (approval) => {
// Look up which Teams conversation this thread belongs to
const mapping = store.getConversationForThread(approval.threadId);
if (!mapping) return;
// Build and send the approval card
const card = cards.approvalCard(approval);
// Use proactive messaging to send the card to the right conversation
await app.sendProactive(mapping.conversationReference, async (send) => {
await send({ attachments: [card] });
});
});
Part 6: Adaptive Card Templates
Adaptive Cards are JSON-based UI components that render natively in Teams across desktop, web, and mobile. They support text, images, inputs, buttons, and fact sets — and automatically adapt to light and dark mode.11
Critical design decisions
Action.Execute vs Action.Submit. Use Action.Execute with Universal Actions. Unlike Action.Submit, it supports sequential workflows where the bot returns a new card that replaces the current one. It also works consistently across Teams, Outlook, and Copilot Chat.12
Schema version. Target Adaptive Cards schema v1.5 for Teams desktop and mobile. Do not use v1.6 features — they are only supported in Bot Framework Web Chat.11
Card size limit. Teams enforces a 28 KB limit for Adaptive Card payloads. For large agent outputs (full reports, multi-page analyses), post a summary card with a link to the full document.
Refresh pattern. Use the refresh property to keep cards up to date. When a user views a card, Teams can automatically invoke the bot to get the latest state — useful for showing real-time approval status.12
Card factory implementation
// src/card-factory.ts
interface TurnResult {
status: string;
output: string;
diffs: string[];
}
interface ApprovalRequest {
id: string;
command: string;
workingDir: string;
sandboxPolicy: string;
details?: {
supplier?: string;
invoice?: string;
amount?: string;
poMatch?: string;
confidence?: string;
};
}
export class CardFactory {
/**
* Renders agent output as a structured result card.
*/
resultCard(result: TurnResult) {
const body: any[] = [
{
type: 'TextBlock',
text: 'Agent Result',
weight: 'Bolder',
size: 'Medium',
style: 'heading',
},
{
type: 'TextBlock',
text: result.output.slice(0, 2000), // Truncate for card size limit
wrap: true,
},
];
// Add diff summary if files were changed
if (result.diffs.length > 0) {
body.push({
type: 'TextBlock',
text: `Files changed: ${result.diffs.length}`,
weight: 'Bolder',
spacing: 'Medium',
});
body.push({
type: 'FactSet',
facts: result.diffs.slice(0, 10).map((diff, i) => ({
title: `Change ${i + 1}`,
value: diff.split('\n')[0] || 'File modified',
})),
});
}
// Status indicator
const statusColour = result.status === 'completed' ? 'Good' :
result.status === 'interrupted' ? 'Warning' : 'Attention';
body.push({
type: 'TextBlock',
text: `Status: ${result.status}`,
color: statusColour,
weight: 'Bolder',
spacing: 'Medium',
});
return {
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.5',
body,
},
};
}
/**
* Renders an approval request as an interactive card.
* Uses Action.Execute for Universal Actions compatibility.
*/
approvalCard(approval: ApprovalRequest) {
const facts: any[] = [];
if (approval.details) {
if (approval.details.supplier)
facts.push({ title: 'Supplier', value: approval.details.supplier });
if (approval.details.invoice)
facts.push({ title: 'Invoice', value: approval.details.invoice });
if (approval.details.amount)
facts.push({ title: 'Amount', value: approval.details.amount });
if (approval.details.poMatch)
facts.push({ title: 'PO Match', value: approval.details.poMatch });
if (approval.details.confidence)
facts.push({ title: 'Confidence', value: approval.details.confidence });
}
facts.push({ title: 'Command', value: `\`${approval.command}\`` });
facts.push({ title: 'Directory', value: approval.workingDir });
facts.push({ title: 'Sandbox', value: approval.sandboxPolicy });
return {
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.5',
body: [
{
type: 'ColumnSet',
columns: [
{
type: 'Column',
width: 'auto',
items: [{
type: 'Image',
url: 'https://adaptivecards.io/content/pending.png',
size: 'Small',
}],
},
{
type: 'Column',
width: 'stretch',
items: [
{
type: 'TextBlock',
text: 'Agent Approval Required',
weight: 'Bolder',
size: 'Medium',
style: 'heading',
},
{
type: 'TextBlock',
text: 'The agent wants to execute the following operation. Review the details and approve or reject.',
wrap: true,
isSubtle: true,
},
],
},
],
},
{
type: 'FactSet',
facts,
separator: true,
spacing: 'Medium',
},
],
actions: [
{
type: 'Action.Execute',
title: 'Approve',
verb: 'approveAction',
style: 'positive',
data: {
action: 'approve',
approvalId: approval.id,
},
},
{
type: 'Action.Execute',
title: 'Reject',
verb: 'rejectAction',
style: 'destructive',
data: {
action: 'reject',
approvalId: approval.id,
},
},
{
type: 'Action.ShowCard',
title: 'Show full command',
card: {
type: 'AdaptiveCard',
body: [
{
type: 'TextBlock',
text: approval.command,
fontType: 'Monospace',
wrap: true,
},
],
},
},
],
},
};
}
/**
* Invoice matching approval card — the fully-worked example.
*/
invoiceApprovalCard(invoices: InvoiceMatch[]) {
const rows = invoices.map((inv) => ({
type: 'ColumnSet',
columns: [
{
type: 'Column',
width: 'auto',
items: [{
type: 'TextBlock',
text: inv.status === 'matched' ? '✅' :
inv.status === 'flagged' ? '🟡' : '❌',
}],
},
{
type: 'Column',
width: 'stretch',
items: [{
type: 'TextBlock',
text: `${inv.invoiceNumber} — ${inv.supplier}`,
weight: 'Bolder',
}],
},
{
type: 'Column',
width: 'auto',
items: [{
type: 'TextBlock',
text: inv.amount,
horizontalAlignment: 'Right',
}],
},
{
type: 'Column',
width: 'auto',
items: [{
type: 'TextBlock',
text: `${inv.confidence}%`,
color: inv.confidence >= 95 ? 'Good' :
inv.confidence >= 80 ? 'Warning' : 'Attention',
}],
},
],
}));
return {
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.5',
body: [
{
type: 'TextBlock',
text: 'Invoice Matching Results',
weight: 'Bolder',
size: 'Large',
style: 'heading',
},
{
type: 'TextBlock',
text: `${invoices.filter(i => i.status === 'matched').length} matched, ${invoices.filter(i => i.status === 'flagged').length} flagged for review`,
isSubtle: true,
spacing: 'None',
},
// Header row
{
type: 'ColumnSet',
columns: [
{ type: 'Column', width: 'auto', items: [{ type: 'TextBlock', text: ' ', size: 'Small' }] },
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: 'Invoice', weight: 'Bolder', size: 'Small' }] },
{ type: 'Column', width: 'auto', items: [{ type: 'TextBlock', text: 'Amount', weight: 'Bolder', size: 'Small' }] },
{ type: 'Column', width: 'auto', items: [{ type: 'TextBlock', text: 'Match', weight: 'Bolder', size: 'Small' }] },
],
separator: true,
},
...rows,
],
actions: invoices.some(i => i.status === 'flagged') ? [
{
type: 'Action.Execute',
title: 'Approve all flagged',
verb: 'approveAllFlagged',
style: 'positive',
data: {
action: 'approve',
approvalId: 'batch-' + Date.now(),
invoiceIds: invoices.filter(i => i.status === 'flagged').map(i => i.id),
},
},
{
type: 'Action.Execute',
title: 'Review individually',
verb: 'reviewIndividual',
data: {
action: 'review',
invoiceIds: invoices.filter(i => i.status === 'flagged').map(i => i.id),
},
},
] : [],
},
};
}
/**
* Confirmation card shown after approval/rejection.
* Replaces the original approval card via Sequential Workflow.
*/
approvalConfirmationCard(
data: { approvalId: string; command?: string },
decision: 'approved' | 'rejected'
) {
return {
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.5',
body: [
{
type: 'TextBlock',
text: decision === 'approved'
? '✅ Action Approved'
: '❌ Action Rejected',
weight: 'Bolder',
size: 'Medium',
color: decision === 'approved' ? 'Good' : 'Attention',
},
{
type: 'FactSet',
facts: [
{ title: 'Decision', value: decision },
{ title: 'Approval ID', value: data.approvalId },
{ title: 'Time', value: new Date().toISOString() },
],
},
],
},
};
}
/**
* Error card for when the agent fails.
*/
errorCard(message: string, detail?: string) {
const body: any[] = [
{
type: 'TextBlock',
text: '⚠️ Agent Error',
weight: 'Bolder',
size: 'Medium',
color: 'Attention',
},
{
type: 'TextBlock',
text: message,
wrap: true,
},
];
if (detail) {
body.push({
type: 'TextBlock',
text: detail,
fontType: 'Monospace',
size: 'Small',
isSubtle: true,
wrap: true,
});
}
return {
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.5',
body,
},
};
}
}
// Type definitions
interface InvoiceMatch {
id: string;
invoiceNumber: string;
supplier: string;
amount: string;
confidence: number;
status: 'matched' | 'flagged' | 'rejected';
poNumber?: string;
discrepancy?: string;
}
Designing cards with the Adaptive Cards Designer
Microsoft provides a browser-based visual designer at adaptivecards.microsoft.com/designer.html where you can prototype and test card layouts in real time without writing JSON manually.13
Set the Target version to 1.5 and the Host app to Microsoft Teams. The designer includes starter templates and live preview across light and dark themes.
The Microsoft Teams Adaptive Card Samples repository on GitHub contains production-quality templates for common patterns including approval workflows, data visualisations, and sequential flows.14
Part 7: Thread and State Management
The thread store
// src/thread-store.ts
import Database from 'better-sqlite3';
interface ThreadMapping {
conversationId: string;
threadId: string;
createdAt: string;
lastTurnAt: string;
conversationReference: string; // Serialised for proactive messaging
}
export class ThreadStore {
private db: Database.Database;
constructor(dbPath = './codex-teams.db') {
this.db = new Database(dbPath);
this.db.exec(`
CREATE TABLE IF NOT EXISTS thread_map (
conversation_id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_turn_at TEXT NOT NULL DEFAULT (datetime('now')),
conversation_reference TEXT
);
CREATE TABLE IF NOT EXISTS approval_log (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
command TEXT NOT NULL,
decision TEXT,
decided_by TEXT,
decided_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_thread_map_thread
ON thread_map(thread_id);
CREATE INDEX IF NOT EXISTS idx_approval_log_thread
ON approval_log(thread_id);
`);
}
getThread(conversationId: string): string | null {
const row = this.db
.prepare('SELECT thread_id FROM thread_map WHERE conversation_id = ?')
.get(conversationId) as { thread_id: string } | undefined;
return row?.thread_id || null;
}
mapThread(
conversationId: string,
threadId: string,
activity?: any
): void {
const ref = activity
? JSON.stringify({
serviceUrl: activity.serviceUrl,
conversationId: activity.conversation.id,
tenantId: activity.conversation.tenantId,
userId: activity.from.id,
})
: null;
this.db
.prepare(`
INSERT OR REPLACE INTO thread_map
(conversation_id, thread_id, conversation_reference)
VALUES (?, ?, ?)
`)
.run(conversationId, threadId, ref);
}
getConversationForThread(threadId: string): ThreadMapping | null {
return this.db
.prepare('SELECT * FROM thread_map WHERE thread_id = ?')
.get(threadId) as ThreadMapping | undefined || null;
}
updateLastTurn(conversationId: string): void {
this.db
.prepare(
"UPDATE thread_map SET last_turn_at = datetime('now') WHERE conversation_id = ?"
)
.run(conversationId);
}
logApproval(
id: string,
threadId: string,
conversationId: string,
command: string,
decision: string,
decidedBy: string
): void {
this.db
.prepare(`
INSERT INTO approval_log
(id, thread_id, conversation_id, command, decision, decided_by, decided_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
`)
.run(id, threadId, conversationId, command, decision, decidedBy);
}
}
Conversation reference persistence
A critical detail for production: you must persist ConversationReference objects immediately after the first interaction. These references contain the serviceUrl, conversationId, tenantId, and userId needed for proactive messaging — sending messages to Teams without the user initiating a conversation.15
Best practices from the Microsoft Q&A community:15
| Practice | Details |
|---|---|
| Database design | Partition by tenantId for multi-tenant safety |
| Include tenantId always | Prevents cross-tenant messaging errors |
| Error handling | Monitor for 403 Forbidden (user blocked or uninstalled bot) |
| TTL for stale references | References can expire; implement cleanup for conversations that haven’t been active in 90+ days |
Part 8: Sequential Approval Workflows
The real power of Teams as a Codex interface is the sequential workflow pattern. When a user approves or rejects, the bot returns a new card that replaces the existing one — creating a multi-step flow within a single card.12
How it works
- Agent finds three invoices. Bot sends a summary card with “Review” button.
- User taps “Review.” Bot replaces the card with the first flagged invoice’s details and “Approve / Reject” buttons.
- User taps “Approve.” Bot replaces the card with the next flagged invoice.
- After the last invoice, bot replaces the card with a final confirmation showing all decisions.
Each step uses Action.Execute with a verb property. The bot’s handler returns an adaptiveCard/action invoke response containing the next card.1216
Bot-side invoke response
When Teams sends an adaptiveCard/action invoke for an Action.Execute button, your bot must return a specific response format:
// Handle sequential card navigation with verb-specific sub-routes
// Each Action.Execute verb gets its own handler — idiomatic Teams SDK v2
app.on('card.action.approveAction', async ({ activity }) => {
const data = activity.value?.action?.data;
await bridge.approveAction(data.approvalId);
store.logApproval(
data.approvalId,
data.threadId,
activity.conversation.id,
data.command || '',
'approved',
activity.from.name
);
// Return a replacement card (Sequential Workflow)
return {
statusCode: 200,
type: 'application/vnd.microsoft.adaptive.card',
value: cards.approvalConfirmationCard(data, 'approved').content,
};
});
app.on('card.action.rejectAction', async ({ activity }) => {
const data = activity.value?.action?.data;
await bridge.rejectAction(data.approvalId);
store.logApproval(
data.approvalId,
data.threadId,
activity.conversation.id,
data.command || '',
'rejected',
activity.from.name
);
return {
statusCode: 200,
type: 'application/vnd.microsoft.adaptive.card',
value: cards.approvalConfirmationCard(data, 'rejected').content,
};
});
app.on('card.action.reviewIndividual', async ({ activity }) => {
const data = activity.value?.action?.data;
const firstInvoice = data.invoiceIds?.[0];
if (firstInvoice) {
return {
statusCode: 200,
type: 'application/vnd.microsoft.adaptive.card',
value: cards.singleInvoiceReviewCard(firstInvoice, data.invoiceIds).content,
};
}
});
The catering bot precedent
Microsoft provides an official Sequential Workflows Adaptive Cards sample that demonstrates this exact pattern. The sample uses a catering ordering scenario — food selection, drink selection, order confirmation — where each Action.Execute returns a new card that replaces the previous one. The Codex approval flow is architecturally identical.16
Part 9: Handling Streaming and Rate Limits
The streaming challenge
The Codex app-server streams agent output via item/agentMessage/delta notifications — often dozens per second during active generation. Teams has rate limits of approximately one message per second per conversation. You cannot forward every delta as a Teams message.
The batching pattern
Buffer deltas and post periodic updates:
class StreamBatcher {
private buffer = '';
private timer: NodeJS.Timeout | null = null;
private readonly intervalMs = 2000; // Update every 2 seconds
private sendFn: (text: string) => Promise<void>;
private lastMessageId: string | null = null;
constructor(sendFn: (text: string) => Promise<void>) {
this.sendFn = sendFn;
}
append(delta: string): void {
this.buffer += delta;
if (!this.timer) {
this.timer = setInterval(async () => {
if (this.buffer.length > 0) {
await this.flush();
}
}, this.intervalMs);
}
}
async flush(): Promise<void> {
if (this.buffer.length === 0) return;
const text = this.buffer;
this.buffer = '';
// Update the existing message rather than posting a new one
// This avoids flooding the conversation with partial updates
await this.sendFn(text);
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}
AI-generated content citations
Teams supports a special format for bot messages that contain AI-generated content, including sensitivity labels, citation references, and feedback buttons. Use this for agent outputs to signal to users that the content was generated by an AI agent.17
Part 10: Production Deployment
Infrastructure architecture
For production, the Codex app-server should not run on a developer laptop. Deploy it on infrastructure the organisation controls:
flowchart TB
subgraph "Azure"
ABS["Azure Bot Service<br/>(message routing)"]
ACA["Azure Container Apps<br/>(Teams SDK bot + harness)"]
ACI["Azure Container Instance<br/>(Codex app-server)"]
SQL["Azure SQL / SQLite<br/>(thread mapping)"]
end
subgraph "Private Network"
ACA -->|WebSocket<br/>private VNET| ACI
ACA -->|read/write| SQL
end
ABS -->|HTTPS| ACA
ACI -->|MCP over stdio| MCP["MCP Tool Servers<br/>(ERP, SharePoint, etc.)"]
Deployment options
| Component | Recommended | Alternative | Notes |
|---|---|---|---|
| Teams bot | Azure Container Apps | Azure App Service | Container Apps scales to zero; App Service is simpler |
| Codex app-server | Azure Container Instance | VM (Ubuntu/Debian) | ACI for ephemeral; VM for persistent threads |
| Database | Azure SQL Database | SQLite on persistent volume | SQLite is fine for single-tenant; SQL for multi-tenant |
| MCP tool servers | Same VNET as app-server | Sidecar containers | Keep MCP servers close to the app-server for latency |
| Secrets | Azure Key Vault | Environment variables | Key Vault for production; env vars for development |
WebSocket transport for multi-tenant
For multi-tenant deployments where multiple Teams conversations need simultaneous agent sessions, use the WebSocket transport instead of stdio:
# Start the Codex app-server with WebSocket transport
codex app-server \
--listen ws://127.0.0.1:8765 \
--ws-auth capability-token \
--ws-token-file /run/secrets/codex-ws-token
The bot connects via WebSocket and can multiplex multiple threads over a single connection. Health endpoints at GET /readyz and GET /healthz support load balancer probes.9
Authentication flow
sequenceDiagram
participant U as Teams User
participant T as Teams Infrastructure
participant B as Bot (Teams SDK)
participant C as Codex App Server
participant O as OpenAI API
U->>T: Send message
T->>B: HTTPS POST /api/messages<br/>(Azure AD token verified)
B->>B: Validate Azure AD token
B->>C: JSON-RPC initialize<br/>(CODEX_API_KEY in env)
C->>O: Authenticate with API key
O-->>C: Session established
C-->>B: Initialize response
B->>C: turn/start (user message)
C->>O: Chat completion
O-->>C: Model response
C-->>B: turn/completed
B-->>T: Adaptive Card response
T-->>U: Render card
Two separate authentication chains:
- Teams -> Bot: Azure AD verifies that incoming requests are legitimately from Teams. The Teams SDK handles this automatically.
- Bot -> Codex: Your harness authenticates to the Codex app-server using an OpenAI API key (set via
CODEX_API_KEYenvironment variable) or ChatGPT auth flow.
Security hardening
| Measure | Implementation |
|---|---|
| Network isolation | Run bot and app-server in the same Azure VNET. No public endpoint for the app-server. |
| mTLS | If using WebSocket transport across machines, terminate TLS at both ends. |
| Approval timeout | Default to reject if no user response within 5 minutes.18 |
| Audit logging | Log all approvals to the approval_log table with timestamp, user, and command. |
| Sandbox policy | Set the app-server’s default sandbox to readOnly or workspaceWrite. Reserve dangerFullAccess for explicitly approved commands. |
| Data residency | Azure Bot Service and Container Apps can be deployed in specific regions. Check your compliance requirements. |
| Telemetry | Codex CLI sends anonymous telemetry by default. For regulated environments, disable with CODEX_TELEMETRY=off. |
Part 11: The NanoClaw Pattern — Lessons from Production
NanoClaw is an open-source personal AI agent that connects to Teams, Telegram, Slack, WhatsApp, Discord, and Gmail. It uses a “skills over features” architecture: instead of building platform integrations as code, contributors submit skills that transform your fork. The project runs every agent group inside an isolated Docker container for security.7
What NanoClaw teaches us about harness design
| NanoClaw concept | Teams-Codex equivalent |
|---|---|
| Messaging adapter (grammy for Telegram, Teams SDK for Teams) | Teams SDK app.on('message') handler |
| Agent runtime (Claude Agent SDK) | Codex app-server (JSON-RPC) |
| Container isolation (Docker per group) | Azure Container Instance per tenant |
| Conversation state (in-memory + persistent) | SQLite thread mapping |
| Approval flow (Telegram inline keyboards) | Adaptive Cards with Action.Execute |
| Proactive messaging (scheduled tasks) | Proactive messaging via stored ConversationReference |
| Skills (Claude Code skills) | MCP tool servers |
The key architectural insight from NanoClaw: the harness should be as thin as possible. NanoClaw’s core is approximately 15 source files. The Codex Teams harness described in this article is four files plus card templates. The agent runtime (Codex app-server or Claude Agent SDK) handles all the intelligence. The harness just translates protocols.
Adapting the NanoClaw pattern for Codex
If you have an existing NanoClaw deployment and want to add Codex as a provider alongside Claude:
- NanoClaw supports
/add-codexas a skill that configures a Codex provider - The skill sets up the JSON-RPC bridge to the Codex app-server
- Messages from any connected platform (including Teams) route through the harness to Codex
- Approvals surface as platform-native interactive elements (Adaptive Cards in Teams, inline keyboards in Telegram)
This is a viable alternative to building a standalone harness: deploy NanoClaw, connect it to Teams, and add the Codex provider.
Part 12: Invoice Matching — The Complete Worked Example
Putting it all together. A finance team member in Teams types:
“Match the invoices from Acme Components that came in this morning”
What happens under the hood
sequenceDiagram
participant Karen as Karen (Finance)
participant Teams as Teams
participant Bot as Teams Bot
participant Codex as Codex App Server
participant ERP as ERP MCP Server
participant PO as PO Database MCP
Karen->>Teams: "Match the invoices from<br/>Acme Components this morning"
Teams->>Bot: Activity (message)
Bot->>Bot: Look up thread for this conversation
Bot->>Codex: turn/start (user text)
Codex->>ERP: MCP: list_invoices(supplier="Acme", date=today)
ERP-->>Codex: 3 invoices found
Codex->>PO: MCP: match_purchase_orders(invoice_ids)
PO-->>Codex: 2 exact matches, 1 discrepancy
Codex->>Codex: Auto-approve 2 within tolerance
Codex-->>Bot: approval/commandExecution<br/>(post journal entry for discrepancy)
Bot->>Teams: Adaptive Card (approval required)
Teams->>Karen: Card with 3 rows:<br/>✅ ✅ 🟡
Karen->>Teams: Taps "Approve with note"
Teams->>Bot: adaptiveCard/action invoke
Bot->>Codex: approval response (approved=true)
Codex->>ERP: MCP: post_journal_entries(3 invoices)
ERP-->>Codex: Success
Codex-->>Bot: turn/completed
Bot->>Teams: Confirmation card
Teams->>Karen: "3 invoices posted. Total: GBP 37,200"
The AGENTS.md for invoice matching
The Codex app-server reads an AGENTS.md file for project-level instructions. For invoice matching:
# Invoice Matching Agent
## Role
You are an invoice matching agent for the finance team.
You match incoming supplier invoices against purchase orders.
## Rules
- NEVER auto-approve invoices above GBP 50,000 — always request human approval
- NEVER auto-approve invoices with confidence below 85%
- ALWAYS show the price discrepancy when flagging an invoice
- ALWAYS include the PO number in approval cards
- Log every decision to the audit trail
## Tolerance
- Price tolerance: 2% or GBP 50, whichever is greater
- Quantity tolerance: 0 (exact match required)
- Date tolerance: 5 business days between invoice and goods receipt
## MCP Servers
- erp-connector: queries invoice inbox and posts journal entries
- po-database: matches invoices against purchase orders
- contract-store: retrieves current contract terms for price validation
Part 13: Testing and Debugging
Local development loop
- Start the Codex app-server:
codex app-server(stdio is the default) - Start your bot:
npm run dev(Express on port 3978) - Start a Dev Tunnel:
devtunnel host -p 3978 - Sideload your app manifest into Teams
- Send a message to your bot in Teams
Microsoft 365 Agents Playground
Microsoft provides the Agents Playground for debugging bots without sideloading into Teams. You can chat with your bot and see its messages and Adaptive Cards as they would appear in Teams.19
Testing Adaptive Cards
Use the Adaptive Cards Designer to preview your cards before deploying. Set the target version to 1.5 and the host app to Microsoft Teams. Test in both light and dark mode.13
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Bot never receives messages | Messaging endpoint URL wrong or tunnel not running | Verify endpoint in Azure Bot Service → Settings → Configuration |
| Adaptive Card doesn’t render | Schema version > 1.5 or malformed JSON | Validate in Adaptive Cards Designer with Teams host |
| Approval times out | Callback not wired between bridge and bot | Check that approval:requested event fires and card is sent |
403 Forbidden on proactive message |
User uninstalled bot or conversation reference stale | Re-install bot; clean stale references |
| App-server exits immediately | Missing CODEX_API_KEY or invalid auth |
Check environment variables and API key validity |
| Card exceeds 28 KB | Agent output too large for single card | Truncate output; link to full report in SharePoint or Codex Sites |
Part 14: Build Complexity — Revised Estimate
| Component | Effort | Notes |
|---|---|---|
| Teams SDK bot scaffolding | 1 day | Teams Developer CLI + Teams SDK v2. Simpler than Bot Framework v4. |
| Codex bridge (JSON-RPC client) | 2–3 days | stdio (default) for MVP; WebSocket (--listen ws://) for production. Handle initialise handshake, thread/turn lifecycle, approval protocol. |
| Adaptive Card templates | 1–2 days | Approval cards, result summaries, invoice tables, error states, sequential workflows. Use Adaptive Cards Designer.13 |
| Thread store + state management | 0.5 days | SQLite schema, conversation reference persistence, approval audit log. |
| MCP tool servers | Variable | Depends on external systems. 1–3 days per integration (ERP, contracts, etc.) |
| Authentication | 0.5 days | Azure AD SSO handled by Teams SDK. Codex API key in environment. |
| Streaming + rate limit handling | 0.5 days | Delta batching, message update pattern. |
| Testing + debugging | 1 day | Agents Playground, Adaptive Cards Designer, end-to-end flow. |
| Total MVP | 5–8 days | For a single-process agent (e.g. invoice matching) |
| Production hardening | +3–5 days | WebSocket transport, multi-tenant, monitoring, error recovery, CI/CD. |
Build complexity rating: 3/5 — Moderate. The Teams SDK handles Teams protocol complexity. The Codex app-server handles agent complexity. Your harness is approximately 300 lines of glue code across four files.
When to Choose Teams
Choose Teams when:
- Your users already live in Teams all day
- You need approval workflows with audit trails
- Enterprise SSO and compliance matter (Azure AD, data residency)
- You want threaded conversations with persistent context
- IT governance requires a managed platform
- You need the Sequential Workflow pattern for multi-step approvals
- Your organisation already uses Microsoft 365
Do not choose Teams when:
- You need a public-facing interface (Teams is internal only)
- Your users are not on Microsoft 365
- You need rich custom UI beyond what Adaptive Cards support (charts, drag-and-drop, custom visualisations)
- Real-time streaming display matters more than structured cards
- You need sub-second message delivery (Teams has inherent latency from Azure Bot Service routing)
Consider Teams alongside another interface when:
- You want Teams for internal users and a web interface (Codex Sites or Retool) for external users
- You want Teams for approvals and Slack for developer notifications
- You want Teams for the primary workflow and email for async summaries
Summary
The Teams-Codex harness is four files and approximately 300 lines of TypeScript:
index.ts— Teams SDK message handler and Adaptive Card action routercodex-bridge.ts— JSON-RPC 2.0 client for the Codex app-servercard-factory.ts— Adaptive Card templates for results, approvals, and errorsthread-store.ts— SQLite persistence for thread mapping and approval audit
The Teams SDK v2 handles the messaging protocol. The Codex app-server handles the agent intelligence. Your harness translates between them. The approval flow maps directly from Codex’s approval/commandExecution to Adaptive Card Action.Execute buttons — and back.
Five to eight days for an MVP. Three hundred lines of code. A finance team that never leaves Teams.
Next in the series: Slack as a Codex Interface — the Bolt SDK pattern with Block Kit and Socket Mode.
Sources
-
Teams SDK (Formerly Teams AI Library) — Microsoft Learn ↩ ↩2 ↩3
-
Announcing the Microsoft Teams SDK and MCP Support — Microsoft 365 Developer Blog ↩ ↩2
-
Multistage Approvals in Agent Flows — Microsoft Copilot Studio ↩
-
Build AI Microsoft Teams Bot for Workflow Automation — Low Code Agency ↩
-
Unlocking the Codex Harness: How We Built the App Server — OpenAI ↩ ↩2
-
Sequential Workflow for Adaptive Cards — Microsoft Learn ↩ ↩2 ↩3 ↩4
-
Integrating Teams Bot with External Agentic Platform — Microsoft Q&A ↩ ↩2
-
Building a High-Performance App Server for Codex Agents — n1n.ai ↩