feat(google-meet): create meeting spaces

This commit is contained in:
Peter Steinberger
2026-04-24 22:11:07 +01:00
parent 86f8c826e2
commit b20208fa4c
6 changed files with 281 additions and 2 deletions

View File

@@ -2,6 +2,7 @@
summary: "Google Meet plugin: join explicit Meet URLs through Chrome or Twilio with realtime voice defaults"
read_when:
- You want an OpenClaw agent to join a Google Meet call
- You want an OpenClaw agent to create a new Google Meet call
- You are configuring Chrome, Chrome node, or Twilio as a Google Meet transport
title: "Google Meet plugin"
---
@@ -9,6 +10,8 @@ title: "Google Meet plugin"
Google Meet participant support for OpenClaw — the plugin is explicit by design:
- It only joins an explicit `https://meet.google.com/...` URL.
- It can create a new Meet space through the Google Meet API, then join the
returned URL.
- `realtime` voice is the default mode.
- Realtime voice can call back into the full OpenClaw agent when deeper
reasoning or tools are needed.
@@ -94,6 +97,33 @@ Or let an agent join through the `google_meet` tool:
}
```
Create a new meeting, then join it:
```bash
openclaw googlemeet create
openclaw googlemeet join https://meet.google.com/new-abcd-xyz --transport chrome-node
```
Or tell an agent: "Create a Google Meet, join it with realtime voice, and send
me the link." The agent should call `google_meet` with `action: "create"`, copy
the returned `meetingUri`, then call `google_meet` with `action: "join"` and
that URL.
```json
{
"action": "create"
}
```
```json
{
"action": "join",
"url": "https://meet.google.com/new-abcd-xyz",
"transport": "chrome-node",
"mode": "realtime"
}
```
For an observe-only/browser-control join, set `"mode": "transcribe"`. That does
not start the duplex realtime model bridge, so it will not talk back into the
meeting.
@@ -381,6 +411,11 @@ The command prints an `oauth` config block with a refresh token. It uses PKCE,
localhost callback on `http://localhost:8085/oauth2callback`, and a manual
copy/paste flow with `--manual`.
The OAuth consent includes Meet space creation, Meet space read access, and
Meet conference media read access. If you authenticated before meeting creation
support existed, rerun `openclaw googlemeet auth login --json` so the refresh
token has the `meetings.space.created` scope.
These environment variables are accepted as fallbacks:
- `OPENCLAW_GOOGLE_MEET_CLIENT_ID` or `GOOGLE_MEET_CLIENT_ID`
@@ -404,6 +439,22 @@ Run preflight before media work:
openclaw googlemeet preflight --meeting https://meet.google.com/abc-defg-hij
```
Create a fresh Meet space with the same OAuth config:
```bash
openclaw googlemeet create
```
The command prints the new `meeting uri` and `space`. Agents can use the
`google_meet` tool with `action: "create"` to create a meeting, then call
`action: "join"` with the returned `meetingUri`.
Creating a Meet space only creates the meeting URL. The Chrome or Chrome-node
transport still needs a signed-in Google Chrome profile to join through the
browser. If the profile is signed out, OpenClaw reports
`manualActionRequired: true` and asks the operator to finish Google login before
retrying the join.
Set `preview.enrollmentAcknowledged: true` only after confirming your Cloud
project, OAuth principal, and meeting participants are enrolled in the Google
Workspace Developer Preview Program for Meet media APIs.
@@ -687,6 +738,18 @@ Common manual actions:
- Grant Chrome microphone/camera permissions.
- Close or repair a stuck Meet permission dialog.
### Meeting creation fails
`googlemeet create` uses the Google Meet API `spaces.create` endpoint. Confirm:
- `oauth.clientId` and `oauth.refreshToken` are configured, or matching
`OPENCLAW_GOOGLE_MEET_*` environment variables are present.
- The refresh token was minted after create support was added. Older tokens may
be missing the `meetings.space.created` scope; rerun
`openclaw googlemeet auth login --json` and update plugin config.
- The Google Cloud project and OAuth principal are allowed to use the required
Google Meet API scopes.
### Agent joins but does not talk
Check the realtime path:

View File

@@ -10,6 +10,7 @@ import { registerGoogleMeetCli } from "./src/cli.js";
import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/config.js";
import {
buildGoogleMeetPreflightReport,
createGoogleMeetSpace,
fetchGoogleMeetSpace,
normalizeGoogleMeetSpaceName,
} from "./src/meet.js";
@@ -348,6 +349,7 @@ describe("google-meet plugin", () => {
type: "string",
enum: [
"join",
"create",
"status",
"setup_status",
"resolve_space",
@@ -411,6 +413,37 @@ describe("google-meet plugin", () => {
);
});
it("creates Meet spaces and returns the meeting URL", async () => {
const fetchMock = vi.fn(async () => {
return new Response(
JSON.stringify({
name: "spaces/new-space",
meetingCode: "new-abcd-xyz",
meetingUri: "https://meet.google.com/new-abcd-xyz",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
await expect(createGoogleMeetSpace({ accessToken: "token" })).resolves.toMatchObject({
meetingUri: "https://meet.google.com/new-abcd-xyz",
space: { name: "spaces/new-space" },
});
expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://meet.googleapis.com/v2/spaces",
init: expect.objectContaining({
method: "POST",
headers: expect.objectContaining({ Authorization: "Bearer token" }),
body: "{}",
}),
policy: { allowedHostnames: ["meet.googleapis.com"] },
auditContext: "google-meet.spaces.create",
}),
);
});
it("surfaces Developer Preview acknowledgment blockers in preflight reports", () => {
expect(
buildGoogleMeetPreflightReport({
@@ -438,6 +471,7 @@ describe("google-meet plugin", () => {
expect(url.searchParams.get("client_id")).toBe("client-id");
expect(url.searchParams.get("code_challenge")).toBe("challenge");
expect(url.searchParams.get("access_type")).toBe("offline");
expect(url.searchParams.get("scope")).toContain("meetings.space.created");
expect(url.searchParams.get("scope")).toContain("meetings.conference.media.readonly");
await expect(
@@ -701,6 +735,48 @@ describe("google-meet plugin", () => {
}
});
it("CLI create prints the new meeting URL", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
const url = input instanceof Request ? input.url : input.toString();
if (url.includes("oauth2.googleapis.com")) {
return new Response(
JSON.stringify({
access_token: "new-access-token",
expires_in: 3600,
token_type: "Bearer",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(
JSON.stringify({
name: "spaces/new-space",
meetingCode: "new-abcd-xyz",
meetingUri: "https://meet.google.com/new-abcd-xyz",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
const program = new Command();
const stdout = captureStdout();
registerGoogleMeetCli({
program,
config: resolveGoogleMeetConfig({
oauth: { clientId: "client-id", refreshToken: "refresh-token" },
}),
ensureRuntime: async () => ({}) as GoogleMeetRuntime,
});
try {
await program.parseAsync(["googlemeet", "create"], { from: "user" });
expect(stdout.output()).toContain("meeting uri: https://meet.google.com/new-abcd-xyz");
expect(stdout.output()).toContain("space: spaces/new-space");
} finally {
stdout.restore();
}
});
it("launches Chrome after the BlackHole check", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin" });

View File

@@ -10,7 +10,11 @@ import {
type GoogleMeetMode,
type GoogleMeetTransport,
} from "./src/config.js";
import { buildGoogleMeetPreflightReport, fetchGoogleMeetSpace } from "./src/meet.js";
import {
buildGoogleMeetPreflightReport,
createGoogleMeetSpace,
fetchGoogleMeetSpace,
} from "./src/meet.js";
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
import { resolveGoogleMeetAccessToken } from "./src/oauth.js";
import { GoogleMeetRuntime } from "./src/runtime.js";
@@ -134,6 +138,7 @@ const GoogleMeetToolSchema = Type.Object({
action: Type.String({
enum: [
"join",
"create",
"status",
"setup_status",
"resolve_space",
@@ -213,6 +218,18 @@ async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record<stri
return { meeting, token, space };
}
async function createSpaceFromParams(config: GoogleMeetConfig, raw: Record<string, unknown>) {
const token = await resolveGoogleMeetAccessToken({
clientId: normalizeOptionalString(raw.clientId) ?? config.oauth.clientId,
clientSecret: normalizeOptionalString(raw.clientSecret) ?? config.oauth.clientSecret,
refreshToken: normalizeOptionalString(raw.refreshToken) ?? config.oauth.refreshToken,
accessToken: normalizeOptionalString(raw.accessToken) ?? config.oauth.accessToken,
expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : config.oauth.expiresAt,
});
const result = await createGoogleMeetSpace({ accessToken: token.accessToken });
return { token, ...result };
}
export default definePluginEntry({
id: "google-meet",
name: "Google Meet",
@@ -262,6 +279,19 @@ export default definePluginEntry({
},
);
api.registerGatewayMethod(
"googlemeet.create",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asParamRecord(params);
const { token: _token, ...result } = await createSpaceFromParams(config, raw);
respond(true, result);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"googlemeet.status",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -364,6 +394,10 @@ export default definePluginEntry({
}),
);
}
case "create": {
const { token: _token, ...result } = await createSpaceFromParams(config, raw);
return json(result);
}
case "test_speech": {
const rt = await ensureRuntime();
return json(

View File

@@ -2,7 +2,11 @@ import { createInterface } from "node:readline/promises";
import { format } from "node:util";
import type { Command } from "commander";
import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js";
import { buildGoogleMeetPreflightReport, fetchGoogleMeetSpace } from "./meet.js";
import {
buildGoogleMeetPreflightReport,
createGoogleMeetSpace,
fetchGoogleMeetSpace,
} from "./meet.js";
import {
buildGoogleMeetAuthUrl,
createGoogleMeetOAuthState,
@@ -44,6 +48,15 @@ type SetupOptions = {
json?: boolean;
};
type CreateOptions = {
accessToken?: string;
refreshToken?: string;
clientId?: string;
clientSecret?: string;
expiresAt?: string;
json?: boolean;
};
function writeStdoutJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
@@ -113,6 +126,25 @@ function resolveTokenOptions(
};
}
function resolveCreateTokenOptions(
config: GoogleMeetConfig,
options: CreateOptions,
): {
clientId?: string;
clientSecret?: string;
refreshToken?: string;
accessToken?: string;
expiresAt?: number;
} {
return {
clientId: options.clientId?.trim() || config.oauth.clientId,
clientSecret: options.clientSecret?.trim() || config.oauth.clientSecret,
refreshToken: options.refreshToken?.trim() || config.oauth.refreshToken,
accessToken: options.accessToken?.trim() || config.oauth.accessToken,
expiresAt: parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt,
};
}
export function registerGoogleMeetCli(params: {
program: Command;
config: GoogleMeetConfig;
@@ -184,6 +216,38 @@ export function registerGoogleMeetCli(params: {
writeStdoutJson(payload);
});
root
.command("create")
.description("Create a new Google Meet space and print its meeting URL")
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
.option("--client-secret <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--json", "Print JSON output", false)
.action(async (options: CreateOptions) => {
const token = await resolveGoogleMeetAccessToken(
resolveCreateTokenOptions(params.config, options),
);
const result = await createGoogleMeetSpace({ accessToken: token.accessToken });
if (options.json) {
writeStdoutJson({
...result,
tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
});
return;
}
writeStdoutLine("meeting uri: %s", result.meetingUri);
writeStdoutLine("space: %s", result.space.name);
if (result.space.meetingCode) {
writeStdoutLine("meeting code: %s", result.space.meetingCode);
}
writeStdoutLine(
"token source: %s",
token.refreshed ? "refresh-token" : "cached-access-token",
);
});
root
.command("join")
.argument("[url]", "Explicit https://meet.google.com/... URL")

View File

@@ -23,6 +23,11 @@ export type GoogleMeetPreflightReport = {
blockers: string[];
};
export type GoogleMeetCreateSpaceResult = {
space: GoogleMeetSpace;
meetingUri: string;
};
export function normalizeGoogleMeetSpaceName(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
@@ -87,6 +92,42 @@ export async function fetchGoogleMeetSpace(params: {
}
}
export async function createGoogleMeetSpace(params: {
accessToken: string;
}): Promise<GoogleMeetCreateSpaceResult> {
const { response, release } = await fetchWithSsrFGuard({
url: `${GOOGLE_MEET_API_BASE_URL}/spaces`,
init: {
method: "POST",
headers: {
Authorization: `Bearer ${params.accessToken}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: "{}",
},
policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] },
auditContext: "google-meet.spaces.create",
});
try {
if (!response.ok) {
const detail = await response.text();
throw new Error(`Google Meet spaces.create failed (${response.status}): ${detail}`);
}
const payload = (await response.json()) as GoogleMeetSpace;
if (!payload.name?.trim()) {
throw new Error("Google Meet spaces.create response was missing name");
}
const meetingUri = payload.meetingUri?.trim();
if (!meetingUri) {
throw new Error("Google Meet spaces.create response was missing meetingUri");
}
return { space: payload, meetingUri };
} finally {
await release();
}
}
export function buildGoogleMeetPreflightReport(params: {
input: string;
space: GoogleMeetSpace;

View File

@@ -11,6 +11,7 @@ export const GOOGLE_MEET_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/aut
export const GOOGLE_MEET_TOKEN_URL = "https://oauth2.googleapis.com/token";
const GOOGLE_MEET_TOKEN_HOST = "oauth2.googleapis.com";
export const GOOGLE_MEET_SCOPES = [
"https://www.googleapis.com/auth/meetings.space.created",
"https://www.googleapis.com/auth/meetings.space.readonly",
"https://www.googleapis.com/auth/meetings.conference.media.readonly",
] as const;