API
MCP server
Connect claude.ai, Claude Desktop, Claude Code, or any MCP-compatible client to ProductOS with a browser sign-in. Your workspace becomes a first-class tool.
ProductOS is a product-management workspace: feedback, themes, features, initiatives, releases and outcomes linked along the Golden Thread. ProductOS speaks the Model Context Protocol. Any MCP-compatible client can read your workspace and act in it: claude.ai and Claude Desktop through a custom connector, Claude Code in your terminal, and editors such as Cursor or Zed.
The server endpoint is:
https://productos.ca/api/mcp/mcpThe trailing /mcp is part of the address. Sign-in is OAuth: the client opens a ProductOS page in your browser, you approve it once, and it gets a token at your own workspace role. Nothing is copied or pasted. Tokens for scripts and CI still work too (see API tokens).
For the bigger picture of how AI fits into ProductOS, see The Golden Thread.
Why use the MCP server
- Chat with your workspace. Ask claude.ai what is blocked, which feedback maps to a theme, or what shipped last release, and let it apply changes through the same validated actions the app uses.
- Lives in your editor. No context switching to a browser tab to check what WRK-000123's hypothesis was.
- Authenticates against your real workspace data, at your real role. Your active session, not a stale snapshot.
- Posts progress back. As Claude Code works, from any machine, it can open a session for the feature, push status updates, attach the PR it just opened, read the PM's answers, and close the session, visible to the PM in real time.
Connect from claude.ai or Claude Desktop
Custom connectors are available on Free, Pro, Max, Team and Enterprise plans.
- In claude.ai, open Customize → Connectors and click Add custom connector (on Team and Enterprise plans an Owner adds it first under Organization settings → Connectors, then each member connects).
- Enter the server URL
https://productos.ca/api/mcp/mcp. Leave the advanced OAuth client fields empty; the server registers the connector automatically. - Click Connect. A ProductOS tab opens. Sign in if you are not already, review the workspace, access level and permissions on the consent screen, and click Allow access.
- Back in claude.ai, enable ProductOS for a conversation from the + menu → Connectors.
Claude Desktop shares the same connector list, so a connector added on claude.ai is available there too.
Connect from Claude Code
- Add the server (CLI ≥ 2.x).
--scope usermakes it available in every project:
claude mcp add --transport http --scope user productos https://productos.ca/api/mcp/mcp- Inside Claude Code run
/mcp, choose productos, then Authenticate. Approve the connection in the browser tab that opens. - The
productostools appear immediately. The sign-in is remembered; Claude Code refreshes the token in the background.
Or write the config directly and authenticate the same way:
{
"mcpServers": {
"productos": {
"type": "http",
"url": "https://productos.ca/api/mcp/mcp"
}
}
}Keeping the connector up to date
A client loads the ProductOS tool list (every tool's name, description and arguments, plus the server instructions) when it connects, and keeps it until you reconnect. The server cannot push a change to a connected client, so a tool added or changed since then is missing or out of date in that client until you reconnect it.
ProductOS tracks this for you. Each tool-list version has a number (the server reports it as its version on connect), and the server records which version each of your connections last loaded. When a connection that is behind a notable update calls a tool, you get:
- a Connector needs a reconnect notification in the bell, and an email with what changed and the steps below (at most one email a day, however many updates ship; turn either off under Settings → Notifications);
- a banner across the app, which clears itself once the client has reconnected and can be dismissed until the next update;
- an answer in the chat itself:
whoamicarries aconnectorblock that says whether this connection isup_to_date, and when it is not, what is new and how to reconnect. The model is told to pass that on once.
Settings → API Tokens lists every Claude or MCP client connected as you in the last 30 days, the version each loaded and whether it needs a reconnect.
To reconnect:
| Client | Steps |
|---|---|
| Claude Code (desktop app or terminal) | Type /mcp, select ProductOS (productos if you added it with claude mcp add, or the name you gave the claude.ai connector) and choose Reconnect. The new tools are available in that session straight away, and new sessions load them too. |
| claude.ai and Claude Desktop chat | Start a new chat and turn ProductOS on from the + button, under Connectors. If the new tools are still missing, open Customize → Connectors, select ProductOS, disconnect it, then choose Connect and approve ProductOS again. |
| Other MCP clients | Restart or reconnect the server from the client's MCP settings, or restart the client. |
How sign-in works
The MCP endpoint is an OAuth 2.1 protected resource and ProductOS is its authorization server. The whole flow is standard, so any MCP client that implements the spec can connect without ProductOS-specific code:
| Step | What happens | Standard |
|---|---|---|
| Discovery | An unauthenticated call gets a 401 whose WWW-Authenticate header points at /.well-known/oauth-protected-resource, which names /.well-known/oauth-authorization-server. | RFC 9728, RFC 8414 |
| Registration | The client registers itself once at /api/oauth/register and receives a client_id. Redirect URIs must be https, or http on localhost for desktop tools. | RFC 7591 |
| Consent | Your browser opens /oauth/authorize. You sign in (MFA included) and see the workspace, the access level (your role) and the permissions being granted. When you belong to several workspaces, you pick the one this connection works in. | RFC 6749, PKCE S256 required |
| Tokens | The client exchanges the code at /api/oauth/token for an access token (valid one hour) and a refresh token (valid 30 days, rotated on every use). | RFC 6749 |
| Revocation | Disconnecting in the client calls /api/oauth/revoke. Revoking the token in Settings → API Tokens has the same effect. | RFC 7009 |
Each connection appears in Settings → API Tokens as <app name> (OAuth) with a small oauth badge (tooltip: Connected app). It is an ordinary API token underneath: the same role checks, the same audit attribution, the same revoke button. Its secret rotates automatically while the app stays connected.
Scopes are read and write, granted in full to every connection. The boundary that is enforced is your workspace role, which the token inherits and which drops automatically if your role is lowered.
API tokens for scripts and CI
For automation that cannot open a browser, issue a token from Settings → API Tokens (an admin or owner does this; tokens default to commenter, pick member if the job needs to write) and pass it as a bearer header:
claude mcp add --transport http --scope user productos \
https://productos.ca/api/mcp/mcp \
--header "Authorization: Bearer pos_live_xxxxxxxxxxxxx"The dispatch flow (open a feature → Send to Claude Code) does this for you with a 24-hour token bound to that one session.
Tools exposed
The server sends instructions when a client connects: the conventions a model would otherwise learn by making mistakes, such as plural entity slugs, what an id can be, listing versus searching, and previewing a write before running it.
Names are the literal tool IDs the client will see. Each tool also carries a readable title and safety hints: the read tools are marked read-only, and only execute_destructive_action is marked destructive, so a client can allow reads and routine edits once and still ask before anything is removed. The tools that call out of ProductOS (complete_with_workspace_model, search_documents, get_session_diff, open_pull_request, sync_repo_connection) are marked open-world.
Every argument may also be sent as a JSON-encoded string, for clients that pass all arguments as text: input as '{"title": "..."}' and dry_run as "true" are accepted, and a string that does not parse as the type the field expects fails with the field's usual validation error.
Links and ids
Every entity a tool returns carries a url: the same page link the app opens on Ctrl-click, inside your workspace, for example https://productos.ca/<workspace>/features/<id>. That applies to rows from search_entities, get_entity, search_features, the entity and every linked collection in get_feature and get_golden_thread, and the row created or updated by execute_action. Entities without a page of their own (battle-card criteria, work-item types, activity log entries) have no url. An external link's url is the link itself; its rows carry feature_url for the Work item it is on.
Wherever a tool takes an id, you can pass any of three things: a UUID, a sequence id such as WRK-000123, or a ProductOS link (/<workspace>/features/<id>, /features/<id>, or the older /features?open=<id>). When get_entity is given a link, entity_type can be left out.
If your workspace renamed a type's ID prefix under Settings → Entity Naming (Feature to Work, FEAT to WRK), tools return the id your screens show, WRK-004082, and accept it back. The standard prefix keeps working too, so ids saved before the rename still resolve. Tool arguments always take the standard slugs (features, not work).
Searching
search_entities, search_features and search_workspace match the way the command palette does:
- Every word, in any order.
palette commandfinds "Command palette". A word matches at the start of a word, or inside one when it is three letters or longer. - Ids.
WRK-000123,wrk 123or just123puts that row first, in the workspace's prefix or the standard one.#123is searched as text instead, since descriptions use it for pull requests ("Pull requests: #123."), so it finds the rows that mention that PR. - Typos, as a fallback. When nothing matches as typed, names are matched again allowing a missing space, initials, and then typos: one in a word of five letters or more, two from nine, never in the first letter. A user's misspelling that the model passes through still finds the row. Descriptions only match as typed.
- Best first. A name that is the query, then one that starts with it, then other name matches, then matches in descriptions.
- Then partial matches.
search_entitiesandsearch_featuresfill the rest of the limit with rows that share only some of the query's words, markedmatch: partialwith thematched_wordsandwhy. Rarer words weigh more, so an extra word or a different word for one of them no longer hides a row. Rows with every word carrymatch: all_words. - Short text.
search_entitiesreturns a 300-character excerpt of a description and the length of the specifications (specifications_chars). Passinclude_text: truefor the full text, or read one row withget_featureorget_entity.
Checking for duplicates
find_similar takes a type and the title you would give a new row, and returns the closest live rows of that type, each with a score from 0 to 1, a band (likely from 0.7, possible from 0.5, related below), why it matched (the words it shares, rarest first, and how alike the titles are) and its url. It works for features, feedback, themes, initiatives, accounts, personas and competitors.
create_and_link and the create_<type> actions run the same check. Over MCP, a create with a possible or likely match is held back: the call fails with the matches, why each matched, and what to do next (compare, add to the existing row if it is the same thing, ask the person when unsure). Repeat the call with allow_duplicate: true once you have checked and the work is genuinely different. A dry run lists the matches as warnings without holding anything back, and the app's own Copilot shows them on its proposal card instead.
The check compares words and spellings, not meaning. It can flag different work that shares uncommon words, and it can miss a duplicate written in other words, so it is a prompt to look rather than a verdict. Two rows that turn out to be the same can be folded together with merge_entities (features, themes, feedback, team-members and accounts) through execute_destructive_action; a merged row's sequence id then opens the survivor. The same check is GET /api/v1/{entity}/similar?title=... over REST.
The query of list_entities stays a plain substring filter, since it pages through a whole set rather than ranking one.
Listing everything
search_entities finds things; it is not a complete list. For work that needs every row, such as "fetch all our open features and feedback and help me prioritize", clients use list_entities:
- Every row, in pages. Each page returns the
totaland anext_cursor. The client calls again with the cursor alone untilnext_cursorisnull. A page holds up to 50 rows by default (200 at most) and comes back shorter when its rows are long, so each page stays small enough for a client to keep in the conversation. - Compact rows. Sequence id, id, title, status, priority and the other short fields, with product, module and work-item type as names and dates without times, plus the
url. Descriptions, specifications and acceptance criteria come back only withinclude_text: true. - What a ranking needs. Feature rows carry their demand:
feedback_count,feedback_votes,account_count,account_arr, the largestaccountsby name, and linkedthemes,initiativesandreleases. Feedback rows carry thefeaturesthey point at (as sequence ids),themes,accounts,account_arrandpersonas. - Narrowing.
filterskeeps rows whose field matches a value or any value in a list;excludedrops them, for example{ "status": ["shipped", "wont-do"] }.querymatches keywords, andsort/orderpick the order. An unknown field or value comes back with the allowed ones. - By assignee.
assigneekeeps the features or initiatives assigned to a person, the same people the app's Assignee filter uses. Name them by UUID, sequence id, email, full name, ormefor the signed-in user;nonekeeps rows with nobody assigned, and a list keeps a team's work together. Rows carry the names inassignees. An ambiguous name comes back with the candidates rather than a guess. - By Lead.
leadkeeps the features whose Lead (the one accountable owner picked in the feature dialog) is that person, in the same forms asassignee;nonekeeps features with no Lead. A Lead need not be among the assignees, so for everything someone is on, list once byassigneeand once bylead. Feature rows carry the Lead's name inlead, andget_featurereturns it with their id. - By date.
sinceanduntilbound a date: an ISO date (midnight UTC) or a date and time with an offset. They comparecreated_atunlessdate_fieldnames another:updated_atfor what changed,deleted_atfor what went to the trash, or one of the type's own dates such astarget_date. The activity log'screated_atanswers "what changed since Monday" for admins. - The trash. Trashed rows are left out.
deleted: "include"lists them alongside the rest anddeleted: "only"lists nothing else; a trashed row carriesdeleted_at. - Counts.
count_entitiestakes the same filters and returns the exact total, so "how many are blocked" is one call. Withgroup_byit also returns each value's count, largest first: any filterable field (people, products, modules and types come back as names),leadfor features, orassigneefor features and initiatives, where a row with two assignees counts for both.
Searching documents
search_documents looks outside the workspace's own entities: pull requests and issues from connected GitHub repositories, Notion pages and Figma files.
- Every word, in any order. A pull request or issue matches when each query word appears in its title or its description. Title matches come first, then matches in the description alone, which carry
matched: "body"and asnippetaround the first word; each group is newest first. Figma file names match the way the command palette does, and Notion answers with its own search. - Pages.
limitsets the results per source on a page (10 by default, 50 at most). Call again with onlynext_cursoruntil it isnull; the cursor carries the query, sources and filters, andlimitmay change between pages. The GitHub pages walk the whole index in order, with no repeats or gaps. - GitHub filters.
repo(asowner/name, or a name only one connected repository has),state(open,closedfor closed without merging, ormerged),author(a GitHub login, such asoctocatordependabot[bot]) andkind(pull_requestorissue) narrow the results.sinceanduntilbounddate_field:opened_atby default,merged_atfor pull requests,closed_atorupdated_at, which also sets the order. Any of them searches GitHub alone, and with one the query can be left out:state: "merged"withdate_field: "merged_at"andsincelists every pull request merged since that date. - Rows. A GitHub result carries
repo,number,state,authorand itsopened_at,merged_at(pull requests),closed_atandupdated_atdates along with itstitleandurl. - When a source cannot answer. A source that cannot answer is listed under
unavailablewith the reason, and the search carries on with the others. For GitHub that is no connection, no active one (a connection in error names the error), or an index still being built with nothing in it yet. When GitHub answers from an incomplete index,warnings.githubsays so: a crawl still running (with how many repositories it has indexed), waiting for GitHub's rate limit or stopped with repositories left, repositories whose full history has not been read yet, pull requests or issues that failed to index, or another connection left out because it is not active. An emptyresultswith nothing underunavailablemeans nothing matched. - What is indexed. GitHub results come from the index the GitHub App crawl fills, not a live call to GitHub.
sync_repo_connectionrefreshes it and carries on a crawl that stopped.
Read
| Tool | What it does |
|---|---|
whoami | Who you are acting for, the workspace and its timezone, your role and what it allows, how the connection is authenticated and when it expires, whether its tool list is up to date (with the reconnect steps when it is not), today's date, and the current sprint. Call it first. |
get_feature | Loads the full feature context: spec, acceptance criteria, linked feedback, themes, initiatives, dependencies, team members, product, module, every external link as external_links, beside linked (see External links), and its task checklist as tasks with tasks_progress (see Tasks). Call once at session start. |
get_golden_thread | Walks the Golden Thread from any feedback, theme, feature, initiative, release, outcome or account: everything it connects to, grouped by type, nearest first, each row with its distance and what it was reached via. depth sets how many links out (default 2, up to 3). feature_id keeps the original feature-context shape. |
search_features | Keyword search over feature titles, descriptions and specifications, best match first (see Searching). Use to spot duplicate or parallel work before creating something new. |
get_workspace_knowledge | Returns workspace identity, vision, competitors, personas, OKRs, recent outcomes, and every connected repo with the pull requests, issues and commits it has indexed (docs for the most recently indexed few). The identity is the workspace's own, the same for every member, as saved on the Knowledge page. Use when implementation decisions hinge on product strategy. |
search_entities | Find entities of one type by keyword or sequence id, best match first, then partial matches, or peek at a few. Not a complete list; use list_entities for that. |
find_similar | The closest existing rows to a title you are about to create, with a score, a band and why each matched. See Checking for duplicates. |
search_workspace | One query across every type you can read, a few matches of each, best first across types. For when you do not know which type something is. |
list_entities | Every row of a type, page by page, with filters, exclusions, date ranges, the trash and sort. Compact rows; features carry linked feedback, accounts and ARR, feedback carries the features, themes, accounts and personas it links to. See Listing everything. |
count_entities | How many rows match the same filters, optionally split by a field, a feature's Lead, or assignees. |
get_entity | Read an entity's current field values by id, sequence id or link, so a diff is against live values. The ids it points at (product, Lead, initiative, account, ...) come back named in refs, with their sequence ids and links; a list field such as an objective's initiative_ids comes back as a list of them. |
get_entities | Up to 50 entities in one call, of several types when each id is a sequence id or a link, in the order asked; ids that match nothing come back under missing. |
describe_entity | An entity type's writable fields, allowed enum values, junctions and action ids. Use it to build a valid execute_action input instead of guessing field names. A field of type uuid[] carries references, the type every id in it must be a live row of (objectives.initiative_ids references initiatives). Each junction lists the extra fields a link on it can carry, with their type, allowed values and range; see Links and their fields. features lists external-links under children, with the actions that write them. |
list_actions | The write actions and the tool that runs each: the catalog behind execute_action and execute_destructive_action, plus every write with a tool of its own (attach_artifact, the upload tools, the session tools, update_feature_status, complete_with_workspace_model, sync_repo_connection), each naming that tool and the role it needs. Optionally filtered by keyword. |
list_sessions | Coding sessions, newest first: for one feature, by status, or only the ones you started. Each row carries started_at and ended_at; a session recorded after it ran also carries backfilled: true. |
get_session | One session: status, repository, branch, PR, the feature, the question waiting for an answer, the recorded outcome, and when it last checked in. Its metadata carries backfilled: true and recorded_at on a session recorded after it ran, whose started_at and ended_at are then the run's own and whose last_activity_at is null. |
get_session_messages | A session's messages since a point in time: progress, questions and the PM's answers, transcript events. Pass the returned next_since to see only what is new. |
get_session_diff | The files a session's pull request changes, with line counts, read from GitHub through the workspace's GitHub App. With no diff to read yet it comes back pending with a reason naming what is missing, the pull request or the repository it lives in. |
get_workspace | The workspace's settings: timezone, language, sprint cadence, its own names for entity types, its custom field definitions (values sit on each row at metadata.custom), whether its public changelog, feedback portal and roadmap are on, under public_surfaces with the link each one opens, and the workspace identity every AI feature is grounded on, under identity. |
get_comments | The comment thread on an entity, oldest first, with authors and replies. To post one, run add_comment through execute_action; @Full Name in the body, or a team member id, email or name in mentions, tags that person and sends them the mention notification. A comment's screenshots are listed under its images; get_attachment opens one. See Screenshots and files to post one. |
list_notifications | Your notifications, newest first, with links and the unread count. |
list_saved_views | Your saved views on list pages. |
get_ai_usage | AI usage and cost over a window, by source and by day, as Settings shows it. |
export_workspace | The whole workspace as the Excel file Settings, Import/Export, Export all data downloads, returned as a download link that works for 15 minutes without signing in. The link is built for the connection that asked for it: disconnecting the connection, or leaving the workspace, stops it. The file is built when the link is opened. See Import / export. |
list_attachments | An entity's files, oldest first: attachments and the screenshots inline in its text, each with id, file_name, content_type, size_bytes, kind, field, the uploader, url, download_url and markdown. See Screenshots and files. |
get_attachment | One file by id, or by the url description and specifications embed: its details and a signed_url that downloads it for an hour without signing in. An image up to 2 MB also comes back as the image itself, so the model can look at a screenshot; a text, Markdown, CSV or JSON file up to 200 KB comes back as its text. |
search_documents | GitHub pull requests and issues, Notion pages and Figma files linked to the workspace. Every query word matches in any order, across pull request and issue titles and descriptions. Pages with limit and next_cursor, and narrows GitHub by repository, state, author, kind and date. A source that cannot answer says why under unavailable instead of failing the search. See Searching documents. |
Write
| Tool | What it does |
|---|---|
execute_action | Apply a catalog write action: create, update, link, restore, set a status, add a comment. dry_run: true previews the old → new diff without writing; a change to a reference field (product_id, initiative_ids, ...) names the current and the proposed rows under refs, and update_entities adds a rows list with each row's own diff. Goes through the same validated core as the REST API and returns an inverse you can replay to undo. Needs member or higher for most actions. |
execute_destructive_action | The same, for the actions that take something away: deletes (to the trash), unlinks, detaching an external link, merges, and deleting comments or custom fields. Kept as its own tool so a client can gate it separately; list_actions says which tool runs each action. |
update_feature_status | Advance a feature through discovery → planned → in-progress → in-review → testing → shipped (or wont-do; blocked / parking-lot are side states that can be entered and left freely). A status_source DAG prevents regressing past a manual PM edit, and progress is settled to 100 when you set shipped. |
start_session | Open a coding session for a feature from any client, so the PM can follow it in the Sessions view. Returns the session_id the other session tools take. A dispatched run already has one. The session starts running; linking it to the feature needs member, and below that the session still opens and says so in warnings. Takes the repository, branch and pr_url up front. If the feature already has a queued or running session, that one comes back with existing: true, its branch and worktree, and a note, and nothing is written, so parallel clients and a client that lost its session_id land in the same session. Pass reuse_existing: false to open a separate one, for example from a second worktree. A new session comes back with existing: false. With started_at it records a session that already happened, always as a new row; see Recording a session that already happened. |
update_session | Record the branch, PR, repository, worktree, model or a line on where things stand. Setting the PR also attaches it to the feature. A session still sitting queued becomes running. A closed session (succeeded, failed or cancelled) keeps what it closed with: changing a value it already has returns an error and changes nothing, while a field it never had, such as a PR recorded after it closed, can still be added. Further work goes in a new session. |
report_progress | Post an info / warn / error / success status line to the session timeline. The first call flips a session still sitting queued (one handed over by a cloud dispatch or by Open in Claude Code) to running. Returns pm_replies when the PM has written to the session since the previous report, so a run reads replies at its checkpoints. Token-bearing strings are redacted server-side. |
log_session_event | Add a raw transcript event (tool call, tool result, assistant or user turn, system note), for clients with no Claude Code hook set up. |
ask_user_question | Surface a structured clarifying question (with optional options[] and default_decision) in the ProductOS Sessions UI. Returns immediately; proceed with the default. The PM's answer comes back through get_session_messages, and the next report_progress returns it in pm_replies. |
request_repo | Ask for another repository the session needs, as owner/name with a reason the PM reads. It must be connected to the workspace's GitHub App. Comes back attached (work in it now; for a cloud run, clone holds the command that clones it on the session's branch, and git gets its credentials from ProductOS), requested (someone decides from the session; the answer also arrives in get_session_messages as a question_answer, so carry on and ask again before finishing) or declined. A local session, or a cloud run dispatched with Let the run add other connected repos it needs without asking, gets it at once. Safe to repeat. See A session in several repositories. |
open_pull_request | Open the pull request for the session's branch in one of its repositories once the branch is pushed, into the default branch or base, through the workspace's GitHub App. Records it on the session and links it to the feature. An open pull request from the branch comes back with existing: true. |
record_session_outcome | Record what the session changed: summary, PR, files and symbols. The daily outcome job later validates it against the merged diff and embeds it. |
mark_session_complete | Close the session with succeeded / cancelled / failed, a one-line summary, and optionally an outcome: merged moves the feature to testing when its workflow allows, as the GitHub merge webhook does; draft or abandoned leave it. The end is stamped at the time of the call; only a session opened with a historical started_at may pass ended_at. Cloud sessions auto-close via the GitHub workflow_run webhook; local sessions need this call. |
attach_artifact | Link a github_pr, github_commit, github_issue, github_branch, claude_session, claude_artifact or other_link URL to a feature. Pass metadata.title to name a Claude artifact or other link. Call the moment a PR is opened. For work that shipped as a commit with no PR, pass github_commit with the commit page, https://github.com/<owner>/<repo>/commit/<sha>, as url. Its external_id is the full 40-character SHA, read from the URL when you leave it out; a URL with an abbreviated SHA needs the full SHA as external_id. Safe to repeat: the same feature and url, or kind and external_id, returns the row already there with existing: true. See External links. |
upload_attachment | Attach a screenshot, PDF, document, spreadsheet or recording to an entity (entity_type defaults to features) from content_base64 (up to 3 MB), or use create_attachment_upload for a file up to 25 MB. source_url is temporarily unavailable while ProductOS waits for a Node.js security fix; calls that pass it are refused. On a Work item, append_to also shows it in description or specifications, through the same update as update_features. field: "comment" marks an image meant for a comment. Returns the file with its id and markdown. Member or higher. |
create_attachment_upload | Start uploading a local file too big to send inline, up to 25 MB: returns a signed upload URL (two hours) to PUT the bytes to, with a ready curl command. |
finish_attachment_upload | Finish an upload started with create_attachment_upload once the bytes are there; refused until they are. Takes append_to like upload_attachment. |
import_meeting_transcript | Bring a meeting and its transcript in: transcript as WebVTT (as Microsoft Teams, Zoom and Google Meet write it) or Name: text lines, or segments as speaker turns, with the title, times, join link, organizer and participants. For Teams, read the calendar event with the Microsoft 365 connector, then its meetingTranscriptUrl, and pass that text with source: "teams" and the transcript's id as external_transcript_id. Speakers and attendees are matched to team members by email or name and to accounts by email, and the matched accounts are linked. The same external_transcript_id returns the meeting already there with created: false. Needs the meetings flag on for the workspace. Member or higher. See Meetings. |
get_meeting_transcript | Read a meeting's transcript as speaker turns, one #12-15 [m:ss] Speaker: text line each (#12-15 is the range of segments the turn covers, to cite), in pages of about 90,000 characters: call again with offset set to next_offset until it is null. Takes a UUID, an MTG- sequence id or a ProductOS link. |
list_meeting_proposals | A processed meeting's summary, decisions, action items, risks and feedback on other tools, and the feedback it proposes: each with its quotes, similar feedback already in the workspace, what accepting does, other options and whether you can accept it. include_closed adds accepted, rejected and replaced proposals. Needs the meeting-processing flag. |
process_meeting | Run ProductOS's meeting processing on your person's key: a summary, decisions, action items, risks and findings on the meeting, and proposals for feedback, Work updates, deal blockers, account health, competitor mentions, market signals, win/loss, persona insights, release commitments and OKR progress. Returns at once; call list_meeting_proposals until status is processed. Needs the meeting-processing flag. |
propose_from_meeting | Store proposals you extracted yourself, on your own plan: summary, decisions, action_items, risks, findings and items (each with kind, title, evidence quoting #ranges from get_meeting_transcript, and fields naming rows by UUID). Quotes are checked against the transcript and items are matched and deduplicated exactly as process_meeting does. Replaces the meeting's pending proposals. Member or higher. |
apply_meeting_proposals | Accept, reject or undo proposals from list_meeting_proposals, only as your person decided: items of { id, decision, option?, edits? }, up to 50. Accepting runs the proposal's catalog actions as your person. Member or higher. See Meetings. |
sync_repo_connection | Refresh the code index by crawling a GitHub App connection: every repo the installation can see, and in each one every pull request, issue and default-branch commit, plus its docs (README, CLAUDE.md, AGENTS.md, ADRs), file tree and symbols. A repo's first crawl reads its full history; later ones read only what changed. Pass connection_id for one connection or leave it out for every active GitHub connection in the workspace. Returns at once with each connection's crawl (status: syncing, already_syncing or rate_limited with resume_after; started_at; repos_indexed of repos_total) and its repos with the pull requests, issues and commits each has indexed. A large installation's crawl takes more than one run: each stops after about four minutes or when GitHub's rate limit runs low and saves its place, and calling again (or the installation's next GitHub event, or a daily fallback) continues it. A repo is done when its last_indexed_at has moved past started_at; get_workspace_knowledge shows each connected repository's counts and last indexed date. A daily pass also re-crawls every connection not crawled in the last 20 hours; this is how a connected client refreshes the index sooner without opening Settings. Owner or admin, the same as Sync now on the GitHub App card under Settings → Integrations. Safe to repeat. |
External links
A feature's links to pull requests, issues, commits, branches, Claude Code sessions, Notion pages and Figma files are rows of external-links.
- Read.
get_featurereturns every link asexternal_links, besidelinkedsince a link'surlis the outside page rather than a ProductOS one, each with itsid,kind,url,external_id,status,metadataandcreated_at.list_entitiesandcount_entitiesonexternal-linksfilter byfeature_id(a UUID, sequence id or link),kind,url,external_idandstatus, and each row names its Work item asfeatureandfeature_url.get_entityreads one by id. - Attach.
attach_artifact, orattach_external_linkthroughexecute_actionfor any kind. A link the feature already has (same url, or same kind andexternal_id) comes back unchanged withexisting: trueand no inverse, so a retried or re-run backfill never doubles a link. One pull request may be on several Work items. - Change.
update_external_linksetsurl,external_id,statusormetadata(replaced whole). It is refused when another link on the same feature already has the new url or id. The inverse puts the old values back. - Remove.
detach_external_linkthroughexecute_destructive_action. Links have no trash; the inverse is anattach_external_linkwith the same fields, which brings it back with a new id.
All four need member, the same as attach_artifact.
external_id takes one format per kind: owner/repo#number for github_pr and github_issue (derived from the url when you leave it out; a bare number is completed from the url; an id naming a different pull request than the url is refused), the full 40-character SHA for github_commit, the session UUID for claude_session, the page id for notion_page, the file key for figma_file and the path for repo_path.
A GitHub link's title in the app comes from its url, so metadata on GitHub kinds is stored and returned but not shown. The app shows title, snippet and icon_emoji on a Notion page, file_name and thumbnail_url on a Figma file, and last_synced_at on any kind. status on a pull request (open, merged or closed) feeds the pull request counts on the Work list.
Code on products and modules
A product or module can be attached to the code that builds it: a GitHub organization, which covers every repo under it including repos added later, or single repos.
- Read.
get_entityon a product or module returnscode, withorgs(eachlogin,repo_countandconnection_id) andrepos(eachfull_nameandrepo_id).get_workspace_knowledgenames the products and modules each connected repository belongs to. - Attach.
attach_codethroughexecute_action, withentity_type(productsormodules),id, andorgs,reposor both, up to 50. An organization is its login, a github.com link or its connection UUID; a repo isowner/name, a github.com link or its UUID. A name that matches no connected organization or repo is refused with the ones that are connected. Links already there come back underdetail.existingand are not written again; the inverse detaches only what this call added. - Remove.
detach_codethroughexecute_destructive_action, with the same input. Detaching an organization leaves repos attached on their own in place.
Both need member and edit in the Products area. The REST API has the same writes at /api/v1/{products|modules}/{id}/code.
Tasks
A Work item's checklist of steps, the same list as its Tasks tab. Each change is recorded on the Work item's Activity the way the app records it.
- Read.
get_featurereturnstasksin list order, each withid,title,is_done,done_at,position,assignee_id(a team member) anddue_date, plustasks_progress(done,total,percent). - Add.
add_feature_tasksthroughexecute_actiontakesfeature_idand up to 100tasks, each with atitleand optionallyassignee_id,due_date(YYYY-MM-DD),is_doneandposition. Without a position they go to the end, in the order given. The inverse deletes them. - Change.
update_feature_tasktakes the task'sidand any oftitle,is_done,assignee_id,due_date(nullclears either) andposition. To move a task between two others, send the midpoint of their positions. The inverse puts the old values back. - Remove.
delete_feature_tasksthroughexecute_destructive_actiontakesidson one Work item. Tasks have no trash; the inverse is anadd_feature_taskswith the same fields at the same positions, which brings them back with new ids.
All three need member and edit on Work.
Outcome metrics
The numbers an outcome is judged on beyond its own metric: a measured primary, guardrails that must not get worse, and supporting metrics, each typed in or measured from the workspace's connected Amplitude project. Part of measured outcomes, a preview: where it is not turned on for the workspace, these actions are refused and get_entity leaves the metrics out.
- Read.
get_entityon an outcome returnsmetrics, each withid,role(primary,guardrailorsupporting),name,direction(increaseordecrease: which way is good),target,source(manualoramplitude),event,kind,baseline,actual, the windows anddaysmeasured, andmetrics_suggestion:hit,mixed,missedornull, with the reason. The suggestion is never applied on its own; set it withset_outcome_result. - Add.
add_outcome_metricthroughexecute_actiontakesoutcome_id,role,name,direction, an optionaltarget, andsource:manualwithbaselineandactual, oramplitudewitheventandkind(event_countorunique_users) and optionallybaseline_daysandmeasure_days(28 each by default). A measured metric is fetched at once. An outcome has at most one primary. The inverse deletes it. - Change.
update_outcome_metrictakesoutcome_id, the metric'sid, and only the fields that change. Changing what a measured metric counts fetches its history again. The inverse puts the old settings back. - Remove.
delete_outcome_metricthroughexecute_destructive_actiontakesoutcome_idandid. The inverse adds it back with the same settings and a new id. - Refresh.
refresh_outcome_metricstakesoutcome_idand fetches every measured metric again.
All four need member and edit on Outcomes, and each change is recorded on the outcome's Activity.
Screenshots and files
Any entity people create and edit can hold files: images (PNG, JPEG, GIF, WebP, AVIF), PDF, text, Markdown, CSV, JSON, Word, Excel, PowerPoint, OpenDocument, RTF, ZIP, MP4, WebM, MOV, MP3, WAV and M4A, up to 25 MB each. The type comes from the file name's extension. In the app, a Work item's description and specifications and every comment box take pasted and dropped screenshots, and Work and Feedback each have an Attachments tab that lists every file and takes drops, Ctrl+V and uploads.
- In text. An image in a description or specifications is markdown,
; a file linked there uses?download=1. Pass that url toget_attachmentto see the image. To put a file in the text, useupload_attachmentwithappend_to, or write itsmarkdowninto the field yourself withupdate_features, keeping the rest of the text. - In comments. To post a comment with a screenshot, upload the image to the same record with
upload_attachmentandfield: "comment", then pass itsidinadd_comment'simages(up to 10;bodymay then be empty). The images show below the comment's text, as one pasted in the app does.get_commentslists each comment'simages. - Kinds.
inlineis an image that sits in a text field or a comment (fieldnames which);attachmentis listed on the Attachments tab. Both appear inlist_attachmentsand inget_featureunderattachments. - Remove.
delete_attachmentthroughexecute_destructive_actionmoves a file to the trash; its inverse,restore_attachment, brings it back with the same id, so text that embeds it shows it again. Both needmember.
Uploads are refused while the attachments feature flag is off for the workspace; reading files that exist never is.
Links and their fields
link_entities, link_entities_bulk and the links of create_and_link connect rows along the Golden Thread through the junctions describe_entity lists on the source type. Three junctions store more than the two ids, and a link sets those columns through fields:
| Junction | Fields | Table |
|---|---|---|
blocks and depends-on on features | kind: blocks (a hard dependency, the default) or relates-to (informational) | feature_dependencies |
themes on feedback, feedback on themes | confidence: 0 to 1 | theme_feedback |
members on features and initiatives, features and initiatives on team members | role (free text, default contributor) and allocation_pct (integer 0 to 100, default 50) | team_member_features, team_member_initiatives |
fields is checked against the junction before anything is written. A value outside the enum or range, a key the junction does not declare, or any field on a junction that has none is refused with a message naming what the junction allows. A column left out keeps its default on a new link, and its current value when the two rows are linked already: linking them again updates the fields instead of failing.
{
"action_id": "link_entities",
"input": {
"source_type": "features",
"source_id": "FEAT-000123",
"junction": "depends-on",
"target_id": "FEAT-000098",
"fields": { "kind": "relates-to" }
},
"dry_run": true
}The preview lists each field beside the link as depends-on.kind, with the value the existing link holds when there is one, and warns that applying updates that link. link_entities_bulk takes one fields object and sets it on every link it writes; each entry of create_and_link's links takes its own, and every link is checked before the row is created. The inverse of a link is the plain unlink, whatever fields it set. The REST route POST /api/v1/{entity}/{id}/links/{junction} takes the same fields object (extra is its older name) and refuses the same things with a 422.
Workspace-level actions
Most catalog actions target a row. Three target the workspace itself and run through execute_action like any other, with dry_run: true for a preview and an inverse on the write.
| Action | What it does | Lowest role |
|---|---|---|
set_vision_mission | Sets the workspace's vision or mission statement. | member |
set_workspace_identity | Sets the workspace identity on the Knowledge page, which every AI feature and get_workspace_knowledge are grounded on. The input is any subset of company_name, industry, product_description, target_audience and additional_context, the fields get_workspace returns under identity; '' clears one. Company name and industry take up to 200 characters, target audience 1,000, product description and additional context 2,000. Only the fields that change are written; the write returns an inverse that restores the previous values. Shares its validation and write with PATCH /api/v1/workspace/identity. | admin, which whoami reports as can.manage_workspace |
update_workspace_public_surfaces | Turns the public changelog, the public feedback portal and the public roadmap on or off. The input is any subset of public_changelog_enabled, public_portal_enabled and public_roadmap_enabled, the flags get_workspace returns under public_surfaces. The preview shows the current and proposed value of each flag and warns when the changelog would open with no public release on it; the write returns an inverse that restores the previous values. Shares its validation and write with PATCH /api/v1/workspace. | admin, which whoami reports as can.manage_workspace |
AI
| Tool | What it does |
|---|---|
complete_with_workspace_model | Generate text using the Anthropic key saved by the connection's user. Tokens and cost are recorded in the workspace's AI usage and (when session_id is provided) attributed to the session. Use this instead of carrying the key into the runner environment. |
Session-scoped tokens (issued by the dispatch flow) carry a claude_session_<id> binding. Every tool that takes a session_id, reads included, enforces the binding and rejects mismatches, and such a token cannot start another session.
Sessions from any client
A session does not depend on the GitHub Action or on a PR comment to hear back. From Claude Code on your own machine, or any connected client:
start_sessionwith the feature opens the sessionrunning, because you are already at work; the Sessions view shows it at once and the feature links to it. When the feature already has a session in progress you get that one back instead, so there is no need to checklist_sessionsfirst.report_progressposts milestones, andupdate_sessionrecords the branch and the PR as they appear.- When a decision belongs to the PM,
ask_user_questionputs it in the Sessions view. The PM answers there, and the session reads the answer withget_session_messages. record_session_outcomestores what changed, andmark_session_completecloses it; with an outcome ofmergedthe feature moves to testing where its workflow allows.
Cloud runs use the same tools: when the GitHub webhooks fire, they update the same rows; when they do not, nothing is left stuck.
A session in several repositories
A cloud run can work in more than one repository. The session's repository is its host, where the GitHub Action runs and where its own pull request opens; get_session lists the others under repos, each with its status (attached, requested or declined) and pull request. Workflow v10 clones the attached ones on the session's branch under $RUNNER_TEMP/productos-repos/<owner>/<name>, and gives git a credential helper that asks ProductOS for a token scoped to one repository for an hour, for that run only.
- When the work needs a repository the session doesn't have, call
request_repowith why. Unless the run may add repositories on its own, the request waits on the session as a question with Add and Decline. - Once it is
attached, clone it with theclonecommand, change it on the same branch and push. - Open its pull request with
open_pull_request. A pull request in another repository is linked to the feature and moves it to in-review when it opens; the host's pull request still decides when the session closes and when the feature moves to testing.
Recording a session that already happened
A session that ran before it was written down (a Claude Code session on someone's machine, a run from before the connector existed) goes in with its real timestamps, not the moment it was recorded. start_session takes started_at, an ISO 8601 timestamp with a zone, and the row keeps that time as its start. Such a session is recorded history: its metadata carries backfilled: true and recorded_at, so list_sessions, get_session and the Sessions view can tell it from a live run, and its transcript notes say it was recorded from history.
- In one call. Pass
ended_atas well, withstatus(succeeded, the default, orcancelled), anoutcome(merged,draftorabandoned, paired with the status asmark_session_completerequires) and asummary. The row is inserted already closed.repo,branchandpr_urlgo in the same call, and the PR attaches to the feature as it would throughupdate_session. - In two calls. Pass
started_atalone and the session opensrunningfrom that time; close it withmark_session_completeand itsended_at. Only a session opened this way acceptsended_at: a live session's end is the time of the call.
The rules, checked before anything is written, each refused with a message naming the one that failed:
| Input | Rule |
|---|---|
started_at | ISO 8601 with a zone, like 2026-05-04T18:20:00Z; in the past; not before 2026-01-01. |
ended_at | The same shape; in the past; not before started_at; and only with a started_at, on the same call or on the session being closed. |
status and outcome on start_session | Only with ended_at. A session recorded as finished is succeeded or cancelled, and the outcome has to pair with the status. |
A recorded merged moves the feature by the same rule as a live one: to testing where the workflow allows, and only for a role that could have moved it by hand. Nothing else about the session is inferred: the transcript holds the two notes the recording wrote, at the time it wrote them, and the row holds the times of the run.
Resources
The server does not expose MCP resources/* endpoints. All workspace context flows through the tools above, most usefully get_workspace_knowledge, search_entities and get_golden_thread. If you need resource-style access, file feedback.
Roles and tokens
A connection works in one workspace: the one picked on the consent screen, or the one a token was issued in. whoami names it, and lists the other workspaces you belong to and how to connect to one. An OAuth connection runs at your workspace role, so an owner chatting from claude.ai can do everything they can do in the app, and a viewer gets read-only tools. Manually issued tokens default to commenter.
| Tools | Lowest role |
|---|---|
| The read tools | viewer |
The read tools on activity-log (the workspace's whole audit trail) | admin |
start_session, report_progress, ask_user_question, mark_session_complete, update_session, log_session_event, record_session_outcome, request_repo, open_pull_request | commenter |
update_feature_status, attach_artifact, complete_with_workspace_model | member |
sync_repo_connection | admin |
execute_action, execute_destructive_action | member for most actions; each action states its own requirement |
execute_action with update_workspace_public_surfaces or set_workspace_identity | admin |
execute_action with attach_external_link or update_external_link, execute_destructive_action with detach_external_link | member |
execute_action with attach_code, execute_destructive_action with detach_code | member |
A call below the line is refused with a message naming the role it needs, and nothing is written. The whole session lifecycle sits at commenter, the role manual tokens are issued at, so a token that can post to a session can also open one. The two Golden Thread links a session writes are the exception: attaching the session to its feature, and attaching a PR with update_session, both need member. Below that the call still succeeds and says in warnings (or note) that the link was not written.
There are no per-tool scope strings. A token never outranks its owner: the role it was issued at is a ceiling, and the owner's current workspace role is checked on every request. Demote someone and their tokens shrink with them; remove them from the workspace and their tokens stop working, with no revoke needed. See Workspaces and roles for the full matrix.
Troubleshooting
- A tool or argument these docs describe is missing means your client loaded the tool list before it shipped. Reconnect it (see Keeping the connector up to date);
whoamisays whether that is the cause. - The connector shows "Failed to connect" before any sign-in means the client could not reach the server or read its discovery documents. Check the URL ends in
/api/mcp/mcpand thathttps://productos.ca/.well-known/oauth-authorization-serverloads in a browser. - Sign-in finishes but the client still says unauthenticated. The consent screen expires after ten minutes; start the connection again from the client rather than reloading the old tab.
- "Redirect not allowed" on the consent screen means the client asked to return somewhere it did not register. This is the server refusing an unsafe redirect, not a ProductOS account problem; update or re-add the connector.
401 unauthorizedwith a static token means the bearer header is missing theBearerprefix, or the token was revoked / its owner left the workspace.- "Your role in this workspace is …, which cannot …" means the call needs a higher role than the token has; the message names the role. For an OAuth connection that is your own role, so ask an admin to promote you. For a manual token, re-issue it at the role named, which cannot exceed your own.
- "Session-scoped token rejected: session_id mismatch" means the token is bound to a single session (typical of the dispatch flow) and was passed a different
session_id. Use the matching session, or connect through OAuth instead. complete_with_workspace_modelreturns "workspace has no Anthropic key configured": paste a key under Settings → AI in ProductOS first.
Next
- Using ProductOS with Claude Code for the in-editor and dispatch workflows.
- API authentication for the underlying token model.
- Workspaces and roles for what each role can do through the MCP tools.
- The Golden Thread for what
get_golden_threadis actually walking.