diff --git a/docs/content/docs/(features)/google-calendar.mdx b/docs/content/docs/(features)/google-calendar.mdx
new file mode 100644
index 000000000..1c9c70af5
--- /dev/null
+++ b/docs/content/docs/(features)/google-calendar.mdx
@@ -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**
+
+
+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**.
+
+
+## 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
+
+
+
+
+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**
+
+
+
+
+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"
+```
+
+
+
+
+## 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"
+```
+
+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.
diff --git a/docs/content/docs/(features)/meta.json b/docs/content/docs/(features)/meta.json
index a9903832a..3a0dc820d 100644
--- a/docs/content/docs/(features)/meta.json
+++ b/docs/content/docs/(features)/meta.json
@@ -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"]
}
diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts
index 8cab46c2d..aea8600f8 100644
--- a/interface/src/api/client.ts
+++ b/interface/src/api/client.ts
@@ -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;
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;
+ };
api_enabled?: boolean;
api_port?: number;
api_bind?: string;
diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx
index 9ea2df47d..00db45254 100644
--- a/interface/src/routes/Settings.tsx
+++ b/interface/src/routes/Settings.tsx
@@ -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 } : {}),
+ },
+ });
+ };
+
+ const handleRemoveGCalCredentials = () => {
+ updateMutation.mutate({
+ google_calendar: {
+ client_id: "",
+ client_secret: "",
+ refresh_token: "",
+ },
+ });
+ };
+
return (
@@ -1701,6 +1733,50 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) {
+
+ {/* Google Calendar */}
+
+
+
+
+
+ Google Calendar
+ {settings?.google_calendar_configured && (
+ ● Configured
+ )}
+
+
+ Enables calendar tools for listing, creating, and managing events
+
+
+
+
+ {settings?.google_calendar_configured && (
+
+ )}
+
+
+
)}
@@ -1748,6 +1824,60 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) {
+
+ {/* Google Calendar Dialog */}
+
);
}
diff --git a/prompts/en/tools/google_calendar_create_event_description.md.j2 b/prompts/en/tools/google_calendar_create_event_description.md.j2
new file mode 100644
index 000000000..2cd178860
--- /dev/null
+++ b/prompts/en/tools/google_calendar_create_event_description.md.j2
@@ -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.
diff --git a/prompts/en/tools/google_calendar_delete_event_description.md.j2 b/prompts/en/tools/google_calendar_delete_event_description.md.j2
new file mode 100644
index 000000000..9123450f9
--- /dev/null
+++ b/prompts/en/tools/google_calendar_delete_event_description.md.j2
@@ -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.
diff --git a/prompts/en/tools/google_calendar_find_free_time_description.md.j2 b/prompts/en/tools/google_calendar_find_free_time_description.md.j2
new file mode 100644
index 000000000..82575399d
--- /dev/null
+++ b/prompts/en/tools/google_calendar_find_free_time_description.md.j2
@@ -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.
diff --git a/prompts/en/tools/google_calendar_list_calendars_description.md.j2 b/prompts/en/tools/google_calendar_list_calendars_description.md.j2
new file mode 100644
index 000000000..92cdc92ef
--- /dev/null
+++ b/prompts/en/tools/google_calendar_list_calendars_description.md.j2
@@ -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.
diff --git a/prompts/en/tools/google_calendar_list_events_description.md.j2 b/prompts/en/tools/google_calendar_list_events_description.md.j2
new file mode 100644
index 000000000..95e6c7eab
--- /dev/null
+++ b/prompts/en/tools/google_calendar_list_events_description.md.j2
@@ -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.
diff --git a/prompts/en/tools/google_calendar_respond_event_description.md.j2 b/prompts/en/tools/google_calendar_respond_event_description.md.j2
new file mode 100644
index 000000000..2894aea71
--- /dev/null
+++ b/prompts/en/tools/google_calendar_respond_event_description.md.j2
@@ -0,0 +1 @@
+Respond to a Google Calendar event invitation. Set your RSVP status to accepted, declined, or tentative. Requires the event ID.
diff --git a/prompts/en/tools/google_calendar_update_event_description.md.j2 b/prompts/en/tools/google_calendar_update_event_description.md.j2
new file mode 100644
index 000000000..be89507f3
--- /dev/null
+++ b/prompts/en/tools/google_calendar_update_event_description.md.j2
@@ -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.
diff --git a/src/api/agents.rs b/src/api/agents.rs
index ce75842c1..80fc359c5 100644
--- a/src/api/agents.rs
+++ b/src/api/agents.rs
@@ -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,
diff --git a/src/api/secrets.rs b/src/api/secrets.rs
index bc97fccd5..b9bc5e97b 100644
--- a/src/api/secrets.rs
+++ b/src/api/secrets.rs
@@ -640,10 +640,18 @@ fn migrate_section_secrets(
// Migrate default (top-level) fields.
for field in fields {
+ // Split dot-separated toml_key segments (e.g. "google_calendar.client_id"
+ // becomes ["google_calendar", "client_id"]) so nested tables are traversed
+ // correctly rather than treated as a single literal key.
+ let key_segments: Vec<&str> = field.toml_key.split('.').collect();
let path: Vec<&str> = if is_adapter {
- vec!["messaging", section, field.toml_key]
+ let mut p = vec!["messaging", section];
+ p.extend_from_slice(&key_segments);
+ p
} else {
- vec![section, field.toml_key]
+ let mut p = vec![section];
+ p.extend_from_slice(&key_segments);
+ p
};
try_migrate_field(store, doc, &path, field.secret_name, migrated);
}
diff --git a/src/api/settings.rs b/src/api/settings.rs
index 52eaf44a4..6b2793e88 100644
--- a/src/api/settings.rs
+++ b/src/api/settings.rs
@@ -9,6 +9,11 @@ use std::sync::Arc;
#[derive(Serialize)]
pub(super) struct GlobalSettingsResponse {
brave_search_key: Option,
+ google_calendar_configured: bool,
+ google_calendar_client_id: Option,
+ google_calendar_client_secret: Option,
+ google_calendar_refresh_token: Option,
+ google_calendar_default_calendar_id: Option,
api_enabled: bool,
api_port: u16,
api_bind: String,
@@ -37,6 +42,7 @@ pub(super) struct OpenCodePermissionsResponse {
#[derive(Deserialize)]
pub(super) struct GlobalSettingsUpdate {
brave_search_key: Option,
+ google_calendar: Option,
api_enabled: Option,
api_port: Option,
api_bind: Option,
@@ -45,6 +51,14 @@ pub(super) struct GlobalSettingsUpdate {
ssh_enabled: Option,
}
+#[derive(Deserialize)]
+pub(super) struct GoogleCalendarSettingsUpdate {
+ client_id: Option,
+ client_secret: Option,
+ refresh_token: Option,
+ default_calendar_id: Option,
+}
+
#[derive(Deserialize)]
pub(super) struct OpenCodeSettingsUpdate {
enabled: Option,
@@ -90,140 +104,204 @@ pub(super) async fn get_global_settings(
) -> Result, StatusCode> {
let config_path = state.config_path.read().await.clone();
- let (brave_search_key, api_enabled, api_port, api_bind, worker_log_mode, opencode, ssh_enabled) =
- if config_path.exists() {
- let content = tokio::fs::read_to_string(&config_path)
- .await
- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
- let doc: toml_edit::DocumentMut = content
- .parse()
- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
-
- let brave_search = doc
- .get("defaults")
- .and_then(|d| d.get("brave_search_key"))
+ let (
+ brave_search_key,
+ google_calendar_configured,
+ gcal_client_id,
+ gcal_client_secret,
+ gcal_refresh_token,
+ gcal_default_calendar_id,
+ api_enabled,
+ api_port,
+ api_bind,
+ worker_log_mode,
+ opencode,
+ ssh_enabled,
+ ) = if config_path.exists() {
+ let content = tokio::fs::read_to_string(&config_path)
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ let doc: toml_edit::DocumentMut = content
+ .parse()
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ let brave_search = doc
+ .get("defaults")
+ .and_then(|d| d.get("brave_search_key"))
+ .and_then(|v| v.as_str())
+ .and_then(|s| {
+ if let Some(var) = s.strip_prefix("env:") {
+ std::env::var(var).ok()
+ } else {
+ Some(s.to_string())
+ }
+ });
+
+ let gcal_read = |g: &toml_edit::Item| {
+ let client_id = g
+ .get("client_id")
.and_then(|v| v.as_str())
- .and_then(|s| {
- if let Some(var) = s.strip_prefix("env:") {
- std::env::var(var).ok()
- } else {
- Some(s.to_string())
- }
- });
-
- let api_enabled = doc
- .get("api")
- .and_then(|a| a.get("enabled"))
- .and_then(|v| v.as_bool())
- .unwrap_or(true);
-
- let api_port = doc
- .get("api")
- .and_then(|a| a.get("port"))
- .and_then(|v| v.as_integer())
- .and_then(|i| u16::try_from(i).ok())
- .unwrap_or(19898);
-
- let api_bind = doc
- .get("api")
- .and_then(|a| a.get("bind"))
+ .map(str::to_owned);
+ let client_secret = g
+ .get("client_secret")
.and_then(|v| v.as_str())
- .unwrap_or("127.0.0.1")
- .to_string();
-
- let worker_log_mode = doc
- .get("defaults")
- .and_then(|d| d.get("worker_log_mode"))
+ .map(str::to_owned);
+ let refresh_token = g
+ .get("refresh_token")
+ .and_then(|v| v.as_str())
+ .map(str::to_owned);
+ let default_calendar_id = g
+ .get("default_calendar_id")
+ .and_then(|v| v.as_str())
+ .map(str::to_owned);
+ (client_id, client_secret, refresh_token, default_calendar_id)
+ };
+ let (gcal_client_id, gcal_client_secret, gcal_refresh_token, gcal_default_calendar_id) =
+ doc.get("defaults")
+ .and_then(|d| d.get("google_calendar"))
+ .map(gcal_read)
+ .or_else(|| {
+ doc.get("agents")
+ .and_then(|a| a.as_array_of_tables())
+ .and_then(|agents| {
+ agents
+ .iter()
+ .find_map(|agent| agent.get("google_calendar").map(gcal_read))
+ })
+ })
+ .unwrap_or((None, None, None, None));
+ let google_calendar_configured = gcal_client_id.as_deref().is_some_and(|s| !s.is_empty())
+ && gcal_client_secret.as_deref().is_some_and(|s| !s.is_empty())
+ && gcal_refresh_token.as_deref().is_some_and(|s| !s.is_empty());
+
+ let api_enabled = doc
+ .get("api")
+ .and_then(|a| a.get("enabled"))
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true);
+
+ let api_port = doc
+ .get("api")
+ .and_then(|a| a.get("port"))
+ .and_then(|v| v.as_integer())
+ .and_then(|i| u16::try_from(i).ok())
+ .unwrap_or(19898);
+
+ let api_bind = doc
+ .get("api")
+ .and_then(|a| a.get("bind"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("127.0.0.1")
+ .to_string();
+
+ let worker_log_mode = doc
+ .get("defaults")
+ .and_then(|d| d.get("worker_log_mode"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("errors_only")
+ .to_string();
+
+ let opencode_table = doc.get("defaults").and_then(|d| d.get("opencode"));
+ let opencode_perms = opencode_table.and_then(|o| o.get("permissions"));
+ let opencode = OpenCodeSettingsResponse {
+ enabled: opencode_table
+ .and_then(|o| o.get("enabled"))
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false),
+ path: opencode_table
+ .and_then(|o| o.get("path"))
.and_then(|v| v.as_str())
- .unwrap_or("errors_only")
- .to_string();
-
- let opencode_table = doc.get("defaults").and_then(|d| d.get("opencode"));
- let opencode_perms = opencode_table.and_then(|o| o.get("permissions"));
- let opencode = OpenCodeSettingsResponse {
- enabled: opencode_table
- .and_then(|o| o.get("enabled"))
- .and_then(|v| v.as_bool())
- .unwrap_or(false),
- path: opencode_table
- .and_then(|o| o.get("path"))
+ .unwrap_or("opencode")
+ .to_string(),
+ max_servers: opencode_table
+ .and_then(|o| o.get("max_servers"))
+ .and_then(|v| v.as_integer())
+ .and_then(|i| usize::try_from(i).ok())
+ .unwrap_or(5),
+ server_startup_timeout_secs: opencode_table
+ .and_then(|o| o.get("server_startup_timeout_secs"))
+ .and_then(|v| v.as_integer())
+ .and_then(|i| u64::try_from(i).ok())
+ .unwrap_or(30),
+ max_restart_retries: opencode_table
+ .and_then(|o| o.get("max_restart_retries"))
+ .and_then(|v| v.as_integer())
+ .and_then(|i| u32::try_from(i).ok())
+ .unwrap_or(5),
+ permissions: OpenCodePermissionsResponse {
+ edit: opencode_perms
+ .and_then(|p| p.get("edit"))
.and_then(|v| v.as_str())
- .unwrap_or("opencode")
+ .unwrap_or("allow")
.to_string(),
- max_servers: opencode_table
- .and_then(|o| o.get("max_servers"))
- .and_then(|v| v.as_integer())
- .and_then(|i| usize::try_from(i).ok())
- .unwrap_or(5),
- server_startup_timeout_secs: opencode_table
- .and_then(|o| o.get("server_startup_timeout_secs"))
- .and_then(|v| v.as_integer())
- .and_then(|i| u64::try_from(i).ok())
- .unwrap_or(30),
- max_restart_retries: opencode_table
- .and_then(|o| o.get("max_restart_retries"))
- .and_then(|v| v.as_integer())
- .and_then(|i| u32::try_from(i).ok())
- .unwrap_or(5),
- permissions: OpenCodePermissionsResponse {
- edit: opencode_perms
- .and_then(|p| p.get("edit"))
- .and_then(|v| v.as_str())
- .unwrap_or("allow")
- .to_string(),
- bash: opencode_perms
- .and_then(|p| p.get("bash"))
- .and_then(|v| v.as_str())
- .unwrap_or("allow")
- .to_string(),
- webfetch: opencode_perms
- .and_then(|p| p.get("webfetch"))
- .and_then(|v| v.as_str())
- .unwrap_or("allow")
- .to_string(),
- },
- };
+ bash: opencode_perms
+ .and_then(|p| p.get("bash"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("allow")
+ .to_string(),
+ webfetch: opencode_perms
+ .and_then(|p| p.get("webfetch"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("allow")
+ .to_string(),
+ },
+ };
- let ssh_enabled = doc
- .get("ssh")
- .and_then(|s| s.get("enabled"))
- .and_then(|v| v.as_bool())
- .unwrap_or(false);
-
- (
- brave_search,
- api_enabled,
- api_port,
- api_bind,
- worker_log_mode,
- opencode,
- ssh_enabled,
- )
- } else {
- (
- None,
- true,
- 19898,
- "127.0.0.1".to_string(),
- "errors_only".to_string(),
- OpenCodeSettingsResponse {
- enabled: false,
- path: "opencode".to_string(),
- max_servers: 5,
- server_startup_timeout_secs: 30,
- max_restart_retries: 5,
- permissions: OpenCodePermissionsResponse {
- edit: "allow".to_string(),
- bash: "allow".to_string(),
- webfetch: "allow".to_string(),
- },
+ let ssh_enabled = doc
+ .get("ssh")
+ .and_then(|s| s.get("enabled"))
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ (
+ brave_search,
+ google_calendar_configured,
+ gcal_client_id,
+ gcal_client_secret,
+ gcal_refresh_token,
+ gcal_default_calendar_id,
+ api_enabled,
+ api_port,
+ api_bind,
+ worker_log_mode,
+ opencode,
+ ssh_enabled,
+ )
+ } else {
+ (
+ None,
+ false,
+ None,
+ None,
+ None,
+ None,
+ true,
+ 19898,
+ "127.0.0.1".to_string(),
+ "errors_only".to_string(),
+ OpenCodeSettingsResponse {
+ enabled: false,
+ path: "opencode".to_string(),
+ max_servers: 5,
+ server_startup_timeout_secs: 30,
+ max_restart_retries: 5,
+ permissions: OpenCodePermissionsResponse {
+ edit: "allow".to_string(),
+ bash: "allow".to_string(),
+ webfetch: "allow".to_string(),
},
- false,
- )
- };
+ },
+ false,
+ )
+ };
Ok(Json(GlobalSettingsResponse {
brave_search_key,
+ google_calendar_configured,
+ google_calendar_client_id: gcal_client_id,
+ google_calendar_client_secret: gcal_client_secret,
+ google_calendar_refresh_token: gcal_refresh_token,
+ google_calendar_default_calendar_id: gcal_default_calendar_id,
api_enabled,
api_port,
api_bind,
@@ -266,6 +344,51 @@ pub(super) async fn update_global_settings(
}
}
+ if let Some(google_calendar) = request.google_calendar {
+ let all_empty = google_calendar
+ .client_id
+ .as_deref()
+ .is_some_and(|s| s.is_empty())
+ && google_calendar
+ .client_secret
+ .as_deref()
+ .is_some_and(|s| s.is_empty())
+ && google_calendar
+ .refresh_token
+ .as_deref()
+ .is_some_and(|s| s.is_empty());
+
+ if all_empty {
+ // Remove the entire section.
+ if let Some(defaults) = doc.get_mut("defaults").and_then(|d| d.as_table_mut()) {
+ defaults.remove("google_calendar");
+ }
+ } else {
+ if doc.get("defaults").is_none() {
+ doc["defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
+ }
+ if doc["defaults"].get("google_calendar").is_none() {
+ doc["defaults"]["google_calendar"] =
+ toml_edit::Item::Table(toml_edit::Table::new());
+ }
+ if let Some(client_id) = google_calendar.client_id {
+ doc["defaults"]["google_calendar"]["client_id"] = toml_edit::value(client_id);
+ }
+ if let Some(client_secret) = google_calendar.client_secret {
+ doc["defaults"]["google_calendar"]["client_secret"] =
+ toml_edit::value(client_secret);
+ }
+ if let Some(refresh_token) = google_calendar.refresh_token {
+ doc["defaults"]["google_calendar"]["refresh_token"] =
+ toml_edit::value(refresh_token);
+ }
+ if let Some(default_calendar_id) = google_calendar.default_calendar_id {
+ doc["defaults"]["google_calendar"]["default_calendar_id"] =
+ toml_edit::value(default_calendar_id);
+ }
+ }
+ }
+
if request.api_enabled.is_some() || request.api_port.is_some() || request.api_bind.is_some() {
requires_restart = true;
diff --git a/src/config/load.rs b/src/config/load.rs
index 3da09e61f..accc7b3cc 100644
--- a/src/config/load.rs
+++ b/src/config/load.rs
@@ -12,12 +12,12 @@ use super::toml_schema::*;
use super::{
AgentConfig, ApiConfig, ApiType, Binding, BrowserConfig, ChannelConfig, ClosePolicy,
CoalesceConfig, CompactionConfig, Config, CortexConfig, CronDef, DefaultsConfig, DiscordConfig,
- DiscordInstanceConfig, EmailConfig, EmailInstanceConfig, GroupDef, HumanDef, IngestionConfig,
- LinkDef, LlmConfig, McpServerConfig, McpTransport, MemoryPersistenceConfig, MessagingConfig,
- MetricsConfig, OpenCodeConfig, ProjectsConfig, ProviderConfig, SlackCommandConfig, SlackConfig,
- SlackInstanceConfig, TelegramConfig, TelegramInstanceConfig, TelemetryConfig, TwitchConfig,
- TwitchInstanceConfig, WarmupConfig, WebhookConfig, normalize_adapter,
- validate_named_messaging_adapters,
+ DiscordInstanceConfig, EmailConfig, EmailInstanceConfig, GoogleCalendarConfig, GroupDef,
+ HumanDef, IngestionConfig, LinkDef, LlmConfig, McpServerConfig, McpTransport,
+ MemoryPersistenceConfig, MessagingConfig, MetricsConfig, OpenCodeConfig, ProjectsConfig,
+ ProviderConfig, SlackCommandConfig, SlackConfig, SlackInstanceConfig, TelegramConfig,
+ TelegramInstanceConfig, TelemetryConfig, TwitchConfig, TwitchInstanceConfig, WarmupConfig,
+ WebhookConfig, normalize_adapter, validate_named_messaging_adapters,
};
use crate::error::{ConfigError, Result};
@@ -26,6 +26,56 @@ use anyhow::Context as _;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
+/// Resolve Google Calendar config from an optional TOML config block.
+///
+/// Resolves each credential via `resolve_env_value`, then falls back to direct
+/// environment variables. Returns `None` when any required credential is missing.
+fn resolve_google_calendar_config(
+ google_calendar_config: Option<&TomlGoogleCalendarConfig>,
+) -> Option {
+ let (client_id, client_secret, refresh_token, default_calendar_id) =
+ match google_calendar_config {
+ Some(google_calendar) => (
+ google_calendar
+ .client_id
+ .as_deref()
+ .and_then(resolve_env_value)
+ .or_else(|| std::env::var("GOOGLE_CALENDAR_CLIENT_ID").ok()),
+ google_calendar
+ .client_secret
+ .as_deref()
+ .and_then(resolve_env_value)
+ .or_else(|| std::env::var("GOOGLE_CALENDAR_CLIENT_SECRET").ok()),
+ google_calendar
+ .refresh_token
+ .as_deref()
+ .and_then(resolve_env_value)
+ .or_else(|| std::env::var("GOOGLE_CALENDAR_REFRESH_TOKEN").ok()),
+ google_calendar
+ .default_calendar_id
+ .as_deref()
+ .and_then(resolve_env_value)
+ .unwrap_or_else(|| "primary".to_string()),
+ ),
+ None => (
+ std::env::var("GOOGLE_CALENDAR_CLIENT_ID").ok(),
+ std::env::var("GOOGLE_CALENDAR_CLIENT_SECRET").ok(),
+ std::env::var("GOOGLE_CALENDAR_REFRESH_TOKEN").ok(),
+ "primary".to_string(),
+ ),
+ };
+
+ match (client_id, client_secret, refresh_token) {
+ (Some(id), Some(secret), Some(token)) => Some(GoogleCalendarConfig {
+ client_id: id,
+ client_secret: secret,
+ refresh_token: token,
+ default_calendar_id,
+ }),
+ _ => None,
+ }
+}
+
/// Resolve a value that might be an "env:VAR_NAME" or "secret:NAME" reference.
///
/// Three resolution modes:
@@ -836,6 +886,7 @@ impl Config {
channel: None,
mcp: None,
brave_search_key: None,
+ google_calendar: None,
cron_timezone: None,
user_timezone: None,
sandbox: None,
@@ -848,6 +899,7 @@ impl Config {
let mut defaults = DefaultsConfig::default();
defaults.browser.chrome_cache_dir = instance_dir.join("chrome_cache");
+ defaults.google_calendar = resolve_google_calendar_config(None);
Ok(Self {
instance_dir: instance_dir.to_path_buf(),
@@ -1508,6 +1560,7 @@ impl Config {
.as_deref()
.and_then(resolve_env_value)
.or_else(|| std::env::var("BRAVE_SEARCH_API_KEY").ok()),
+ google_calendar: resolve_google_calendar_config(toml.defaults.google_calendar.as_ref()),
cron_timezone: toml
.defaults
.cron_timezone
@@ -1715,6 +1768,7 @@ impl Config {
None => None,
},
brave_search_key: a.brave_search_key.as_deref().and_then(resolve_env_value),
+ google_calendar: resolve_google_calendar_config(a.google_calendar.as_ref()),
cron_timezone: a.cron_timezone.as_deref().and_then(resolve_env_value),
user_timezone: a.user_timezone.as_deref().and_then(resolve_env_value),
sandbox: a.sandbox,
@@ -1769,6 +1823,7 @@ impl Config {
channel: None,
mcp: None,
brave_search_key: None,
+ google_calendar: None,
cron_timezone: None,
user_timezone: None,
sandbox: None,
diff --git a/src/config/runtime.rs b/src/config/runtime.rs
index 58c36ce47..4b4fd7943 100644
--- a/src/config/runtime.rs
+++ b/src/config/runtime.rs
@@ -5,8 +5,9 @@ use arc_swap::ArcSwap;
use super::{
BrowserConfig, ChannelConfig, CoalesceConfig, CompactionConfig, Config, CortexConfig,
- DefaultsConfig, IngestionConfig, McpServerConfig, MemoryPersistenceConfig, OpenCodeConfig,
- ResolvedAgentConfig, WarmupConfig, WarmupStatus, WorkReadiness, evaluate_work_readiness,
+ DefaultsConfig, GoogleCalendarConfig, IngestionConfig, McpServerConfig,
+ MemoryPersistenceConfig, OpenCodeConfig, ResolvedAgentConfig, WarmupConfig, WarmupStatus,
+ WorkReadiness, evaluate_work_readiness,
};
use crate::llm::routing::RoutingConfig;
use crate::tools::browser::SharedBrowserHandle;
@@ -40,6 +41,7 @@ pub struct RuntimeConfig {
pub mcp: ArcSwap>,
pub history_backfill_count: ArcSwap,
pub brave_search_key: ArcSwap