diff --git a/frontend/src/components/EventCard.tsx b/frontend/src/components/EventCard.tsx
index 48185fe9..62d84d73 100644
--- a/frontend/src/components/EventCard.tsx
+++ b/frontend/src/components/EventCard.tsx
@@ -1,5 +1,8 @@
import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useMitzoStore } from '@mitzo/client/hooks';
import type { CalendarEvent } from '../hooks/useCalendarData';
+import { buildMeetingPrepPrompt, buildMeetingContext } from '../lib/calendar-utils';
function formatTime(isoStr: string): string {
if (!isoStr.includes('T')) return '';
@@ -9,6 +12,18 @@ function formatTime(isoStr: string): string {
export function EventCard({ event }: { event: CalendarEvent }) {
const [expanded, setExpanded] = useState(false);
+ const navigate = useNavigate();
+ const setPendingSession = useMitzoStore((s) => s.setPendingSession);
+
+ function handlePrepClick(e: React.MouseEvent) {
+ e.stopPropagation();
+ setPendingSession({
+ prompt: buildMeetingPrepPrompt(event),
+ context: buildMeetingContext(event),
+ agentName: 'mitzo-calendar',
+ });
+ navigate('/chat');
+ }
if (event.type === 'milestone') {
return (
@@ -70,17 +85,22 @@ export function EventCard({ event }: { event: CalendarEvent }) {
)}
)}
- {event.hangoutLink && (
- e.stopPropagation()}
- >
- Join video call
-
- )}
+
)}
diff --git a/frontend/src/lib/__tests__/calendar-utils.test.ts b/frontend/src/lib/__tests__/calendar-utils.test.ts
new file mode 100644
index 00000000..a3ae46d9
--- /dev/null
+++ b/frontend/src/lib/__tests__/calendar-utils.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect } from 'vitest';
+import { buildMeetingPrepPrompt, buildMeetingContext } from '../calendar-utils';
+import type { CalendarEvent } from '../../hooks/useCalendarData';
+
+function makeEvent(overrides: Partial = {}): CalendarEvent {
+ return {
+ id: 'evt-1',
+ type: 'meeting',
+ title: 'Standup',
+ start: '2026-05-22T10:00:00Z',
+ end: '2026-05-22T10:30:00Z',
+ ...overrides,
+ };
+}
+
+describe('buildMeetingPrepPrompt', () => {
+ it('includes title and formatted time range', () => {
+ const result = buildMeetingPrepPrompt(makeEvent());
+ expect(result).toContain('Prepare for "Standup"');
+ expect(result).toContain(' at ');
+ });
+
+ it('omits time for all-day events (no T in start)', () => {
+ const result = buildMeetingPrepPrompt(makeEvent({ start: '2026-05-22', end: '2026-05-23' }));
+ expect(result).toBe('Prepare for "Standup"');
+ });
+
+ it('omits time when start is empty', () => {
+ const result = buildMeetingPrepPrompt(makeEvent({ start: '' }));
+ expect(result).toBe('Prepare for "Standup"');
+ });
+});
+
+describe('buildMeetingContext', () => {
+ it('includes meeting title', () => {
+ const result = buildMeetingContext(makeEvent());
+ expect(result).toContain('**Meeting:** Standup');
+ });
+
+ it('includes location when present', () => {
+ const result = buildMeetingContext(makeEvent({ location: 'Room 42' }));
+ expect(result).toContain('**Where:** Room 42');
+ });
+
+ it('omits location when absent', () => {
+ const result = buildMeetingContext(makeEvent());
+ expect(result).not.toContain('**Where:**');
+ });
+
+ it('formats attendees from email addresses', () => {
+ const result = buildMeetingContext(
+ makeEvent({ attendees: ['alice@example.com', 'bob@example.com'] }),
+ );
+ expect(result).toContain('**Attendees:** alice, bob');
+ });
+
+ it('truncates attendees beyond 10 with count', () => {
+ const attendees = Array.from({ length: 12 }, (_, i) => `user${i}@example.com`);
+ const result = buildMeetingContext(makeEvent({ attendees }));
+ expect(result).toContain('(+2 more)');
+ expect(result).not.toContain('user10');
+ });
+
+ it('includes video link when present', () => {
+ const result = buildMeetingContext(makeEvent({ hangoutLink: 'https://meet.google.com/abc' }));
+ expect(result).toContain('**Video:** [Join call](https://meet.google.com/abc)');
+ });
+
+ it('includes context prompts at the end', () => {
+ const result = buildMeetingContext(makeEvent());
+ expect(result).toContain('Review recent Jira activity');
+ expect(result).toContain('key topics and decisions');
+ });
+});
diff --git a/frontend/src/lib/calendar-utils.ts b/frontend/src/lib/calendar-utils.ts
new file mode 100644
index 00000000..1fb05e0f
--- /dev/null
+++ b/frontend/src/lib/calendar-utils.ts
@@ -0,0 +1,68 @@
+import type { CalendarEvent } from '../hooks/useCalendarData';
+
+export function buildMeetingPrepPrompt(event: CalendarEvent): string {
+ const time = formatEventTime(event);
+ const when = time ? ` at ${time}` : '';
+
+ return `Prepare for "${event.title}"${when}`;
+}
+
+export function buildMeetingContext(event: CalendarEvent): string {
+ const lines: string[] = [];
+
+ lines.push(`**Meeting:** ${event.title}`);
+
+ if (event.start) {
+ const startDate = new Date(event.start);
+ const dateStr = startDate.toLocaleDateString([], {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+ lines.push(`**When:** ${dateStr}`);
+ }
+
+ if (event.location) {
+ lines.push(`**Where:** ${event.location}`);
+ }
+
+ if (event.attendees && event.attendees.length > 0) {
+ const displayAttendees = event.attendees
+ .slice(0, 10)
+ .map((email) => {
+ const name = email.split('@')[0];
+ return name;
+ })
+ .join(', ');
+
+ const suffix = event.attendees.length > 10 ? ` (+${event.attendees.length - 10} more)` : '';
+ lines.push(`**Attendees:** ${displayAttendees}${suffix}`);
+ }
+
+ if (event.hangoutLink) {
+ lines.push(`**Video:** [Join call](${event.hangoutLink})`);
+ }
+
+ lines.push('');
+ lines.push('**Context for this meeting:**');
+ lines.push('- Review recent Jira activity for attendees');
+ lines.push('- Check relevant docs and recent conversations');
+ lines.push('- Identify key topics and decisions needed');
+
+ return lines.join('\n');
+}
+
+function formatEventTime(event: CalendarEvent): string {
+ if (!event.start || !event.start.includes('T')) return '';
+
+ const start = new Date(event.start);
+ const end = event.end ? new Date(event.end) : null;
+
+ const startTime = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+ if (!end) return startTime;
+
+ const endTime = end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+ return `${startTime}–${endTime}`;
+}
diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx
index 3a571988..d9008017 100644
--- a/frontend/src/pages/ChatView.tsx
+++ b/frontend/src/pages/ChatView.tsx
@@ -138,6 +138,7 @@ export function ChatView() {
model: modelState,
mode,
...(pendingSession.telosTaskId ? { telosTaskId: pendingSession.telosTaskId } : {}),
+ ...(pendingSession.agentName ? { agentName: pendingSession.agentName } : {}),
});
clearPendingSession();
forceScrollToBottom();
diff --git a/frontend/src/pages/TodoView.tsx b/frontend/src/pages/TodoView.tsx
index 8f185f30..0ee70878 100644
--- a/frontend/src/pages/TodoView.tsx
+++ b/frontend/src/pages/TodoView.tsx
@@ -190,6 +190,8 @@ export function TodoView() {
setPendingSession({
prompt: buildPrompt(item),
context: buildTodoContext(item),
+ telosTaskId: item.id,
+ agentName: 'mitzo-telos',
});
navigate('/chat');
}
diff --git a/frontend/src/styles/calendar.css b/frontend/src/styles/calendar.css
index 99d6beb8..cda87bab 100644
--- a/frontend/src/styles/calendar.css
+++ b/frontend/src/styles/calendar.css
@@ -304,12 +304,39 @@
color: var(--text-dim);
}
+.cal-event-actions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-top: var(--space-2);
+}
+
+.cal-action-prep {
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ padding: var(--space-2) var(--space-3);
+ border-radius: 6px;
+ font-size: var(--text-sm);
+ font-weight: 500;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+
+.cal-action-prep:hover {
+ opacity: 0.9;
+}
+
+.cal-action-prep:active {
+ transform: scale(0.98);
+}
+
.cal-detail-link {
display: inline-block;
- margin-top: var(--space-1h);
color: var(--accent);
font-size: var(--text-xs);
text-decoration: none;
+ padding: var(--space-1) 0;
}
.cal-detail-link:hover {
diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts
index 02dc35a2..8e5ee23f 100644
--- a/packages/client/src/store.ts
+++ b/packages/client/src/store.ts
@@ -56,12 +56,14 @@ export interface SendMessageOptions {
extraTools?: string;
isolation?: boolean;
telosTaskId?: string;
+ agentName?: string;
}
export interface PendingSession {
prompt: string;
context: string;
telosTaskId?: string;
+ agentName?: string;
}
export interface MitzoStoreState {
@@ -317,6 +319,7 @@ export function createMitzoStore(options: MitzoStoreOptions): StoreApi ({
startChat: vi.fn(),
@@ -2742,3 +2743,107 @@ describe('dispatchV2Message session_suspend', () => {
expect(sessionReg.suspend).toHaveBeenCalledWith('conn-1:sess-1', 5);
});
});
+// ─── handleSendV2 agentName parameter ────────────────────────────────────────
+
+describe('handleSendV2 agentName', () => {
+ it('forwards agentName from message to startChat', () => {
+ (startChat as ReturnType).mockClear();
+ const ctx = createContext();
+ const transport = mockTransport();
+ ctx.connRegistry.register('c1', transport);
+
+ handleSendV2(
+ 'c1',
+ transport,
+ {
+ type: 'send' as const,
+ sessionId: null,
+ prompt: 'test',
+ clientMsgId: 'msg-1',
+ agentName: 'mitzo-telos',
+ },
+ ctx,
+ );
+
+ const callArgs = (startChat as ReturnType).mock.calls[0];
+ const options = callArgs[3];
+ expect(options.agentName).toBe('mitzo-telos');
+ });
+
+ it('omits agentName when not provided in message', () => {
+ (startChat as ReturnType).mockClear();
+ const ctx = createContext();
+ const transport = mockTransport();
+ ctx.connRegistry.register('c1', transport);
+
+ handleSendV2(
+ 'c1',
+ transport,
+ {
+ type: 'send' as const,
+ sessionId: null,
+ prompt: 'test',
+ clientMsgId: 'msg-1',
+ },
+ ctx,
+ );
+
+ const callArgs = (startChat as ReturnType).mock.calls[0];
+ const options = callArgs[3];
+ expect(options.agentName).toBeUndefined();
+ });
+
+ it('rejects path traversal attempts via schema validation', () => {
+ const ctx = createContext();
+ const transport = mockTransport();
+ ctx.connRegistry.register('c1', transport);
+
+ // The Zod schema should reject these before handleSendV2 is called
+ // This test documents the expected validation behavior
+ expect(() =>
+ V2SendMessage.parse({
+ type: 'send',
+ sessionId: null,
+ prompt: 'test',
+ clientMsgId: 'msg-1',
+ agentName: '../../etc/passwd',
+ }),
+ ).toThrow();
+
+ expect(() =>
+ V2SendMessage.parse({
+ type: 'send',
+ sessionId: null,
+ prompt: 'test',
+ clientMsgId: 'msg-1',
+ agentName: '../secrets',
+ }),
+ ).toThrow();
+
+ expect(() =>
+ V2SendMessage.parse({
+ type: 'send',
+ sessionId: null,
+ prompt: 'test',
+ clientMsgId: 'msg-1',
+ agentName: 'mitzo/evil',
+ }),
+ ).toThrow();
+ });
+
+ it('accepts valid agent names', () => {
+ // These should all parse successfully
+ const validNames = ['mitzo-telos', 'mitzo-calendar', 'mitzo_test', 'agent123', 'a-b_c-1'];
+
+ validNames.forEach((name) => {
+ const result = V2SendMessage.parse({
+ type: 'send',
+ sessionId: null,
+ prompt: 'test',
+ clientMsgId: 'msg-1',
+ agentName: name,
+ });
+ expect(result.agentName).toBe(name);
+ });
+ });
+});
diff --git a/server/chat.ts b/server/chat.ts
index 819914f9..49820893 100644
--- a/server/chat.ts
+++ b/server/chat.ts
@@ -36,6 +36,7 @@ import {
SESSION_MESSAGES_LIMIT,
USER_CLOSEOUT_TIMEOUT_MS,
ZERO_TURN_GRACE_MS,
+ DEFAULT_AGENT_NAME,
} from './constants.js';
import { INTERNAL_TOKEN } from './internal-token.js';
import { buildTaskSystemPrompt } from './task-context.js';
@@ -570,6 +571,7 @@ export async function startChat(
clientMsgId?: string;
onSessionResolved?: (sessionId: string) => void;
telosTaskId?: string;
+ agentName?: string;
},
) {
return withSpanAsync(
@@ -599,6 +601,7 @@ async function _startChatInner(
clientMsgId?: string;
onSessionResolved?: (sessionId: string) => void;
telosTaskId?: string;
+ agentName?: string;
},
) {
const abortController = new AbortController();
@@ -722,6 +725,8 @@ async function _startChatInner(
// Build session env with worktree paths for the agent (all repos including primary)
const sessionEnv = sdkEnv();
sessionEnv.MITZO_SESSION_ID = wtId;
+ const agentName = options.agentName ?? DEFAULT_AGENT_NAME;
+ sessionEnv.MITZO_AGENT_NAME = agentName;
for (const [name, { path }] of repoWorktrees) {
sessionEnv[`MITZO_REPO_${name.toUpperCase()}`] = path;
}
@@ -773,7 +778,7 @@ async function _startChatInner(
let tokenBudget = DEFAULT_TOKEN_BUDGET;
try {
if (compileModule.loadAgentDefinition) {
- const recipePath = join(cwd, '.agents', 'mitzo-conversational.yaml');
+ const recipePath = join(cwd, '.agents', `${agentName}.yaml`);
const def = (await compileModule.loadAgentDefinition(recipePath)) as Record<
string,
unknown
diff --git a/server/constants.ts b/server/constants.ts
index 0b0aeb64..fb977b38 100644
--- a/server/constants.ts
+++ b/server/constants.ts
@@ -42,6 +42,9 @@ export const SHUTDOWN_GRACE_MS = 5_000;
export const GUARD_STATS_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
export const WORKTREE_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
+// --- Agent Selection ---
+export const DEFAULT_AGENT_NAME = 'mitzo-conversational';
+
// --- Session List ---
/** Grace period for zero-turn inactive sessions — recently created ones stay visible. */
export const ZERO_TURN_GRACE_MS = 60 * 60 * 1000; // 1 hour
diff --git a/server/ws-handler-v2.ts b/server/ws-handler-v2.ts
index e56969d3..8bc24877 100644
--- a/server/ws-handler-v2.ts
+++ b/server/ws-handler-v2.ts
@@ -449,6 +449,7 @@ export function handleSendV2(
contextBlocks: msg.contextBlocks,
clientMsgId: msg.clientMsgId,
telosTaskId: msg.telosTaskId,
+ agentName: msg.agentName,
});
applySkillPolicy(sessionClientId);
} else {
@@ -469,6 +470,7 @@ export function handleSendV2(
clientMsgId: msg.clientMsgId,
onSessionResolved,
telosTaskId: msg.telosTaskId,
+ agentName: msg.agentName,
});
applySkillPolicy(sessionClientId);
}