Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created November 21, 2025 19:24
Show Gist options
  • Select an option

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

Select an option

Save shricodev/c2918c741dce9dbd2288b7c39b45cfa5 to your computer and use it in GitHub Desktop.
OpenAI GPT-5.1 Calendar AI Agent Test - Blog Demo
import React, { useState } from "react";
type ChatInputProps = {
disabled?: boolean;
onSend: (message: string) => void;
};
export const ChatInput: React.FC<ChatInputProps> = ({
disabled,
onSend,
}) => {
const [value, setValue] = useState("");
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
};
return (
<form
onSubmit={handleSubmit}
className="flex w-full items-center gap-3 rounded-2xl border border-zinc-200 bg-white p-2 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<textarea
className="max-h-32 min-h-[40px] flex-1 resize-none border-0 bg-transparent px-2 py-2 text-sm text-zinc-900 outline-none dark:text-zinc-50"
placeholder="Ask me to schedule, list, or cancel meetings..."
value={value}
onChange={(event) => setValue(event.target.value)}
disabled={disabled}
/>
<button
type="submit"
disabled={disabled || value.trim().length === 0}
className="inline-flex h-9 items-center justify-center rounded-full bg-sky-600 px-4 text-xs font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-sky-500 dark:hover:bg-sky-400"
>
Send
</button>
</form>
);
};
export default ChatInput;
import React from "react";
import { ChatMessage } from "../lib/types";
import { mapToolResponseToMeetings } from "../lib/meetingUtils";
import { MeetingCard } from "./MeetingCard";
type ChatMessageBubbleProps = {
message: ChatMessage;
};
export const ChatMessageBubble: React.FC<ChatMessageBubbleProps> = ({
message,
}) => {
const isUser = message.role === "user";
const meetings = mapToolResponseToMeetings(message.toolResponses);
const hasToolError =
message.toolResponses &&
message.toolResponses.some(
(tool) => tool.result?.successful === false || tool.result?.error,
);
return (
<div
className={`flex w-full gap-2 ${
isUser ? "justify-end" : "justify-start"
}`}
>
{!isUser && (
<div className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-900 text-xs font-semibold text-zinc-50 dark:bg-zinc-100 dark:text-zinc-900">
A
</div>
)}
<div className={`flex max-w-lg flex-col ${isUser ? "items-end" : ""}`}>
<div
className={`rounded-2xl px-3 py-2 text-sm leading-relaxed shadow-sm ${
isUser
? "bg-sky-600 text-white dark:bg-sky-500"
: "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50"
}`}
>
{message.content}
</div>
{hasToolError && !isUser && (
<div className="mt-1 rounded-xl bg-red-50 px-3 py-1 text-xs text-red-700 dark:bg-red-950/60 dark:text-red-200">
I ran into an issue while talking to Google Calendar. Please try
again or adjust your request.
</div>
)}
{meetings.length > 0 && (
<div className="mt-2 flex w-full flex-col gap-2">
{meetings.map((meeting) => (
<MeetingCard
key={meeting.id ?? meeting.title}
meeting={meeting}
/>
))}
</div>
)}
</div>
{isUser && (
<div className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-sky-100 text-xs font-semibold text-sky-700 dark:bg-sky-900 dark:text-sky-200">
U
</div>
)}
</div>
);
};
export default ChatMessageBubble;
import React from "react";
import { ChatMessage } from "../lib/types";
import ChatMessageBubble from "./ChatMessageBubble";
type ChatMessagesListProps = {
messages: ChatMessage[];
isLoading?: boolean;
};
export const ChatMessagesList: React.FC<ChatMessagesListProps> = ({
messages,
isLoading,
}) => {
return (
<div className="flex-1 space-y-4 overflow-y-auto px-2 py-4">
{messages.map((message) => (
<ChatMessageBubble key={message.id} message={message} />
))}
{isLoading && (
<div className="flex justify-start">
<div className="flex max-w-xs items-center gap-2 rounded-2xl bg-zinc-100 px-3 py-2 text-xs text-zinc-700 shadow-sm dark:bg-zinc-800 dark:text-zinc-200">
<span>Assistant is thinking</span>
<span className="flex items-center gap-0.5">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-500 [animation-delay:-0.2s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-500 [animation-delay:-0.1s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-500" />
</span>
</div>
</div>
)}
</div>
);
};
export default ChatMessagesList;
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 { Meeting } from "../lib/types";
import { formatMeetingTime } from "../lib/meetingUtils";
type MeetingCardProps = {
meeting: Meeting;
};
export const MeetingCard: React.FC<MeetingCardProps> = ({ meeting }) => {
const statusLabel =
meeting.status === "cancelled"
? "Cancelled"
: meeting.status === "tentative"
? "Tentative"
: "Confirmed";
const statusClassName =
meeting.status === "cancelled"
? "bg-red-100 text-red-700 border-red-200"
: meeting.status === "tentative"
? "bg-amber-100 text-amber-700 border-amber-200"
: "bg-emerald-100 text-emerald-700 border-emerald-200";
return (
<div className="mt-3 w-full max-w-md rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{meeting.title}
</h3>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${statusClassName}`}
>
{statusLabel}
</span>
</div>
<p className="mb-2 text-xs text-zinc-600 dark:text-zinc-400">
{formatMeetingTime(meeting.start, meeting.end)}
</p>
{meeting.organizerEmail && (
<p className="mb-1 text-xs text-zinc-600 dark:text-zinc-400">
<span className="font-medium text-zinc-700 dark:text-zinc-200">
Organizer:
</span>{" "}
{meeting.organizerEmail}
</p>
)}
{meeting.attendees && meeting.attendees.length > 0 && (
<div className="mt-2">
<p className="mb-1 text-xs font-medium text-zinc-700 dark:text-zinc-200">
Attendees
</p>
<ul className="space-y-0.5">
{meeting.attendees.map((attendee, index) => (
<li
key={attendee.email ?? index}
className="flex items-center justify-between text-[11px] text-zinc-600 dark:text-zinc-400"
>
<span>{attendee.email ?? "Unknown attendee"}</span>
{attendee.responseStatus && (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] capitalize text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
{attendee.responseStatus}
</span>
)}
</li>
))}
</ul>
</div>
)}
{meeting.htmlLink && (
<a
href={meeting.htmlLink}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex items-center text-xs font-medium text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
>
Open in Google Calendar
</a>
)}
</div>
);
};
export default MeetingCard;
import { Meeting, ToolResponse } from "./types";
type ResponseData = NonNullable<
NonNullable<NonNullable<ToolResponse["result"]>["data"]>["response_data"]
>;
export function mapToolResponseToMeetings(
toolResponses?: ToolResponse[],
): Meeting[] {
if (!toolResponses || toolResponses.length === 0) return [];
const meetings: Meeting[] = [];
for (const tr of toolResponses) {
const responseData = tr.result?.data?.response_data as
| ResponseData
| { items?: ResponseData[] }
| undefined;
if (!responseData) continue;
// Allow a list response via an `items` array.
const items =
"items" in responseData && Array.isArray(responseData.items)
? responseData.items
: [responseData as ResponseData];
for (const item of items) {
if (!item) continue;
meetings.push({
id: item.id,
title: item.summary ?? "Untitled meeting",
htmlLink: item.htmlLink,
start: item.start,
end: item.end,
attendees: item.attendees,
organizerEmail: item.organizer?.email,
status:
(item.status as Meeting["status"]) ??
(tr.toolType === "deleteEvent" ? "cancelled" : "confirmed"),
});
}
}
return meetings;
}
export function formatMeetingTime(
start?: { dateTime?: string; timeZone?: string },
end?: { dateTime?: string; timeZone?: string },
): string {
if (!start?.dateTime || !end?.dateTime) {
return "Time not available";
}
try {
const startDate = new Date(start.dateTime);
const endDate = new Date(end.dateTime);
const timeZone = start.timeZone || end.timeZone || "UTC";
const dateFormatter = new Intl.DateTimeFormat(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
timeZone,
});
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
timeZone,
});
const dateStr = dateFormatter.format(startDate);
const startTimeStr = timeFormatter.format(startDate);
const endTimeStr = timeFormatter.format(endDate);
return `${dateStr}, ${startTimeStr} – ${endTimeStr} (${timeZone})`;
} catch {
return "Time not available";
}
}
"use client";
import React, { useCallback, useState } from "react";
import ChatMessagesList from "../components/ChatMessagesList";
import ChatInput from "../components/ChatInput";
import {
AgentResponsePayload,
ChatMessage,
AgentRequestPayload,
} from "../lib/types";
function createInitialMessages(): ChatMessage[] {
return [
{
id: "assistant-welcome",
role: "assistant",
content:
"Hi! I’m your calendar assistant. Ask me to create a meeting, show your agenda, or cancel an event in Google Calendar.",
createdAt: new Date().toISOString(),
},
];
}
export default function Home() {
const [messages, setMessages] = useState<ChatMessage[]>(() =>
createInitialMessages(),
);
const [isLoading, setIsLoading] = useState(false);
const handleSend = useCallback(
async (text: string) => {
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: text,
createdAt: new Date().toISOString(),
};
const nextMessages = [...messages, userMessage];
setMessages(nextMessages);
setIsLoading(true);
try {
const payload: AgentRequestPayload = {
messages: nextMessages,
};
const res = await fetch("/api/agent", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`Agent request failed with status ${res.status}`);
}
const data = (await res.json()) as AgentResponsePayload;
if (data.messages && data.messages.length > 0) {
setMessages((current) => [...current, ...data.messages]);
}
} catch {
const fallbackAssistant: ChatMessage = {
id: `assistant-error-${Date.now()}`,
role: "assistant",
content:
"Something went wrong while talking to the agent. Please try again.",
createdAt: new Date().toISOString(),
};
setMessages((current) => [...current, fallbackAssistant]);
} finally {
setIsLoading(false);
}
},
[messages],
);
return (
<div className="flex min-h-screen justify-center bg-zinc-50 px-4 py-6 font-sans text-zinc-900 dark:bg-black dark:text-zinc-50">
<main className="flex h-[min(720px,calc(100vh-3rem))] w-full max-w-3xl flex-col rounded-3xl border border-zinc-200 bg-white shadow-lg shadow-zinc-200/40 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-none">
<header className="flex items-center justify-between border-b border-zinc-200 px-5 py-4 dark:border-zinc-800">
<div>
<h1 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
Calendar Assistant
</h1>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
Chat with an AI agent that can manage your Google Calendar using
Composio Tool Router.
</p>
</div>
</header>
<ChatMessagesList messages={messages} isLoading={isLoading} />
<div className="border-t border-zinc-200 bg-zinc-50 px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900/60">
<ChatInput disabled={isLoading} onSend={handleSend} />
<p className="mt-1 px-1 text-[10px] text-zinc-500 dark:text-zinc-400">
This is a demo. The agent talks to a mocked Composio Tool Router
endpoint so you can plug in a real model later.
</p>
</div>
</main>
</div>
);
}
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { Composio } from "@composio/core";
import { OpenAIAgentsProvider } from "@composio/openai-agents";
import { Agent, hostedMcpTool, run } from "@openai/agents";
import {
AgentRequestPayload,
AgentResponsePayload,
ChatMessage,
ToolResponse,
} from "../../../lib/types";
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()) as AgentRequestPayload;
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"
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, list, 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. When asked to list events, summarise the agenda clearly.
5. 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) => `${m.role.toUpperCase()}: ${m.content}`)
.join("\n\n");
const result = await run(agent, transcript);
const { finalOutput, newItems } = result as {
finalOutput: string;
newItems?: unknown[];
};
let toolResponseData: ToolResponse | undefined;
if (Array.isArray(newItems)) {
// We look for the last tool message to show the most relevant card
type ToolCallItem = {
type?: string;
rawItem?: { output?: string };
};
const toolItem = [...newItems]
.reverse()
.find(
(item): item is ToolCallItem =>
typeof item === "object" &&
item !== null &&
(item as ToolCallItem).type === "tool_call_item" &&
!!(item as ToolCallItem).rawItem?.output,
);
if (toolItem?.rawItem?.output) {
try {
const parsedContent = JSON.parse(toolItem.rawItem.output);
toolResponseData = {
toolType: "google_calendar",
result: parsedContent,
};
} catch (e) {
console.error("Error parsing tool response:", e);
}
}
}
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: finalOutput,
createdAt: new Date().toISOString(),
toolResponses: toolResponseData ? [toolResponseData] : undefined,
};
const response: AgentResponsePayload = {
messages: [assistantMessage],
toolResponses: assistantMessage.toolResponses,
};
return NextResponse.json(response);
} catch (error: unknown) {
console.error("Error in chat route:", error);
const errorMessage =
error instanceof Error ? error.message : "Internal Server Error";
const assistantMessage: ChatMessage = {
id: `assistant-error-${Date.now()}`,
role: "assistant",
content:
"I ran into an error while using Composio Tool Router and Google Calendar. Please try again.",
createdAt: new Date().toISOString(),
toolResponses: [
{
toolType: "tool_router",
result: {
successful: false,
error: errorMessage,
},
},
],
};
const response: AgentResponsePayload = {
messages: [assistantMessage],
toolResponses: assistantMessage.toolResponses,
};
return NextResponse.json(response, { status: 500 });
}
}
export type Role = "user" | "assistant";
export type ToolResponse = {
toolType?: string;
result?: {
successful?: boolean;
data?: {
// Shape follows Composio Tool Router + Google Calendar event
response_data?: {
summary?: string;
htmlLink?: string;
start?: { dateTime?: string; timeZone?: string };
end?: { dateTime?: string; timeZone?: string };
attendees?: {
email?: string;
organizer?: boolean;
responseStatus?: string;
self?: boolean;
}[];
organizer?: { email?: string; self?: boolean };
status?: string;
id?: string;
};
};
error?: unknown;
};
};
export type Meeting = {
id?: string;
title: string;
htmlLink?: string;
start?: { dateTime?: string; timeZone?: string };
end?: { dateTime?: string; timeZone?: string };
attendees?: {
email?: string;
organizer?: boolean;
responseStatus?: string;
self?: boolean;
}[];
organizerEmail?: string;
status?: "confirmed" | "cancelled" | "tentative" | "unknown";
};
export type ChatMessage = {
id: string;
role: Role;
content: string;
createdAt: string;
// Optional tool responses that this message is describing
toolResponses?: ToolResponse[];
};
export type AgentRequestPayload = {
messages: ChatMessage[];
};
export type AgentResponsePayload = {
messages: ChatMessage[];
toolResponses?: ToolResponse[];
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment