Skip to content

Commit fe1f4a8

Browse files
committed
feat(tools): add list/delete workout tools for intervals.icu
Let the athlete remove scheduled workouts they no longer want. Agent calls intervals_list_events first, shows the list, and asks which to delete. intervals_delete_workout uses ?notBefore=today so the server protects history — past workouts cannot be deleted.
1 parent 8f793ce commit fe1f4a8

3 files changed

Lines changed: 112 additions & 6 deletions

File tree

src/agent/core.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@ export class CyclingCoachAgent {
5858
athleteId: config.intervals.athleteId,
5959
})
6060
: null;
61+
const intervalsAuth = config.intervals.apiKey
62+
? { apiKey: config.intervals.apiKey, athleteId: config.intervals.athleteId }
63+
: null;
6164

62-
this.tools = createTools(this.memory, intervals);
65+
this.tools = createTools(this.memory, intervals, intervalsAuth);
6366
this.systemPrompt = buildSystemPrompt(this.memory);
6467
}
6568

src/agent/tools.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ export function createMemoryReadTool(memory: Memory) {
4242
// TOOL BUILDER
4343
// ============================================================================
4444

45-
export function createTools(memory: Memory, intervals: IntervalsClient | null) {
45+
export function createTools(
46+
memory: Memory,
47+
intervals: IntervalsClient | null,
48+
intervalsAuth: { apiKey: string; athleteId: string } | null,
49+
) {
4650
return {
4751
// ── Cycling logic tools (local, no API) ─────────────────────────────
4852

@@ -207,15 +211,18 @@ export function createTools(memory: Memory, intervals: IntervalsClient | null) {
207211

208212
// ── Intervals.icu tools ─────────────────────────────────────────────
209213

210-
...(intervals ? createIntervalsTools(intervals) : {}),
214+
...(intervals && intervalsAuth ? createIntervalsTools(intervals, intervalsAuth) : {}),
211215
};
212216
}
213217

214218
// ============================================================================
215219
// INTERVALS.ICU TOOLS
216220
// ============================================================================
217221

218-
function createIntervalsTools(client: IntervalsClient) {
222+
function createIntervalsTools(
223+
client: IntervalsClient,
224+
auth: { apiKey: string; athleteId: string },
225+
) {
219226
return {
220227
intervals_fetch_athlete: tool({
221228
description:
@@ -303,5 +310,56 @@ function createIntervalsTools(client: IntervalsClient) {
303310
return { created: true, event: result.value };
304311
},
305312
}),
313+
314+
intervals_list_events: tool({
315+
description:
316+
"List scheduled calendar workouts on intervals.icu for a date range. " +
317+
"Use this BEFORE deleting so you can show the athlete the list (id, date, name) " +
318+
"and ask which one to delete. Filters to WORKOUT category only.",
319+
inputSchema: zodSchema(
320+
z.object({
321+
oldest: z.string().describe("Oldest date (YYYY-MM-DD)"),
322+
newest: z.string().optional().describe("Newest date (YYYY-MM-DD)"),
323+
}),
324+
),
325+
execute: async (input: { oldest: string; newest?: string }) => {
326+
const result = await client.events.list({
327+
oldest: input.oldest,
328+
newest: input.newest ?? undefined,
329+
category: ["WORKOUT"],
330+
});
331+
if (!result.ok) return { error: result.error.kind };
332+
return result.value;
333+
},
334+
}),
335+
336+
intervals_delete_workout: tool({
337+
description:
338+
"Delete a scheduled workout from the intervals.icu calendar by event ID. " +
339+
"ALWAYS call intervals_list_events first, show the athlete the list, and " +
340+
"confirm which workout to delete before calling this. Past workouts (before " +
341+
"today) are protected — the server will reject the delete.",
342+
inputSchema: zodSchema(
343+
z.object({
344+
eventId: z.number().int().describe("Event ID from intervals_list_events"),
345+
}),
346+
),
347+
execute: async (input: { eventId: number }) => {
348+
const today = new Date().toISOString().split("T")[0];
349+
const url =
350+
`https://intervals.icu/api/v1/athlete/${auth.athleteId}` +
351+
`/events/${input.eventId}?notBefore=${today}`;
352+
const basic = Buffer.from(`API_KEY:${auth.apiKey}`).toString("base64");
353+
const res = await fetch(url, {
354+
method: "DELETE",
355+
headers: { Authorization: `Basic ${basic}` },
356+
});
357+
if (!res.ok) {
358+
const body = await res.text().catch(() => "");
359+
return { error: `http_${res.status}`, message: body || res.statusText };
360+
}
361+
return { deleted: true };
362+
},
363+
}),
306364
};
307365
}

tests/helpers/mock-intervals.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
* a mutable array of every workout POSTed via the events endpoint.
77
*
88
* Usage:
9-
* const { server, createdWorkouts } = createMockIntervalsServer();
9+
* const { server, createdWorkouts, deletedEventIds } = createMockIntervalsServer();
1010
* server.listen({ onUnhandledRequest: "bypass" });
1111
* // ... run agent ...
1212
* server.close();
1313
* console.log(createdWorkouts); // inspect created workouts
14+
* console.log(deletedEventIds); // inspect deleted event IDs
1415
*/
1516

1617
import { http, HttpResponse } from "msw";
@@ -294,6 +295,7 @@ export function createMockIntervalsServer(options: MockIntervalsOptions = {}) {
294295
const activities = defaultActivities(options.activities);
295296
const wellness = defaultWellness(options.wellness);
296297
const createdWorkouts: CreatedWorkout[] = [];
298+
const deletedEventIds: number[] = [];
297299
let nextEventId = 5000;
298300

299301
const handlers = [
@@ -352,9 +354,52 @@ export function createMockIntervalsServer(options: MockIntervalsOptions = {}) {
352354
createdWorkouts.push(workout);
353355
return HttpResponse.json({ id: eventId, ...body }, { status: 200 });
354356
}),
357+
358+
// GET /api/v1/athlete/:id/events — list scheduled events (filters created workouts
359+
// by optional oldest/newest query params; ignores category since we only emit WORKOUT)
360+
http.get("https://intervals.icu/api/v1/athlete/:id/events", ({ request }) => {
361+
const url = new URL(request.url);
362+
const oldest = url.searchParams.get("oldest");
363+
const newest = url.searchParams.get("newest");
364+
365+
let filtered = createdWorkouts;
366+
if (oldest) {
367+
filtered = filtered.filter((w) => w.start_date_local >= oldest);
368+
}
369+
if (newest) {
370+
filtered = filtered.filter((w) => w.start_date_local <= newest + "T23:59:59");
371+
}
372+
return HttpResponse.json(filtered);
373+
}),
374+
375+
// DELETE /api/v1/athlete/:id/events/:eventId — delete scheduled event; honors
376+
// ?notBefore=YYYY-MM-DD by rejecting when the event's date is earlier.
377+
http.delete(
378+
"https://intervals.icu/api/v1/athlete/:id/events/:eventId",
379+
({ request, params }) => {
380+
const eventId = Number(params.eventId);
381+
const url = new URL(request.url);
382+
const notBefore = url.searchParams.get("notBefore");
383+
384+
const idx = createdWorkouts.findIndex((w) => w.id === eventId);
385+
if (idx === -1) {
386+
return HttpResponse.json({ error: "not_found" }, { status: 404 });
387+
}
388+
const workout = createdWorkouts[idx];
389+
if (notBefore && workout.start_date_local.slice(0, 10) < notBefore) {
390+
return HttpResponse.json(
391+
{ error: "event is before notBefore" },
392+
{ status: 400 },
393+
);
394+
}
395+
createdWorkouts.splice(idx, 1);
396+
deletedEventIds.push(eventId);
397+
return new HttpResponse(null, { status: 200 });
398+
},
399+
),
355400
];
356401

357402
const server = setupServer(...handlers);
358403

359-
return { server, createdWorkouts };
404+
return { server, createdWorkouts, deletedEventIds };
360405
}

0 commit comments

Comments
 (0)