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

# Google Calendar

Native Google Calendar integration that gives workers and branches 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 | Branches | 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 |

Branches get read-only tools. Workers get both read and write tools. This follows the delegation model — when you ask the channel to check your schedule, it spawns a branch to do the lookup, and only workers can modify calendar data.

## 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]]
id = "personal-assistant"

[agents.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"]
}
11 changes: 11 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,11 @@ export interface OpenCodeSettingsUpdate {

export interface GlobalSettingsResponse {
brave_search_key: string | null;
google_calendar_configured: boolean;
google_calendar_client_id: string | null;
google_calendar_client_secret: string | null;
google_calendar_refresh_token: string | null;
google_calendar_default_calendar_id: string | null;
Comment on lines 1339 to +1345

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

Keep Google OAuth secrets out of the read response.

GlobalSettingsResponse now models google_calendar_client_secret and google_calendar_refresh_token as values returned to the browser. That turns GET /settings into a secret-read API for long-lived credentials, which materially widens the blast radius of any admin-session XSS or accidental client-side logging. These should stay write-only; the read shape should expose only configured/present state or masked metadata.

🔐 Suggested contract change
 export interface GlobalSettingsResponse {
 	brave_search_key: string | null;
 	google_calendar_configured: boolean;
 	google_calendar_client_id: string | null;
-	google_calendar_client_secret: string | null;
-	google_calendar_refresh_token: string | null;
+	google_calendar_has_client_secret: boolean;
+	google_calendar_has_refresh_token: boolean;
 	google_calendar_default_calendar_id: string | null;
 	api_enabled: boolean;
 	api_port: number;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/api/client.ts` around lines 1339 - 1345, GlobalSettingsResponse
currently exposes sensitive fields (google_calendar_client_secret,
google_calendar_refresh_token); remove those fields from the response interface
and replace them with non-secret indicators (e.g., google_calendar_configured
already exists or add google_calendar_has_refresh_token: boolean /
google_calendar_secret_masked: boolean) so the GET /settings response never
returns long‑lived secrets; update any code that constructs/serializes the
GlobalSettingsResponse (where GET /settings is implemented) to stop including
the actual secret values and instead set only the presence/masked flags.

api_enabled: boolean;
api_port: number;
api_bind: string;
Expand All @@ -1347,6 +1352,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
132 changes: 131 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,28 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) {
updateMutation.mutate({ brave_search_key: null });
};

const handleSaveGCalCredentials = () => {
const calId = gCalDefaultCalendarId.trim();
updateMutation.mutate({
google_calendar: {
client_id: gCalClientId.trim(),
client_secret: gCalClientSecret.trim(),
refresh_token: gCalRefreshToken.trim(),
...(calId ? { default_calendar_id: calId } : {}),
},
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 +1733,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(settings?.google_calendar_client_id || "");
setGCalClientSecret(settings?.google_calendar_client_secret || "");
setGCalRefreshToken(settings?.google_calendar_refresh_token || "");
setGCalDefaultCalendarId(settings?.google_calendar_default_calendar_id || "");
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 +1824,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" rel="noopener noreferrer" 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_id`. Only the fields you provide will be changed. Requires the event ID from a previous list or create call.
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