Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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
151 changes: 151 additions & 0 deletions docs/content/docs/(features)/google-calendar.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
---
title: Google Calendar
description: Native Google Calendar integration for listing, creating, and managing events.
---

# Google Calendar

Native Google Calendar integration that gives workers and channels access to calendar tools — list events, create meetings, find free time, and more. No MCP server needed; credentials are configured directly in Spacebot.

## Overview

When configured, Spacebot registers calendar tools on the appropriate process types:

| Tool | Channels | Workers |
|------|----------|---------|
| `google_calendar_list_events` | read-only | read-only |
| `google_calendar_list_calendars` | read-only | read-only |
| `google_calendar_find_free_time` | read-only | read-only |
| `google_calendar_create_event` | — | write |
| `google_calendar_update_event` | — | write |
| `google_calendar_delete_event` | — | write |
| `google_calendar_respond_event` | — | write |

Channels get read-only tools. Workers get both read and write tools. This follows the delegation model — channels can check your schedule, but only workers modify it.

## Prerequisites

1. A Google Cloud project with the **Google Calendar API** enabled
2. An OAuth 2.0 Client ID (type: **Web application** or **Desktop**)
3. A refresh token obtained via the OAuth consent flow

## Step 1: Create OAuth Credentials

1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select an existing one
3. Navigate to **APIs & Services → Library**
4. Search for **Google Calendar API** and enable it
5. Go to **APIs & Services → Credentials**
6. Click **Create Credentials → OAuth client ID**
7. Choose **Web application** as the application type
8. Under **Authorized redirect URIs**, add: `https://developers.google.com/oauthplayground`
9. Click **Create** and note the **Client ID** and **Client Secret**

<Callout type="info">
If your project's OAuth consent screen is in "Testing" mode, add your Google account as a test user under **APIs & Services → OAuth consent screen → Test users**.
</Callout>

## Step 2: Get a Refresh Token

### Option A: OAuth Playground

1. Open [Google OAuth Playground](https://developers.google.com/oauthplayground)
2. Click the gear icon (⚙️) in the top right
3. Check **Use your own OAuth credentials**
4. Enter your Client ID and Client Secret
5. In the left panel, find **Google Calendar API v3** and select `https://www.googleapis.com/auth/calendar`
6. Click **Authorize APIs** and complete the consent flow
7. Click **Exchange authorization code for tokens**
8. Copy the **Refresh token**

### Option B: Manual curl

```bash
# 1. Open this URL in your browser (replace CLIENT_ID):
https://accounts.google.com/o/oauth2/v2/auth?\
client_id=YOUR_CLIENT_ID&\
redirect_uri=urn:ietf:wg:oauth:2.0:oob&\
response_type=code&\
scope=https://www.googleapis.com/auth/calendar&\
access_type=offline&\
prompt=consent
Comment on lines +63 to +71

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make the auth URL browser-copyable.

This is introduced as a browser URL, but the backslashes are shell line-continuation syntax. Copying this block into an address bar will fail unless the user manually rewrites it.

✏️ Suggested doc fix
-https://accounts.google.com/o/oauth2/v2/auth?\
-client_id=YOUR_CLIENT_ID&\
-redirect_uri=urn:ietf:wg:oauth:2.0:oob&\
-response_type=code&\
-scope=https://www.googleapis.com/auth/calendar&\
-access_type=offline&\
-prompt=consent
+https://accounts.google.com/o/oauth2/v2/auth?client_id=YOUR_CLIENT_ID&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/calendar&access_type=offline&prompt=consent
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/`(features)/google-calendar.mdx around lines 63 - 71, The
OAuth authorization URL block currently uses shell line-continuation backslashes
which break when pasted into a browser; replace the multi-line,
backslash-escaped form with a single continuous browser-copyable URL (keeping
the same query parameters client_id, redirect_uri=urn:ietf:wg:oauth:2.0:oob,
response_type=code, scope=https://www.googleapis.com/auth/calendar,
access_type=offline, prompt=consent) so users can paste it directly into the
address bar, or provide both a single-line URL and a separate shell-friendly
version if you want to preserve CLI examples.


# 2. After consent, you'll get an authorization code. Exchange it:
curl -s -X POST https://oauth2.googleapis.com/token \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d code=AUTH_CODE \
-d grant_type=authorization_code \
-d redirect_uri=urn:ietf:wg:oauth:2.0:oob | jq .refresh_token
```

## Step 3: Configure Spacebot

<Tabs items={["Dashboard UI", "TOML Config"]}>
<Tab value="Dashboard UI">

1. Open the Spacebot dashboard
2. Go to **Settings → API Keys**
3. Find the **Google Calendar** card and click **Configure**
4. Enter your Client ID, Client Secret, and Refresh Token
5. Optionally set a Default Calendar ID (defaults to `primary`)
6. Click **Save**

</Tab>
<Tab value="TOML Config">

Add to your `config.toml`:

```toml
[defaults.google_calendar]
client_id = "YOUR_CLIENT_ID.apps.googleusercontent.com"
client_secret = "GOCSPX-YOUR_SECRET"
refresh_token = "1//YOUR_REFRESH_TOKEN"
default_calendar_id = "primary"
```

You can also use `env:` references to load from environment variables:

```toml
[defaults.google_calendar]
client_id = "env:GOOGLE_CALENDAR_CLIENT_ID"
client_secret = "env:GOOGLE_CALENDAR_CLIENT_SECRET"
refresh_token = "env:GOOGLE_CALENDAR_REFRESH_TOKEN"
default_calendar_id = "primary"
```

</Tab>
</Tabs>

## Per-Agent Override

You can override Google Calendar credentials for a specific agent:

```toml
[agents.personal-assistant.google_calendar]
client_id = "DIFFERENT_CLIENT_ID.apps.googleusercontent.com"
client_secret = "GOCSPX-DIFFERENT_SECRET"
refresh_token = "1//DIFFERENT_REFRESH_TOKEN"
default_calendar_id = "work@example.com"
Comment thread
tomasmach marked this conversation as resolved.
```

This lets different agents access different Google accounts or calendars.

## Troubleshooting

**"invalid_grant" errors**
- Your refresh token may have expired. Google revokes tokens if the OAuth consent screen is in "Testing" mode and the token is older than 7 days. Publish your app or re-generate the token.
- Make sure `access_type=offline` and `prompt=consent` were used when obtaining the token.

**Tools not appearing**
- Verify all three required fields are set: `client_id`, `client_secret`, `refresh_token`.
- Check the dashboard — the Google Calendar card should show "Configured".
- Config changes hot-reload automatically; no restart needed.

**"Access Not Configured" or 403 errors**
- Ensure the Google Calendar API is enabled in your Google Cloud project.
- Verify the OAuth consent screen includes your account as a test user (if in Testing mode).

**Wrong calendar**
- Set `default_calendar_id` to the specific calendar email address (e.g., `work@example.com`) instead of `primary`.
- Use the `google_calendar_list_calendars` tool to see available calendars.
2 changes: 1 addition & 1 deletion docs/content/docs/(features)/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "Features",
"pages": ["workers", "tasks", "opencode", "tools", "mcp", "browser", "cron", "skills", "ingestion"]
"pages": ["workers", "tasks", "opencode", "tools", "mcp", "browser", "cron", "skills", "ingestion", "google-calendar"]
}
7 changes: 7 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,7 @@ export interface OpenCodeSettingsUpdate {

export interface GlobalSettingsResponse {
brave_search_key: string | null;
google_calendar_configured: boolean;
api_enabled: boolean;
api_port: number;
api_bind: string;
Expand All @@ -1347,6 +1348,12 @@ export interface GlobalSettingsResponse {

export interface GlobalSettingsUpdate {
brave_search_key?: string | null;
google_calendar?: {
client_id?: string;
client_secret?: string;
refresh_token?: string;
default_calendar_id?: string;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
api_enabled?: boolean;
api_port?: number;
api_bind?: string;
Expand Down
131 changes: 130 additions & 1 deletion interface/src/routes/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { PlatformCatalog, InstanceCard, AddInstanceCard } from "@/components/Cha
import { ModelSelect } from "@/components/ModelSelect";
import { ProviderIcon } from "@/lib/providerIcons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { faSearch, faCalendarDays } from "@fortawesome/free-solid-svg-icons";

import { parse as parseToml } from "smol-toml";
import { useTheme, THEMES, type ThemeId } from "@/hooks/useTheme";
Expand Down Expand Up @@ -1619,6 +1619,11 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) {
const queryClient = useQueryClient();
const [editingBraveKey, setEditingBraveKey] = useState(false);
const [braveKeyInput, setBraveKeyInput] = useState("");
const [editingGCalCredentials, setEditingGCalCredentials] = useState(false);
const [gCalClientId, setGCalClientId] = useState("");
const [gCalClientSecret, setGCalClientSecret] = useState("");
const [gCalRefreshToken, setGCalRefreshToken] = useState("");
const [gCalDefaultCalendarId, setGCalDefaultCalendarId] = useState("");
const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null);

const updateMutation = useMutation({
Expand All @@ -1627,6 +1632,11 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) {
if (result.success) {
setEditingBraveKey(false);
setBraveKeyInput("");
setEditingGCalCredentials(false);
setGCalClientId("");
setGCalClientSecret("");
setGCalRefreshToken("");
setGCalDefaultCalendarId("");
setMessage({ text: result.message, type: "success" });
queryClient.invalidateQueries({ queryKey: ["global-settings"] });
} else {
Expand All @@ -1646,6 +1656,27 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) {
updateMutation.mutate({ brave_search_key: null });
};

const handleSaveGCalCredentials = () => {
updateMutation.mutate({
google_calendar: {
client_id: gCalClientId.trim(),
client_secret: gCalClientSecret.trim(),
refresh_token: gCalRefreshToken.trim(),
default_calendar_id: gCalDefaultCalendarId.trim() || "primary",
},
Comment thread
tomasmach marked this conversation as resolved.
});
Comment on lines +1659 to +1668

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Allow partial Google Calendar updates.

The request type is field-optional, but this handler always sends client_id, client_secret, and refresh_token, and the Save button stays disabled until all three are re-entered. That blocks rotating one credential or changing default_calendar_id unless the user can supply every existing secret again.

✂️ Suggested fix
 const handleSaveGCalCredentials = () => {
+	const clientId = gCalClientId.trim();
+	const clientSecret = gCalClientSecret.trim();
+	const refreshToken = gCalRefreshToken.trim();
 	const calId = gCalDefaultCalendarId.trim();
 	updateMutation.mutate({
 		google_calendar: {
-			client_id: gCalClientId.trim(),
-			client_secret: gCalClientSecret.trim(),
-			refresh_token: gCalRefreshToken.trim(),
+			...(clientId ? { client_id: clientId } : {}),
+			...(clientSecret ? { client_secret: clientSecret } : {}),
+			...(refreshToken ? { refresh_token: refreshToken } : {}),
 			...(calId ? { default_calendar_id: calId } : {}),
 		},
 	});
 };
@@
 						<Button
 							onClick={handleSaveGCalCredentials}
-							disabled={!gCalClientId.trim() || !gCalClientSecret.trim() || !gCalRefreshToken.trim()}
+							disabled={
+								settings?.google_calendar_configured
+									? !gCalClientId.trim() &&
+									  !gCalClientSecret.trim() &&
+									  !gCalRefreshToken.trim() &&
+									  !gCalDefaultCalendarId.trim()
+									: !gCalClientId.trim() ||
+									  !gCalClientSecret.trim() ||
+									  !gCalRefreshToken.trim()
+							}
 							loading={updateMutation.isPending}
 							size="sm"
 						>

Also applies to: 1870-1873

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/routes/Settings.tsx` around lines 1659 - 1668, The handler
handleSaveGCalCredentials always sends client_id, client_secret, and
refresh_token even when empty, preventing partial updates; change the payload
construction passed to updateMutation.mutate so google_calendar only includes
keys for which the user provided a non-empty trimmed value (e.g., include
client_id, client_secret, refresh_token only if their trimmed strings are
non-empty, and include default_calendar_id only if calId is non-empty), and
apply the same conditional-key logic to the similar handler referenced around
lines 1870-1873 so both update paths support partial Google Calendar updates.

};

