Plan Your Work exposes projects and tasks through the Model Context Protocol (MCP).
The production endpoint is **https://planyour.work/mcp**.
It uses Streamable HTTP and OAuth authorization.

## Connect an agent

1. Add a remote MCP server in your agent's connection settings.
2. Enter `https://planyour.work/mcp` as the server URL.
3. If the agent asks for an authentication method, select OAuth sign-in.
4. Sign in to Plan Your Work in the browser.
5. Check the app name, callback host, and requested permissions.
6. Select **Allow access**.
7. Return to your agent.

Compatible clients discover the authorization endpoints and register themselves.
The agent stores the tokens and refreshes access automatically.
No Google client secret, Rails master key, or connector token is needed.

If your client requires a client ID, register it through `/oauth/register` as described below.
Use the exact callback URL supplied by that client.

## Manage access

Open **Settings → AI agents → Manage agent access**.
Select **Revoke access** beside an app to disconnect it.
Revocation invalidates that app's access tokens, refresh tokens, and unused authorization codes for your account.
The app must request approval again before it can reconnect.

Access covers the signed-in account across all areas. Selecting Work in the dashboard does not restrict an agent to Work.
Use `area_id` in list tools to narrow results.

| OAuth scope | Access |
| --- | --- |
| `mcp:read` | Read projects, tasks, notes, dates, recurrence metadata, source links, calendar events, and the app's own task annotations. |
| `mcp:write` | Create and change tasks, projects, and the app's own task annotations, in addition to `mcp:read`. |

The default request includes both scopes and displays both permissions on the consent screen.
A client can request only `mcp:read`. Such a connection lists only read tools and cannot call write tools.

## Discover the product guides

The same guides are available on the website and through MCP.
Call `get_documentation` without arguments to list guides.
Pass a `slug`, such as `tasks`, `calendars`, or `mcp`, to read one as Markdown.

Clients with resource support can call `resources/list` to discover every guide.
Use `resources/read` with a returned URI to read it.
Each URI also works as a public Markdown URL, such as `https://planyour.work/docs/tasks.md`.
Documentation is available with either read-only or read-and-write access.

```json
{"name":"get_documentation","arguments":{"slug":"tasks"}}
```

## Available tools

Run `tools/list` to obtain the complete JSON schemas, required arguments, descriptions, and tool annotations.
IDs are integer IDs from Plan Your Work, not IDs from Google or another source.

| Tool | Purpose | Required arguments |
| --- | --- | --- |
| `get_documentation` | List product guides or read a guide as Markdown. | None (`slug` selects a guide) |
| `list_areas` | List areas and show whether areas are enabled. | None |
| `list_projects` | Find projects and folders, including their area and source mappings. | None |
| `get_project` | Read one project and its source mappings. | `project_id` |
| `create_project` | Create a local project or folder. | `name` |
| `update_project` | Rename, organize, archive, reorder, or assign a project to an area. | `project_id` |
| `delete_project` | Delete a local project or folder without direct tasks. | `project_id` |
| `link_projects` | Combine two connected projects in Plan Your Work. | `from_mapping_id`, `to_mapping_id` |
| `list_tasks` | Search and filter tasks, including calendar events. | None |
| `get_task` | Read task details, notes, dates, recurrence metadata, source links, origin, and latest activity. | `task_id` |
| `create_task` | Create a task in a project or the inbox. | `name` |
| `update_task` | Edit task fields or clear optional values. | `task_id` |
| `complete_task` | Complete a task. | `task_id` |
| `reopen_task` | Reopen a completed task. | `task_id` |
| `delete_task` | Delete a task. | `task_id` |
| `move_task` | Move a task to the top or bottom of another list. | `task_id`, `project_id` |
| `reorder_tasks` | Order selected active tasks while preserving other tasks' positions. | `task_ids` |
| `get_task_annotations` | Read this app's annotation on a task. | `task_id` |
| `find_tasks_by_annotation` | Find tasks, including completed ones, through exact references in this app's annotations. | None |
| `add_task_annotation` | Add one session, external reference, related task, or artifact. | `task_id` and one entry |
| `update_task_annotation` | Set the summary, handoff, or metadata, or change an existing entry. | `task_id` and at least one change |
| `remove_task_annotation` | Remove one entry, clear one field, or delete this app's annotation. | `task_id` and one target |

### Search and pagination

`list_projects` accepts `query`, `area_id`, `include_archived`, `page`, and `per_page`.
`query` searches project names.

`list_tasks` accepts these arguments:

| Argument | Values and behavior |
| --- | --- |
| `query` | Search task titles and notes. |
| `project_id` | Limit results to one project. |
| `include_subprojects` | Include nested lists. Defaults to `true`. |
| `area_id` | Limit results to one area, including inherited project assignments. The Inbox belongs to no area, so its tasks are left out unless `view` is `inbox`. |
| `view` | `all`, `inbox`, `today`, `upcoming`, or `completed`. Defaults to `all`. |
| `timing` | `all`, `ready`, `later`, or `overdue`. Defaults to `all`. |
| `sort` | `due`, `manual`, `added`, `state`, or `list`. `state` and `list` return tasks in the order the workspace groups them. Like the workspace, defaults to `state` for a `source`, or a GitHub or Shortcut `project_id`, whose tasks have states; otherwise `due`. |
| `source` | Only tasks from one source, by slug, such as `github`, `shortcut`, or `internal`. |
| `state` | Only tasks in one state, by the name in a task's `state`, such as `Review requested`, `Approved`, `Dependency updates`, or a Shortcut workflow state. |
| `time_zone` | IANA display zone, such as `America/New_York`. Controls date filters for timed calendar events, and the working hours that pull request ages count. |
| `page` | Page number, starting at 1. |
| `per_page` | Results per page, from 1 to 100. Defaults to 50. |

