Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions flight-booking-app/app/api/hooks/approval/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { bookingApprovalHook } from '@/workflows/chat/hooks/approval';

export async function POST(request: Request) {
const { toolCallId, approved, comment } = await request.json();
// Schema validation happens automatically
// Can throw a zod schema validation error, or a
await bookingApprovalHook.resume(toolCallId, {
approved,
comment,
});
return Response.json({ success: true });
}
Comment on lines +3 to +12
Copy link
Collaborator

@pranaygp pranaygp Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should just use webhook (with custom token mode if needed) since the API route isn't really doing much

seems simpler

imo only use hook when you need more control. recommend webhook preferably whenever we can (and make recs if there's something missing from webhook api we can improve)

90 changes: 76 additions & 14 deletions flight-booking-app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "@/components/ai-elements/tool";
import ChatInput from "@/components/chat-input";
import type { MyUIMessage } from "@/schemas/chat";
import { BookingApproval } from "@/components/booking-approval";

const SUGGESTIONS = [
"Find me flights from San Francisco to Los Angeles",
Expand All @@ -29,6 +30,8 @@ const SUGGESTIONS = [
"What's the baggage allowance for United Airlines economy?",
"Book a flight from New York to Miami",
];
const FULL_EXAMPLE_PROMPT =
"Book me the cheapest flight from San Francisco to Los Angeles for July 27 2025. My name is Pranay Prakash. I like window seats. Don't ask me for approval.";

export default function ChatPage() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
Expand All @@ -38,7 +41,7 @@ export default function ChatPage() {
return localStorage.getItem("active-workflow-run-id") ?? undefined;
}, []);

const { stop, messages, sendMessage, status, setMessages } =
const { stop, error, messages, sendMessage, status, setMessages } =
useChat<MyUIMessage>({
resume: !!activeWorkflowRunId,
onError(error) {
Expand All @@ -63,15 +66,15 @@ export default function ChatPage() {
// Update the chat history in `localStorage` to include the latest user message
localStorage.setItem(
"chat-history",
JSON.stringify(options.messages),
JSON.stringify(options.messages)
);

// We'll store the workflow run ID in `localStorage` to allow the client
// to resume the chat session after a page refresh or network interruption
const workflowRunId = response.headers.get("x-workflow-run-id");
if (!workflowRunId) {
throw new Error(
'Workflow run ID not found in "x-workflow-run-id" response header',
'Workflow run ID not found in "x-workflow-run-id" response header'
);
}
localStorage.setItem("active-workflow-run-id", workflowRunId);
Expand Down Expand Up @@ -121,6 +124,16 @@ export default function ChatPage() {
<p className="text-muted-foreground">Book a flight using workflows</p>
</div>

{/* Error display */}
{error && (
<div className="text-sm mb-4 p-4 rounded-lg border border-red-500/50 bg-red-500/10 text-red-600 dark:text-red-400">
<div className="flex items-start gap-2">
<span className="font-medium">Error:</span>
<span className="flex-1">{error.message}</span>
</div>
</div>
)}

{messages.length === 0 && (
<div className="mb-8 space-y-4">
<div className="text-center">
Expand Down Expand Up @@ -154,15 +167,13 @@ export default function ChatPage() {
type="button"
onClick={() => {
sendMessage({
text: "Book me the cheapest flight from San Francisco to Los Angeles for July 27 2025. My name is Pranay Prakash. I like window seats. Don't ask me for confirmation.",
text: FULL_EXAMPLE_PROMPT,
metadata: { createdAt: Date.now() },
});
}}
className="text-sm border px-3 py-2 rounded-md bg-muted/50 text-left hover:bg-muted/75 transition-colors cursor-pointer"
>
Book me the cheapest flight from San Francisco to Los Angeles for
July 27 2025. My name is Pranay Prakash. I like window seats.
Don't ask me for confirmation.
{FULL_EXAMPLE_PROMPT}
</button>
</div>
</div>
Expand All @@ -171,15 +182,10 @@ export default function ChatPage() {
<ConversationContent>
{messages.map((message, index) => {
const hasText = message.parts.some((part) => part.type === "text");
const isLastMessage = index === messages.length - 1;

return (
<div key={message.id}>
{message.role === "assistant" &&
index === messages.length - 1 &&
(status === "submitted" || status === "streaming") &&
!hasText && (
<Shimmer className="text-sm">Thinking...</Shimmer>
)}
<Message from={message.role}>
<MessageContent>
{message.parts.map((part, partIndex) => {
Expand Down Expand Up @@ -212,7 +218,8 @@ export default function ChatPage() {
part.type === "tool-checkFlightStatus" ||
part.type === "tool-getAirportInfo" ||
part.type === "tool-bookFlight" ||
part.type === "tool-checkBaggageAllowance"
part.type === "tool-checkBaggageAllowance" ||
part.type === "tool-sleep"
) {
// Additional type guard to ensure we have the required properties
if (!("toolCallId" in part) || !("state" in part)) {
Expand Down Expand Up @@ -240,13 +247,58 @@ export default function ChatPage() {
</Tool>
);
}
if (part.type === "tool-bookingApproval") {
return (
<BookingApproval
key={partIndex}
toolCallId={part.toolCallId}
input={
part.input as {
flightNumber: string;
passengerName: string;
price: number;
}
}
output={part.output as string}
/>
);
}
return null;
})}
{/* Loading indicators */}
{message.role === "assistant" &&
isLastMessage &&
!hasText && (
<>
{status === "submitted" && (
<Shimmer className="text-sm">
Sending message...
</Shimmer>
)}
{status === "streaming" && (
<Shimmer className="text-sm">
Waiting for response...
</Shimmer>
)}
</>
)}
</MessageContent>
</Message>
</div>
);
})}
{/* Show loading indicator when message is sent but no assistant response yet */}
{messages.length > 0 &&
messages[messages.length - 1].role === "user" &&
status === "submitted" && (
<Message from="assistant">
<MessageContent>
<Shimmer className="text-sm">
Processing your request...
</Shimmer>
</MessageContent>
</Message>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
Expand Down Expand Up @@ -414,6 +466,16 @@ function renderToolOutput(part: any) {
);
}

case "tool-sleep": {
return (
<div className="space-y-2">
<p className="text-sm font-medium">
Sleeping for {part.input.durationMs}ms...
</p>
</div>
);
}

default:
return null;
}
Expand Down
109 changes: 109 additions & 0 deletions flight-booking-app/components/booking-approval.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
'use client';
import { useState } from 'react';
interface BookingApprovalProps {
toolCallId: string;
input?: {
flightNumber: string;
passengerName: string;
price: number;
};
output?: string;
}
export function BookingApproval({
toolCallId,
input,
output,
}: BookingApprovalProps) {
const [comment, setComment] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

// If we have output, the approval has been processed
if (output) {
try {
const json = JSON.parse(output) as { output: { value: string } };
return (
<div className="border rounded-lg p-4">
<p className="text-sm text-muted-foreground">{json.output.value}</p>
</div>
);
} catch (error) {
return (
<div className="border rounded-lg p-4">
<p className="text-sm text-muted-foreground">
Error parsing approval result: {(error as Error).message}
</p>
</div>
);
}
}

const handleSubmit = async (approved: boolean) => {
setIsSubmitting(true);
setError(null);
try {
const response = await fetch('/api/hooks/approval', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolCallId, approved, comment }),
});

if (!response.ok) {
const errorData = await response.text();
throw new Error(`API error: ${response.status} - ${errorData || response.statusText}`);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to submit approval';
setError(errorMessage);
setIsSubmitting(false);
return;
}
setIsSubmitting(false);
};
return (
<div className="border rounded-lg p-4 space-y-4">
{error && (
<div className="border border-red-300 rounded bg-red-50 p-3">
<p className="text-sm text-red-700">{error}</p>
</div>
)}
<div className="space-y-2">
<p className="font-medium">Approve this booking?</p>
<div className="text-sm text-muted-foreground">
{input && (
<>
<div>Flight: {input.flightNumber}</div>
<div>Passenger: {input.passengerName}</div>
<div>Price: ${input.price}</div>
</>
)}
</div>
</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Add a comment (optional)..."
className="w-full border rounded p-2 text-sm"
rows={2}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleSubmit(true)}
disabled={isSubmitting}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
>
{isSubmitting ? 'Submitting...' : 'Approve'}
</button>
<button
type="button"
onClick={() => handleSubmit(false)}
disabled={isSubmitting}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
>
{isSubmitting ? 'Submitting...' : 'Reject'}
</button>
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion flight-booking-app/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
export async function register() {
if (process.env.NEXT_RUNTIME !== 'edge') {
import('workflow/runtime').then(async ({ getWorld }) => {
console.log('Calling world.start()');
console.log('Initializing workflow World');
await getWorld().start?.();
});
}
Expand Down
9 changes: 9 additions & 0 deletions flight-booking-app/workflows/chat/hooks/approval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineHook } from 'workflow';
import { z } from 'zod';

export const bookingApprovalHook = defineHook({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment re: using webhook instead :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

webhook should support zod schemas too with validation

schema: z.object({
approved: z.boolean(),
comment: z.string().optional(),
}),
});
44 changes: 43 additions & 1 deletion flight-booking-app/workflows/chat/steps/tools.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FatalError } from 'workflow';
import { FatalError, sleep } from 'workflow';
import { z } from 'zod';
import { bookingApprovalHook } from '../hooks/approval';

export const mockAirports: Record<
string,
Expand Down Expand Up @@ -288,6 +289,31 @@ export async function checkBaggageAllowance({
};
}

async function executeSleep({ durationMs }: { durationMs: number }) {
// Note: No "use step" here - sleep is a workflow-level function
await sleep(durationMs);
return { message: `Slept for ${durationMs}ms` };
}

async function executeBookingApproval(
{
flightNumber,
passengerName,
price,
}: { flightNumber: string; passengerName: string; price: number },
{ toolCallId }: { toolCallId: string }
) {
// Note: No "use step" here - hooks are workflow-level primitives
// Use the toolCallId as the hook token so the UI can reference it
const hook = bookingApprovalHook.create({ token: toolCallId });
// Workflow pauses here until the hook is resolved
const { approved, comment } = await hook;
if (!approved) {
return `Booking rejected: ${comment || 'No reason provided'}`;
}
return `Booking approved for ${passengerName} on flight ${flightNumber} (Price: ${price})${comment ? ` - Note: ${comment}` : ''}`;
}

// Tool definitions
export const flightBookingTools = {
searchFlights: {
Expand Down Expand Up @@ -337,6 +363,22 @@ export const flightBookingTools = {
}),
execute: checkBaggageAllowance,
},
sleep: {
description: 'Pause execution for a specified duration',
inputSchema: z.object({
durationMs: z.number().describe('Duration to sleep in milliseconds'),
}),
execute: executeSleep,
},
Comment on lines +366 to +372
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for future, we should really ship @workflow/ai/tools with sleepTool amongst other tools (just like @cramforce shipped bashtool for just bash). We can write a nicer, longer description with example usage to encourage the patterns we like

bookingApproval: {
description: 'Request human approval before booking a flight',
inputSchema: z.object({
flightNumber: z.string().describe('Flight number to book'),
passengerName: z.string().describe('Name of the passenger'),
price: z.number().describe('Total price of the booking'),
}),
execute: executeBookingApproval,
},
};

// System prompt
Expand Down