-
Notifications
You must be signed in to change notification settings - Fork 360
feat: add Google Calendar native tool integration #378
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c5090c7
adfb402
002f514
cceefcf
d7bf540
1bb4410
18b953e
fb83a8b
d9bead1
c1a5062
6828b9f
f1e06b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| # 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" | ||
|
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. | ||
| 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"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep Google OAuth secrets out of the read response.
🔐 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 |
||
| api_enabled: boolean; | ||
| api_port: number; | ||
| api_bind: string; | ||
|
|
@@ -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; | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| api_enabled?: boolean; | ||
| api_port?: number; | ||
| api_bind?: string; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
|
@@ -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({ | ||
|
|
@@ -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 { | ||
|
|
@@ -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 } : {}), | ||
| }, | ||
|
tomasmach marked this conversation as resolved.
|
||
| }); | ||
|
Comment on lines
+1659
to
+1668
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Allow partial Google Calendar updates. The request type is field-optional, but this handler always sends ✂️ 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 |
||
| }; | ||
|
|
||
| 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"> | ||
|
|
@@ -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> | ||
| )} | ||
|
|
||
|
|
@@ -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. | ||
|
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| </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> | ||
| ); | ||
| } | ||
|
|
||
| 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
🤖 Prompt for AI Agents