const handleRemoveGCalCredentials = () => {
updateMutation.mutate({
google_calendar: {
client_id: "",
client_secret: "",
refresh_token: "",
},
});
};

return (
<div className="mx-auto max-w-2xl px-6 py-6">
<div className="mb-6">
Expand Down Expand Up @@ -1701,6 +1732,50 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) {
</div>
</div>
</div>

{/* Google Calendar */}
<div className="rounded-lg border border-app-line bg-app-box p-4">
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faCalendarDays} className="text-ink-faint" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink">Google Calendar</span>
{settings?.google_calendar_configured && (
<span className="text-tiny text-green-400">● Configured</span>
)}
</div>
<p className="mt-0.5 text-sm text-ink-dull">
Enables calendar tools for listing, creating, and managing events
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => {
setEditingGCalCredentials(true);
setGCalClientId("");
setGCalClientSecret("");
setGCalRefreshToken("");
setGCalDefaultCalendarId("");
setMessage(null);
}}
variant="outline"
size="sm"
>
{settings?.google_calendar_configured ? "Update" : "Configure"}
</Button>
{settings?.google_calendar_configured && (
<Button
onClick={handleRemoveGCalCredentials}
variant="outline"
size="sm"
loading={updateMutation.isPending}
>
Remove
</Button>
)}
</div>
</div>
</div>
</div>
)}

