# O365 Calendar Integration — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable meeting note creation from O365 calendar events with automatic participant matching, series recognition, and agenda pre-population — via Templater and via Claude.
**Architecture:** Two independent creation paths (Templater User Script + MCP Server) share the same note format and matching logic. Node.js scripts handle Graph API access and person matching for the Templater path. Claude applies the same rules via MCP tools and CLAUDE.md instructions.
**Tech Stack:** Node.js (v25), @azure/msal-node, @microsoft/microsoft-graph-client, Templater plugin, Softeria graph-mcp-server (or alternative)
**Vault:** `/home/chk/Dokumente/brain` (no git repo — no commits, verify manually)
**Spec:** `docs/superpowers/specs/2026-04-12-o365-calendar-integration-design.md`
---
## File Structure
```
scripts/ # Templater user scripts folder
package.json # Dependencies
.env # OAuth credentials (NOT synced)
.env.example # Template for credentials
o365-calendar.js # Graph API client: auth, fetch events (ES module)
person-matcher.js # Read vault persons, match by email/name, auto-create (ES module)
meeting-builder.js # Orchestrator: combines calendar + matching + agenda → note data (ES module)
create-meeting-note.js # Templater user script entry point (CommonJS wrapper)
test/
o365-calendar.test.js # Unit tests for calendar fetching
person-matcher.test.js # Unit tests for matching logic
meeting-builder.test.js # Integration test for full flow
Templates/ # Templater template folder (new)
Meeting Note.md # Thin Templater template calling user script
03 Bereiche/Meetings/ # New folder for meeting notes
03 Bereiche/IT-Management/
O365 Kalender Integration.md # User documentation
CLAUDE.md # Updated with matching rules for Claude path
```
---
### Task 1: Vault Structure & Frontmatter Updates
**Files:**
- Create: `03 Bereiche/Meetings/.gitkeep`
- Modify: `00 Kontext/Personen/Christopher Klein.md` (add `email`)
- Modify: `00 Kontext/Personen/Eray Kara.md` (add `email`)
- Modify: `00 Kontext/Personen/Florian Fiala.md` (add `email`)
- Modify: `00 Kontext/Personen/Josiah Stieve.md` (add `email`)
- Modify: `00 Kontext/Personen/Philip Losch.md` (add `email`)
- Modify: `00 Kontext/Personen/Steffen Ackerschott.md` (add `email`)
- Modify: `00 Kontext/Personen/Steffen Brauer.md` (add `email`)
- Modify: `00 Kontext/Firmen/KRAH.md` (add `domain`)
- [ ] **Step 1: Create Meetings folder**
```bash
mkdir -p "03 Bereiche/Meetings"
```
Note: No `.gitkeep` needed — vault has no git repo.
- [ ] **Step 2: Add `domain` to KRAH company note**
In `00 Kontext/Firmen/KRAH.md`, add `domain: krah.de` to frontmatter:
```yaml
---
tags: [firma]
name: KRAH Elektrotechnische Fabrik GmbH & Co. KG
kurzname: KRAH
branche: Elektrotechnik
ort: Drolshagen, NRW
domain: krah.de
---
```
- [ ] **Step 3: Add `email` to all KRAH person notes**
For each person note in `00 Kontext/Personen/`, add an `email` field to the frontmatter. The user must provide the actual email addresses. Use this pattern:
`Christopher Klein.md`:
```yaml
---
tags: [person, mitarbeiter]
vorname: Christopher
nachname: Klein
email: TODO@krah.de
kategorie: Mitarbeiter
# ... rest unchanged
---
```
Repeat for: `Eray Kara.md`, `Florian Fiala.md`, `Josiah Stieve.md`, `Philip Losch.md`, `Steffen Ackerschott.md`, `Steffen Brauer.md`.
**Important:** Ask the user for the actual email addresses before filling them in. Do not guess.
- [ ] **Step 4: Verify all person notes have `email` field**
Read each person note and confirm the `email` field is present in frontmatter.
---
### Task 2: Node.js Project Setup
**Files:**
- Create: `scripts/package.json`
- Create: `scripts/.env`
- Create: `scripts/.env.example`
- [ ] **Step 1: Create `scripts/package.json`**
```json
{
"name": "obsidian-o365-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "O365 calendar integration scripts for Obsidian Templater",
"scripts": {
"test": "node --test test/*.test.js"
},
"dependencies": {
"@azure/msal-node": "^2.16.0",
"@microsoft/microsoft-graph-client": "^3.0.7"
}
}
```
- [ ] **Step 2: Create `scripts/.env.example`**
```env
# Azure AD App Registration
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret
# User whose calendar to read (for application flow)
AZURE_USER_EMAIL=christian.kauer@krah.de
```
- [ ] **Step 3: Create `scripts/.env` with actual credentials**
Copy `.env.example` to `.env` and fill in the actual values from the user's Azure App Registration. Ask user for:
- Tenant ID
- Client ID
- Client Secret
- User email for calendar access
- [ ] **Step 4: Install dependencies**
```bash
cd scripts && npm install
```
- [ ] **Step 5: Verify `.env` is excluded from sync**
Check that Remotely Save or any sync mechanism ignores `scripts/.env`. If the vault uses `.gitignore` or similar, add:
```
scripts/.env
scripts/node_modules/
```
---
### Task 3: O365 Calendar Client
**Files:**
- Create: `scripts/o365-calendar.js`
- Create: `scripts/test/o365-calendar.test.js`
- [ ] **Step 1: Write tests for calendar client**
Create `scripts/test/o365-calendar.test.js`:
```javascript
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { parseEventToMeeting, formatEventChoice, isRecurring } from '../o365-calendar.js';
describe('parseEventToMeeting', () => {
it('extracts meeting data from Graph API event', () => {
const event = {
id: 'AAMkAG123',
subject: 'IT Team Weekly',
start: { dateTime: '2026-04-14T09:00:00', timeZone: 'Europe/Berlin' },
end: { dateTime: '2026-04-14T10:00:00', timeZone: 'Europe/Berlin' },
body: { content: '
Agenda: Status updates
', contentType: 'html' },
attendees: [
{
emailAddress: { name: 'Christopher Klein', address: 'c.klein@krah.de' },
type: 'required'
},
{
emailAddress: { name: 'Philip Losch', address: 'p.losch@krah.de' },
type: 'required'
}
],
seriesMasterId: 'AAMkSeries456'
};
const result = parseEventToMeeting(event);
assert.equal(result.id, 'AAMkAG123');
assert.equal(result.title, 'IT Team Weekly');
assert.equal(result.date, '2026-04-14');
assert.equal(result.start, '09:00');
assert.equal(result.end, '10:00');
assert.equal(result.bodyText, 'Agenda: Status updates');
assert.equal(result.attendees.length, 2);
assert.equal(result.attendees[0].email, 'c.klein@krah.de');
assert.equal(result.attendees[0].name, 'Christopher Klein');
assert.equal(result.isRecurring, true);
});
it('handles event without attendees', () => {
const event = {
id: 'AAMkAG789',
subject: 'Focus Time',
start: { dateTime: '2026-04-14T14:00:00', timeZone: 'Europe/Berlin' },
end: { dateTime: '2026-04-14T15:00:00', timeZone: 'Europe/Berlin' },
body: { content: '', contentType: 'text' },
attendees: []
};
const result = parseEventToMeeting(event);
assert.equal(result.attendees.length, 0);
assert.equal(result.isRecurring, false);
assert.equal(result.bodyText, '');
});
});
describe('formatEventChoice', () => {
it('formats event for selection list', () => {
const meeting = {
title: 'IT Team Weekly',
date: '2026-04-14',
start: '09:00',
end: '10:00'
};
const result = formatEventChoice(meeting);
assert.equal(result, '📅 09:00-10:00 IT Team Weekly');
});
});
describe('isRecurring', () => {
it('returns true when seriesMasterId exists', () => {
assert.equal(isRecurring({ seriesMasterId: 'ABC' }), true);
});
it('returns false when no seriesMasterId', () => {
assert.equal(isRecurring({}), false);
assert.equal(isRecurring({ seriesMasterId: null }), false);
});
});
```
- [ ] **Step 2: Run tests — expect failure**
```bash
cd scripts && node --test test/o365-calendar.test.js
```
Expected: fail — module `../o365-calendar.js` not found.
- [ ] **Step 3: Implement `scripts/o365-calendar.js`**
```javascript
import { ConfidentialClientApplication } from '@azure/msal-node';
import { Client } from '@microsoft/microsoft-graph-client';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
function loadEnv() {
const envPath = resolve(__dirname, '.env');
const content = readFileSync(envPath, 'utf-8');
const vars = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const [key, ...rest] = trimmed.split('=');
vars[key.trim()] = rest.join('=').trim();
}
return vars;
}
function stripHtml(html) {
return html
.replace(/
/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export function isRecurring(event) {
return Boolean(event.seriesMasterId);
}
export function parseEventToMeeting(event) {
const startDate = event.start.dateTime.split('T');
const endDate = event.end.dateTime.split('T');
return {
id: event.id,
title: event.subject,
date: startDate[0],
start: startDate[1].substring(0, 5),
end: endDate[1].substring(0, 5),
bodyText: event.body?.contentType === 'html'
? stripHtml(event.body.content)
: (event.body?.content || '').trim(),
attendees: (event.attendees || []).map(a => ({
name: a.emailAddress.name,
email: a.emailAddress.address.toLowerCase()
})),
isRecurring: isRecurring(event)
};
}
export function formatEventChoice(meeting) {
return `📅 ${meeting.start}-${meeting.end} ${meeting.title}`;
}
export async function getCalendarEvents(daysAhead = 7) {
const env = loadEnv();
const cca = new ConfidentialClientApplication({
auth: {
clientId: env.AZURE_CLIENT_ID,
clientSecret: env.AZURE_CLIENT_SECRET,
authority: `https://login.microsoftonline.com/${env.AZURE_TENANT_ID}`
}
});
const tokenResponse = await cca.acquireTokenByClientCredential({
scopes: ['https://graph.microsoft.com/.default']
});
const client = Client.init({
authProvider: (done) => done(null, tokenResponse.accessToken)
});
const now = new Date();
const future = new Date(now);
future.setDate(future.getDate() + daysAhead);
const response = await client
.api(`/users/${env.AZURE_USER_EMAIL}/calendarView`)
.query({
startDateTime: now.toISOString(),
endDateTime: future.toISOString()
})
.select('id,subject,start,end,body,attendees,seriesMasterId')
.orderby('start/dateTime')
.top(50)
.get();
return (response.value || []).map(parseEventToMeeting);
}
```
- [ ] **Step 4: Run tests — expect pass**
```bash
cd scripts && node --test test/o365-calendar.test.js
```
Expected: all tests pass.
---
### Task 4: Person Matcher
**Files:**
- Create: `scripts/person-matcher.js`
- Create: `scripts/test/person-matcher.test.js`
- [ ] **Step 1: Write tests for person matcher**
Create `scripts/test/person-matcher.test.js`:
```javascript
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
parseFrontmatter,
matchAttendeeToPersons,
buildNewPersonNote,
resolveCompanyFromDomain
} from '../person-matcher.js';
describe('parseFrontmatter', () => {
it('extracts YAML frontmatter from markdown', () => {
const content = `---
tags: [person, mitarbeiter]
vorname: Christopher
nachname: Klein
email: c.klein@krah.de
kategorie: Mitarbeiter
---
# Christopher Klein
`;
const fm = parseFrontmatter(content);
assert.equal(fm.vorname, 'Christopher');
assert.equal(fm.nachname, 'Klein');
assert.equal(fm.email, 'c.klein@krah.de');
});
it('returns empty object for no frontmatter', () => {
const fm = parseFrontmatter('# Just a heading');
assert.deepEqual(fm, {});
});
});
describe('matchAttendeeToPersons', () => {
const persons = [
{ file: 'Christopher Klein.md', fm: { vorname: 'Christopher', nachname: 'Klein', email: 'c.klein@krah.de' } },
{ file: 'Philip Losch.md', fm: { vorname: 'Philip', nachname: 'Losch', email: 'p.losch@krah.de' } }
];
it('matches by email (priority)', () => {
const result = matchAttendeeToPersons(
{ name: 'Chris K.', email: 'c.klein@krah.de' },
persons
);
assert.equal(result.matched, true);
assert.equal(result.file, 'Christopher Klein.md');
assert.equal(result.matchType, 'email');
});
it('falls back to name match', () => {
const result = matchAttendeeToPersons(
{ name: 'Philip Losch', email: 'philip@private.de' },
persons
);
assert.equal(result.matched, true);
assert.equal(result.file, 'Philip Losch.md');
assert.equal(result.matchType, 'name');
});
it('returns unmatched for unknown attendee', () => {
const result = matchAttendeeToPersons(
{ name: 'Max Müller', email: 'max@landata.de' },
persons
);
assert.equal(result.matched, false);
});
});
describe('resolveCompanyFromDomain', () => {
const companies = [
{ file: 'KRAH.md', fm: { domain: 'krah.de', kurzname: 'KRAH' } }
];
it('resolves known domain to wikilink', () => {
const result = resolveCompanyFromDomain('c.klein@krah.de', companies);
assert.equal(result, '[[00 Kontext/Firmen/KRAH]]');
});
it('returns plain domain for unknown company', () => {
const result = resolveCompanyFromDomain('max@landata.de', companies);
assert.equal(result, 'landata.de');
});
});
describe('buildNewPersonNote', () => {
it('generates markdown for new person', () => {
const note = buildNewPersonNote({
name: 'Max Müller',
email: 'max.mueller@landata.de',
firma: 'landata.de'
});
assert.ok(note.includes('vorname: Max'));
assert.ok(note.includes('nachname: Müller'));
assert.ok(note.includes('email: max.mueller@landata.de'));
assert.ok(note.includes('kategorie: Extern'));
assert.ok(note.includes('status: ungeprüft'));
assert.ok(note.includes('# Max Müller'));
});
it('handles single-word display names', () => {
const note = buildNewPersonNote({
name: 'Siri',
email: 'siri@example.com',
firma: 'example.com'
});
assert.ok(note.includes('vorname: Siri'));
assert.ok(note.includes('nachname: ""'));
});
});
```
- [ ] **Step 2: Run tests — expect failure**
```bash
cd scripts && node --test test/person-matcher.test.js
```
Expected: fail — module not found.
- [ ] **Step 3: Implement `scripts/person-matcher.js`**
```javascript
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
import { resolve, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const VAULT_ROOT = resolve(__dirname, '..');
const PERSONS_DIR = resolve(VAULT_ROOT, '00 Kontext/Personen');
const COMPANIES_DIR = resolve(VAULT_ROOT, '00 Kontext/Firmen');
export function parseFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const fm = {};
for (const line of match[1].split('\n')) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.substring(0, colonIdx).trim();
let value = line.substring(colonIdx + 1).trim();
// Remove quotes
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Skip arrays and complex values for simple extraction
if (value.startsWith('[') || value.startsWith('{')) continue;
fm[key] = value;
}
return fm;
}
export function loadPersons() {
if (!existsSync(PERSONS_DIR)) return [];
return readdirSync(PERSONS_DIR)
.filter(f => f.endsWith('.md'))
.map(file => {
const content = readFileSync(resolve(PERSONS_DIR, file), 'utf-8');
return { file, fm: parseFrontmatter(content) };
});
}
export function loadCompanies() {
if (!existsSync(COMPANIES_DIR)) return [];
return readdirSync(COMPANIES_DIR)
.filter(f => f.endsWith('.md'))
.map(file => {
const content = readFileSync(resolve(COMPANIES_DIR, file), 'utf-8');
return { file, fm: parseFrontmatter(content) };
});
}
export function matchAttendeeToPersons(attendee, persons) {
// Priority 1: email match
const emailMatch = persons.find(p =>
p.fm.email && p.fm.email.toLowerCase() === attendee.email.toLowerCase()
);
if (emailMatch) {
return { matched: true, file: emailMatch.file, matchType: 'email' };
}
// Priority 2: name match (vorname + nachname)
const nameMatch = persons.find(p => {
const fullName = `${p.fm.vorname || ''} ${p.fm.nachname || ''}`.trim().toLowerCase();
return fullName && fullName === attendee.name.toLowerCase();
});
if (nameMatch) {
return { matched: true, file: nameMatch.file, matchType: 'name' };
}
return { matched: false };
}
export function resolveCompanyFromDomain(email, companies) {
const domain = email.split('@')[1]?.toLowerCase();
if (!domain) return '';
const match = companies.find(c =>
c.fm.domain && c.fm.domain.toLowerCase() === domain
);
if (match) {
const name = match.file.replace('.md', '');
return `[[00 Kontext/Firmen/${name}]]`;
}
return domain;
}
function splitName(displayName) {
const parts = displayName.trim().split(/\s+/);
if (parts.length === 1) {
return { vorname: parts[0], nachname: '' };
}
const nachname = parts.pop();
return { vorname: parts.join(' '), nachname };
}
export function buildNewPersonNote({ name, email, firma }) {
const { vorname, nachname } = splitName(name);
return `---
tags: [person]
vorname: ${vorname}
nachname: ${nachname ? nachname : '""'}
email: ${email}
kategorie: Extern
firma: "${firma}"
status: ungeprüft
---
# ${name}
## Zur Person
- **E-Mail:** ${email}
- **Firma:** ${firma}
## Notizen
`;
}
export function createPersonNote({ name, email }, companies) {
const firma = resolveCompanyFromDomain(email, companies);
const content = buildNewPersonNote({ name, email, firma });
const { vorname, nachname } = splitName(name);
const fileName = nachname ? `${vorname} ${nachname}.md` : `${vorname}.md`;
const filePath = resolve(PERSONS_DIR, fileName);
if (!existsSync(filePath)) {
writeFileSync(filePath, content, 'utf-8');
}
return fileName;
}
export function resolveAttendees(attendees) {
const persons = loadPersons();
const companies = loadCompanies();
return attendees.map(attendee => {
const match = matchAttendeeToPersons(attendee, persons);
if (match.matched) {
const personName = match.file.replace('.md', '');
return { wikilink: `[[${personName}]]`, name: attendee.name, isNew: false };
}
// Auto-create new person
const fileName = createPersonNote(attendee, companies);
const personName = fileName.replace('.md', '');
return { wikilink: `[[${personName}]]`, name: attendee.name, isNew: true };
});
}
```
- [ ] **Step 4: Run tests — expect pass**
```bash
cd scripts && node --test test/person-matcher.test.js
```
Expected: all tests pass.
---
### Task 5: Meeting Builder (Orchestrator)
**Files:**
- Create: `scripts/meeting-builder.js`
- Create: `scripts/test/meeting-builder.test.js`
- [ ] **Step 1: Write tests for meeting builder**
Create `scripts/test/meeting-builder.test.js`:
```javascript
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { detectSeries, loadJourFixeAgenda, buildMeetingNote, buildFileName } from '../meeting-builder.js';
describe('detectSeries', () => {
const jourFixeFolders = ['IT Team', 'Christian Hermann', 'SAP Team'];
it('matches event title to Jour Fixe folder', () => {
assert.equal(detectSeries('IT Team Weekly', jourFixeFolders), 'IT Team');
});
it('matches partial title containing folder name', () => {
assert.equal(detectSeries('Jour Fixe Christian Hermann', jourFixeFolders), 'Christian Hermann');
});
it('returns empty string for no match', () => {
assert.equal(detectSeries('Call mit LANdata', jourFixeFolders), '');
});
});
describe('buildFileName', () => {
it('builds date-prefixed filename', () => {
assert.equal(buildFileName('2026-04-14', 'IT Team Weekly'), '2026-04-14 IT Team Weekly.md');
});
it('sanitizes invalid filename characters', () => {
assert.equal(
buildFileName('2026-04-14', 'Review: Q2/Q3 Plan'),
'2026-04-14 Review - Q2-Q3 Plan.md'
);
});
});
describe('buildMeetingNote', () => {
it('generates complete meeting note markdown', () => {
const data = {
title: 'IT Team Weekly',
date: '2026-04-14',
start: '09:00',
end: '10:00',
serie: 'IT Team',
o365_id: 'AAMkAG123',
teilnehmer: [
{ wikilink: '[[Christopher Klein]]', name: 'Christopher Klein', isNew: false },
{ wikilink: '[[Philip Losch]]', name: 'Philip Losch', isNew: false }
],
agenda: '- Allgemeiner Status-Update KIT'
};
const note = buildMeetingNote(data);
assert.ok(note.includes('tags: [meeting]'));
assert.ok(note.includes('date: 2026-04-14'));
assert.ok(note.includes('serie: "IT Team"'));
assert.ok(note.includes('- "[[Christopher Klein]]"'));
assert.ok(note.includes('# IT Team Weekly'));
assert.ok(note.includes('- Allgemeiner Status-Update KIT'));
});
});
```
- [ ] **Step 2: Run tests — expect failure**
```bash
cd scripts && node --test test/meeting-builder.test.js
```
- [ ] **Step 3: Implement `scripts/meeting-builder.js`**
```javascript
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const VAULT_ROOT = resolve(__dirname, '..');
const JOUR_FIXE_DIR = resolve(VAULT_ROOT, '03 Bereiche/Jour Fixe');
export function getJourFixeFolders() {
if (!existsSync(JOUR_FIXE_DIR)) return [];
return readdirSync(JOUR_FIXE_DIR, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name);
}
export function detectSeries(eventTitle, jourFixeFolders) {
const titleLower = eventTitle.toLowerCase();
// Exact match first
const exact = jourFixeFolders.find(f => f.toLowerCase() === titleLower);
if (exact) return exact;
// Folder name contained in title
const contained = jourFixeFolders.find(f => titleLower.includes(f.toLowerCase()));
if (contained) return contained;
return '';
}
export function loadJourFixeAgenda(serie) {
const agendaPath = resolve(JOUR_FIXE_DIR, serie, 'Agenda.md');
if (!existsSync(agendaPath)) return '';
const content = readFileSync(agendaPath, 'utf-8');
// Extract 🔴 Offen section
const openMatch = content.match(/## 🔴 Offen\n([\s\S]*?)(?=\n## |$)/);
if (!openMatch) return '';
const items = openMatch[1].trim();
return items || '';
}
export function buildFileName(date, title) {
const sanitized = title
.replace(/:/g, ' -')
.replace(/[\/\\]/g, '-')
.replace(/[<>"|?*]/g, '')
.trim();
return `${date} ${sanitized}.md`;
}
export function buildMeetingNote(data) {
const teilnehmerYaml = data.teilnehmer
.map(t => ` - "${t.wikilink}"`)
.join('\n');
const teilnehmerList = data.teilnehmer
.map(t => `- ${t.wikilink}${t.isNew ? ' *(neu angelegt)*' : ''}`)
.join('\n');
const agenda = data.agenda || '';
return `---
tags: [meeting]
date: ${data.date}
start: "${data.start}"
end: "${data.end}"
serie: "${data.serie}"
projekt:
teilnehmer:
${teilnehmerYaml}
status: offen
o365_id: "${data.o365_id}"
---
# ${data.title}
## Teilnehmer
${teilnehmerList}
## Agenda
${agenda}
## Notizen
## Entscheidungen
## Aufgaben
`;
}
export function buildMeetingFromEvent(meeting, resolvedAttendees) {
const jourFixeFolders = getJourFixeFolders();
const serie = meeting.isRecurring ? detectSeries(meeting.title, jourFixeFolders) : '';
let agenda = '';
if (serie) {
agenda = loadJourFixeAgenda(serie);
}
if (!agenda && meeting.bodyText) {
agenda = meeting.bodyText;
}
return {
title: meeting.title,
date: meeting.date,
start: meeting.start,
end: meeting.end,
serie,
o365_id: meeting.id,
teilnehmer: resolvedAttendees,
agenda
};
}
```
- [ ] **Step 4: Run tests — expect pass**
```bash
cd scripts && node --test test/meeting-builder.test.js
```
Expected: all tests pass.
---
### Task 6: Templater Template
**Files:**
- Create: `Templates/Meeting Note.md`
- [ ] **Step 1: Create Templates folder**
```bash
mkdir -p Templates
```
- [ ] **Step 2: Create Templater user script wrapper**
Templater user scripts must export a function callable via `tp.user.scriptName(tp)`. Create `scripts/create-meeting-note.js` as the Templater entry point that wraps the ES module scripts:
```javascript
// scripts/create-meeting-note.js
// Templater user script — called via tp.user.create_meeting_note(tp)
// Uses dynamic import() to load ES module scripts
async function create_meeting_note(tp) {
const scriptsDir = app.vault.adapter.basePath + '/scripts';
const { getCalendarEvents, formatEventChoice } = await import(scriptsDir + '/o365-calendar.js');
const { resolveAttendees } = await import(scriptsDir + '/person-matcher.js');
const { buildMeetingFromEvent, buildMeetingNote, buildFileName } = await import(scriptsDir + '/meeting-builder.js');
// Fetch events
const meetings = await getCalendarEvents(7);
if (meetings.length === 0) {
new Notice('No calendar events found in the next 7 days.');
return '';
}
// Build selection list
const choices = meetings.map(m => formatEventChoice(m));
const selected = await tp.system.suggester(choices, meetings);
if (!selected) return '';
// Resolve attendees against vault persons
const resolvedAttendees = resolveAttendees(selected.attendees);
// Build meeting data with series detection + agenda
const meetingData = buildMeetingFromEvent(selected, resolvedAttendees);
// Build note content
const noteContent = buildMeetingNote(meetingData);
// Create the note file
const fileName = buildFileName(meetingData.date, meetingData.title);
const filePath = '03 Bereiche/Meetings/' + fileName;
await app.vault.create(filePath, noteContent);
await app.workspace.openLinkText(filePath, '');
return ''; // Template output is empty — note was created as separate file
}
module.exports = create_meeting_note;
```
- [ ] **Step 3: Create `Templates/Meeting Note.md`**
The template is a thin wrapper that calls the user script:
```markdown
<%* await tp.user.create_meeting_note(tp); await app.vault.delete(tp.config.target_file); %>
```
- [ ] **Step 3: Configure Templater plugin**
Open Obsidian Settings → Templater:
- **Template folder location:** `Templates`
- **User script functions folder:** `scripts`
- Enable **Trigger Templater on new file creation:** OFF (we trigger manually)
Verify the template appears in the Templater command palette (Ctrl+P → "Templater: Create new note from template").
- [ ] **Step 4: Manual smoke test**
1. Open Obsidian
2. Ctrl+P → "Templater: Create new note from template" → Select "Meeting Note"
3. Verify: calendar events appear in the selection list
4. Select an event → verify the meeting note is created in `03 Bereiche/Meetings/`
5. Verify: participants are matched/linked correctly
6. Check `00 Kontext/Personen/` for any auto-created person notes
---
### Task 7: MCP Server Setup
**Files:**
- Modify: `~/.claude/settings.json` or equivalent MCP config
- [ ] **Step 1: Evaluate Softeria graph-mcp-server**
```bash
npx -y @softeria/graph-mcp-server --help
```
Check if the server:
- Supports delegated or application auth
- Exposes calendar event listing with attendees and body
- Is compatible with Claude Code's MCP protocol
If Softeria doesn't meet requirements, check alternatives:
- `nicola-2021/mcp-server-office365`
- `kenzaxtazi/outlook-mcp`
- [ ] **Step 2: Configure MCP server for Claude Code**
Add to Claude Code's MCP configuration (exact path depends on evaluation in Step 1):
```json
{
"mcpServers": {
"microsoft-graph": {
"command": "npx",
"args": ["-y", "@softeria/graph-mcp-server"],
"env": {
"AZURE_TENANT_ID": "",
"AZURE_CLIENT_ID": "",
"AZURE_CLIENT_SECRET": "",
"AZURE_USER_EMAIL": "christian.kauer@krah.de"
}
}
}
}
```
- [ ] **Step 3: Verify MCP server access**
In a Claude Code session, test:
- List calendar events for today
- Get details of a specific event (attendees, body)
- Confirm data matches what the Templater path returns
---
### Task 8: CLAUDE.md Updates
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: Add Meeting Notes section to CLAUDE.md**
Append the following section to the vault's `CLAUDE.md`:
```markdown
## Meeting-Notizen
### Anlegen einer Meeting-Notiz
Wenn der Nutzer eine Meeting-Notiz anlegen möchte:
1. Kalender-Events per MCP abrufen (nächste 7 Tage)
2. Event auswählen lassen oder aus dem Kontext ableiten
3. Teilnehmer-Matching durchführen (siehe unten)
4. Notiz erstellen in `03 Bereiche/Meetings/YYYY-MM-DD Titel.md`
### Teilnehmer-Matching (Priorität)
1. **E-Mail-Match:** Suche in `00 Kontext/Personen/` nach `email` im Frontmatter
2. **Name-Fallback:** Vergleiche Display Name mit `vorname` + `nachname`
3. **Neu anlegen:** Kein Match → neue Personen-Notiz mit `status: ungeprüft`
### Firmen-Zuordnung
- Domain aus E-Mail extrahieren (z.B. `landata.de` aus `max@landata.de`)
- Suche in `00 Kontext/Firmen/` nach `domain` im Frontmatter
- Match → Wikilink, kein Match → Domain als Plain Text
### Serie-Erkennung
- Wiederkehrendes Meeting → Titel gegen `03 Bereiche/Jour Fixe/*/` matchen
- Serie erkannt → offene Punkte aus `Agenda.md` (🔴 Offen) als Agenda übernehmen
- Keine Serie → O365-Termin-Body als Agenda
- Beides leer → leere Agenda-Sektion
### Meeting-Notiz Format
Dateiname: `YYYY-MM-DD Titel.md`
Ort: `03 Bereiche/Meetings/`
Frontmatter: tags, date, start, end, serie, projekt, teilnehmer, status, o365_id
Sektionen: Teilnehmer, Agenda, Notizen, Entscheidungen, Aufgaben
```
- [ ] **Step 2: Verify by asking Claude to create a test meeting note**
Test the Claude path end-to-end:
1. "Zeig mir meine Kalender-Events für heute"
2. "Leg eine Meeting-Notiz an für [Event]"
3. Verify note format, participant matching, series recognition
---
### Task 9: User Documentation
**Files:**
- Create: `03 Bereiche/IT-Management/O365 Kalender Integration.md`
- [ ] **Step 1: Create user documentation**
Create `03 Bereiche/IT-Management/O365 Kalender Integration.md`:
```markdown
---
tags: [it-management, integration]
status: aktiv
date: 2026-04-12
---
# O365 Kalender Integration
## Was ist das?
Diese Integration verbindet den Office 365 Kalender mit dem Obsidian Vault. Du kannst Meeting-Notizen aus Kalender-Events erstellen — entweder manuell über Templater oder über Claude.
## Wie funktioniert es?
Zwei Wege zum selben Ergebnis:
### Weg 1: Manuell (Templater)
1. **Ctrl+P** → "Templater: Create new note from template"
2. Wähle **"Meeting Note"**
3. Eine Liste deiner nächsten 7 Tage Kalender-Events erscheint
4. Wähle das Meeting → Notiz wird automatisch angelegt
### Weg 2: Über Claude
Sag einfach:
- "Leg eine Meeting-Notiz an für das IT Team Meeting heute"
- "Was steht heute im Kalender?"
- "Erstelle Meeting-Notiz für den nächsten Termin"
## Was passiert automatisch?
### Teilnehmer-Erkennung
- Teilnehmer werden automatisch mit Personen-Notizen verknüpft
- **Match über E-Mail-Adresse** (bevorzugt) oder Name
- Unbekannte Teilnehmer werden als neue Personen-Notiz angelegt mit `status: ungeprüft`
### Firmen-Zuordnung
- Die E-Mail-Domain wird gegen bekannte Firmen-Notizen abgeglichen
- `max@landata.de` → Suche nach Firma mit `domain: landata.de`
- Unbekannte Domains werden als Text gespeichert
### Serien-Erkennung
- Wiederkehrende Meetings werden automatisch der Jour-Fixe-Serie zugeordnet
- Die offenen Agenda-Punkte aus der Jour-Fixe-Agenda werden übernommen
- Für Ad-hoc-Meetings wird der Termin-Text aus O365 als Agenda genutzt
## Wo landen die Notizen?
- **Meeting-Notizen:** `03 Bereiche/Meetings/YYYY-MM-DD Titel.md`
- **Neue Personen:** `00 Kontext/Personen/Vorname Nachname.md`
## Aufräumen
- Prüfe regelmäßig `00 Kontext/Personen/` auf Einträge mit `status: ungeprüft`
- Entscheide: Behalten und vervollständigen oder löschen
- Meeting-Notizen mit `status: offen` nach dem Meeting auf `abgeschlossen` setzen
## Technische Details
- **Azure App Registration:** Authentifizierung über OAuth2 (Application Credentials)
- **Microsoft Graph API:** Lese-Zugriff auf Kalender-Events
- **Node.js Scripts:** Liegen in `scripts/` — `o365-calendar.js`, `person-matcher.js`, `meeting-builder.js`
- **MCP Server:** Claude nutzt einen separaten MCP-Server für den Graph API Zugriff
- **Kein Rückschreiben:** Die Integration liest nur — es wird nichts in O365 geändert
## Bekannte Einschränkungen
- Nur Kalender (E-Mail-Integration ist für Phase 2 geplant)
- Kein automatischer Sync — Meeting-Notizen werden immer bewusst erstellt
- Firmen werden nicht automatisch angelegt, nur die Domain gespeichert
- Teilnehmer-Match funktioniert nur wenn die E-Mail-Adresse in der Personen-Notiz hinterlegt ist
## Siehe auch
- [[docs/superpowers/specs/2026-04-12-o365-calendar-integration-design]] — Design Spec
```
---
## Task Order & Dependencies
```
Task 1 (Vault Structure) — no dependencies
Task 2 (Node.js Setup) — no dependencies
Task 3 (Calendar Client) — depends on Task 2
Task 4 (Person Matcher) — depends on Task 2
Task 5 (Meeting Builder) — depends on Task 3 + 4
Task 6 (Templater Template) — depends on Task 5
Task 7 (MCP Server) — depends on Task 2 (for .env)
Task 8 (CLAUDE.md) — depends on Task 7
Task 9 (Documentation) — no dependencies (can run anytime)
```
Tasks 1, 2, 9 can run in parallel. Tasks 3, 4 can run in parallel after Task 2.