Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created November 21, 2025 17:30
Show Gist options
  • Select an option

  • Save shricodev/595a4570477bee4c99c4872f0801037d to your computer and use it in GitHub Desktop.

Select an option

Save shricodev/595a4570477bee4c99c4872f0801037d to your computer and use it in GitHub Desktop.
Google's Gemioni 3 Pro Calendar AI Agent Test - Blog Demo
import React, { useState, useRef, useEffect } from "react";
import { SendHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
interface ChatInputProps {
onSend: (content: string) => void;
disabled?: boolean;
}
export const ChatInput: React.FC<ChatInputProps> = ({ onSend, disabled }) => {
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${Math.min(
textareaRef.current.scrollHeight,
128,
)}px`;
}
}, [input]);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
if (!input.trim() || disabled) return;
onSend(input);
setInput("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="w-full bg-white dark:bg-zinc-950 border-t border-zinc-200 dark:border-zinc-800 p-4">
<form
onSubmit={handleSubmit}
className="max-w-3xl mx-auto relative flex items-end gap-2 bg-zinc-100 dark:bg-zinc-900 p-2 rounded-2xl border border-transparent focus-within:border-blue-500/50 focus-within:ring-2 focus-within:ring-blue-500/20 transition-all shadow-sm"
>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask to schedule a meeting..."
disabled={disabled}
rows={1}
className="w-full bg-transparent border-none focus:ring-0 resize-none max-h-32 py-3 px-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-500 dark:placeholder:text-zinc-400 leading-normal"
style={{ minHeight: "44px" }}
/>
<button
type="submit"
disabled={!input.trim() || disabled}
className={cn(
"p-2 rounded-xl mb-1 transition-all duration-200 ease-in-out flex-shrink-0",
input.trim() && !disabled
? "bg-blue-600 text-white hover:bg-blue-700 shadow-md shadow-blue-600/20 active:scale-95"
: "bg-zinc-200 text-zinc-400 dark:bg-zinc-800 dark:text-zinc-600 cursor-not-allowed",
)}
aria-label="Send message"
>
<SendHorizontal className="w-5 h-5" />
</button>
</form>
<div className="text-center mt-3">
<p className="text-[10px] uppercase tracking-widest text-zinc-400 dark:text-zinc-600 font-semibold">
Powered by Composio Tool Router
</p>
</div>
</div>
);
};
import React from "react";
import { Message } from "@/app/types";
import { MeetingCard } from "./MeetingCard";
import { cn } from "@/lib/utils";
import { Bot, User } from "lucide-react";
interface ChatMessageProps {
message: Message;
}
export const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
const isUser = message.role === "user";
return (
<div className={cn(
"flex w-full mb-6",
isUser ? "justify-end" : "justify-start"
)}>
<div className={cn(
"flex max-w-[80%] md:max-w-[70%] gap-3",
isUser ? "flex-row-reverse" : "flex-row"
)}>
{/* Avatar */}
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
isUser ? "bg-blue-500 text-white" : "bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300"
)}>
{isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
</div>
{/* Content */}
<div className="flex flex-col items-start">
<div className={cn(
"px-4 py-3 rounded-2xl text-sm whitespace-pre-wrap",
isUser
? "bg-blue-500 text-white rounded-tr-none"
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 rounded-tl-none"
)}>
{message.content}
{message.isPending && (
<span className="inline-flex ml-2">
<span className="animate-bounce mx-0.5">.</span>
<span className="animate-bounce mx-0.5 delay-100">.</span>
<span className="animate-bounce mx-0.5 delay-200">.</span>
</span>
)}
</div>
{/* Tool Response (Meeting Card) */}
{message.toolResponse && (
<MeetingCard toolResponse={message.toolResponse} className="mt-3" />
)}
</div>
</div>
</div>
);
};
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
import React from "react";
import { GoogleCalendarEventData, ToolResponse } from "@/app/types";
import { format, parseISO } from "date-fns";
import { Calendar, Clock, User, Users, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
interface MeetingCardProps {
toolResponse: ToolResponse;
className?: string;
}
export const MeetingCard: React.FC<MeetingCardProps> = ({
toolResponse,
className,
}) => {
// Recursive function to find the Google Calendar event object
// It looks for an object that has 'kind': 'calendar#event' or keys like 'summary' + 'start'
const findEventData = (obj: unknown): GoogleCalendarEventData | null => {
if (!obj || typeof obj !== "object") return null;
const record = obj as Record<string, unknown>;
// Check if this object IS the event
// We cast to unknown first then verify structure, but for simplicity in this recursive search
// we just check for key existence.
if (record.kind === "calendar#event") return record as GoogleCalendarEventData;
if (record.summary && record.start && record.end) return record as GoogleCalendarEventData;
// Check specific keys that might hold it
if (record.response_data) return findEventData(record.response_data);
if (record.data) return findEventData(record.data);
if (record.result) return findEventData(record.result);
// Check arrays (Composio often returns arrays of results)
if (Array.isArray(obj)) {
for (const item of obj) {
const found = findEventData(item);
if (found) return found;
}
return null;
}
// Check 'results' explicitly
if (Array.isArray(record.results)) {
for (const item of record.results) {
const found = findEventData(item);
if (found) return found;
}
}
if (record.response) return findEventData(record.response);
return null;
};
const event = findEventData(toolResponse);
if (!event) {
return null;
}
// Determine Status
// 1. Check explicit status in event
// 2. Check if toolType (if we had it) implied deletion
// 3. Check if the event status itself is 'cancelled'
let statusLabel = "Created";
if (event.status === "cancelled") {
statusLabel = "Cancelled";
} else if (toolResponse.toolType?.toLowerCase().includes("delete")) {
statusLabel = "Cancelled";
}
const startTime = event.start?.dateTime
? parseISO(event.start.dateTime)
: null;
const endTime = event.end?.dateTime ? parseISO(event.end.dateTime) : null;
return (
<div
className={cn(
"bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-sm overflow-hidden w-full max-w-md my-2",
className,
)}
>
{/* Header with Status */}
<div
className={cn(
"px-4 py-2 text-xs font-semibold uppercase tracking-wider border-b",
statusLabel === "Cancelled"
? "bg-red-50 text-red-700 border-red-100 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/50"
: "bg-green-50 text-green-700 border-green-100 dark:bg-green-900/20 dark:text-green-400 dark:border-green-900/50",
)}
>
Status: {statusLabel}
</div>
<div className="p-4 space-y-4">
{/* Title */}
<h3 className="text-lg font-bold text-zinc-900 dark:text-zinc-100">
{event.summary || "No Title"}
</h3>
{/* Time */}
<div className="flex items-start gap-3 text-sm text-zinc-600 dark:text-zinc-400">
<Calendar className="w-4 h-4 mt-0.5 shrink-0" />
<div className="flex flex-col">
{startTime && (
<span>{format(startTime, "EEEE, MMMM d, yyyy")}</span>
)}
<div className="flex items-center gap-1 mt-0.5">
<Clock className="w-3 h-3" />
<span>
{startTime ? format(startTime, "h:mm a") : "--"} -{" "}
{endTime ? format(endTime, "h:mm a") : "--"}
</span>
{event.start?.timeZone && (
<span className="text-xs text-zinc-500 ml-1">
({event.start.timeZone})
</span>
)}
</div>
</div>
</div>
{/* Organizer */}
{event.organizer && (
<div className="flex items-center gap-3 text-sm text-zinc-600 dark:text-zinc-400">
<User className="w-4 h-4 shrink-0" />
<div className="flex flex-col">
<span className="text-xs font-medium text-zinc-500 uppercase">
Organizer
</span>
<span>{event.organizer.email}</span>
</div>
</div>
)}
{/* Attendees */}
{event.attendees && event.attendees.length > 0 && (
<div className="flex items-start gap-3 text-sm text-zinc-600 dark:text-zinc-400">
<Users className="w-4 h-4 mt-0.5 shrink-0" />
<div className="flex flex-col w-full">
<span className="text-xs font-medium text-zinc-500 uppercase mb-1">
Attendees
</span>
<ul className="space-y-1">
{event.attendees.map((attendee, idx) => (
<li
key={idx}
className="flex items-center justify-between w-full"
>
<span
className="truncate max-w-[180px]"
title={attendee.email}
>
{attendee.email}
</span>
{attendee.responseStatus && (
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded-full capitalize",
attendee.responseStatus === "accepted"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: attendee.responseStatus === "declined"
? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
)}
>
{attendee.responseStatus}
</span>
)}
</li>
))}
</ul>
</div>
</div>
)}
{/* Link */}
{event.htmlLink && (
<div className="pt-2 mt-2 border-t border-zinc-100 dark:border-zinc-800">
<a
href={event.htmlLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-2 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
View in Google Calendar
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
</div>
);
};
"use client";
import React, { useState } from "react";
import { Message } from "./types";
import { ChatMessage } from "@/components/ChatMessage";
import { ChatInput } from "@/components/ChatInput";
import { Bot } from "lucide-react";
export default function ChatPage() {
const [messages, setMessages] = useState<Message[]>([
{
id: "welcome",
role: "assistant",
content: "Hello! I can help you schedule or cancel Google Calendar meetings. What would you like to do?",
},
]);
const [loading, setLoading] = useState(false);
const handleSend = async (content: string) => {
const userMsg: Message = {
id: Date.now().toString(),
role: "user",
content,
};
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setLoading(true);
// Add a temporary pending message
const pendingId = "pending-" + Date.now();
setMessages((prev) => [
...prev,
{
id: pendingId,
role: "assistant",
content: "Thinking...",
isPending: true,
},
]);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: newMessages }),
});
if (!response.ok) {
throw new Error("Failed to fetch response");
}
const data = await response.json();
// Remove pending message and add actual response
setMessages((prev) => {
const filtered = prev.filter((m) => m.id !== pendingId);
return [
...filtered,
{
id: Date.now().toString(),
role: "assistant",
content: data.content,
toolResponse: data.toolResponse,
},
];
});
} catch (error) {
console.error(error);
setMessages((prev) => {
const filtered = prev.filter((m) => m.id !== pendingId);
return [
...filtered,
{
id: Date.now().toString(),
role: "assistant",
content: "Sorry, something went wrong. Please try again.",
},
];
});
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col h-screen bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 font-sans">
{/* Header */}
<header className="flex items-center px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-950/80 backdrop-blur-md sticky top-0 z-10">
<div className="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-600/20 mr-4">
<Bot className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold tracking-tight">Calendar Agent</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400">Powered by Composio Tool Router</p>
</div>
</header>
{/* Chat Area */}
<main className="flex-1 overflow-y-auto p-4 md:p-6 scroll-smooth">
<div className="max-w-3xl mx-auto space-y-6 pb-4">
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
</div>
</main>
{/* Input Area */}
<ChatInput onSend={handleSend} disabled={loading} />
</div>
);
}
import { NextResponse } from "next/server";
import { Composio } from "@composio/core";
import { OpenAIAgentsProvider } from "@composio/openai-agents";
import { Agent, hostedMcpTool, run } from "@openai/agents";
// Ensure API keys are present
const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY;
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
export async function POST(req: Request) {
try {
if (!COMPOSIO_API_KEY || !OPENAI_API_KEY) {
return NextResponse.json({ error: "Missing API Keys" }, { status: 500 });
}
const body = await req.json();
const { messages } = body;
// Simple session ID or user ID
const userId = "user-session-" + new Date().toISOString().split("T")[0];
const composio = new Composio({
apiKey: COMPOSIO_API_KEY,
provider: new OpenAIAgentsProvider(),
});
// Create Tool Router Session
// We limit to google_calendar as per instructions to "create/delete calendar meetings"
// The prompt example used 'gmail', 'github'. I'll use 'google_calendar'.
const session = await composio.experimental.toolRouter.createSession(
userId,
{
toolkits: ["googlecalendar"],
},
);
const agent = new Agent({
name: "Calendar Assistant",
model: "gpt-4o",
instructions: `You are a helpful assistant that manages Google Calendar.
You can create and delete meetings.
RULES:
1. When asked to create a meeting, identify: attendees, date/time, duration, title.
2. Before calling the tool, confirm the details with the user.
3. When asked to delete, identify the meeting (time, attendee, etc). Confirm if ambiguous.
4. If the tool fails, describe the error clearly.
`,
tools: [
hostedMcpTool({
serverLabel: "tool_router",
serverUrl: session.url,
}),
],
});
// Construct the "task" from the conversation history
const transcript = messages
.map((m: { role: string; content: string }) => `${m.role.toUpperCase()}: ${m.content}`)
.join("\n\n");
const result = await run(agent, transcript);
console.log("result finaloutput:", result.finalOutput);
console.log("result newItems:", result.newItems);
let toolResponseData = null;
// Look for tool execution results in the new items
if (result.newItems && Array.isArray(result.newItems)) {
// We look for the last tool message to show the most relevant card
const toolItem = result.newItems
.slice()
.reverse()
.find(
(item: { type: string; rawItem?: { output?: string } }) =>
item.type === "tool_call_item" &&
item.rawItem &&
item.rawItem.output
);
if (toolItem && toolItem.rawItem?.output) {
try {
const parsedContent = JSON.parse(toolItem.rawItem.output);
toolResponseData = {
toolType: "google_calendar", // Assuming this based on the toolkit
result: parsedContent,
};
} catch (e) {
console.error("Error parsing tool response:", e);
// If it's not JSON, we might pass it as raw text or ignore it
// For now, we ignore non-JSON tool outputs for the card
}
}
}
return NextResponse.json({
role: "assistant",
content: result.finalOutput,
toolResponse: toolResponseData,
});
} catch (error: unknown) {
console.error("Error in chat route:", error);
const errorMessage =
error instanceof Error ? error.message : "Internal Server Error";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
export interface GoogleCalendarStartEnd {
dateTime?: string;
timeZone?: string;
}
export interface GoogleCalendarAttendee {
email?: string;
organizer?: boolean;
responseStatus?: string;
self?: boolean;
}
export interface GoogleCalendarOrganizer {
email?: string;
self?: boolean;
}
export interface GoogleCalendarEventData {
summary?: string;
htmlLink?: string;
start?: GoogleCalendarStartEnd;
end?: GoogleCalendarStartEnd;
attendees?: GoogleCalendarAttendee[];
organizer?: GoogleCalendarOrganizer;
status?: string; // Add status field
}
export interface ToolResponse {
toolType?: string;
result?: unknown;
}
export type Role = "user" | "assistant" | "system";
export interface Message {
id: string;
role: Role;
content: string;
toolResponse?: ToolResponse;
isPending?: boolean;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment