# Schedules and planning

> Build timetables, plan study blocks, read what is on today, and manage calendar feeds.

Source: https://workroad.app/docs/api/schedules

## Timetables

`POST /schedules/timetable` creates a timetable with its periods and every day's lessons in one call, all or nothing, as the app's setup does. A `defined` timetable has `periods` and one entry per period on each day; a `dynamic` one has none, and each lesson carries `timeStart` and `timeEnd`. A lesson's `subjectName` links it to the user's subject with that name; one that matches none stays as written on the timetable, or becomes a new subject with `createSubjectsIfMissing: true`.

`GET /schedules/{scheduleId}` says what a key can change. `owned` is true for the user's own schedules, and `editable` is true when the lesson grid can be written. Only the owner writes. A schedule shared with the user, or anyone else's, returns 404 on every write.

`PATCH /schedules/{scheduleId}` renames, archives, restores or sets the default, and changes the days the schedule shows. It works on any schedule the user owns, including one read from an integration. Every field in a call applies, or none does. Archiving the default clears it. To make an archived schedule the default, restore it in the same call with `archived: false`.

The grid, meaning the periods and the lessons in them, can only be written on the owner's `DEFINED` schedules kept in WorkRoad. On an external schedule a grid write returns 409 `conflict` with `details.reason` `external-schedule`, and on a `DYNAMIC` one `dynamic-schedule`. Their lessons change in the app or at the provider.

To build a timetable:

1. `POST /schedules` with a `name` and the `structure` of periods. Each period has a `name` and either both `start` and `end` or neither.
2. `POST /schedules/{scheduleId}/elements` with a `day` (0 is Sunday), a `period` index and a `lessonName`, once per lesson. The first lesson lays out an empty period on every weekday. If the user has no default schedule, this one becomes it.
3. Change a lesson with `PATCH /schedules/{scheduleId}/elements/{elementId}` and empty its period with `DELETE`. The element keeps its id with `lessonName: null`, the way the app stores an empty period, and a later `POST` for that period fills the same element. Removing a lesson needs `schedule:delete`, the same way deleting a study set term needs `sets:delete`.

Don't store element ids. Saving the timetable in the app deletes and recreates every element, so an id from an earlier read can return 404. Read `GET /schedules/{scheduleId}` before each lesson write and take the id from `days[].elements[]`.

`PUT /schedules/{scheduleId}/structure` renames and retimes periods at any point. It can change how many periods there are only until the first lesson is added, up to 30; after that it returns 409 with `details.reason` `period-count`. A period's `start` and `end` may be empty strings, which is how the app stores a time still being set up, so a `structure` you read can be sent back unchanged. Adding to a period that doesn't exist returns 409 `no-such-period`, adding to one that has a lesson returns 409 `slot-taken`, and updating an empty one returns 409 `slot-empty`.

Every write returns the schedule as `GET /schedules/{scheduleId}` shows it. A `subjectId` must be one of the user's subject ids; any other returns 400 with `details.field` `subjectId` and `details.reason` `unknown-subject`. `PATCH /schedules/{scheduleId}` with `isDefault: true` on an external schedule first asks the provider whether setup finished. If that check fails it returns 503 before saving anything, and retrying the same body is safe. A lesson reads back with `subjectId` when it points at a subject, or with `subjectLabel` when an older timetable stored free text there; at most one is set. Writing `subjectId` replaces the label. When the provider can't be read for the response after a save, the schedule comes back with `issue: "failed"`.

## Study blocks

A study block is study time planned on the user's calendar, the way the planning copilot plans it. `POST /study-blocks` takes a `title`, a future `startsAt` and an `endsAt` 5 minutes to 8 hours later on the same day in the user's time zone, and optionally the `subjectId`, `taskId` or `setId` it is for. A block can't overlap a lesson, including lessons on connected external calendars, or another block, and can't end after a linked task's due day; those return 409 `conflict` with `details.reason` `planning-conflict` or `planning-after-deadline`. With an external calendar connected, a block can only be planned this week or next, so its lessons can be checked (`planning-calendar-incomplete`).

`GET /study-blocks?from=2026-10-20&to=2026-10-26` lists blocks by day in the user's time zone, up to 62 days at a time. `PATCH` moves or relinks one and checks it again as a whole; `DELETE` removes it.

## Today

`GET /today` answers "what should I study now?" in one call, from the same data the app's dashboard and planning copilot read. `study` has the cards due now by subject and the sets with the most due, today's goal, streak and remaining new cards. `tasks` has overdue tasks and tasks due in the next 7 days, with exams flagged. `lessons` and `studyBlocks` hold today's timetable and planned study time.

Each section needs its own scope and is `null` without it: `study` needs `activity:view` (its `sets` list also needs `sets:view`), `tasks` needs `tasks:view`, and `lessons` and `studyBlocks` need `schedule:view`. `suggestions` lists what to do next, most useful first. Each one has a sentence for the user, the operation that acts on it as `tool`, and its `arguments`, such as `startPracticeSession` with `{ "subjectId": "..." }`. A suggestion only names an operation the key may call.

## Calendar feeds

`GET /calendar-feeds` lists feed metadata without returning secret subscription URLs. `DELETE /calendar-feeds/{feedId}` revokes a feed. Create feeds or rotate their URLs in [Settings → Calendar](https://workroad.app/user/calendar). Anyone holding a subscription URL can read its calendar data, even after the API key used to list the feed is revoked.

## List schedules

`GET https://workroad.app/api/v1/schedules`

Every active schedule the user owns, default first, then the ones other users shared with them. Archived schedules are left out.

- MCP tool: `listSchedules` on `https://workroad.app/api/mcp`
- Required scopes: schedule:view
- Test mode: `GET https://workroad.app/api/test/v1/schedules` checks the request and returns the example response without changing anything.

### Response

`200` OK

- `data`: array of objects, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times.
  - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad.
  - `owned`: boolean, required. False when another user shared the schedule with this user.
  - `isDefault`: boolean, required. The user's default schedule.
  - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday).
  - `archived`: boolean, required. Always false here: archived schedules are left out of this list.

#### Example

```json
{
  "data": [
    {
      "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
      "name": "Year 12",
      "type": "DEFINED",
      "external": null,
      "owned": true,
      "isDefault": true,
      "selectedDays": [
        1,
        2,
        3,
        4,
        5
      ],
      "archived": false
    }
  ]
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No endpoint exists at this path.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Get a week of lessons

`GET https://workroad.app/api/v1/schedules/week`

Every lesson in one week across the user's active schedules, or one schedule, as dated occurrences in the user's time zone. External schedules are read from their provider, up to 7 seconds each; one that fails or times out is listed in `sources` with an `issue` and adds no lessons. Archived schedules are not part of the week, so a `scheduleId` naming one returns 404.

- MCP tool: `getScheduleWeek` on `https://workroad.app/api/mcp`
- Required scopes: schedule:view
- Test mode: `GET https://workroad.app/api/test/v1/schedules/week` checks the request and returns the example response without changing anything.

### Query parameters

- `date`: string (date). Any day (YYYY-MM-DD) in the week to read. Defaults to today in the user's time zone. Only the previous, current and next week can be read.
- `scheduleId`: string (uuid). Only this schedule. Omit for every active schedule the user can read.

### Response

`200` OK

- `data`: object, required
  - `weekStart`: string, required. The Monday the week starts on (YYYY-MM-DD).
  - `timeZone`: string, required. The user's time zone, or UTC when none is set. Dates and times use it.
  - `sources`: array of objects, required. Every schedule read for the week, including ones that failed.
    - `scheduleId`: string (uuid), required
    - `name`: string, required
    - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad.
    - `owned`: boolean, required. False when another user shared the schedule with this user.
    - `issue`: one of `"unavailable"`, `"failed"`, `"timeout"` or null, required. Why an external schedule has no lessons this week: `unavailable` (the integration cannot read that week), `failed`, or `timeout` after 7 seconds. Null when it was read.
  - `occurrences`: array of objects, required
    - `scheduleId`: string (uuid), required
    - `date`: string, required. The day (YYYY-MM-DD).
    - `endDate`: string or null, required. The last day of an event that spans several days, or null.
    - `time`: one of the objects below, required. When the lesson runs, in the week's time zone.
      - One of `kind`: `"allDay"`
        - `kind`: `"allDay"`, required
      - Or `kind`: `"timed"`
        - `kind`: `"timed"`, required
        - `startMinute`: integer, required. Minutes after midnight.
        - `endMinute`: integer, required. Minutes after midnight.
      - Or `kind`: `"period"`
        - `kind`: `"period"`, required
        - `period`: integer, required. The period index. The period has no times.
        - `name`: string, required
    - `timeZone`: string or null, required. The event's own time zone, when an integration reports one.
    - `title`: string, required
    - `subtitle`: string or null, required. Room and teacher, when known.

#### Example

```json
{
  "data": {
    "weekStart": "2026-01-12",
    "timeZone": "Europe/London",
    "sources": [
      {
        "scheduleId": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
        "name": "Year 12",
        "external": null,
        "owned": true,
        "issue": null
      }
    ],
    "occurrences": [
      {
        "scheduleId": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
        "date": "2026-01-12",
        "endDate": null,
        "time": {
          "kind": "timed",
          "startMinute": 540,
          "endMinute": 600
        },
        "timeZone": null,
        "title": "Biology",
        "subtitle": "S2 · Dr Patel"
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Only the previous, current and next week can be read.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No schedule with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Get a schedule

`GET https://workroad.app/api/v1/schedules/{scheduleId}`

The schedule's periods and its days with their lessons. The owner can also open an archived schedule. An external schedule shows the current week, read from its provider. `editable` covers the lesson grid only. Metadata changes (name, days, archived, default) apply to any schedule the user owns, external ones included.

- MCP tool: `getSchedule` on `https://workroad.app/api/mcp`
- Required scopes: schedule:view
- Test mode: `GET https://workroad.app/api/test/v1/schedules/{scheduleId}` checks the request and returns the example response without changing anything.

### Path parameters

- `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user.

### Response

`200` OK

- `data`: object, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times.
  - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad.
  - `owned`: boolean, required. False when another user shared the schedule with this user.
  - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false.
  - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules.
  - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday).
  - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise.
  - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads.
    - `periods`: array of objects, required
      - `index`: integer, required. The period number elements refer to.
      - `name`: string, required
      - `start`: string or null, required. Start time (HH:MM), or null.
      - `end`: string or null, required. End time (HH:MM), or null.
  - `days`: array of objects, required. An external schedule's days are the current week's.
    - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday).
    - `elements`: array of objects, required
      - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones.
      - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day.
      - `lessonName`: string or null, required. Null for an empty period.
      - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set.
      - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it.
      - `room`: string or null, required
      - `teacher`: string or null, required
      - `timeStart`: string or null, required. Start time (HH:MM), when set.
      - `timeEnd`: string or null, required. End time (HH:MM), when set.

#### Example

```json
{
  "data": {
    "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
    "name": "Year 12",
    "type": "DEFINED",
    "external": null,
    "owned": true,
    "editable": true,
    "firstDay": 1,
    "selectedDays": [
      1,
      2,
      3,
      4,
      5
    ],
    "issue": null,
    "structure": {
      "periods": [
        {
          "index": 0,
          "name": "Period 1",
          "start": "09:00",
          "end": "10:00"
        },
        {
          "index": 1,
          "name": "Period 2",
          "start": "10:00",
          "end": "11:00"
        }
      ]
    },
    "days": [
      {
        "day": 1,
        "elements": [
          {
            "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817",
            "period": 0,
            "lessonName": "Biology",
            "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
            "subjectLabel": null,
            "room": "S2",
            "teacher": "Dr Patel",
            "timeStart": null,
            "timeEnd": null
          },
          {
            "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928",
            "period": 1,
            "lessonName": null,
            "subjectId": null,
            "subjectLabel": null,
            "room": null,
            "teacher": null,
            "timeStart": null,
            "timeEnd": null
          }
        ]
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No schedule with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Create a schedule

`POST https://workroad.app/api/v1/schedules`

Creates a `DEFINED` timetable kept in WorkRoad, optionally with its periods. It has no lessons yet: add them with `POST /schedules/{scheduleId}/elements`. The first lesson lays out every weekday and, if the user has no default schedule, makes this one the default.

- MCP tool: `createSchedule` on `https://workroad.app/api/mcp`
- Required scopes: schedule:create
- Test mode: `POST https://workroad.app/api/test/v1/schedules` checks the request and returns the example response without changing anything.
- Next: add lessons with `createScheduleElement`.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Request body

JSON.

- `name`: string, required, 2-64 characters. 2-64 characters. Unique among the user's active schedules.
- `firstDay`: integer, 0 to 6. The weekday the timetable starts on. Defaults to 1 (Monday).
- `selectedDays`: array of integer, up to 7 items. Weekdays the schedule shows. Defaults to all seven.
- `structure`: object. The periods. Omit to send them later with `PUT /schedules/{scheduleId}/structure`.
  - `periods`: array of objects, required, 1-30 items. 1-30 periods, in the order of the day. Elements refer to a period by its position.
    - `name`: string, required, up to 200 characters. Shown in the timetable, like `Period 1` or `Lunch`.
    - `start`: string or null, required, matches `^(?:|(?:[01]\d|2[0-3]):[0-5]\d)$`. Start time (HH:MM), an empty string while unset, or null.
    - `end`: string or null, required, matches `^(?:|(?:[01]\d|2[0-3]):[0-5]\d)$`. End time (HH:MM), an empty string while unset, or null.

### Response

`201` Created

- `data`: object, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times.
  - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad.
  - `owned`: boolean, required. False when another user shared the schedule with this user.
  - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false.
  - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules.
  - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday).
  - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise.
  - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads.
    - `periods`: array of objects, required
      - `index`: integer, required. The period number elements refer to.
      - `name`: string, required
      - `start`: string or null, required. Start time (HH:MM), or null.
      - `end`: string or null, required. End time (HH:MM), or null.
  - `days`: array of objects, required. An external schedule's days are the current week's.
    - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday).
    - `elements`: array of objects, required
      - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones.
      - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day.
      - `lessonName`: string or null, required. Null for an empty period.
      - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set.
      - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it.
      - `room`: string or null, required
      - `teacher`: string or null, required
      - `timeStart`: string or null, required. Start time (HH:MM), when set.
      - `timeEnd`: string or null, required. End time (HH:MM), when set.

#### Example

```json
{
  "data": {
    "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
    "name": "Year 12",
    "type": "DEFINED",
    "external": null,
    "owned": true,
    "editable": true,
    "firstDay": 1,
    "selectedDays": [
      1,
      2,
      3,
      4,
      5
    ],
    "issue": null,
    "structure": {
      "periods": [
        {
          "index": 0,
          "name": "Period 1",
          "start": "09:00",
          "end": "10:00"
        },
        {
          "index": 1,
          "name": "Period 2",
          "start": "10:00",
          "end": "11:00"
        }
      ]
    },
    "days": [
      {
        "day": 1,
        "elements": [
          {
            "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817",
            "period": 0,
            "lessonName": "Biology",
            "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
            "subjectLabel": null,
            "room": "S2",
            "teacher": "Dr Patel",
            "timeStart": null,
            "timeEnd": null
          },
          {
            "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928",
            "period": 1,
            "lessonName": null,
            "subjectId": null,
            "subjectLabel": null,
            "room": null,
            "teacher": null,
            "timeStart": null,
            "timeEnd": null
          }
        ]
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid body.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No endpoint exists at this path.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: The user already has an active schedule with this name (`details.reason` is `name-taken`). The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Create a timetable with its lessons

`POST https://workroad.app/api/v1/schedules/timetable`

Creates a timetable kept in WorkRoad with its periods and every day's lessons in one call, as the app's setup does: the first timetable becomes the default, and the days with lessons are the days it shows. Use it to save a timetable you read from a photo, a PDF or what the user typed; it uses no AI. Link lessons to the user's subjects with `subjectId` or `subjectName`, and list the subjects first (`GET /subjects`) so the names match. The timetable is saved whole or not at all.

Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses.

- MCP tool: `createTimetable` on `https://workroad.app/api/mcp`
- Required scopes: schedule:create
- Test mode: `POST https://workroad.app/api/test/v1/schedules/timetable` checks the request and returns the example response without changing anything.
- Next: read it back for the week with `getScheduleWeek`.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Request body

JSON.

- `name`: string, required, 2-64 characters. 2-64 characters. Unique among the user's active schedules.
- `scheduleType`: one of `"defined"`, `"dynamic"`, required. `defined`: every day shares one grid of `periods`, and each day lists one entry per period (`null` for a free period). `dynamic`: no periods; each day lists its lessons, each with `timeStart` and `timeEnd`.
- `firstDay`: integer, 0 to 6. The weekday the timetable starts on, which is the first entry of `days`. Defaults to 1 (Monday).
- `periods`: array of objects, 1-30 items. `defined` only: 1-30 periods, in order, not overlapping.
  - `periodName`: string, required, up to 200 characters. Such as `Period 1` or `Break`.
  - `time`: object or null, required. `HH:MM` start and end, or `null` when the timetable shows no time.
    - `start`: string, required, up to 5 characters
    - `end`: string, required, up to 5 characters
- `days`: array of array of object or null, required, 1-7 items. One entry per weekday from `firstDay`, up to 7: the day's lessons in order. For a `defined` timetable every day has exactly one entry per period. A day with no lessons is an empty list (`dynamic`) or all `null` (`defined`), and days left off the end are free.
  - `lessonName`: string, up to 200 characters, default `""`. What the timetable calls the lesson, such as `Maths` or `Lunch`. Empty for a free period.
  - `subjectId`: string (uuid). The user's subject the lesson is for.
  - `subjectName`: string, 1-200 characters. The course by name. It links the lesson to the user's active subject with that name, ignoring case and accents. A name that matches none is created as a subject when `createSubjectsIfMissing` is true, and otherwise kept as written on the timetable without a link.
  - `room`: string, up to 200 characters, default `""`. The room, or empty.
  - `teacher`: string, up to 200 characters, default `""`. The teacher, or empty.
  - `timeStart`: string, up to 5 characters. A `dynamic` lesson's start. `HH:MM`, 24-hour.
  - `timeEnd`: string, up to 5 characters. A `dynamic` lesson's end. `HH:MM`, 24-hour.
- `createSubjectsIfMissing`: boolean. Creates a subject for each `subjectName` the user doesn't have yet, so the lessons link to it. Needs the `subjects:create` scope.

### Response

`201` Created

- `data`: object, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times.
  - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad.
  - `owned`: boolean, required. False when another user shared the schedule with this user.
  - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false.
  - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules.
  - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday).
  - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise.
  - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads.
    - `periods`: array of objects, required
      - `index`: integer, required. The period number elements refer to.
      - `name`: string, required
      - `start`: string or null, required. Start time (HH:MM), or null.
      - `end`: string or null, required. End time (HH:MM), or null.
  - `days`: array of objects, required. An external schedule's days are the current week's.
    - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday).
    - `elements`: array of objects, required
      - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones.
      - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day.
      - `lessonName`: string or null, required. Null for an empty period.
      - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set.
      - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it.
      - `room`: string or null, required
      - `teacher`: string or null, required
      - `timeStart`: string or null, required. Start time (HH:MM), when set.
      - `timeEnd`: string or null, required. End time (HH:MM), when set.

