Connect an AI agent
Connect with OAuth, discover the guides, and work with projects and tasks through MCP.
Read as MarkdownPlan 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
- Add a remote MCP server in your agent’s connection settings.
- Enter
https://planyour.work/mcpas the server URL. - If the agent asks for an authentication method, select OAuth sign-in.
- Sign in to Plan Your Work in the browser.
- Check the app name, callback host, and requested permissions.
- Select Allow access.
- 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.
{"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:
stateis where the task stands in its source, or null:name,since,age(such as1d 4h, counted in working time when the task’s area has working hours),working_time, andwaiting_on_you.linked_taskslists tasks in another source that this one names: a pull request’s Shortcut stories (withtask_idwhen the story is imported, and its workflowstate), or a story’s pull requests.missing_ticketis true for your ready pull request that names no story, in a GitHub account a Shortcut connection expects stories from. Filter to these withstate: "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 withget_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.
create_task accepts an optional idempotency_key, described in 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:00matches2026-10-08T21:00:00Z. - An omitted optional field matches the same field sent as null.
- An omitted
project_idmeans the Inbox at creation time. It does not match a request that sends the Inbox’sproject_id. idempotency_keyitself 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:
{"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:
{"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:
{"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:
{"name":"create_task","arguments":{"name":"Send the receipt","idempotency_key":"invoice-7f3c2a"}}
It returns an error result with isError set to true:
{"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.
{"name":"create_task","arguments":{"name":"Draft agenda","agent_display_name":"Release helper"}}
{"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.
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.
{"name":"get_task_annotations","arguments":{"task_id":123}}
{"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:
summaryreplaces the summary. Null clears it.handoffpatches handoff fields. Omitted fields stay. Null on one field clears that field, and null forhandoffclears all of it.metadatareplaces the stored object whole. Null clears it.session,external_ref, orartifactpatches one existing entry selected by its key field. Omitted fields stay, a suppliedmessage_referencesarray 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:
entrywithcollectionandkeyremoves one entry.fieldclearssummary,handoff,metadata, or one handoff field such ashandoff.next_action.namespace: truedeletes 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. |
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:
{"name":"create_task","arguments":{"project_id":42,"name":"Ship the release"}}
Record the current conversation and a workflow reference:
{"name":"add_task_annotation","arguments":{"task_id":123,"session":{"conversation_id":"conv-42","url":"https://agent.example/c/conv-42","message_references":["msg-7"]}}}
{"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:
{"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:
{"name":"get_task_annotations","arguments":{"task_id":123}}
Record the new conversation, then hand off again. The omitted progress stays and null clears blockers:
{"name":"add_task_annotation","arguments":{"task_id":123,"session":{"conversation_id":"conv-57"}}}
{"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:
{"name":"remove_task_annotation","arguments":{"task_id":123,"entry":{"collection":"external_refs","key":"release-run"}}}
{"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. |
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.
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 |
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:
{"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.
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-42andconv-57and workflowrun-981. - Task 124, Publish release notes, is active. Its annotation records conversation
conv-57, workflowrun-981, tracker taskLIN-311, and a handoff.
Find the tasks of conversation conv-42. The completed task in the archived list is included:
{"name":"find_tasks_by_annotation","arguments":{"conversation_id":"conv-42"}}
{"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:
{"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:
{"name":"find_tasks_by_annotation","arguments":{"external_ref":{"kind":"workflow","external_id":"run-981"},"completion":"active"}}
{"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:
{"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:
{"name":"find_tasks_by_annotation","arguments":{"external_ref":{"kind":"task","external_id":"LIN-312"}}}
{"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:
{"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:
{"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, andexternal_state_id. - Its list, parent task, and position in the list, including changes from
reorder_tasksand 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 withInvalid 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:
{"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:
{"name":"get_task","arguments":{"task_id":123}}
{"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:
{"name":"update_task","arguments":{"task_id":123,"description":"Draft\n\nAdd the release checklist.","expected_revision":"MTIzOjQ1"}}
{"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:
{"name":"get_task","arguments":{"task_id":123}}
{"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:
{"name":"update_task","arguments":{"task_id":123,"description":"Final draft\n\nAdd the release checklist.","expected_revision":"MTIzOjQ2"}}
{"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 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 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:
{"name":"list_tasks","arguments":{"area_id":2,"timing":"later","sort":"due","time_zone":"America/New_York"}}
Create a task:
{"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:
{"name":"update_task","arguments":{"task_id":123,"due_at":null}}
Read-only request with an OAuth access token:
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:
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 and Doorkeeper.
See the SDK’s Rails transport guide and the MCP authorization specification.
- Run
bundle install. - Run
bin/rails db:migrate. - 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:
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:
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.