Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions hub-config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ sources:
# clientId: "your-client-id.apps.googleusercontent.com"
# clientSecret: "your-client-secret"

google_calendar:
enabled: true
owner_auth:
type: oauth2
# Uncomment to use your own OAuth app:
# clientId: "your-client-id.apps.googleusercontent.com"
# clientSecret: "your-client-secret"

github:
enabled: true
owner_auth:
Expand Down
46 changes: 34 additions & 12 deletions packages/personaldatahub/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,34 +40,50 @@ export const PROPOSE_TOOL_SCHEMA = {
properties: {
source: {
type: 'string' as const,
description: 'The source service for this action (e.g., "gmail")',
description: 'The source service for this action (e.g., "gmail", "google_calendar")',
},
action_type: {
type: 'string' as const,
description: 'The type of action to propose (e.g., "draft_email", "send_email", "reply_email")',
description: 'The type of action to propose (e.g., "draft_email", "send_email", "create_event")',
},
to: {
type: 'string' as const,
description: 'Recipient email address',
description: 'Recipient email address (for email actions)',
},
subject: {
type: 'string' as const,
description: 'Email subject line',
description: 'Email subject line (for email actions)',
},
body: {
type: 'string' as const,
description: 'Email body content',
description: 'Email body or event description',
},
title: {
type: 'string' as const,
description: 'Event title (for calendar actions)',
},
start: {
type: 'string' as const,
description: 'Event start time in ISO format (for calendar actions)',
},
end: {
type: 'string' as const,
description: 'Event end time in ISO format (for calendar actions)',
},
location: {
type: 'string' as const,
description: 'Event location (for calendar actions)',
},
in_reply_to: {
type: 'string' as const,
description: 'Message ID to reply to (for reply_email and threaded draft_email). Optional.',
description: 'Message ID to reply to (for email actions). Optional.',
},
purpose: {
type: 'string' as const,
description: 'A clear description of why this action is being proposed. Required for transparency and audit.',
},
},
required: ['source', 'action_type', 'to', 'subject', 'body', 'purpose'] as const,
required: ['source', 'action_type', 'body', 'purpose'] as const,
};