#### Example

```json
{
  "data": {
    "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
    "name": "Year 12",
    "type": "DEFINED",
    "external": null,
    "owned": true,
    "editable": true,
    "firstDay": 1,
    "selectedDays": [
      1,
      2,
      3,
      4,
      5
    ],
    "issue": null,
    "structure": {
      "periods": [
        {
          "index": 0,
          "name": "Period 1",
          "start": "09:00",
          "end": "10:00"
        },
        {
          "index": 1,
          "name": "Period 2",
          "start": "10:00",
          "end": "11:00"
        }
      ]
    },
    "days": [
      {
        "day": 1,
        "elements": [
          {
            "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817",
            "period": 0,
            "lessonName": "Biology",
            "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
            "subjectLabel": null,
            "room": "S2",
            "teacher": "Dr Patel",
            "timeStart": null,
            "timeEnd": null
          },
          {
            "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928",
            "period": 1,
            "lessonName": null,
            "subjectId": null,
            "subjectLabel": null,
            "room": null,
            "teacher": null,
            "timeStart": null,
            "timeEnd": null
          }
        ]
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request` with `details.reason`: `invalid-timetable`, `time-invalid`, `grid-mismatch` or `unknown-subject`.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: `createSubjectsIfMissing` was sent and the key lacks `subjects:create` (`details.missingScopes`).
- `404`: No endpoint exists at this path.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: The user already has an active schedule with this name (`details.reason` is `name-taken`). The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Update a schedule

`PATCH https://workroad.app/api/v1/schedules/{scheduleId}`

Renames, archives or restores a schedule the user owns, changes the days it shows, or makes it the default. Works on external schedules too, and the response then reads the current week from the provider. Everything in one call succeeds or fails together. Retrying with the same body is safe: every field sets a value, so a second call changes nothing more.

- MCP tool: `updateSchedule` on `https://workroad.app/api/mcp`
- Required scopes: schedule:update
- Test mode: `PATCH https://workroad.app/api/test/v1/schedules/{scheduleId}` checks the request and returns the example response without changing anything.

### Path parameters