`view: "all"` returns active tasks. Use `view: "completed"` to read completed tasks.
Today includes overdue tasks, consistent with the dashboard.
Today also includes pull requests that have waited a day on you, in **Review requested**, **Changes requested**, or **Approved**, unless **Show from** puts them off.
`sort: "state"` puts the states that need you first and the longest wait first within each. `sort: "list"` keeps each list together, with dependency updates last.

Each task includes `state` and `linked_tasks`, as the workspace shows them:

- `state` is where the task stands in its source, or null: `name`, `since`, `age` (such as `1d 4h`, counted in working time when the task's area has working hours), `working_time`, and `waiting_on_you`.
- `linked_tasks` lists tasks in another source that this one names: a pull request's Shortcut stories (with `task_id` when the story is imported, and its workflow `state`), or a story's pull requests.
- `missing_ticket` is true for your ready pull request that names no story, in a GitHub account a Shortcut connection expects stories from. Filter to these with `state: "No ticket"`.
Ready, Later, and Overdue use the same date rules as the dashboard.
Search matches literal text, including `%` and `_` characters.
Archived projects are omitted from task lists. Known task IDs remain readable with `get_task`.

List results include `total`, `page`, `per_page`, and `next_page`.
Continue with `next_page` until it is null.
Pages are live results. Changes between requests can move records between pages.

### Task fields

`create_task` and `update_task` accept `name`, `description`, `due_at`, `priority`, `show_on`, and `expires_at`.
`create_task` also accepts `project_id`. Omission selects the account's one Inbox.
`create_task` also accepts an optional self-reported `agent_display_name`; see [Task attribution](#task-attribution).
`create_task` accepts an optional `idempotency_key`, described in [Retry task creation safely](#retry-task-creation-safely).

Omitted update fields stay unchanged.
Send null to clear `description`, `due_at`, `priority`, `show_on`, or `expires_at`.
Task names cannot be empty or null.
Use `complete_task` and `reopen_task` for completion changes.

Send dates as `YYYY-MM-DD` and timestamps with an explicit UTC offset, such as `2026-10-01T12:00:00-04:00`.
`show_on` is the local attention date.
`expires_at` enables automatic completion after that timestamp.
It cannot precede `due_at`.

Task results include `editable_fields`, `source_data`, `completion_managed_by_source`, `deletable`, `calendar_event`, `all_day`, `event_time_zone`, recurrence metadata, and source links.
Calendar timestamps and their source time zones remain stored unchanged.
All-day events retain their calendar dates.
Agents can read recurrence metadata. This endpoint does not introduce a new recurrence editor.

### Retry task creation safely

`create_task` accepts an optional `idempotency_key`.
Send a key when a timeout or lost response could make your client retry the call.
A retry with the same key and the same request returns the original result and creates nothing.
Without a key, every call creates a new task and the result has no `replayed` field.

A key is a string of 1 to 255 characters with at least one non-whitespace character.
Keys are compared exactly. Letter case and surrounding spaces are part of the key, so `Retry-1`, `retry-1`, and ` retry-1` are three keys.
Generate one key per intended task, such as a UUID, and reuse it only for retries of that task.

A key belongs to your account and to the connected app that sent it.
Another account or another connected app can use the same key for its own task.
Token refresh, a replacement access token, or approving the same app again after revocation keeps the app's keys.
Registering a new client through `/oauth/register` creates a new connected app with no keys.
A revoked, expired, or read-only token cannot create tasks, so it cannot read a stored result either.

A successful key stays reserved for the life of the account.
Keys never expire and cannot be released or reused for a different task.
Deleting the account removes its keys.
If creation fails, for example because of an invalid date or an unknown `project_id`, nothing is stored and the key stays free.
Correct the request and send it again with the same key.

The first successful call returns the task with `"replayed": false`.
Each matching retry returns the same stored result with `"replayed": true`.
The stored result is a historical receipt of the creation.
It keeps the original task ID, details, `origin`, and `latest_activity` after the task is edited, moved, completed, or deleted, and after its original project is deleted.
Call `get_task` with the returned ID to read the current task. A `not_found` error means the task was deleted.

A retry matches when it sends the same creation request:

- `name`, `agent_display_name`, and other text fields must match exactly, including case and whitespace.
- Argument order does not matter.
- Timestamps match when they name the same instant, so `2026-10-08T17:00:00-04:00` matches `2026-10-08T21:00:00Z`.
- An omitted optional field matches the same field sent as null.
- An omitted `project_id` means the Inbox at creation time. It does not match a request that sends the Inbox's `project_id`.
- `idempotency_key` itself and transport details, such as the MCP session, JSON-RPC request ID, and access token, are not compared.

A different request with a used key returns an `idempotency_conflict` error.
This includes a retry whose dates or timestamps are malformed, because a used key is compared before the request is validated.
An unused key with malformed dates returns the usual `invalid_operation` error and stays free.
The error contains no earlier task details, and no task changes.
Use a new key for a new task.

An idempotency key is separate from conversation, session, and workflow identifiers.
It only deduplicates `create_task` calls. Updates, moves, deletions, and project changes do not accept a key.
Plan Your Work does not deduplicate tasks by conversation or workflow ID.

Create a task with a key:

```json
{"name":"create_task","arguments":{"name":"Send the invoice","due_at":"2026-10-08T17:00:00-04:00","idempotency_key":"invoice-7f3c2a"}}
```

The first result, with the task shortened to a few fields:

```json
{"task":{"id":123,"name":"Send the invoice","project_id":7,"due_at":"2026-10-08T21:00:00Z","added_at":"2026-10-06T14:30:00Z","updated_at":"2026-10-06T14:30:00.000000Z"},"replayed":false}
```

The same call sent again returns the stored result:

```json
{"task":{"id":123,"name":"Send the invoice","project_id":7,"due_at":"2026-10-08T21:00:00Z","added_at":"2026-10-06T14:30:00Z","updated_at":"2026-10-06T14:30:00.000000Z"},"replayed":true}
```

A different request with the same key:

```json
{"name":"create_task","arguments":{"name":"Send the receipt","idempotency_key":"invoice-7f3c2a"}}
```

It returns an error result with `isError` set to true:

```json
{"error":"idempotency_conflict","message":"This idempotency key was already used for a different task."}
```

### Task attribution

Every task result includes `origin` and `latest_activity`.
`list_tasks`, `get_task`, and each tool that returns a task include both objects.
Attribution comes from the OAuth connection. Tool arguments and `clientInfo` cannot set it.

`origin` describes how the task was created and never changes afterwards.

| Key | Value |
| --- | --- |
| `channel` | `direct`, `mcp`, `import`, `system`, or `unknown` |
| `created_at` | The task's creation time in Plan Your Work, as an ISO 8601 UTC timestamp with microseconds |
| `account_id`, `account_label` | The account that owns the task, and its name when the task was created |
| `actor_id`, `actor_label` | The signed-in person for `direct` creation; otherwise null |
| `application_id`, `application_label` | The connected app's OAuth application ID and registered client name for `mcp` creation; otherwise null |
| `source`, `source_label` | The trusted connector for `import` creation; otherwise null |
| `agent_display_name` | The optional self-reported agent name supplied to `create_task`; otherwise null |

`channel: "unknown"` means the creator was never recorded, as for tasks created before attribution existed.
Its `created_at` is still the known creation time, and every other key is null.
Current source links never fill in an unknown origin.

`latest_activity` is the newest change after creation, or null when the task has not changed since.
It has the same identity keys as `origin` except `agent_display_name`, plus `action` and `created_at`.
`action` is `edited`, `completed`, `reopened`, or `moved`.
`created_at` is the server time when the change was recorded, which can differ from the task's `completed_at`.
A `channel: "unknown"` activity ran without attribution, so its actor and app are null.
Annotation changes, reordering, and sync bookkeeping do not create activity.

`application_id` is the stable identity of a connection. Two apps can register the same name, so compare IDs, not labels.
`application_label` is the client name the app chose when it registered, not a verified brand.
Labels are snapshots: renaming or disconnecting an app does not rewrite recorded attribution.

`create_task` accepts an optional `agent_display_name` of 1 to 100 characters.
It is stored only as self-reported detail on the origin and never replaces the app identity.
Other tools reject it.

```json
{"name":"create_task","arguments":{"name":"Draft agenda","agent_display_name":"Release helper"}}
```

```json
{"origin":{"channel":"mcp","created_at":"2026-10-06T14:20:00.123456Z","account_id":7,"account_label":"Nate","actor_id":null,"actor_label":null,"application_id":12,"application_label":"Example Agent","source":null,"source_label":null,"agent_display_name":"Release helper"},"latest_activity":null}
```

### Lists, areas, and linking

The app calls task containers **lists**. MCP tool names and fields still use `project` for compatibility.

Project fields include `name`, `parent_id`, `folder`, `color`, `favorite`, `archived`, `area_id`, and `area_container`.
`update_project` also accepts a one-based `position` among siblings.
Send null for `parent_id` to move a project to the top level.
Send null for `area_id` to resume area inheritance.
Area assignments require areas to be enabled in Settings.

`delete_project` accepts local projects and folders without direct tasks.
Nested projects and their tasks move up one level and keep their areas.
It rejects the Inbox, connected projects, and projects with direct tasks, including completed tasks.
Use `update_project` with `archived: true` to hide another project locally.
Project changes follow existing import rules. A source resync can overwrite metadata that the connector controls.

For linking, read each project's `sources` with `get_project`.
Pass the two source `mapping_id` values to `link_projects`.
The first project's name and ID remain. The second project's tasks join it locally.
The tool rejects folders, nested pairs, and projects that already share a source.

## Task annotations

Task annotations let an agent keep its own context on a task across conversations.
Use them for session references, handoffs, and links to related work.
They stay separate from the task's notes, fields, and source data, and they never change the task.

### Whose annotations you see

Each connected app has its own annotation namespace on every task.
The namespace comes from the OAuth app that issued the access token.
Tool arguments, `clientInfo`, app display names, and transport session headers never select it.
The tools reject `account_id` and any other argument that their schema does not list.
Only `find_tasks_by_annotation` accepts `application_id`, and only as a check that it equals the connected app's ID; see [Find tasks by annotation](#find-tasks-by-annotation).
The other annotation tools reject it.

Every conversation and token of the same app reads the same namespace, so a later session can resume an earlier handoff.
Two apps never see each other's annotations, even when they use the same `conversation_id`.
An app that registers again through `/oauth/register` receives a new client ID and starts with an empty namespace.

The task owner can read every app's annotations in the task details on the dashboard.
Do not store secrets or credentials in annotations.

Revoking an app's access keeps its annotations on the task. Deleting a task deletes its annotations.
Moving a task keeps its annotations.

### Annotation content

| Field | Shape |
| --- | --- |
| `summary` | Context summary string. |
| `handoff` | Object with `progress`, `next_action`, and `blockers` strings. |
| `sessions` | Entries keyed by `conversation_id`, with optional `url` and `message_references`. |
| `external_refs` | Entries keyed by `reference_id`, with `kind` (`task` or `workflow`), `external_id`, and optional `label` and `url`. |
| `related_tasks` | Entries keyed by `task_id`, a task in your account. |
| `artifacts` | Entries keyed by `artifact_id`, with `url` and an optional `label`. |
| `metadata` | Any JSON object for app-specific values. |

Keys are case-sensitive and must not be blank.
`related_tasks` returns each `task_id` as a string.
A related task must belong to your account when you add it. A reference to a task deleted later stays readable and removable.

Write summaries and handoff fields in concise Markdown. Use short headings, bullet lists for findings or alternatives, and descriptive web links. The task view renders headings, lists, emphasis, code, and tables. Existing plain text remains readable. HTML and unsafe link targets are filtered; remote images are not loaded.

For example, a summary can use `## Findings`, a short bulleted list, and `## Next action` instead of one long paragraph. Keep the existing character limits in mind.

### Limits

| Content | Limit |
| --- | --- |
| `summary` and each handoff field | 2,000 characters |
| Identifiers, labels, and message references | 256 characters |
| URLs | 2,048 characters |
| Entries in each collection | 50 |
| Message references in one session | 20 |
| `metadata` | 16,384 bytes of compact UTF-8 JSON, nested at most five objects or arrays deep, counting `metadata` itself |
| Whole annotation | 65,536 bytes of compact UTF-8 JSON |

Values must be JSON strings, finite numbers, booleans, null, arrays, or objects.
When a collection is full, remove obsolete entries before adding new ones.

### Reading annotations

`get_task_annotations` returns this app's annotation, its `updated_at` timestamp, the task ID, and the task's `revision`.
When this app has no annotation on the task, `annotation` is `{}` and `updated_at` is null.
Reading never creates storage.

```json
{"name":"get_task_annotations","arguments":{"task_id":123}}
```

```json
{"task_id":123,"annotation":{"summary":"Release prep","handoff":{"next_action":"Tag v2"},"sessions":[{"conversation_id":"conv-42"}]},"updated_at":"2026-10-06T14:20:00.123456Z","revision":"MTIzOjQ1"}
```

### Changing annotations

All three write tools need `mcp:write`. They return the same shape as `get_task_annotations`, plus `changed`.
`changed` is false when the request matched what was already stored. A request that changes nothing leaves `updated_at` unchanged.

`add_task_annotation` adds one entry. Supply exactly one of `session`, `external_ref`, `related_task`, or `artifact`.
Existing entries stay.
Adding an identical entry again changes nothing.
Adding different content under an existing key fails; change that entry with `update_task_annotation`.

`update_task_annotation` changes content in one request:

- `summary` replaces the summary. Null clears it.
- `handoff` patches handoff fields. Omitted fields stay. Null on one field clears that field, and null for `handoff` clears all of it.
- `metadata` replaces the stored object whole. Null clears it.
- `session`, `external_ref`, or `artifact` patches one existing entry selected by its key field. Omitted fields stay, a supplied `message_references` array replaces the stored one, and null clears an optional field.

The first update creates the namespace.
Updating an entry that does not exist fails without creating it; add it first.
Key fields cannot change. Related tasks have no other fields, so remove and add them instead.

`remove_task_annotation` takes exactly one target:

- `entry` with `collection` and `key` removes one entry.
- `field` clears `summary`, `handoff`, `metadata`, or one handoff field such as `handoff.next_action`.
- `namespace: true` deletes this app's whole annotation on the task.

Removing something that is not there succeeds with `changed: false`.

### Annotation errors

Annotation errors use the same `isError` tool results as other tools, and nothing is stored when a request fails.

| `error` | Meaning |
| --- | --- |
| `not_found` | The task does not exist in your account. |
| `invalid_operation` | The request conflicts with stored entries, targets a missing entry, or supplies the wrong number of targets. For `find_tasks_by_annotation`, an argument breaks a query rule or `application_id` is not the connected app's ID. The message says what to change. |
| `response_too_large` | A `find_tasks_by_annotation` page exceeds the result limit. Retry with a smaller `per_page`. |
| `revision_conflict` | The task changed after you read it. See [Conditional writes](#conditional-writes). |
| `validation_failed` | Content breaks the schema or a limit. The message names the field and the limit, such as `metadata must be at most 16,384 bytes of compact UTF-8 JSON (got 16,500).` |

Arguments that break the published JSON schema fail before the tool runs with an `Invalid arguments` message that names the argument path and limit.
This covers unknown arguments, missing targets, and strings over their character limit.

### Example: resume a handoff in a later conversation

Create the task:

```json
{"name":"create_task","arguments":{"project_id":42,"name":"Ship the release"}}
```

Record the current conversation and a workflow reference:

```json
{"name":"add_task_annotation","arguments":{"task_id":123,"session":{"conversation_id":"conv-42","url":"https://agent.example/c/conv-42","message_references":["msg-7"]}}}
```

```json
{"name":"add_task_annotation","arguments":{"task_id":123,"external_ref":{"reference_id":"release-run","kind":"workflow","external_id":"run-981","label":"Release pipeline"}}}
```

Leave a summary, a handoff, and app-specific metadata:

```json
{"name":"update_task_annotation","arguments":{"task_id":123,"summary":"Preparing the v2 release.","handoff":{"progress":"Changelog drafted.","next_action":"Tag v2 after CI passes.","blockers":"CI is red on main."},"metadata":{"agent":{"plan_step":3}}}}
```

In a later conversation, read the handoff:

```json
{"name":"get_task_annotations","arguments":{"task_id":123}}
```

Record the new conversation, then hand off again. The omitted `progress` stays and null clears `blockers`:

```json
{"name":"add_task_annotation","arguments":{"task_id":123,"session":{"conversation_id":"conv-57"}}}
```

```json
{"name":"update_task_annotation","arguments":{"task_id":123,"handoff":{"next_action":"Publish release notes.","blockers":null},"session":{"conversation_id":"conv-42","message_references":["msg-7","msg-9"]}}}
```

Remove an obsolete entry, then delete the whole annotation when the work is done:

```json
{"name":"remove_task_annotation","arguments":{"task_id":123,"entry":{"collection":"external_refs","key":"release-run"}}}
```

```json
{"name":"remove_task_annotation","arguments":{"task_id":123,"namespace":true}}
```

### Find tasks by annotation

`find_tasks_by_annotation` finds tasks through exact references in this app's annotations.
Use it to recover an earlier conversation's tasks, resume a workflow, or check whether a task for an external item already exists.
It needs only `mcp:read`, so read-only connections can call it. It never changes a task or an annotation.

Recall searches only the connected app's namespace, the same one that `get_task_annotations` reads.
It covers only your account, so tasks of other accounts never appear in `tasks` or count toward `total`.
Every result returns the searched namespace as `application_id`.
The optional `application_id` argument is a check, not a selector: omit it, or send the connected app's ID.
Any other value fails with the same `invalid_operation` error, whether or not that app exists or has matching annotations.
`clientInfo`, app display names, and transport session IDs never select a namespace, and an MCP session ID is not a `conversation_id`.

| Argument | Values and behavior |
| --- | --- |
| `conversation_id` | Matches a task when any of its `sessions` entries has this `conversation_id`. |
| `external_ref` | Object with exactly `kind` (`task` or `workflow`) and `external_id`. One `external_refs` entry must match both. |
| `metadata` | Object with 1 to 5 top-level keys. Each stored key must hold exactly the supplied value. See [Metadata filters](#metadata-filters). |
| `completion` | `all`, `active`, or `completed`. Defaults to `all`, which includes completed tasks. |
| `application_id` | The connected app's OAuth application ID. Optional. |
| `page` | Page number, starting at 1. Defaults to 1. |
| `per_page` | Results per page, from 1 to 10. Defaults to 5. |

Supplied filters combine with AND, so a task must match all of them.
With no `conversation_id`, `external_ref`, or `metadata`, the tool returns every task that has an annotation from this app.
`conversation_id` and `external_id` are nonblank strings of at most 256 characters.
Matching is exact and case-sensitive, without trimming, prefixes, or wildcards.
Recall does not search task names, notes, summaries, handoffs, `reference_id`, or `message_references`. Use `list_tasks` to search names and notes.
Tasks in archived projects are included, unlike in `list_tasks`.

Arguments outside these rules fail before the tool runs with `Invalid arguments`, or with `invalid_operation` and a message that names the rule.
This includes unknown arguments, null values, and strings over their limits.

#### Metadata filters

`metadata` filters match top-level keys of the stored `metadata` object.
Keys are literal, nonblank strings of at most 64 characters. A `.`, quote, or `%` in a key is part of its name, not a path or a wildcard.
Each value is a string of at most 256 characters, `true`, or `false`.
A value matches only the identical JSON value, so `"true"` does not match `true`.
A key missing from the stored metadata never matches.
An empty `metadata` object, nested objects, arrays, numbers, and null are not supported filter values.

Stored metadata can still hold nested objects and any JSON type, as described in [Annotation content](#annotation-content).
Those values come back in each result's `annotation`, but this query subset cannot match them.
To find tasks by such a value, also store it under a top-level key as a string or boolean.

#### Results

| Key | Value |
| --- | --- |
| `tasks` | Matching tasks on this page, in ascending task ID order |
| `application_id` | The connected app whose namespace was searched |
| `total` | Matching tasks across all pages |
| `page`, `per_page` | The returned page and its size |
| `next_page` | The next page number, or null on the last page |

Each task has these keys:

| Key | Value |
| --- | --- |
| `id`, `project_id` | The task and its current list |
| `completed`, `completed_at` | The current completion state. `completed_at` is null for an active task |
| `external_state_id` | The connected source's current workflow state, or null |
| `origin` | How the task was created, as described in [Task attribution](#task-attribution) |
| `annotation` | This app's whole annotation on the task |
| `annotation_updated_at` | When this app's annotation last changed |

Results leave out task names, notes, `source_data`, and other apps' annotations. Call `get_task` with a returned ID to read the task.
A page after the last one returns an empty `tasks` array with the same `total`.

Ordering by task ID makes each page repeatable while the matching tasks and annotations stay unchanged.
Pages are live results, so a task or annotation change between requests can move, add, or remove matches.

A whole tool result, including its text content and `structuredContent`, is limited to 4,194,304 bytes of UTF-8 JSON.
Annotations are never truncated and matching tasks are never skipped.
A page over the limit returns this error with `isError` set to true and no tasks:

```json
{"error":"response_too_large","message":"This page exceeds the 4,194,304-byte result limit. Retry with a smaller per_page."}
```

Retry with a smaller `per_page`. Page numbers depend on `per_page`, so continue with the `next_page` values of the new results.

#### Existence checks do not prevent duplicates

A recall result shows which tasks matched when the query ran. It does not reserve a reference or block a later creation.
Two conversations that check at the same time can both find nothing and both create a task.
Send an `idempotency_key` to make retries of one `create_task` call safe, as described in [Retry task creation safely](#retry-task-creation-safely).
An idempotency key does not deduplicate by conversation, workflow, or external reference either.

### Example: recall a conversation, resume a workflow, and check for an external task

Earlier conversations of this app left two annotated tasks:

- Task 123, **Ship the release**, is completed, and its list is archived. Its annotation records conversations `conv-42` and `conv-57` and workflow `run-981`.
- Task 124, **Publish release notes**, is active. Its annotation records conversation `conv-57`, workflow `run-981`, tracker task `LIN-311`, and a handoff.

Find the tasks of conversation `conv-42`. The completed task in the archived list is included:

```json
{"name":"find_tasks_by_annotation","arguments":{"conversation_id":"conv-42"}}
```

```json
{"tasks":[{"id":123,"project_id":41,"completed":true,"completed_at":"2026-10-07T16:05:00Z","external_state_id":null,"origin":{"channel":"mcp","created_at":"2026-10-06T14:20:00.123456Z","account_id":7,"account_label":"Nate","actor_id":null,"actor_label":null,"application_id":12,"application_label":"Example Agent","source":null,"source_label":null,"agent_display_name":null},"annotation":{"summary":"Shipped v2.","sessions":[{"conversation_id":"conv-42","url":"https://agent.example/c/conv-42"},{"conversation_id":"conv-57"}],"external_refs":[{"reference_id":"release-run","kind":"workflow","external_id":"run-981","label":"Release pipeline"}],"metadata":{"release":"v2","customer_visible":true}},"annotation_updated_at":"2026-10-07T16:06:00.000000Z"}],"application_id":12,"total":1,"page":1,"per_page":5,"next_page":null}
```

Conversation `conv-57` worked on both tasks, so it recovers both. Task 123 matches through either conversation:

```json
{"name":"find_tasks_by_annotation","arguments":{"conversation_id":"conv-57"}}
```

Resume workflow `run-981` at its open task, and continue from the handoff in the returned annotation:

```json
{"name":"find_tasks_by_annotation","arguments":{"external_ref":{"kind":"workflow","external_id":"run-981"},"completion":"active"}}
```

```json
{"tasks":[{"id":124,"project_id":42,"completed":false,"completed_at":null,"external_state_id":null,"origin":{"channel":"mcp","created_at":"2026-10-07T16:10:00.123456Z","account_id":7,"account_label":"Nate","actor_id":null,"actor_label":null,"application_id":12,"application_label":"Example Agent","source":null,"source_label":null,"agent_display_name":null},"annotation":{"summary":"Release notes for v2.","handoff":{"progress":"Draft written.","next_action":"Publish release notes."},"sessions":[{"conversation_id":"conv-57"}],"external_refs":[{"reference_id":"release-run","kind":"workflow","external_id":"run-981"},{"reference_id":"tracker","kind":"task","external_id":"LIN-311","url":"https://tracker.example/LIN-311"}],"metadata":{"release":"v2","customer_visible":false}},"annotation_updated_at":"2026-10-07T16:12:00.000000Z"}],"application_id":12,"total":1,"page":1,"per_page":5,"next_page":null}
```

Before you create a task for tracker task `LIN-311`, check whether one already references it. Task 124 does, so update it instead:

```json
{"name":"find_tasks_by_annotation","arguments":{"external_ref":{"kind":"task","external_id":"LIN-311"}}}
```

No task references `LIN-312`, so the result is empty and you can create one:

```json
{"name":"find_tasks_by_annotation","arguments":{"external_ref":{"kind":"task","external_id":"LIN-312"}}}
```

```json
{"tasks":[],"application_id":12,"total":0,"page":1,"per_page":5,"next_page":null}
```

Combine a string and a boolean metadata filter. Only task 123 has both values:

```json
{"name":"find_tasks_by_annotation","arguments":{"metadata":{"release":"v2","customer_visible":true}}}
```

Confirm the namespace and read only completed work. Use the `application_id` from an earlier result:

```json
{"name":"find_tasks_by_annotation","arguments":{"application_id":12,"conversation_id":"conv-57","completion":"completed"}}
```

## Conditional writes

Every task has a `revision`. It changes whenever the task's content changes, from any writer.
Send the revision you read as `expected_revision`, and the write happens only if nothing changed since that read.
Otherwise nothing changes and you receive `revision_conflict`.

### Where revisions appear

`get_task`, each `list_tasks` entry, and `get_task_annotations` return `revision`.
Each result returns the revision of the content in that same result.
Two separate reads can return different revisions when the task changed between them. Reread both before you combine them.
List pages are live, so tasks on different pages can come from different moments.

Successful writes return the task's new revision: `update_task`, `complete_task`, `reopen_task`, and `move_task` in `task.revision`, and the annotation tools in `revision`.
A write that changes nothing, such as an identical annotation entry, returns the current revision unchanged.
`delete_task` returns only `deleted_task_id`, because the task no longer exists. Later requests for that task return `not_found`.

The revision is an opaque string. Compare it only for equality, and do not parse it or build one yourself.
It names its task, so one task's revision never works for another task.
A revision is never reused: a change followed by a change back still produces a new one.
Read-only connections can read revisions.

### What changes the revision

The revision covers the task as a whole:

- The task's `name`, `description`, `due_at`, `expires_at`, `show_on`, `priority`, completion, recurrence, calendar data, `source_data`, and `external_state_id`.
- Its list, parent task, and position in the list, including changes from `reorder_tasks` and project linking.
- The annotations of every connected app on the task, not only yours.

Changes from any source count: other MCP clients, the dashboard, connected sources during sync, automatic completion at `expires_at`, and annotation changes.
Another app's annotation change makes your revision stale even though you cannot read that annotation. Reread and send your change again.

These leave the revision unchanged: timestamps such as `updated_at`, the task's `origin` and activity history, sync bookkeeping, and the names and source details of the task's list.

### Which tools accept expected_revision

| Tool | Revision check |
| --- | --- |
| `update_task`, `complete_task`, `reopen_task`, `delete_task`, `move_task` | Optional `expected_revision` |
| `add_task_annotation`, `update_task_annotation`, `remove_task_annotation` | Optional `expected_revision` |
| `create_task`, `reorder_tasks`, and the project tools | Not accepted; sending it fails |

Omitting `expected_revision` keeps the earlier behavior: the write applies to whatever the task holds now.
Such a write is not protected against changes made after your read and can overwrite them.
Send `expected_revision` whenever your change depends on what you read.

A supplied value is always checked and never ignored:

- Null, an empty string, a non-string, or a string with characters other than letters, digits, `-`, and `_` fails the published schema with `Invalid arguments`.
- A value of the right characters that is not a revision fails with `invalid_operation`.
- A revision of another task fails with `revision_conflict`.

The format of a supplied value is checked before the task is looked up.
A malformed value therefore fails with `Invalid arguments` or `invalid_operation` even when the task is outside your account, and the error never reveals whether that task exists.
With a well-formed revision or none, a task outside your account returns `not_found`, and the revision is never compared.
A read-only connection cannot write, and the rules for each field, such as calendar events only allowing `show_on`, still apply.

### Conflicts

A stale revision returns an `isError` result. The text content holds the same JSON as `structuredContent`:

```json
{"error":"revision_conflict","task_id":123,"current_revision":"MTIzOjQ2","message":"This task changed after you read it. Reread it with get_task and get_task_annotations, decide how your change applies to the current state, and send the revised change with the new revision. Do not resend the stale request unchanged."}
```

`current_revision` is the task's revision now. A conflict never includes task content or any app's annotations.
Nothing from the rejected request is stored: no task change, annotation change, activity, or outbound sync change.

Do not send the stale request again with `current_revision` copied into it, because that overwrites the change you have not seen.
Reread with `get_task`, and with `get_task_annotations` when your change concerns annotations.
Decide how your change applies to the current task, then send that revised change with the revision from the reread.
Sending the old revision again keeps failing.

### Example: reconcile an edit after a dashboard change

Read the task:

```json
{"name":"get_task","arguments":{"task_id":123}}
```

```json
{"task":{"id":123,"name":"Prepare the agenda","description":"Draft","revision":"MTIzOjQ1"}}
```

The task owner then changes the notes to `Final draft` in the dashboard. The agent's conditional update, based on its earlier read, returns a conflict:

```json
{"name":"update_task","arguments":{"task_id":123,"description":"Draft\n\nAdd the release checklist.","expected_revision":"MTIzOjQ1"}}
```

```json
{"error":"revision_conflict","task_id":123,"current_revision":"MTIzOjQ2","message":"This task changed after you read it. Reread it with get_task and get_task_annotations, decide how your change applies to the current state, and send the revised change with the new revision. Do not resend the stale request unchanged."}
```

Reread the task:

```json
{"name":"get_task","arguments":{"task_id":123}}
```

```json
{"task":{"id":123,"name":"Prepare the agenda","description":"Final draft","revision":"MTIzOjQ2"}}
```

Keep the owner's notes, add the checklist line to them, and send the revised change with the new revision:

```json
{"name":"update_task","arguments":{"task_id":123,"description":"Final draft\n\nAdd the release checklist.","expected_revision":"MTIzOjQ2"}}
```

```json
{"task":{"id":123,"name":"Prepare the agenda","description":"Final draft\n\nAdd the release checklist.","revision":"MTIzOjQ3"}}
```

The task examples above show only some of the task fields.

## Connector behavior

Agent operations use the app's existing task services and callbacks.
Edits, completions, moves, and deletions retain the same supported outbound sync behavior as dashboard actions.
Unsupported outbound changes remain local.

Linking projects never copies tasks between external sources.
Each imported task retains its source identity.
New imports continue to enter the linked project.
New local tasks in a linked project remain local.
Creating a task in an unlinked source project follows that connector's existing creation behavior.

Google Calendar remains an import-only source.
Agents can read events and change their local `show_on` date.
Agents cannot rename, move, complete, reopen, or delete calendar events.
GitHub issue completion and supported edits sync back. PR completion and all GitHub deletions remain in GitHub.
GitHub dates, priorities, and list placement stay local. See [GitHub](github.md) for the complete sync behavior.

Shortcut stories support title, notes, deadline, and workflow state updates.
Use `external_state_id` from `source_data.states` with `update_task`.
Completion selects a Done state. Story creation and deletion remain in Shortcut.
See [Shortcut](shortcut.md) for connection and workflow details.
Events complete automatically at their imported end time.

Annotation changes stay in Plan Your Work. They never sync to a source or change the task.

Connector credentials and OAuth secrets are never included in tool results.

## Examples

These examples show the `params` object for an MCP `tools/call` request.
Replace example IDs with IDs returned by the list tools.

Find future work in an area:

```json
{"name":"list_tasks","arguments":{"area_id":2,"timing":"later","sort":"due","time_zone":"America/New_York"}}
```

Create a task:

```json
{"name":"create_task","arguments":{"project_id":42,"name":"Prepare the meeting agenda","due_at":"2026-10-01T12:00:00-04:00","description":"Include the release checklist."}}
```

Clear a due date:

```json
{"name":"update_task","arguments":{"task_id":123,"due_at":null}}
```

Read-only request with an OAuth access token:

```sh
curl https://planyour.work/mcp \
  -H "Authorization: Bearer $MCP_ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-11-25' \
  --data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_projects","arguments":{"per_page":20}}}'
```

`MCP_ACCESS_TOKEN` above is a client-side token from OAuth. It is not a deployment setting.
Clients normally obtain and refresh it without manual commands.

## OAuth protocol details

| Endpoint | Purpose |
| --- | --- |
| `/.well-known/oauth-protected-resource/mcp` | MCP resource metadata. Also available without the `/mcp` suffix. |
| `/.well-known/oauth-authorization-server` | Authorization server metadata. |
| `POST /oauth/register` | Register a public client. |
| `GET /oauth/authorize` | Sign in and review consent. |
| `POST /oauth/authorize` | Approve access with browser CSRF protection. |
| `DELETE /oauth/authorize` | Deny access with browser CSRF protection. |
| `POST /oauth/token` | Exchange an authorization code or refresh token. |
| `POST /oauth/revoke` | Revoke an access or refresh token for the requesting client. |

Register a public client:

```sh
curl https://planyour.work/oauth/register \
  -H 'Content-Type: application/json' \
  --data '{"client_name":"My agent","redirect_uris":["http://127.0.0.1:8765/callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none"}'
```

Registration returns a `client_id`, not permission to read account data.
Redirect URLs must use HTTPS, except HTTP loopback URLs for native clients.
Redirect paths and queries must match registration. Native IP loopback ports can vary during authorization.

Authorization uses the authorization code flow with mandatory S256 PKCE.
Implicit, password, and client-credentials grants are disabled.
Send `resource=https://planyour.work/mcp` during authorization and token exchange.
For compatibility, omission selects this sole resource. The server rejects any different resource.
Tokens remain bound to this resource through refresh.

Authorization codes expire after five minutes and work once.
Access tokens expire after one hour.
Refresh exchanges rotate and immediately revoke the old token.
A refresh token expires after 90 days without refresh. After that period, reconnect through the browser.
The database stores hashes of authorization codes, access tokens, and refresh tokens.

Requests to `/mcp` require the `Authorization: Bearer` header.
Browser sessions and query-string tokens do not authenticate MCP requests.
A 401 response includes resource discovery in `WWW-Authenticate`.
A valid token without `mcp:read` receives 403 with an insufficient-scope challenge.
Tool errors use MCP's `isError` result. Malformed protocol messages use JSON-RPC errors.

The endpoint is stateless and supports multiple Rails workers.
GET does not provide a persistent event stream. Clients must use Streamable HTTP POST requests.
Tools do not emit background notifications or implement subscriptions.
Request bodies are limited to 1 MiB at the MCP transport.
Basic request limits cover client registration, token exchange, and MCP requests.

## Deployment and development

The implementation uses the official [`mcp` Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk) and [Doorkeeper](https://github.com/doorkeeper-gem/doorkeeper).
See the SDK's [Rails transport guide](https://ruby.sdk.modelcontextprotocol.io/server/transports/) and the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).

1. Run `bundle install`.
2. Run `bin/rails db:migrate`.
3. Restart the web and worker processes.

Task revisions need the migration and every web and worker process running the new code.
An older process can change a task without changing its revision, so stop all of them before you rely on `expected_revision`.

OAuth tables live in the `user` schema.
No external OAuth provider, new client secret, or deployment environment variable is required.
Existing Rails session encryption protects browser consent.

The default production origin is `https://planyour.work`.
Development uses `https://planyour.test`.
For another deployment origin, set this optional Rails credential before issuing tokens:

```yaml
mcp:
  base_url: https://your-host.example
```

Use an origin without a path, query, or fragment.
Changing the origin changes the OAuth issuer and token audience. Existing clients must reconnect.

The `agent` component handles MCP transport and tool schemas.
The `user` component handles OAuth and connected-app access.
`Task::AgentWorkspace` scopes all operations to the authenticated account and uses existing task services.
Annotation tools also receive the token's OAuth application, which selects the namespace in `task.annotations`.
Dashboard and MCP date filters share `Task::DateScopes`.

Run the focused tests:

```sh
PARALLEL_WORKERS=1 bin/rails test \
  components/agent/test/controllers/agent/endpoint_test.rb \
  components/user/test/controllers/user/mcp_oauth_test.rb \
  components/task/test/services/task/agent_workspace_test.rb \
  components/task/test/services/task/agent_source_sync_test.rb \
  components/task/test/services/task/agent_revision_concurrency_test.rb \
  components/task/test/models/task/annotation_test.rb
```

Run the full suite with `PARALLEL_WORKERS=1 bin/rails test:everything`.