Expand Down Expand Up @@ -1748,6 +1823,60 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) {
</DialogFooter>
</DialogContent>
</Dialog>

{/* Google Calendar Dialog */}
<Dialog open={editingGCalCredentials} onOpenChange={(open) => { if (!open) setEditingGCalCredentials(false); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{settings?.google_calendar_configured ? "Update" : "Configure"} Google Calendar</DialogTitle>
<DialogDescription>
Enter your Google OAuth credentials. See the{" "}
<a href="/docs/google-calendar" target="_blank" className="text-accent underline">setup guide</a>{" "}
for instructions.
Comment thread
tomasmach marked this conversation as resolved.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
type="password"
value={gCalClientId}
onChange={(e) => setGCalClientId(e.target.value)}
placeholder="xxxx.apps.googleusercontent.com"
autoFocus
/>
<Input
type="password"
value={gCalClientSecret}
onChange={(e) => setGCalClientSecret(e.target.value)}
placeholder="GOCSPX-..."
/>
<Input
type="password"
value={gCalRefreshToken}
onChange={(e) => setGCalRefreshToken(e.target.value)}
placeholder="1//0..."
/>
<Input
type="text"
value={gCalDefaultCalendarId}
onChange={(e) => setGCalDefaultCalendarId(e.target.value)}
placeholder="primary (optional)"
/>
Comment on lines +1839 to +1864

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Give the Google Calendar fields accessible labels.

These inputs rely on placeholders only, so screen-reader users cannot reliably tell which credential is focused once the field has a value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/routes/Settings.tsx` around lines 1838 - 1863, The Google
Calendar credential inputs lack accessible labels; update the Input usages for
gCalClientId, gCalClientSecret, gCalRefreshToken, and gCalDefaultCalendarId to
provide proper accessible labeling (either wrap each Input with a <label> or add
descriptive aria-label/aria-labelledby attributes), ensure the label text is
unique and descriptive (e.g., "Google Client ID", "Google Client Secret",
"Google Refresh Token", "Default Calendar ID"), and if you don't want visual
labels use visually-hidden/sr-only text so screen readers can announce the
fields while preserving the current layout.

</div>
<DialogFooter>
<Button onClick={() => setEditingGCalCredentials(false)} variant="ghost" size="sm">
Cancel
</Button>
<Button
onClick={handleSaveGCalCredentials}
disabled={!gCalClientId.trim() || !gCalClientSecret.trim() || !gCalRefreshToken.trim()}
loading={updateMutation.isPending}
size="sm"
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Create a new event on Google Calendar. Specify a title, start/end time, and optionally a description, location, attendees, or recurrence rule. Use this to schedule meetings, set reminders, or block time.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Delete an event from Google Calendar. Requires the event ID. Use this to cancel meetings or remove events that are no longer needed.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Check free/busy status across one or more Google Calendars for a given time range. Returns busy periods. Use this to find available meeting slots or check if someone is free at a specific time.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
List all calendars available to the authenticated Google account. Returns calendar names, IDs, and access roles. Use this to discover which calendars exist before querying events.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
List events from Google Calendar. Returns event titles, times, locations, and attendees. Use this to check someone's schedule, find upcoming meetings, or search for specific events by keyword.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Respond to a Google Calendar event invitation. Set your RSVP status to accepted, declined, or tentative. Requires the event ID.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Update an existing Google Calendar event. Modify the title, time, description, location, attendees, or color. Only the fields you provide will be changed. Requires the event ID from a previous list or create call.
Comment thread
tomasmach marked this conversation as resolved.
Outdated
1 change: 1 addition & 0 deletions src/api/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ pub async fn create_agent_internal(
channel: None,
mcp: None,
brave_search_key: None,
google_calendar: None,
cron_timezone: None,
user_timezone: None,
sandbox: None,
Expand Down
Loading
Loading