diff --git a/nodejs/claude/sample-agent/.babelrc b/nodejs/claude/sample-agent/.babelrc new file mode 100644 index 00000000..1320b9a3 --- /dev/null +++ b/nodejs/claude/sample-agent/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@babel/preset-env"] +} diff --git a/nodejs/claude/sample-agent/README.md b/nodejs/claude/sample-agent/README.md new file mode 100644 index 00000000..6d80f726 --- /dev/null +++ b/nodejs/claude/sample-agent/README.md @@ -0,0 +1,247 @@ +# Simple Claude Agent + +An integration of **Claude Code SDK** with **Microsoft 365 Agents SDK** and **Agent 365 SDK** for conversational AI experiences. + +## 🚀 Quick Start + +### Prerequisites + +- Node.js 18+ +- Anthropic API key from [https://console.anthropic.com/](https://console.anthropic.com/) + +### Setup + +1. **Install Dependencies** + + ```bash + cd nodejs/claude/sample-agent + npm install + ``` + +2. **Configure Claude API Key** + + ```bash + # 1. Get your API key from https://console.anthropic.com/ + # 2. Set your Anthropic API key in .env file + ANTHROPIC_API_KEY=your_anthropic_api_key_here + ``` + +3. **Configure Environment** (optional) + + ```bash + cp env.TEMPLATE .env + # Edit .env if needed for Azure Bot Service deployment or MCP Tooling + # the .env is already configured for connecting to the Mock MCP Server. + ``` + +4. **Start the Agent** + + ```bash + npm run dev + ``` + +5. **Test with Playground** + ```bash + npm run test-tool + ``` + +## 💡 How It Works + +This agent demonstrates the simplest possible integration between: + +- **Claude Code SDK**: Provides AI capabilities with tool access +- **Microsoft 365 Agents SDK**: Handles conversational interface and enterprise features +- **Agents 365 SDK**: Handles MCP tooling and Agent Notification set up + +### Key Features + +- ✨ **Direct Claude Integration**: Uses `query()` API for natural conversations +- 🔧 **Tool Access**: Claude can use Read, Write, WebSearch, Bash, and Grep tools +- 🎴 **Adaptive Card Responses**: Beautiful, interactive card-based responses +- 💬 **Streaming Progress**: Real-time processing indicators +- 🏢 **Enterprise Features**: Sensitivity labels and compliance features + +## 📝 Usage Examples + +Just chat naturally with the agent: + +``` +"Use MailTools to send an email." +"Query my calendar with CalendarTools." +"Summarize a web page using NLWeb." +"Search SharePoint files with SharePointTools." +"Access my OneDrive files using OneDriveMCPServer." +``` + +## 🏗️ Architecture + +``` +User Input → M365 Agent → Claude Code SDK → AI Response → User +``` + +### Core Components + +1. `src/index.js`: Server startup and configuration +2. `src/agent.js`: Main agent orchestration and turn handling +3. `src/claudeAgent.js`: Claude agent wrapper and higher-level logic +4. `src/claudeClient.js`: Claude SDK client +5. `src/adaptiveCards.js`: Adaptive card utilities for rich responses +6. `src/telemetry.js`: Application telemetry and tracing helpers +7. `src/evals/`: Evaluation scripts and result viewers (benchmarks and test harness) + +### Simple Integration Pattern + +```javascript +import { query } from "@anthropic-ai/claude-agent-sdk"; + +for await (const message of query({ + prompt: userMessage, + options: { + allowedTools: ["Read", "Write", "WebSearch"], + maxTurns: 3, + }, +})) { + if (message.type === "result") { + // Create adaptive card response + const responseCard = createClaudeResponseCard(message.result, userMessage); + const cardAttachment = MessageFactory.attachment({ + contentType: "application/vnd.microsoft.card.adaptive", + content: responseCard, + }); + await context.sendActivity(cardAttachment); + } +} +``` + +## 🎴 Adaptive Card Features + +The agent displays Claude responses in interactive adaptive cards featuring: + +- **Rich Formatting**: Markdown rendering with styling +- **User Context**: Shows the original query for reference +- **Timestamps**: Generated response time for tracking +- **Interactive Actions**: Follow-up buttons and error recovery +- **Error Handling**: error cards with troubleshooting steps + +### Card Types + +1. **Response Cards**: Main Claude responses with formatted text +2. **Error Cards**: Friendly error handling with action buttons +3. **Thinking Cards**: Processing indicators (optional) + +## 🔧 Customization + +### Agent Notification Handling + +You can handle agent notifications (such as email, mentions, etc.) using the `OnAgentNotification` method from the Agent 365 SDK. This allows your agent to respond to custom activities and notifications. + +#### Example: Registering a Notification Handler + +```javascript +import "@microsoft/agents-a365-notifications"; +import { ClaudeAgent } from "./claudeAgent.js"; + +const claudeAgent = new ClaudeAgent(simpleClaudeAgent.authorization); + +// Route all notifications (any channel) +simpleClaudeAgent.onAgentNotification( + "*", + claudeAgent.handleAgentNotificationActivity.bind(claudeAgent) +); +``` + +**Note:** + +- The first argument to `onAgentNotification` can be a specific `channelId` (such as `'email'`, `'mention'`, etc.) to route only those notifications, or use `'*'` to route all notifications regardless of channel. + +This enables flexible notification routing for your agent, allowing you to handle all notifications or only those from specific channels as needed. + +### Add More Tools + +Tools can be added directly to the `options` passed to the query, or dynamically registered using Agent 365 SDK's `McpToolRegistrationService.addMcpToolServers()` method. + +#### Example: Registering MCP Tool Servers + +```javascript +import { McpToolRegistrationService } from "@microsoft/agents-a365-tooling-extensions-claude"; + +const toolServerService = new McpToolRegistrationService(); +const agentOptions = { + allowedTools: ["Read", "Write", "WebSearch", "Bash", "Grep"], + // ...other options +}; + +await toolServerService.addMcpToolServers( + agentOptions, + process.env.AGENTIC_USER_ID || "", // Only required outside development mode + process.env.MCP_ENVIRONMENT_ID || "", // Only required outside development mode + app.authorizaiton, + turnContext, + process.env.MCP_AUTH_TOKEN || "" // Only required if your mcp server requires this +); +``` + +This will register all MCP tool servers found in your ToolingManifest.json and make them available to the agent at runtime. + +Depending on your environment, tool servers may also be discovered dynamically from a tooling gateway (such as via the Agent 365 SDK) instead of or in addition to ToolingManifest.json. This enables flexible and environment-specific tool server registration for your agent. + +**Note:** The `allowedTools` and `mcpServers` properties in your agent options will be automatically modified by appending the tools found in the tool servers specified. This enables dynamic tool access for Claude and the agent, based on the current MCP tool server configuration. + +**Note:** This sample uses the agentic authorization flow if MCP_AUTH_TOKEN is not provided. To run agentic auth you must provide values that match your Azure app registrations and tenant: + +- `AGENT_APPLICATION_ID` — agent application (client) id +- `AGENT_CLIENT_SECRET` (optional) — if not using managed identity, provide the agent application client secret securely +- `AGENT_ID` — agent identity client id +- `USER_PRINCIPAL_NAME` — the agent's username (UPN) +- `AGENTIC_USER_ID` — agentic user id (used by some tooling flows) +- `MANAGED_IDENTITY_TOKEN` (optional, dev) — pre-acquired managed identity token used as a client_assertion fallback for local development + +### Custom System Prompt + +```javascript +appendSystemPrompt: "You are a specialized assistant for..."; +``` + +### Conversation Memory + +The agent maintains conversation context automatically through the M365 SDK. + +## 🚢 Deployment + +### Local Development + +```bash +npm start # Runs on localhost:3978 +``` + +### Azure Bot Service + +1. Create Azure Bot Service +2. Set environment variables in `.env` +3. Deploy to Azure App Service +4. Configure messaging endpoint + +## ⚙️ Configuration + +### Environment Variables + +- `TENANT_ID`, `CLIENT_ID`, `CLIENT_SECRET`: Azure Bot Service credentials +- `ANTHROPIC_API_KEY`: Anthropic API key for Claude authentication (required) +- `NODE_ENV`: Environment (development/production) +- `PORT`: Server port (default: 3978) + +### Claude Authentication + +- Obtain an API key from [Anthropic Console](https://console.anthropic.com/) +- Set `ANTHROPIC_API_KEY` in your `.env` file +- Suitable for all deployment scenarios + +## 🤝 Contributing + +This is a minimal example. Extend it by [WIP] + +## 📚 Learn More + +- [Claude Agent SDK Documentation](https://docs.anthropic.com/claude-agent-sdk) +- [Microsoft 365 Agents SDK](https://github.com/microsoft/agents) +- [Agent Playground Tool](https://www.npmjs.com/package/@microsoft/m365agentsplayground) diff --git a/nodejs/claude/sample-agent/ToolingManifest.json b/nodejs/claude/sample-agent/ToolingManifest.json new file mode 100644 index 00000000..74748dde --- /dev/null +++ b/nodejs/claude/sample-agent/ToolingManifest.json @@ -0,0 +1,19 @@ +{ + "mcpServers": [ + { + "mcpServerName": "mcp_MailTools" + }, + { + "mcpServerName": "mcp_CalendarTools" + }, + { + "mcpServerName": "mcp_NLWeb" + }, + { + "mcpServerName": "mcp_SharePointTools" + }, + { + "mcpServerName": "mcp_OneDriveServer" + } + ] +} \ No newline at end of file diff --git a/nodejs/claude/sample-agent/env.TEMPLATE b/nodejs/claude/sample-agent/env.TEMPLATE new file mode 100644 index 00000000..023aa599 --- /dev/null +++ b/nodejs/claude/sample-agent/env.TEMPLATE @@ -0,0 +1,54 @@ +# Simple Claude Agent Environment Configuration +# Copy this file to .env and fill in your values + +# Microsoft 365 Bot Configuration (optional - for Azure Bot Service) +# Only needed if deploying to Azure Bot Service +TENANT_ID= +CLIENT_ID= +CLIENT_SECRET= + +# Agent365 Authentication Configuration +AGENT_APPLICATION_ID= +AGENT_ID= +USER_PRINCIPAL_NAME= +AGENTIC_USER_ID= + +# Claude Code SDK Configuration +# Get your API key from https://console.anthropic.com/ +ANTHROPIC_API_KEY= + +# Development Settings +NODE_ENV=development +PORT=3978 + +# Note: Get your Anthropic API key from https://console.anthropic.com/ +# and set ANTHROPIC_API_KEY above + +# MockMCPServer Settings +# Note: MCP_ENVIRONMENT_ID is only needed for Tooling Gateway or if your MCP +# server uses an environment id in its path. +TOOLS_MODE=MockMCPServer +MCP_AUTH_TOKEN= +MCP_ENVIRONMENT_ID= + + +# Service Connection Settings +connections__service_connection__settings__clientId= +connections__service_connection__settings__clientSecret= +connections__service_connection__settings__tenantId= +connections__service_connection__settings__altBlueprintConnectionName=agentBlueprint + +# Agent Authentication Connection Settings +connections__agentBlueprint__settings__clientId= +connections__agentBlueprint__settings__clientSecret= +connections__agentBlueprint__settings__tenantId= +connections__agentBlueprint__settings__authority=https://login.microsoftonline.com + +# Set service connection as default +connectionsMap__0__serviceUrl=* +connectionsMap__0__connection=service_connection + +# AgenticAuthentication Options +agentic_altBlueprintConnectionName=agentBlueprint +agentic_scopes=https://graph.microsoft.com/.default +agentic_type=agentic \ No newline at end of file diff --git a/nodejs/claude/sample-agent/package.json b/nodejs/claude/sample-agent/package.json new file mode 100644 index 00000000..994921d1 --- /dev/null +++ b/nodejs/claude/sample-agent/package.json @@ -0,0 +1,47 @@ +{ + "name": "agent365-sdk-claude-sample-agent", + "version": "0.1.0", + "description": "Sample agent integrating Claude Code SDK with Microsoft Agent 365 SDK", + "main": "src/index.js", + "type": "module", + "scripts": { + "preinstall": "node preinstall-local-packages.js", + "start": "node src/index.js", + "dev": "node --env-file .env --watch src/index.js", + "test-tool": "agentsplayground", + "eval": "node --env-file .env src/evals/index.js", + "build": "babel src -d dist", + "install:clean": "npm run clean && npm install", + "clean": "rimraf node_modules package-lock.json" + }, + "keywords": [ + "claude-code-sdk", + "microsoft-365", + "agent", + "ai" + ], + "author": "airaamane@microsoft.com", + "license": "MIT", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.1", + "@microsoft/agents-activity": "^1.1.0-alpha.78", + "@microsoft/agents-hosting": "^1.1.0-alpha.78", + "@microsoft/agents-hosting-express": "^1.1.0-alpha.78", + "express": "^5.1.0", + "node-fetch": "^3.3.2", + "uuid": "^9.0.0" + }, + "overrides": { + "@microsoft/agents-activity": "^1.1.0-alpha.78", + "@microsoft/agents-hosting": "^1.1.0-alpha.78", + "@microsoft/agents-hosting-express": "^1.1.0-alpha.78" + }, + "devDependencies": { + "@azure/monitor-opentelemetry-exporter": "^1.0.0-beta.32", + "@babel/cli": "^7.28.3", + "@babel/core": "^7.28.4", + "@babel/preset-env": "^7.28.3", + "@microsoft/m365agentsplayground": "^0.2.16", + "rimraf": "^5.0.0" + } +} diff --git a/nodejs/claude/sample-agent/preinstall-local-packages.js b/nodejs/claude/sample-agent/preinstall-local-packages.js new file mode 100644 index 00000000..7e913fd7 --- /dev/null +++ b/nodejs/claude/sample-agent/preinstall-local-packages.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import { readdir } from 'fs/promises'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { execSync } from 'child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Look for *.tgz files two directories above +const tgzDir = join(__dirname, '../../'); + +// Define the installation order +const installOrder = [ + 'microsoft-agents-a365-runtime-', + 'microsoft-agents-a365-notifications-', + 'microsoft-agents-a365-observability-', + 'microsoft-agents-a365-tooling-', + 'microsoft-agents-a365-tooling-extensions-claude-' +]; + +async function findTgzFiles() { + try { + const files = await readdir(tgzDir); + return files.filter(file => file.endsWith('.tgz')); + } catch (error) { + console.log('No tgz directory found or no files to install'); + return []; + } +} + +function findFileForPattern(files, pattern) { + return files.find(file => file.startsWith(pattern)); +} + +async function installPackages() { + const tgzFiles = await findTgzFiles(); + + if (tgzFiles.length === 0) { + console.log('No .tgz files found in', tgzDir); + return; + } + + console.log('Found .tgz files:', tgzFiles); + + for (const pattern of installOrder) { + const file = findFileForPattern(tgzFiles, pattern); + if (file) { + const filePath = join(tgzDir, file); + console.log(`Installing ${file}...`); + try { + execSync(`npm install "${filePath}"`, { + stdio: 'inherit', + cwd: __dirname + }); + console.log(`✓ Successfully installed ${file}`); + } catch (error) { + console.error(`✗ Failed to install ${file}:`, error.message); + process.exit(1); + } + } else { + console.log(`No file found matching pattern: ${pattern}`); + } + } +} + +// Run the installation +installPackages().catch(error => { + console.error('Error during package installation:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/nodejs/claude/sample-agent/src/adaptiveCards.js b/nodejs/claude/sample-agent/src/adaptiveCards.js new file mode 100644 index 00000000..03ea6c31 --- /dev/null +++ b/nodejs/claude/sample-agent/src/adaptiveCards.js @@ -0,0 +1,433 @@ +/** + * Adaptive Card utilities for Claude responses + */ + +export function createClaudeResponseCard(response, userQuery) { + // Clean and format the response text + const formattedResponse = formatResponseText(response) + + return { + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.4", + type: "AdaptiveCard", + body: [ + { + type: "Container", + style: "emphasis", + items: [ + { + type: "ColumnSet", + columns: [ + { + type: "Column", + width: "auto", + items: [ + { + type: "Image", + url: "https://cdn.jsdelivr.net/gh/microsoft/fluentui-emoji/assets/Robot/3D/robot_3d.png", + size: "Small", + style: "Person" + } + ] + }, + { + type: "Column", + width: "stretch", + items: [ + { + type: "TextBlock", + text: "Claude Assistant", + weight: "Bolder", + size: "Medium" + }, + { + type: "TextBlock", + text: `Responding to: "${truncateText(userQuery, 100)}"`, + isSubtle: true, + size: "Small", + wrap: true + } + ] + } + ] + } + ] + }, + { + type: "Container", + items: [ + { + type: "TextBlock", + text: formattedResponse, + wrap: true, + spacing: "Medium" + } + ] + }, + { + type: "Container", + separator: true, + items: [ + { + type: "ColumnSet", + columns: [ + { + type: "Column", + width: "stretch", + items: [ + { + type: "TextBlock", + text: `Generated at ${new Date().toLocaleTimeString()}`, + isSubtle: true, + size: "Small" + } + ] + }, + { + type: "Column", + width: "auto", + items: [ + { + type: "TextBlock", + text: "🤖 Powered by Claude Code SDK", + isSubtle: true, + size: "Small" + } + ] + } + ] + } + ] + } + ], + actions: [ + { + type: "Action.Submit", + title: "Ask Follow-up", + data: { + action: "followup", + context: truncateText(response, 200) + } + } + ] + } +} + +export function createErrorCard(error, userQuery) { + return { + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.4", + type: "AdaptiveCard", + body: [ + { + type: "Container", + style: "attention", + items: [ + { + type: "ColumnSet", + columns: [ + { + type: "Column", + width: "auto", + items: [ + { + type: "TextBlock", + text: "⚠️", + size: "Large" + } + ] + }, + { + type: "Column", + width: "stretch", + items: [ + { + type: "TextBlock", + text: "Error Processing Request", + weight: "Bolder", + color: "Attention" + }, + { + type: "TextBlock", + text: error.message || "An unexpected error occurred", + wrap: true, + isSubtle: true + } + ] + } + ] + } + ] + }, + { + type: "Container", + items: [ + { + type: "TextBlock", + text: "**Troubleshooting Steps:**", + weight: "Bolder", + spacing: "Medium" + }, + { + type: "TextBlock", + text: "• Ensure ANTHROPIC_API_KEY is set in your environment", + wrap: true + }, + { + type: "TextBlock", + text: "• Get your API key from https://console.anthropic.com/", + wrap: true, + isSubtle: true + }, + { + type: "TextBlock", + text: "• Check your network connection", + wrap: true + }, + { + type: "TextBlock", + text: "• Try rephrasing your question", + wrap: true + } + ] + } + ], + actions: [ + { + type: "Action.Submit", + title: "Try Again", + data: { + action: "retry", + originalQuery: userQuery + } + } + ] + } +} + +export function createThinkingCard(query) { + return { + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.4", + type: "AdaptiveCard", + body: [ + { + type: "Container", + style: "emphasis", + items: [ + { + type: "ColumnSet", + columns: [ + { + type: "Column", + width: "auto", + items: [ + { + type: "TextBlock", + text: "🤔", + size: "Large" + } + ] + }, + { + type: "Column", + width: "stretch", + items: [ + { + type: "TextBlock", + text: "Claude is thinking...", + weight: "Bolder" + }, + { + type: "TextBlock", + text: `Processing: "${truncateText(query, 80)}"`, + isSubtle: true, + wrap: true + } + ] + } + ] + } + ] + } + ] + } +} + +function formatResponseText(text) { + if (!text) return "No response received" + + // Basic markdown-like formatting for adaptive cards + return text + .replace(/\*\*(.*?)\*\*/g, '**$1**') // Keep bold + .replace(/\*(.*?)\*/g, '*$1*') // Keep italic + .replace(/`([^`]+)`/g, '`$1`') // Keep inline code + .replace(/^### (.*$)/gm, '**$1**') // Convert h3 to bold + .replace(/^## (.*$)/gm, '**$1**') // Convert h2 to bold + .replace(/^# (.*$)/gm, '**$1**') // Convert h1 to bold +} + +export function createCodeAnalysisCard(analysis, filePath, userQuery) { + // Parse analysis if it's a string + let analysisData + try { + analysisData = typeof analysis === 'string' ? JSON.parse(analysis) : analysis + } catch { + // If not JSON, treat as plain text analysis + analysisData = { summary: analysis } + } + + return { + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.4", + type: "AdaptiveCard", + body: [ + { + type: "Container", + style: "emphasis", + items: [ + { + type: "ColumnSet", + columns: [ + { + type: "Column", + width: "auto", + items: [ + { + type: "TextBlock", + text: "🔍", + size: "Large" + } + ] + }, + { + type: "Column", + width: "stretch", + items: [ + { + type: "TextBlock", + text: "Code Analysis Complete", + weight: "Bolder", + size: "Medium" + }, + { + type: "TextBlock", + text: `File: ${filePath || 'Unknown'}`, + isSubtle: true, + size: "Small" + } + ] + } + ] + } + ] + }, + { + type: "Container", + items: [ + { + type: "TextBlock", + text: analysisData.summary || analysis, + wrap: true, + spacing: "Medium" + } + ] + }, + ...(analysisData.issues ? [{ + type: "Container", + separator: true, + items: [ + { + type: "TextBlock", + text: "🚨 Issues Found", + weight: "Bolder", + color: "Attention" + }, + ...analysisData.issues.slice(0, 5).map(issue => ({ + type: "TextBlock", + text: `• **${issue.type || 'Issue'}**: ${issue.description || issue}`, + wrap: true, + spacing: "Small" + })) + ] + }] : []), + ...(analysisData.recommendations ? [{ + type: "Container", + separator: true, + items: [ + { + type: "TextBlock", + text: "💡 Recommendations", + weight: "Bolder", + color: "Good" + }, + ...analysisData.recommendations.slice(0, 3).map(rec => ({ + type: "TextBlock", + text: `• ${rec}`, + wrap: true, + spacing: "Small" + })) + ] + }] : []), + { + type: "Container", + separator: true, + items: [ + { + type: "ColumnSet", + columns: [ + { + type: "Column", + width: "stretch", + items: [ + { + type: "TextBlock", + text: `Analyzed at ${new Date().toLocaleTimeString()}`, + isSubtle: true, + size: "Small" + } + ] + }, + { + type: "Column", + width: "auto", + items: [ + { + type: "TextBlock", + text: "🤖 Claude Code Analysis", + isSubtle: true, + size: "Small" + } + ] + } + ] + } + ] + } + ], + actions: [ + { + type: "Action.Submit", + title: "Analyze Another File", + data: { + action: "analyze_another", + previousFile: filePath + } + }, + { + type: "Action.Submit", + title: "Get Detailed Report", + data: { + action: "detailed_analysis", + file: filePath + } + } + ] + } +} + +function truncateText(text, maxLength) { + if (!text) return "" + if (text.length <= maxLength) return text + return text.substring(0, maxLength - 3) + "..." +} \ No newline at end of file diff --git a/nodejs/claude/sample-agent/src/agent.js b/nodejs/claude/sample-agent/src/agent.js new file mode 100644 index 00000000..e121cbb6 --- /dev/null +++ b/nodejs/claude/sample-agent/src/agent.js @@ -0,0 +1,48 @@ +import { ActivityTypes } from '@microsoft/agents-activity' +import { AgentApplicationBuilder, MemoryStorage } from '@microsoft/agents-hosting' + +import '@microsoft/agents-a365-notifications' +import { ClaudeAgent } from './claudeAgent.js' + +const storage = new MemoryStorage(); + +export const simpleClaudeAgent = new AgentApplicationBuilder() + .withAuthorization({ + agentic: { } // We have the type and scopes set in the .env file + }) + .withStorage(storage) + .build(); + +// Create Claude Agent +// Pass the authorization from the agent application +const claudeAgent = new ClaudeAgent(simpleClaudeAgent.authorization); + +// Register notification handler +// simpleClaudeAgent.onAgentNotification("*", claudeAgent.handleAgentNotificationActivity.bind(claudeAgent)); +simpleClaudeAgent.onAgenticEmailNotification(claudeAgent.emailNotificationHandler.bind(claudeAgent)); + +simpleClaudeAgent.onAgenticWordNotification(claudeAgent.wordNotificationHandler.bind(claudeAgent)); + +// Welcome message when user joins +simpleClaudeAgent.onConversationUpdate('membersAdded', async (context, state) => { + const welcomeMessage = ` +🤖 **Simple Claude Agent** is ready! + +This agent demonstrates MCP tooling integration and notification routing. + +**Features:** +- Handles email notifications and @-mentions from Word and Excel using notification routing +- Integrates with Microsoft 365 via MCP Tooling + +**Try these commands:** + - Ask the agent to use MCP tools from tool servers + - Send mock custom activities (email, mentions) + ` + await context.sendActivity(welcomeMessage) +}) + +// Handle user messages +simpleClaudeAgent.onActivity(ActivityTypes.Message, claudeAgent.handleAgentMessageActivity.bind(claudeAgent)); + +// Handle installation updates +simpleClaudeAgent.onActivity(ActivityTypes.InstallationUpdate, claudeAgent.handleInstallationUpdateActivity.bind(claudeAgent)) \ No newline at end of file diff --git a/nodejs/claude/sample-agent/src/claudeAgent.js b/nodejs/claude/sample-agent/src/claudeAgent.js new file mode 100644 index 00000000..eb561009 --- /dev/null +++ b/nodejs/claude/sample-agent/src/claudeAgent.js @@ -0,0 +1,284 @@ + +import { createClaudeResponseCard, createErrorCard } from './adaptiveCards.js' +import { MessageFactory } from "@microsoft/agents-hosting" +import { ClaudeClient } from './claudeClient.js'; +import { McpToolRegistrationService } from '@microsoft/agents-a365-tooling-extensions-claude' +import { NotificationType } from '@microsoft/agents-a365-notifications'; + +// When running in debug mode, these variables can interfere with Claude's child +// processes +const cleanEnv = { ...process.env }; +delete cleanEnv.NODE_OPTIONS; +delete cleanEnv.VSCODE_INSPECTOR_OPTIONS; + +/** + * ClaudeClient provides an interface to interact with the Claude Code SDK. + * It maintains agentOptions as an instance field and exposes an invokeAgent method. + */ +export class ClaudeAgent { + /** + * Indicates if the application is installed (installation update state). + */ + isApplicationInstalled = false; + + /** + * Indicates if the user has accepted terms and conditions. + */ + termsAndConditionsAccepted = false; + + toolServerService = new McpToolRegistrationService() + + /** + * @param {object} agentOptions - Configuration for the Claude agent (tooling, system prompt, etc). + */ + constructor(authorization) { + this.authorization = authorization + } + + /** + * Handles incoming user messages, streams progress, and sends adaptive card responses using Claude. + * Manages feedback, sensitivity labels, and error handling for conversational activities. + */ + async handleAgentMessageActivity(turnContext, state) { + // Set up streaming response + turnContext.streamingResponse.setFeedbackLoop(true) + turnContext.streamingResponse.setSensitivityLabel({ + type: 'https://schema.org/Message', + '@type': 'CreativeWork', + name: 'Internal' + }) + turnContext.streamingResponse.setGeneratedByAILabel(true) + + if (!this.isApplicationInstalled) { + await turnContext.sendActivity(MessageFactory.Text("Please install the application before sending messages.")); + return; + } + + if (!this.termsAndConditionsAccepted) { + if (turnContext.activity.text?.trim().toLowerCase() === "i accept") { + this.termsAndConditionsAccepted = true; + await turnContext.sendActivity("Thank you for accepting the terms and conditions! How can I assist you today?"); + return; + } else { + await turnContext.sendActivity("Please accept the terms and conditions to proceed. Send 'I accept' to accept."); + return; + } + } + + const userMessage = turnContext.activity.text?.trim() || '' + + if (!userMessage) { + await turnContext.streamingResponse.queueTextChunk('Please send me a message and I\'ll help you!') + await turnContext.streamingResponse.endStream() + return + } + + try { + // Show processing indicator + await turnContext.streamingResponse.queueInformativeUpdate('🤔 Thinking with Claude...') + + const claudeClient = await this.getClaudeClient(turnContext) + + // Use Claude Code SDK to process the user's request + const claudeResponse = await claudeClient.invokeAgentWithScope(userMessage) + + // End streaming and send adaptive card response + await turnContext.streamingResponse.endStream() + + // Create and send adaptive card with Claude's response + const responseCard = createClaudeResponseCard(claudeResponse, userMessage) + + const cardAttachment = MessageFactory.attachment({ + contentType: 'application/vnd.microsoft.card.adaptive', + content: responseCard + }) + + await turnContext.sendActivity(cardAttachment) + } catch (error) { + console.error('Claude query error:', error) + + // End streaming first + await turnContext.streamingResponse.endStream() + + // Send error as adaptive card + const errorCard = createErrorCard(error, userMessage) + const errorAttachment = MessageFactory.attachment({ + contentType: 'application/vnd.microsoft.card.adaptive', + content: errorCard + }) + + await turnContext.sendActivity(errorAttachment) + } + } + + /** + * Handles agent notification activities by parsing the activity type. + * Supports: + * - Email notifications + * - @-mentions from Word and Excel + * - Agent on-boarding and off-boarding activities + * + * @param {object} turnContext - The context object for the current turn. + * @param {object} state - The state object for the current turn. + * @param {object} agentNotificationActivity - The incoming activity to handle. + */ + async handleAgentNotificationActivity(turnContext, state, agentNotificationActivity) { + try { + if (!this.isApplicationInstalled) { + await turnContext.sendActivity(MessageFactory.Text("Please install the application before sending notifications.")); + return; + } + + if (!this.termsAndConditionsAccepted) { + if (turnContext.activity.text?.trim().toLowerCase() === "i accept") { + this.termsAndConditionsAccepted = true; + await turnContext.sendActivity("Thank you for accepting the terms and conditions! How can I assist you today?"); + return; + } else { + await turnContext.sendActivity("Please accept the terms and conditions to proceed. Send 'I accept' to accept."); + return; + } + } + + // Find the first known notification type entity + + switch (agentNotificationActivity.notificationType) { + case NotificationType.EmailNotification: + await this.emailNotificationHandler(turnContext, state, agentNotificationActivity); + break; + case NotificationType.WpxComment: + await this.wordNotificationHandler(turnContext, state, agentNotificationActivity); + break; + default: + await turnContext.sendActivity('Notification type not yet implemented.'); + } + } catch (error) { + console.error('Error handling agent notification activity:', error); + await turnContext.sendActivity(`Error handling notification: ${error.message || error}`); + } + } + + /** + * Handles agent installation and removal events, updating internal state and prompting for terms acceptance. + * Sends a welcome or farewell message based on the activity action. + */ + async handleInstallationUpdateActivity(turnContext, state) { + if (turnContext.activity.action === 'add') { + this.isApplicationInstalled = true; + this.termsAndConditionsAccepted = false; + await turnContext.sendActivity('Thank you for hiring me! Looking forward to assisting you in your professional journey! Before I begin, could you please confirm that you accept the terms and conditions? Send "I accept" to accept.'); + } else if (turnContext.activity.action === 'remove') { + this.isApplicationInstalled = false; + this.termsAndConditionsAccepted = false; + await turnContext.sendActivity('Thank you for your time, I enjoyed working with you.'); + } + } + + /** + * Handles @-mention notification activities. + * @param {object} turnContext - The context object for the current turn. + * @param {object} mentionNotificationEntity - The mention notification entity. + */ + async wordNotificationHandler(turnContext, state, wordActivity) { + await turnContext.sendActivity('Thanks for the @-mention notification! Working on a response...'); + const mentionNotificationEntity = wordActivity.wpxCommentNotification; + + if (!mentionNotificationEntity) { + await turnContext.sendActivity('I could not find the mention notification details.'); + return; + } + + // Use correct fields from mentionActivity.json + const documentId = mentionNotificationEntity.documentId; + const odataId = mentionNotificationEntity["odata.id"]; + const initiatingCommentId = mentionNotificationEntity.initiatingCommentId; + const subjectCommentId = mentionNotificationEntity.subjectCommentId; + + let mentionPrompt = + `You have been mentioned in a Word document. + Document ID: ${documentId || 'N/A'} + OData ID: ${odataId || 'N/A'} + Initiating Comment ID: ${initiatingCommentId || 'N/A'} + Subject Comment ID: ${subjectCommentId || 'N/A'} + Please retrieve the text of the initiating comment and return it in plain text.`; + + const claudeClient = await this.getClaudeClient(turnContext); + const commentContent = await claudeClient.invokeAgentWithScope(mentionPrompt); + + const response = await claudeClient.invokeAgentWithScope( + `You have received the following comment. Please follow any instructions in it. ${commentContent.content}` + ); + + await turnContext.sendActivity(response); + return; + } + + /** + * Handles email notification activities. + * @param {object} turnContext - The context object for the current turn. + * @param {object} emailNotificationEntity - The email notification entity. + */ + async emailNotificationHandler(turnContext, state, emailActivity) { + await turnContext.sendActivity('Thanks for the email notification! Working on a response...'); + + const emailNotificationEntity = emailActivity.emailNotification; + if (!emailNotificationEntity) { + await turnContext.sendActivity('I could not find the email notification details.'); + return; + } + + const emailNotificationId = emailNotificationEntity.Id; + const emailNotificationConversationId = emailNotificationEntity.conversationId; + const emailNotificationConversationIndex = emailNotificationEntity.conversationIndex; + const emailNotificationChangeKey = emailNotificationEntity.changeKey; + + const claudeClient = await this.getClaudeClient(turnContext); + const emailContent = await claudeClient.invokeAgentWithScope( + `You have a new email from ${turnContext.activity.from?.name} with id '${emailNotificationId}', + ConversationId '${emailNotificationConversationId}', ConversationIndex '${emailNotificationConversationIndex}', + and ChangeKey '${emailNotificationChangeKey}'. Please retrieve this message and return it in text format.` + ); + + const response = await claudeClient.invokeAgentWithScope( + `You have received the following email. Please follow any instructions in it. ${emailContent.content}` + ); + + await turnContext.sendActivity(response); + return; + } + + async getClaudeClient(turnContext) { + const agentOptions = { + appendSystemPrompt: `You are a helpful AI assistant integrated with Microsoft 365.`, + maxTurns: 3, + allowedTools: ['Read', 'Write', 'WebSearch', 'Bash', 'Grep'], + env: { + ...cleanEnv + }, + } + + const mcpEnvironmentId = process.env.MCP_ENVIRONMENT_ID || ''; + const agenticUserId = process.env.AGENTIC_USER_ID || ''; + const mcpAuthToken = process.env.MCP_AUTH_TOKEN || ''; + + if (mcpEnvironmentId && agenticUserId) { + try { + + await this.toolServerService.addToolServers( + agentOptions, + agenticUserId, + mcpEnvironmentId, + this.authorization, + turnContext, + mcpAuthToken + ) + } catch (error) { + console.warn('Failed to register MCP tool servers:', error.message); + } + } else { + console.log('MCP configuration not provided, using basic Claude agent functionality'); + } + + return new ClaudeClient(agentOptions) + } +} \ No newline at end of file diff --git a/nodejs/claude/sample-agent/src/claudeClient.js b/nodejs/claude/sample-agent/src/claudeClient.js new file mode 100644 index 00000000..16f16f2e --- /dev/null +++ b/nodejs/claude/sample-agent/src/claudeClient.js @@ -0,0 +1,116 @@ +import { InferenceScope, InvokeAgentScope, InferenceOperationType } from '@microsoft/agents-a365-observability'; +import { query } from '@anthropic-ai/claude-agent-sdk'; + +export class ClaudeClient { + + constructor(agentOptions = {}) { + this.agentOptions = agentOptions; + this.configureAuthentication(); + } + + /** + * Configures authentication for Claude API. + * Requires API key authentication. + */ + configureAuthentication() { + // Check if API key is provided in environment + if (!process.env.ANTHROPIC_API_KEY) { + throw new Warning('ANTHROPIC_API_KEY environment variable is required. Get your API key from https://console.anthropic.com/'); + } + + // Ensure the API key is available in the environment for the Claude SDK + this.agentOptions.env = { + ...this.agentOptions.env, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY + }; + } + + /** + * Sends a user message to the Claude Code SDK and returns the AI's response. + * Handles streaming results and error reporting. + * + * @param {string} userMessage - The message or prompt to send to Claude. + * @returns {Promise} The response from Claude, or an error message if the query fails. + */ + async invokeAgent(userMessage) { + let claudeResponse = ""; + try { + for await (const message of query({ + prompt: userMessage, + options: this.agentOptions + })) { + if (message.type === 'result' && message.result) { + claudeResponse = message.result; + break; + } + } + if (!claudeResponse) { + return "Sorry, I couldn't get a response from Claude :("; + } + return claudeResponse; + } catch (error) { + console.error('Claude query error:', error); + return `Error: ${error.message || error}`; + } + } + + /** + * Wrapper for invokeAgent that adds tracing and span management using Agent365 SDK. + * @param prompt - The prompt to send to Claude. + */ + async invokeAgentWithScope(prompt) { + const invokeAgentDetails = { agentId: process.env.AGENT_ID || 'sample-agent' }; + const invokeAgentScope = InvokeAgentScope.start(invokeAgentDetails); + + if (!invokeAgentScope) { + // fallback: do the work without active parent span + await new Promise((resolve) => setTimeout(resolve, 200)); + return await this.invokeAgent(prompt); + } + + try { + const inferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: 'gpt-4', + providerName: 'openai', + inputTokens: 45, + outputTokens: 78, + responseId: `resp-${Date.now()}`, + finishReasons: ['stop'] + }; + return await invokeAgentScope.withActiveSpanAsync(async () => { + // Create the inference (child) scope while the invoke span is active + const scope = InferenceScope.start(inferenceDetails); + + if (!scope) { + await new Promise((resolve) => setTimeout(resolve, 200)); + return await this.invokeAgent(prompt); + } + + try { + // Activate the inference span for the inference work + const result = await scope.withActiveSpanAsync(async () => { + const response = await this.invokeAgent(prompt); + scope.recordOutputMessages([{ + content: response, + responseId: `resp-${Date.now()}`, + finishReason: 'stop', + inputTokens: 45, + outputTokens: 78, + totalTokens: 123, + }]); + return response; + }); + return result; + } catch (error) { + scope.recordError(error); + throw error; + } finally { + scope.dispose(); + } + }); + } finally { + invokeAgentScope.dispose(); + } + } +} \ No newline at end of file diff --git a/nodejs/claude/sample-agent/src/index.js b/nodejs/claude/sample-agent/src/index.js new file mode 100644 index 00000000..7ad1f7f5 --- /dev/null +++ b/nodejs/claude/sample-agent/src/index.js @@ -0,0 +1,46 @@ +import express from 'express'; +import { CloudAdapter, authorizeJWT, loadAuthConfigFromEnv } from '@microsoft/agents-hosting'; +import { simpleClaudeAgent } from './agent.js'; +import { observabilityManager } from './telemetry.js'; + +console.log('🚀 Starting Simple Claude Agent...'); +console.log(' Claude Code SDK + Microsoft 365 Agents SDK'); +console.log(' Access at: http://localhost:3978'); +console.log(''); + +const authConfig = {}; +const adapter = new CloudAdapter(); + +const app = express(); +app.use(express.json()); +app.use(authorizeJWT(authConfig)); + +observabilityManager.start(); + +app.post('/api/messages', async (req, res) => { + await adapter.process(req, res, async (context) => { + await simpleClaudeAgent.run(context); + }); +}); + +const port = process.env.PORT || 3978; +const server = app.listen(port, () => { + console.log(`Server listening to port ${port} on sdk 1.0.15 for debug ${process.env.DEBUG}`); +}); + +server.on('error', async (err) => { + console.error(err); + await observabilityManager.shutdown(); + process.exit(1); +}).on('close', async () => { + console.log('Observability Manager is shutting down...'); + await observabilityManager.shutdown(); +}); + +process.on('SIGINT', () => { + console.log('Received SIGINT. Shutting down gracefully...'); + server.close(() => { + console.log('Server closed.'); + process.exit(0); + }); +}); diff --git a/nodejs/claude/sample-agent/src/telemetry.js b/nodejs/claude/sample-agent/src/telemetry.js new file mode 100644 index 00000000..8872928f --- /dev/null +++ b/nodejs/claude/sample-agent/src/telemetry.js @@ -0,0 +1,11 @@ +import { + ObservabilityManager, + Builder, +} from '@microsoft/agents-a365-observability'; + +export const observabilityManager = ObservabilityManager.configure( + (builder) => + builder + .withService('TypeScript Sample Agent', '1.0.0') +); +