- `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user.

### Request body

JSON.

- `name`: string, 2-64 characters. 2-64 characters. Unique among the user's active schedules.
- `selectedDays`: array of integer, up to 7 items. Weekdays the schedule shows, each once.
- `archived`: boolean. `true` archives the schedule and, if it was the default, clears the default. `false` restores it.
- `isDefault`: boolean. `true` makes it the user's default schedule. `false` clears the default if this schedule is it.

### Response

`200` OK

- `data`: object, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times.
  - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad.
  - `owned`: boolean, required. False when another user shared the schedule with this user.
  - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false.
  - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules.
  - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday).
  - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise.
  - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads.
    - `periods`: array of objects, required
      - `index`: integer, required. The period number elements refer to.
      - `name`: string, required
      - `start`: string or null, required. Start time (HH:MM), or null.
      - `end`: string or null, required. End time (HH:MM), or null.
  - `days`: array of objects, required. An external schedule's days are the current week's.
    - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday).
    - `elements`: array of objects, required
      - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones.
      - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day.
      - `lessonName`: string or null, required. Null for an empty period.
      - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set.
      - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it.
      - `room`: string or null, required
      - `teacher`: string or null, required
      - `timeStart`: string or null, required. Start time (HH:MM), when set.
      - `timeEnd`: string or null, required. End time (HH:MM), when set.

#### Example

```json
{
  "data": {
    "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
    "name": "Year 12",
    "type": "DEFINED",
    "external": null,
    "owned": true,
    "editable": true,
    "firstDay": 1,
    "selectedDays": [
      1,
      2,
      3,
      4,
      5
    ],
    "issue": null,
    "structure": {
      "periods": [
        {
          "index": 0,
          "name": "Period 1",
          "start": "09:00",
          "end": "10:00"
        },
        {
          "index": 1,
          "name": "Period 2",
          "start": "10:00",
          "end": "11:00"
        }
      ]
    },
    "days": [
      {
        "day": 1,
        "elements": [
          {
            "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817",
            "period": 0,
            "lessonName": "Biology",
            "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
            "subjectLabel": null,
            "room": "S2",
            "teacher": "Dr Patel",
            "timeStart": null,
            "timeEnd": null
          },
          {
            "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928",
            "period": 1,
            "lessonName": null,
            "subjectId": null,
            "subjectLabel": null,
            "room": null,
            "teacher": null,
            "timeStart": null,
            "timeEnd": null
          }
        ]
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid body, or no field to update.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No schedule with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `conflict` with `details.reason` `name-taken` when another active schedule has the name, `archived-schedule` when an archived schedule would become the default, or `integration-not-ready` when an external schedule that isn't connected yet would become the default.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error
- `503`: Setting `isDefault` on an external schedule asks its integration whether setup finished, and that check failed. Nothing was saved, so the same request can be retried.

## Set a schedule's periods

`PUT https://workroad.app/api/v1/schedules/{scheduleId}/structure`

Replaces the periods of a `DEFINED` schedule kept in WorkRoad. Once the first lesson is added, the number of periods is fixed; names and times can still change. The app stores a time that is still being set up as an empty string, so the `structure` from `GET /schedules/{scheduleId}` can be sent back unchanged.

- MCP tool: `putScheduleStructure` on `https://workroad.app/api/mcp`
- Required scopes: schedule:update
- Test mode: `PUT https://workroad.app/api/test/v1/schedules/{scheduleId}/structure` checks the request and returns the example response without changing anything.

### Path parameters

- `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user.

### Request body

JSON.

- `periods`: array of objects, required, at least 1 item. The periods, in the order of the day. Elements refer to a period by its position. At most 30 while the number of periods can still change.
  - `name`: string, required, up to 200 characters. Shown in the timetable, like `Period 1` or `Lunch`.
  - `start`: string or null, required, matches `^(?:|(?:[01]\d|2[0-3]):[0-5]\d)$`. Start time (HH:MM), an empty string while unset, or null.
  - `end`: string or null, required, matches `^(?:|(?:[01]\d|2[0-3]):[0-5]\d)$`. End time (HH:MM), an empty string while unset, or null.

### Response

`200` OK

- `data`: object, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times.
  - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad.
  - `owned`: boolean, required. False when another user shared the schedule with this user.
  - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false.
  - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules.
  - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday).
  - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise.
  - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads.
    - `periods`: array of objects, required
      - `index`: integer, required. The period number elements refer to.
      - `name`: string, required
      - `start`: string or null, required. Start time (HH:MM), or null.
      - `end`: string or null, required. End time (HH:MM), or null.
  - `days`: array of objects, required. An external schedule's days are the current week's.
    - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday).
    - `elements`: array of objects, required
      - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones.
      - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day.
      - `lessonName`: string or null, required. Null for an empty period.
      - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set.
      - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it.
      - `room`: string or null, required
      - `teacher`: string or null, required
      - `timeStart`: string or null, required. Start time (HH:MM), when set.
      - `timeEnd`: string or null, required. End time (HH:MM), when set.

#### Example

```json
{
  "data": {
    "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
    "name": "Year 12",
    "type": "DEFINED",
    "external": null,
    "owned": true,
    "editable": true,
    "firstDay": 1,
    "selectedDays": [
      1,
      2,
      3,
      4,
      5
    ],
    "issue": null,
    "structure": {
      "periods": [
        {
          "index": 0,
          "name": "Period 1",
          "start": "09:00",
          "end": "10:00"
        },
        {
          "index": 1,
          "name": "Period 2",
          "start": "10:00",
          "end": "11:00"
        }
      ]
    },
    "days": [
      {
        "day": 1,
        "elements": [
          {
            "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817",
            "period": 0,
            "lessonName": "Biology",
            "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
            "subjectLabel": null,
            "room": "S2",
            "teacher": "Dr Patel",
            "timeStart": null,
            "timeEnd": null
          },
          {
            "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928",
            "period": 1,
            "lessonName": null,
            "subjectId": null,
            "subjectLabel": null,
            "room": null,
            "teacher": null,
            "timeStart": null,
            "timeEnd": null
          }
        ]
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid body, or more than 30 periods while the number of periods can still change (`details.reason` is `too-many-periods`).
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No schedule with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `conflict` with `details.reason` `external-schedule` for a schedule read from an integration, or `dynamic-schedule` for a `DYNAMIC` one. `period-count` when the schedule has lessons laid out and the number of periods would change.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Add a lesson

`POST https://workroad.app/api/v1/schedules/{scheduleId}/elements`

Puts a lesson into an empty period of a `DEFINED` schedule kept in WorkRoad. Set the periods first with `PUT /schedules/{scheduleId}/structure`. Returns the schedule; the lesson is the entry in `days[]` for `day`, at `period` in its `elements`. Element ids change whenever the timetable is saved in the app, so read `GET /schedules/{scheduleId}` for the current id instead of keeping one.

- MCP tool: `createScheduleElement` on `https://workroad.app/api/mcp`
- Required scopes: schedule:update
- Test mode: `POST https://workroad.app/api/test/v1/schedules/{scheduleId}/elements` checks the request and returns the example response without changing anything.

### Path parameters

- `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Request body

JSON.

- `day`: integer, required, 0 to 6. Weekday, 0 (Sunday) to 6 (Saturday).
- `period`: integer, required, at least 0. The period's `index` in `structure`.
- `lessonName`: string, required, 1-200 characters
- `subjectId`: string (uuid) or null. Subject id owned by the user, or null.
- `room`: string or null, up to 200 characters
- `teacher`: string or null, up to 200 characters

### Response

`201` Created

- `data`: object, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times.
  - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad.
  - `owned`: boolean, required. False when another user shared the schedule with this user.
  - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false.
  - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules.
  - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday).
  - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise.
  - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads.
    - `periods`: array of objects, required
      - `index`: integer, required. The period number elements refer to.
      - `name`: string, required
      - `start`: string or null, required. Start time (HH:MM), or null.
      - `end`: string or null, required. End time (HH:MM), or null.
  - `days`: array of objects, required. An external schedule's days are the current week's.
    - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday).
    - `elements`: array of objects, required
      - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones.
      - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day.
      - `lessonName`: string or null, required. Null for an empty period.
      - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set.
      - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it.
      - `room`: string or null, required
      - `teacher`: string or null, required
      - `timeStart`: string or null, required. Start time (HH:MM), when set.
      - `timeEnd`: string or null, required. End time (HH:MM), when set.

#### Example

```json
{
  "data": {
    "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
    "name": "Year 12",
    "type": "DEFINED",
    "external": null,
    "owned": true,
    "editable": true,
    "firstDay": 1,
    "selectedDays": [
      1,
      2,
      3,
      4,
      5
    ],
    "issue": null,
    "structure": {
      "periods": [
        {
          "index": 0,
          "name": "Period 1",
          "start": "09:00",
          "end": "10:00"
        },
        {
          "index": 1,
          "name": "Period 2",
          "start": "10:00",
          "end": "11:00"
        }
      ]
    },
    "days": [
      {
        "day": 1,
        "elements": [
          {
            "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817",
            "period": 0,
            "lessonName": "Biology",
            "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
            "subjectLabel": null,
            "room": "S2",
            "teacher": "Dr Patel",
            "timeStart": null,
            "timeEnd": null
          },
          {
            "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928",
            "period": 1,
            "lessonName": null,
            "subjectId": null,
            "subjectLabel": null,
            "room": null,
            "teacher": null,
            "timeStart": null,
            "timeEnd": null
          }
        ]
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid body, or a `subjectId` that is not one of the user's subjects (`details.reason` is `unknown-subject`).
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No schedule with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `conflict` with `details.reason` `external-schedule` for a schedule read from an integration, or `dynamic-schedule` for a `DYNAMIC` one. `no-such-period` when the schedule has no period with that index, and `slot-taken` when the period already has a lesson. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Update a lesson

`PATCH https://workroad.app/api/v1/schedules/{scheduleId}/elements/{elementId}`

Changes the lesson in one period of a `DEFINED` schedule kept in WorkRoad. Only the fields you send change. Returns the schedule. Element ids change whenever the timetable is saved in the app, so read `GET /schedules/{scheduleId}` for the current id instead of keeping one.

- MCP tool: `updateScheduleElement` on `https://workroad.app/api/mcp`
- Required scopes: schedule:update
- Test mode: `PATCH https://workroad.app/api/test/v1/schedules/{scheduleId}/elements/{elementId}` checks the request and returns the example response without changing anything.

### Path parameters

- `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user.
- `elementId`: string (uuid), required. Element id from the schedule's `days[].elements[]`.

### Request body

JSON.

- `lessonName`: string, 1-200 characters
- `subjectId`: string (uuid) or null. Subject id owned by the user, or null.
- `room`: string or null, up to 200 characters
- `teacher`: string or null, up to 200 characters

### Response

`200` OK

- `data`: object, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times.
  - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad.
  - `owned`: boolean, required. False when another user shared the schedule with this user.
  - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false.
  - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules.
  - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday).
  - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise.
  - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads.
    - `periods`: array of objects, required
      - `index`: integer, required. The period number elements refer to.
      - `name`: string, required
      - `start`: string or null, required. Start time (HH:MM), or null.
      - `end`: string or null, required. End time (HH:MM), or null.
  - `days`: array of objects, required. An external schedule's days are the current week's.
    - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday).
    - `elements`: array of objects, required
      - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones.
      - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day.
      - `lessonName`: string or null, required. Null for an empty period.
      - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set.
      - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it.
      - `room`: string or null, required
      - `teacher`: string or null, required
      - `timeStart`: string or null, required. Start time (HH:MM), when set.
      - `timeEnd`: string or null, required. End time (HH:MM), when set.

#### Example

```json
{
  "data": {
    "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
    "name": "Year 12",
    "type": "DEFINED",
    "external": null,
    "owned": true,
    "editable": true,
    "firstDay": 1,
    "selectedDays": [
      1,
      2,
      3,
      4,
      5
    ],
    "issue": null,
    "structure": {
      "periods": [
        {
          "index": 0,
          "name": "Period 1",
          "start": "09:00",
          "end": "10:00"
        },
        {
          "index": 1,
          "name": "Period 2",
          "start": "10:00",
          "end": "11:00"
        }
      ]
    },
    "days": [
      {
        "day": 1,
        "elements": [
          {
            "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817",
            "period": 0,
            "lessonName": "Biology",
            "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
            "subjectLabel": null,
            "room": "S2",
            "teacher": "Dr Patel",
            "timeStart": null,
            "timeEnd": null
          },
          {
            "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928",
            "period": 1,
            "lessonName": null,
            "subjectId": null,
            "subjectLabel": null,
            "room": null,
            "teacher": null,
            "timeStart": null,
            "timeEnd": null
          }
        ]
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid body, no field to update, or a `subjectId` that is not one of the user's subjects (`details.reason` is `unknown-subject`).
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No schedule with that id exists for this key's user, or the schedule has no element with that id.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `conflict` with `details.reason` `external-schedule` for a schedule read from an integration, or `dynamic-schedule` for a `DYNAMIC` one. `slot-empty` when the period has no lesson to update.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Remove a lesson

`DELETE https://workroad.app/api/v1/schedules/{scheduleId}/elements/{elementId}`

Empties one period of a `DEFINED` schedule kept in WorkRoad. The element keeps its id and stays in the grid with `lessonName: null`, as when a lesson is removed in the app. Emptying an empty period succeeds. Returns the schedule. Element ids change whenever the timetable is saved in the app, so read `GET /schedules/{scheduleId}` for the current id instead of keeping one.

- MCP tool: `deleteScheduleElement` on `https://workroad.app/api/mcp`
- Required scopes: schedule:delete
- Test mode: `DELETE https://workroad.app/api/test/v1/schedules/{scheduleId}/elements/{elementId}` checks the request and returns the example response without changing anything.

### Path parameters

- `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user.
- `elementId`: string (uuid), required. Element id from the schedule's `days[].elements[]`.

### Response

`200` OK

- `data`: object, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times.
  - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad.
  - `owned`: boolean, required. False when another user shared the schedule with this user.
  - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false.
  - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules.
  - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday).
  - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise.
  - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads.
    - `periods`: array of objects, required
      - `index`: integer, required. The period number elements refer to.
      - `name`: string, required
      - `start`: string or null, required. Start time (HH:MM), or null.
      - `end`: string or null, required. End time (HH:MM), or null.
  - `days`: array of objects, required. An external schedule's days are the current week's.
    - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday).
    - `elements`: array of objects, required
      - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones.
      - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day.
      - `lessonName`: string or null, required. Null for an empty period.
      - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set.
      - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it.
      - `room`: string or null, required
      - `teacher`: string or null, required
      - `timeStart`: string or null, required. Start time (HH:MM), when set.
      - `timeEnd`: string or null, required. End time (HH:MM), when set.

#### Example

```json
{
  "data": {
    "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
    "name": "Year 12",
    "type": "DEFINED",
    "external": null,
    "owned": true,
    "editable": true,
    "firstDay": 1,
    "selectedDays": [
      1,
      2,
      3,
      4,
      5
    ],
    "issue": null,
    "structure": {
      "periods": [
        {
          "index": 0,
          "name": "Period 1",
          "start": "09:00",
          "end": "10:00"
        },
        {
          "index": 1,
          "name": "Period 2",
          "start": "10:00",
          "end": "11:00"
        }
      ]
    },
    "days": [
      {
        "day": 1,
        "elements": [
          {
            "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817",
            "period": 0,
            "lessonName": "Biology",
            "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
            "subjectLabel": null,
            "room": "S2",
            "teacher": "Dr Patel",
            "timeStart": null,
            "timeEnd": null
          },
          {
            "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928",
            "period": 1,
            "lessonName": null,
            "subjectId": null,
            "subjectLabel": null,
            "room": null,
            "teacher": null,
            "timeStart": null,
            "timeEnd": null
          }
        ]
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No schedule with that id exists for this key's user, or the schedule has no element with that id.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `conflict` with `details.reason` `external-schedule` for a schedule read from an integration, or `dynamic-schedule` for a `DYNAMIC` one.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## List study blocks

`GET https://workroad.app/api/v1/study-blocks`

Study time the user planned on their calendar, by start time, for a range of days in their time zone (today and the next 6 by default, at most 62 days).

- MCP tool: `listStudyBlocks` on `https://workroad.app/api/mcp`
- Required scopes: schedule:view
- Test mode: `GET https://workroad.app/api/test/v1/study-blocks` checks the request and returns the example response without changing anything.

### Query parameters

- `from`: string (date). First day (YYYY-MM-DD) in the user's time zone. Defaults to today. At most 62 days before `to`.
- `to`: string (date). Last day (YYYY-MM-DD), inclusive. Defaults to 6 days after `from`.

### Response

`200` OK

- `data`: array of objects, required
  - `id`: string (uuid), required
  - `title`: string, required
  - `startsAt`: string (date-time), required. Start, in UTC.
  - `endsAt`: string (date-time), required. End, in UTC.
  - `date`: string, required. The day (YYYY-MM-DD) in the user's time zone.
  - `timeZone`: string, required. The time zone the block was planned in.
  - `subjectId`: string or null, required. The subject studied, when set.
  - `taskId`: string or null, required. The task worked on, when set.
  - `setId`: string or null, required. The study set practised, when set.
  - `planned`: boolean, required
  - `activity`: one of `"FLASHCARDS"`, `"MEMORY"`, `"MATCH"`, `"GUESS_THE_TERM"`, `"SENTENCE_SCRAMBLE"` or null, required
  - `assessmentId`: string or null, required
  - `doneAt`: string (date-time) or null, required
  - `planKind`: one of `"due"`, `"new"`, `"refresh"` or null, required
  - `planCardIds`: array of string, required
  - `createdAt`: string (date-time), required. Creation timestamp.
  - `updatedAt`: string (date-time), required. Last update timestamp.

#### Example

```json
{
  "data": [
    {
      "id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
      "title": "Spanish verbs",
      "planned": false,
      "activity": null,
      "assessmentId": null,
      "doneAt": null,
      "planKind": null,
      "planCardIds": [],
      "startsAt": "2026-02-02T17:00:00.000Z",
      "endsAt": "2026-02-02T17:45:00.000Z",
      "date": "2026-02-02",
      "timeZone": "Europe/London",
      "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
      "taskId": null,
      "setId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b",
      "createdAt": "2026-01-15T09:30:00.000Z",
      "updatedAt": "2026-02-01T12:00:00.000Z"
    }
  ]
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_query`: `to` is before `from`, or the range is longer than 62 days.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No endpoint exists at this path.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Plan a study block

`POST https://workroad.app/api/v1/study-blocks`

Puts study time on the user's calendar, the way the planning copilot does: a title, a future start and end in the user's time zone, and optionally the subject, task or study set it is for. The block can't overlap a lesson (including external calendars, read live) or another block, and can't end after a linked task's due day. Find free time first with `getToday` or `getScheduleWeek`.

- MCP tool: `createStudyBlock` on `https://workroad.app/api/mcp`
- Required scopes: schedule:create
- Test mode: `POST https://workroad.app/api/test/v1/study-blocks` checks the request and returns the example response without changing anything.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Request body

JSON.

- `title`: string, required, 1-120 characters. What the user studies, 1-120 characters.
- `startsAt`: string (date-time), required. When the block starts, in the future. ISO timestamp with `Z` or an offset, such as `2026-10-21T16:00:00+01:00`.
- `endsAt`: string (date-time), required. When it ends: 5 minutes to 8 hours after `startsAt`, on the same day in the user's time zone. ISO timestamp with `Z` or an offset, such as `2026-10-21T16:00:00+01:00`.
- `subjectId`: string (uuid) or null. The subject studied. Null clears it.
- `taskId`: string (uuid) or null. The task this block works on. The block must end by the task's due day. Null clears it.
- `setId`: string (uuid) or null. The study set to practise in the block. Null clears it.

### Response

`201` Created

- `data`: object, required
  - `id`: string (uuid), required
  - `title`: string, required
  - `startsAt`: string (date-time), required. Start, in UTC.
  - `endsAt`: string (date-time), required. End, in UTC.
  - `date`: string, required. The day (YYYY-MM-DD) in the user's time zone.
  - `timeZone`: string, required. The time zone the block was planned in.
  - `subjectId`: string or null, required. The subject studied, when set.
  - `taskId`: string or null, required. The task worked on, when set.
  - `setId`: string or null, required. The study set practised, when set.
  - `planned`: boolean, required
  - `activity`: one of `"FLASHCARDS"`, `"MEMORY"`, `"MATCH"`, `"GUESS_THE_TERM"`, `"SENTENCE_SCRAMBLE"` or null, required
  - `assessmentId`: string or null, required
  - `doneAt`: string (date-time) or null, required
  - `planKind`: one of `"due"`, `"new"`, `"refresh"` or null, required
  - `planCardIds`: array of string, required
  - `createdAt`: string (date-time), required. Creation timestamp.
  - `updatedAt`: string (date-time), required. Last update timestamp.

#### Example

```json
{
  "data": {
    "id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
    "title": "Spanish verbs",
    "planned": false,
    "activity": null,
    "assessmentId": null,
    "doneAt": null,
    "planKind": null,
    "planCardIds": [],
    "startsAt": "2026-02-02T17:00:00.000Z",
    "endsAt": "2026-02-02T17:45:00.000Z",
    "date": "2026-02-02",
    "timeZone": "Europe/London",
    "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
    "taskId": null,
    "setId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b",
    "createdAt": "2026-01-15T09:30:00.000Z",
    "updatedAt": "2026-02-01T12:00:00.000Z"
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request` with `details.reason`: `planning-date-invalid` (in the past, shorter than 5 minutes, longer than 8 hours, or across midnight), `planning-clock-change`, or `planning-link-invalid` (a subject, task or set that isn't the user's).
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No endpoint exists at this path.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `conflict` with `details.reason`: `planning-conflict` (overlaps a lesson or another block; read `getToday` or `getScheduleWeek` for free times), `planning-after-deadline` (ends after the linked task's due day), `planning-calendar-incomplete` (an external calendar couldn't be read, or the block is beyond next week while one is connected). The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Update a study block

`PATCH https://workroad.app/api/v1/study-blocks/{studyBlockId}`

Moves, renames or relinks a planned block. Fields you leave out keep their value, and the whole block is checked again as if it were new, so a block already in the past can't be edited.

- MCP tool: `updateStudyBlock` on `https://workroad.app/api/mcp`
- Required scopes: schedule:update
- Test mode: `PATCH https://workroad.app/api/test/v1/study-blocks/{studyBlockId}` checks the request and returns the example response without changing anything.

### Path parameters

- `studyBlockId`: string (uuid), required. Study block id, scoped to the key's user.

### Request body

JSON.

- `title`: string, 1-120 characters. What the user studies, 1-120 characters.
- `startsAt`: string (date-time). New start, in the future. ISO timestamp with `Z` or an offset, such as `2026-10-21T16:00:00+01:00`.
- `endsAt`: string (date-time). New end. ISO timestamp with `Z` or an offset, such as `2026-10-21T16:00:00+01:00`.
- `subjectId`: string (uuid) or null. The subject studied. Null clears it.
- `taskId`: string (uuid) or null. The task this block works on. The block must end by the task's due day. Null clears it.
- `setId`: string (uuid) or null. The study set to practise in the block. Null clears it.

### Response

`200` OK

- `data`: object, required
  - `id`: string (uuid), required
  - `title`: string, required
  - `startsAt`: string (date-time), required. Start, in UTC.
  - `endsAt`: string (date-time), required. End, in UTC.
  - `date`: string, required. The day (YYYY-MM-DD) in the user's time zone.
  - `timeZone`: string, required. The time zone the block was planned in.
  - `subjectId`: string or null, required. The subject studied, when set.
  - `taskId`: string or null, required. The task worked on, when set.
  - `setId`: string or null, required. The study set practised, when set.
  - `planned`: boolean, required
  - `activity`: one of `"FLASHCARDS"`, `"MEMORY"`, `"MATCH"`, `"GUESS_THE_TERM"`, `"SENTENCE_SCRAMBLE"` or null, required
  - `assessmentId`: string or null, required
  - `doneAt`: string (date-time) or null, required
  - `planKind`: one of `"due"`, `"new"`, `"refresh"` or null, required
  - `planCardIds`: array of string, required
  - `createdAt`: string (date-time), required. Creation timestamp.
  - `updatedAt`: string (date-time), required. Last update timestamp.

#### Example

```json
{
  "data": {
    "id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
    "title": "Spanish verbs",
    "planned": false,
    "activity": null,
    "assessmentId": null,
    "doneAt": null,
    "planKind": null,
    "planCardIds": [],
    "startsAt": "2026-02-02T17:00:00.000Z",
    "endsAt": "2026-02-02T17:45:00.000Z",
    "date": "2026-02-02",
    "timeZone": "Europe/London",
    "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
    "taskId": null,
    "setId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b",
    "createdAt": "2026-01-15T09:30:00.000Z",
    "updatedAt": "2026-02-01T12:00:00.000Z"
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request` with `details.reason`: `planning-date-invalid` (in the past, shorter than 5 minutes, longer than 8 hours, or across midnight), `planning-clock-change`, or `planning-link-invalid` (a subject, task or set that isn't the user's).
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No study block with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `conflict` with `details.reason`: `planning-conflict` (overlaps a lesson or another block; read `getToday` or `getScheduleWeek` for free times), `planning-after-deadline` (ends after the linked task's due day), `planning-calendar-incomplete` (an external calendar couldn't be read, or the block is beyond next week while one is connected).
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Delete a study block

`DELETE https://workroad.app/api/v1/study-blocks/{studyBlockId}`

Removes a planned block from the user's calendar.

- MCP tool: `deleteStudyBlock` on `https://workroad.app/api/mcp`
- Required scopes: schedule:delete
- Test mode: `DELETE https://workroad.app/api/test/v1/study-blocks/{studyBlockId}` checks the request and returns the example response without changing anything.

### Path parameters

- `studyBlockId`: string (uuid), required. Study block id, scoped to the key's user.

### Response

`200` OK

- `data`: object, required
  - `id`: string (uuid), required
  - `deleted`: `true`, required

#### Example

```json
{
  "data": {
    "id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
    "deleted": true
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No study block with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Get today's study overview

`GET https://workroad.app/api/v1/today`

Answers "what should I study now?" in one call. Returns the flashcards due now by subject and set, today's goal, streak and new-card limit, overdue and upcoming tasks, today's lessons and planned study blocks, and `suggestions`: what to do next, most useful first, each naming the tool and arguments that act on it. Each section needs its own scope (`activity:view`, `tasks:view`, `schedule:view`) and is null without it. Lessons from external calendars are read live and can take a few seconds.

- MCP tool: `getToday` on `https://workroad.app/api/mcp`
- Required scopes: activity:view or tasks:view or schedule:view
- Test mode: `GET https://workroad.app/api/test/v1/today` checks the request and returns the example response without changing anything.
- Next: act on the first suggestion, for example `startPracticeSession` with its arguments.

### Response

`200` OK

- `data`: object, required
  - `date`: string, required. Today (YYYY-MM-DD) in the user's time zone.
  - `timeZone`: string, required. The user's time zone, or UTC when none is set.
  - `serverTime`: string (date-time), required. The server clock the view was computed at.
  - `study`: object or null, required. Flashcards, goal and streak. Null without `activity:view`.
    - `dueNow`: integer, required. Cards due now across every set.
    - `dueLaterToday`: integer, required. Cards that become due later in today's study day.
    - `newCards`: integer, required. Cards never studied.
    - `goal`: object, required
      - `kind`: one of `"cards"`, `"minutes"`, required. What the daily goal counts.
      - `target`: integer, required
      - `progress`: number, required. Cards answered or minutes studied.
      - `reached`: boolean, required
      - `streakDays`: integer, required. Days in a row the goal was reached.
      - `longestStreakDays`: integer, required
    - `limits`: object, required
      - `newRemaining`: integer, required. New cards the user may still start today.
      - `reviewsRemaining`: integer or null, required. Reviews left under the daily cap. Null when uncapped.
    - `studyDayEndsAt`: string (date-time), required. When today's study day ends (4am in the user's time zone) and the limits reset.
    - `subjects`: array of objects, required. Card counts per subject.
      - `subjectId`: string or null, required. Null for sets without a subject.
      - `name`: string, required
      - `dueNow`: integer, required
      - `overdue`: integer, required. Due before today's study day began.
      - `dueLaterToday`: integer, required
      - `newCards`: integer, required
    - `sets`: array of objects or null, required. The sets with the most cards due now, up to 10. Null without `sets:view`.
      - `setId`: string (uuid), required
      - `name`: string, required
      - `subjectId`: string or null, required. Subject id, when set.
      - `dueNow`: integer, required
  - `tasks`: object or null, required. Null without `tasks:view`.
    - `overdue`: array of objects, required. Unfinished tasks past due, oldest first, up to 5.
      - `id`: string (uuid), required
      - `name`: string, required
      - `type`: string, required
      - `subjectId`: string or null, required. Subject id, when set.
      - `subjectName`: string or null, required. The subject's name, when set.
      - `dueDate`: string (date-time) or null, required. Due date, when set.
      - `dueDay`: string or null, required. The due day (YYYY-MM-DD) in the user's time zone.
      - `exam`: boolean, required. True when the type or name reads like an exam, test or quiz.
    - `overdueTotal`: integer, required
    - `dueSoon`: array of objects, required. Unfinished tasks due today or in the next 6 days, up to 10.
      - `id`: string (uuid), required
      - `name`: string, required
      - `type`: string, required
      - `subjectId`: string or null, required. Subject id, when set.
      - `subjectName`: string or null, required. The subject's name, when set.
      - `dueDate`: string (date-time) or null, required. Due date, when set.
      - `dueDay`: string or null, required. The due day (YYYY-MM-DD) in the user's time zone.
      - `exam`: boolean, required. True when the type or name reads like an exam, test or quiz.
    - `dueSoonTotal`: integer, required
  - `lessons`: object or null, required. Today's lessons across active schedules. Null without `schedule:view`.
    - `occurrences`: array of objects, required
      - `scheduleId`: string (uuid), required
      - `date`: string, required. The day (YYYY-MM-DD).
      - `endDate`: string or null, required. The last day of an event that spans several days, or null.
      - `time`: one of the objects below, required. When the lesson runs, in the week's time zone.
        - One of `kind`: `"allDay"`
          - `kind`: `"allDay"`, required
        - Or `kind`: `"timed"`
          - `kind`: `"timed"`, required
          - `startMinute`: integer, required. Minutes after midnight.
          - `endMinute`: integer, required. Minutes after midnight.
        - Or `kind`: `"period"`
          - `kind`: `"period"`, required
          - `period`: integer, required. The period index. The period has no times.
          - `name`: string, required
      - `timeZone`: string or null, required. The event's own time zone, when an integration reports one.
      - `title`: string, required
      - `subtitle`: string or null, required. Room and teacher, when known.
    - `issues`: array of objects, required. External schedules that could not be read.
      - `scheduleId`: string (uuid), required
      - `issue`: one of `"unavailable"`, `"failed"`, `"timeout"`, required
  - `studyBlocks`: array of objects or null, required. Study blocks planned for today. Null without `schedule:view`.
    - `id`: string (uuid), required
    - `title`: string, required
    - `startsAt`: string (date-time), required. Start, in UTC.
    - `endsAt`: string (date-time), required. End, in UTC.
    - `date`: string, required. The day (YYYY-MM-DD) in the user's time zone.
    - `timeZone`: string, required. The time zone the block was planned in.
    - `subjectId`: string or null, required. The subject studied, when set.
    - `taskId`: string or null, required. The task worked on, when set.
    - `setId`: string or null, required. The study set practised, when set.
    - `planned`: boolean, required
    - `activity`: one of `"FLASHCARDS"`, `"MEMORY"`, `"MATCH"`, `"GUESS_THE_TERM"`, `"SENTENCE_SCRAMBLE"` or null, required
    - `assessmentId`: string or null, required
    - `doneAt`: string (date-time) or null, required
    - `planKind`: one of `"due"`, `"new"`, `"refresh"` or null, required
    - `planCardIds`: array of string, required
    - `createdAt`: string (date-time), required. Creation timestamp.
    - `updatedAt`: string (date-time), required. Last update timestamp.
  - `suggestions`: array of objects, required. What to do next, most useful first, built only from the sections this key may read.
    - `kind`: one of `"practice"`, `"learn"`, `"exam"`, `"overdue_task"`, `"lesson"`, `"plan"`, required. `practice`: flashcards are due. `learn`: nothing is due but new cards are left today. `exam`: an exam-like task is due within 7 days. `overdue_task`: an unfinished task is past due. `lesson`: the next lesson today. `plan`: nothing is planned today and tasks are due soon.
    - `message`: string, required. One sentence to show or say to the user.
    - `tool`: string or null, required. The operation (MCP tool) that acts on it, such as `startPracticeSession`. Only tools this key may call. Null when there is nothing to call.
    - `arguments`: any value, required. Arguments to pass to `tool`, as a JSON object. `{}` when it needs none.

#### Example

```json
{
  "data": {
    "date": "2026-02-02",
    "timeZone": "Europe/London",
    "serverTime": "2026-02-02T08:15:00.000Z",
    "study": {
      "dueNow": 12,
      "dueLaterToday": 3,
      "newCards": 40,
      "goal": {
        "kind": "cards",
        "target": 20,
        "progress": 4,
        "reached": false,
        "streakDays": 6,
        "longestStreakDays": 14
      },
      "limits": {
        "newRemaining": 46,
        "reviewsRemaining": null
      },
      "studyDayEndsAt": "2026-02-03T04:00:00.000Z",
      "subjects": [
        {
          "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
          "name": "Chemistry",
          "dueNow": 12,
          "overdue": 2,
          "dueLaterToday": 3,
          "newCards": 40
        }
      ],
      "sets": [
        {
          "setId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b",
          "name": "Spanish verbs",
          "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
          "dueNow": 12
        }
      ]
    },
    "tasks": {
      "overdue": [],
      "overdueTotal": 0,
      "dueSoon": [
        {
          "id": "4d3c2b1a-0f9e-4d8c-9b7a-6f5e4d3c2b1a",
          "name": "Finish lab report",
          "type": "Generic",
          "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
          "subjectName": "Chemistry",
          "dueDate": "2026-02-10T17:00:00.000Z",
          "dueDay": "2026-02-10",
          "exam": false
        }
      ],
      "dueSoonTotal": 1
    },
    "lessons": {
      "occurrences": [
        {
          "scheduleId": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
          "date": "2026-02-02",
          "endDate": null,
          "time": {
            "kind": "timed",
            "startMinute": 540,
            "endMinute": 600
          },
          "timeZone": null,
          "title": "Biology",
          "subtitle": "S2 · Dr Patel"
        }
      ],
      "issues": []
    },
    "studyBlocks": [],
    "suggestions": [
      {
        "kind": "practice",
        "message": "12 flashcards are due now, most of them in Chemistry.",
        "tool": "startPracticeSession",
        "arguments": {
          "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f"
        }
      },
      {
        "kind": "lesson",
        "message": "Next lesson: Biology at 09:00.",
        "tool": null,
        "arguments": {}
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No endpoint exists at this path.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## List calendar feeds

`GET https://workroad.app/api/v1/calendar-feeds`

Lists the user's calendar feed metadata, oldest first. Secret subscription URLs are not returned; each URL is shown only when it is issued.

A user can have at most 10 feeds. This list is not paginated. Create feeds or rotate their URLs in the app under Settings → Calendar.

- MCP tool: `listCalendarFeeds` on `https://workroad.app/api/mcp`
- Required scopes: settings:view and schedule:view
- Test mode: `GET https://workroad.app/api/test/v1/calendar-feeds` checks the request and returns the example response without changing anything.

### Response

`200` OK

- `data`: array of objects, required
  - `id`: string, required. The feed's id. Rotating a feed in the app gives it a new one.
  - `schedules`: array of objects or null, required. The schedules the feed covers, with null names for deleted ones. Null: every schedule kept in WorkRoad, plus tasks and exams.
    - `id`: string (uuid), required
    - `name`: string or null, required
  - `createdAt`: string (date-time), required. When the address was issued.
  - `lastUsedAt`: string (date-time) or null, required. When a calendar app last fetched it, or null.

#### Example

```json
{
  "data": [
    {
      "id": "kq0V3mJ8yQe7Lw2nR5tXcB1dF4gH6jK9",
      "schedules": [
        {
          "id": "8c2b5a1e-7d4f-4e3a-9b6c-1f0e2d3c4b5a",
          "name": "Year 12 timetable"
        }
      ],
      "createdAt": "2026-01-15T09:30:00.000Z",
      "lastUsedAt": "2026-02-01T12:00:00.000Z"
    }
  ]
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No endpoint exists at this path.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Delete a calendar feed

`DELETE https://workroad.app/api/v1/calendar-feeds/{feedId}`

The address stops working at once. Calendar apps subscribed to it keep the events they already have but get no updates.

- MCP tool: `deleteCalendarFeed` on `https://workroad.app/api/mcp`
- Required scopes: settings:update
- Test mode: `DELETE https://workroad.app/api/test/v1/calendar-feeds/{feedId}` checks the request and returns the example response without changing anything.

### Path parameters

- `feedId`: string, required, 1-128 characters. Calendar feed id, from the list.

### Response

`200` OK

- `data`: object, required
  - `id`: string, required. The feed's id. Rotating a feed in the app gives it a new one.
  - `deleted`: `true`, required

#### Example

```json
{
  "data": {
    "id": "kq0V3mJ8yQe7Lw2nR5tXcB1dF4gH6jK9",
    "deleted": true
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No calendar feed with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error