/**
Expand Down Expand Up @@ -130,13 +146,19 @@ export function createProposeTool(client: HubClient) {
const purpose = args.purpose as string;

const action_data: Record<string, unknown> = {
to: args.to,
subject: args.subject,
body: args.body,
};
if (args.in_reply_to) {
action_data.in_reply_to = args.in_reply_to;
}

// Gmail fields
if (args.to) action_data.to = args.to;
if (args.subject) action_data.subject = args.subject;
if (args.in_reply_to) action_data.in_reply_to = args.in_reply_to;

// Calendar fields
if (args.title) action_data.title = args.title;
if (args.start) action_data.start = args.start;
if (args.end) action_data.end = args.end;
if (args.location) action_data.location = args.location;

const result = await client.propose({
source,
Expand Down
119 changes: 119 additions & 0 deletions skills/calendar-assistant/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
---
name: calendar-assistant
description: Manage your Google Calendar by checking availability and scheduling events through PersonalDataHub
user_invocable: true
---

# Calendar Assistant

Help users manage their schedule by checking for conflicts, listing upcoming events, and proposing new calendar items.

## Instructions

### 1. Read the PersonalDataHub config

Read `~/.pdh/config.json` to get the `hubUrl`. If the file doesn't exist, tell the user to run `npx pdh init` and `npx pdh start` first.

### 2. Verify the hub is running

Run `curl -s <hubUrl>/health` via Bash. If it fails, tell the user to start the server with `npx pdh start`.

### 3. Parse the user's request

Analyze the user's message to identify:
- **Intent** — list events, check availability, create a new event, update/delete an existing one.
- **Time context** — specific dates, relative times ("next Tuesday", "tomorrow afternoon").
- **Event details** — title, description, location, attendees.
- **Missing information** — required fields like start time, duration, or participant emails.

### 4. Plan the search strategy

Before scheduling or responding, check existing commitments. Plan queries for:
- **Current schedule** — Pull events for the relevant time range to check for overlaps.
- **Contextual data** — If the meeting relates to an email thread or GitHub issue, pull that data for context (titles, participants).

### 5. Execute the searches (Pull)

Pull calendar data from PersonalDataHub:

```bash
curl -s -X POST <hubUrl>/app/v1/pull \
-H "Content-Type: application/json" \
-d '{"source": "google_calendar", "query": "<optional_search_term>", "after": "<iso_timestamp>", "limit": 20, "purpose": "Checking availability for <context>"}'
```

**Guidelines:**
- Use the `after` field to limit results to relevant upcoming times.
- If checking a specific day, pull events for that entire 24h window.
- Deduplicate results if running multiple queries.

### 6. Analyze and synthesize

Review the retrieved events:
- Identify free slots.
- Note any direct conflicts.
- Extract event IDs if the user wants to update or delete a specific item.

Present a summary of the current schedule or the proposed time to the user.

### 7. Propose Actions

Calendar modifications require user approval via the `propose` endpoint.

#### Create an Event:
```bash
curl -s -X POST <hubUrl>/app/v1/propose \
-H "Content-Type: application/json" \
-d '{
"source": "google_calendar",
"action_type": "create_event",
"action_data": {
"title": "<summary>",
"body": "<description>",
"location": "<location>",
"start": "<iso_timestamp>",
"end": "<iso_timestamp>",
"timeZone": "UTC",
"attendees": [{"email": "user@example.com"}]
},
"purpose": "Scheduling <title> as requested by user"
}'
```

#### Update an Event:
```bash
curl -s -X POST <hubUrl>/app/v1/propose \
-H "Content-Type: application/json" \
-d '{
"source": "google_calendar",
"action_type": "update_event",
"action_data": {
"eventId": "<id>",
"title": "<new_summary>"
},
"purpose": "Updating event title"
}'
```

#### Delete an Event:
```bash
curl -s -X POST <hubUrl>/app/v1/propose \
-H "Content-Type: application/json" \
-d '{
"source": "google_calendar",
"action_type": "delete_event",
"action_data": { "eventId": "<id>" },
"purpose": "Deleting cancelled event"
}'
```

### 8. Finalize

Tell the user the action has been proposed and is waiting for their approval in the PersonalDataHub GUI at `<hubUrl>`.

## Important notes

- **All data goes through PersonalDataHub's access control.** You will only see events the owner has authorized.
- **Modifications require owner approval.** The `propose` endpoint stages the change — it does NOT immediately update the calendar.
- **Always provide a clear `purpose`.** Every API call is audited.
- **Show your work.** List the events you found that led to your recommendation or time suggestion.
123 changes: 123 additions & 0 deletions src/ai/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,119 @@ function registerGitHubTools(server: McpServer, hubUrl: string): void {
);
}

function registerCalendarTools(server: McpServer, hubUrl: string): void {
server.tool(
'read_calendar_events',
'Pull events from Google Calendar. Data is filtered according to the owner\'s access control policy.',
{
query: z.string().optional().describe('Search query for events'),
after: z.string().optional().describe('ISO timestamp to fetch events after (e.g. "2026-03-20T00:00:00Z")'),
limit: z.number().optional().describe('Maximum number of results'),
purpose: z.string().describe('Why this data is needed (logged for audit)'),
},
{ readOnlyHint: true, destructiveHint: false },
async ({ query, after, limit, purpose }) => {
const body: Record<string, unknown> = { source: 'google_calendar', purpose };
if (query) body.query = query;
if (after) body.after = after;
if (limit) body.limit = limit;

const res = await fetch(`${hubUrl}/app/v1/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

const json = await res.json();
return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] };
},
);

server.tool(
'create_calendar_event',
'Propose a new Google Calendar event. The event is staged for the data owner to review — it is NOT created until approved.',
{
title: z.string().describe('Event summary'),
body: z.string().optional().describe('Event description'),
location: z.string().optional().describe('Event location'),
start: z.string().describe('ISO timestamp for start time'),
end: z.string().describe('ISO timestamp for end time'),
timeZone: z.string().optional().describe('Timezone (e.g. "UTC", "America/Los_Angeles")'),
purpose: z.string().describe('Why this action is being proposed (logged for audit)'),
},
{ readOnlyHint: false, destructiveHint: false },
async ({ title, body, location, start, end, timeZone, purpose }) => {
const res = await fetch(`${hubUrl}/app/v1/propose`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'google_calendar',
action_type: 'create_event',
action_data: { title, body, location, start, end, timeZone },
purpose,
}),
});

const json = await res.json();
return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] };
},
);

server.tool(
'update_calendar_event',
'Propose an update to an existing Google Calendar event. Staged for owner review.',
{
eventId: z.string().describe('The ID of the event to update'),
title: z.string().optional().describe('New event summary'),
body: z.string().optional().describe('New event description'),
start: z.string().optional().describe('New ISO start time'),
end: z.string().optional().describe('New ISO end time'),
purpose: z.string().describe('Why this action is being proposed (logged for audit)'),
},
{ readOnlyHint: false, destructiveHint: false },
async ({ eventId, title, body, start, end, purpose }) => {
const res = await fetch(`${hubUrl}/app/v1/propose`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'google_calendar',
action_type: 'update_event',
action_data: { eventId, title, body, start, end },
purpose,
}),
});

const json = await res.json();
return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] };
},
);

server.tool(
'delete_calendar_event',
'Propose deleting a Google Calendar event. Staged for owner review.',
{
eventId: z.string().describe('The ID of the event to delete'),
purpose: z.string().describe('Why this action is being proposed (logged for audit)'),
},
{ readOnlyHint: false, destructiveHint: true },
async ({ eventId, purpose }) => {
const res = await fetch(`${hubUrl}/app/v1/propose`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'google_calendar',
action_type: 'delete_event',
action_data: { eventId },
purpose,
}),
});

const json = await res.json();
return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] };
},
);
}

export async function startMcpServer(): Promise<McpServer> {
const config = readConfig();
if (!config) {
Expand All @@ -227,6 +340,16 @@ export async function startMcpServer(): Promise<McpServer> {
registeredTools.push('read_emails', 'draft_email', 'send_email', 'reply_to_email');
}

if (sources.google_calendar?.connected) {
registerCalendarTools(server, hubUrl);
registeredTools.push(
'read_calendar_events',
'create_calendar_event',
'update_calendar_event',
'delete_calendar_event',
);
}

if (sources.github?.connected) {
registerGitHubTools(server, hubUrl);
registeredTools.push('search_github_issues', 'search_github_prs');
Expand Down
6 changes: 6 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ export async function init(targetDir?: string, options?: InitOptions): Promise<I
' type: oauth2',
` clientId: "${oauthCreds.google.clientId}"`,
` clientSecret: "${oauthCreds.google.clientSecret}"`,
' google_calendar:',
' enabled: true',
' owner_auth:',
' type: oauth2',
` clientId: "${oauthCreds.google.clientId}"`,
` clientSecret: "${oauthCreds.google.clientSecret}"`,
);
}

Expand Down
Loading