ProductOSDocs

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/mcp

The 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.

  1. 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).
  2. Enter the server URL https://productos.ca/api/mcp/mcp. Leave the advanced OAuth client fields empty; the server registers the connector automatically.
  3. 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.
  4. 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

  1. Add the server (CLI ≥ 2.x). --scope user makes it available in every project:
claude mcp add --transport http --scope user productos https://productos.ca/api/mcp/mcp
  1. Inside Claude Code run /mcp, choose productos, then Authenticate. Approve the connection in the browser tab that opens.
  2. The productos tools 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: whoami carries a connector block that says whether this connection is up_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:

ClientSteps
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 chatStart 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 clientsRestart 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:

StepWhat happensStandard
DiscoveryAn 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
RegistrationThe 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
ConsentYour 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
TokensThe 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
RevocationDisconnecting 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.

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 command finds "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 123 or just 123 puts that row first, in the workspace's prefix or the standard one. #123 is 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_entities and search_features fill the rest of the limit with rows that share only some of the query's words, marked match: partial with the matched_words and why. 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 carry match: all_words.
  • Short text. search_entities returns a 300-character excerpt of a description and the length of the specifications (specifications_chars). Pass include_text: true for the full text, or read one row with get_feature or get_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 total and a next_cursor. The client calls again with the cursor alone until next_cursor is null. 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 with include_text: true.
  • What a ranking needs. Feature rows carry their demand: feedback_count, feedback_votes, account_count, account_arr, the largest accounts by name, and linked themes, initiatives and releases. Feedback rows carry the features they point at (as sequence ids), themes, accounts, account_arr and personas.
  • Narrowing. filters keeps rows whose field matches a value or any value in a list; exclude drops them, for example { "status": ["shipped", "wont-do"] }. query matches keywords, and sort / order pick the order. An unknown field or value comes back with the allowed ones.
  • By assignee. assignee keeps 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, or me for the signed-in user; none keeps rows with nobody assigned, and a list keeps a team's work together. Rows carry the names in assignees. An ambiguous name comes back with the candidates rather than a guess.
  • By Lead. lead keeps the features whose Lead (the one accountable owner picked in the feature dialog) is that person, in the same forms as assignee; none keeps features with no Lead. A Lead need not be among the assignees, so for everything someone is on, list once by assignee and once by lead. Feature rows carry the Lead's name in lead, and get_feature returns it with their id.
  • By date. since and until bound a date: an ISO date (midnight UTC) or a date and time with an offset. They compare created_at unless date_field names another: updated_at for what changed, deleted_at for what went to the trash, or one of the type's own dates such as target_date. The activity log's created_at answers "what changed since Monday" for admins.
  • The trash. Trashed rows are left out. deleted: "include" lists them alongside the rest and deleted: "only" lists nothing else; a trashed row carries deleted_at.
  • Counts. count_entities takes the same filters and returns the exact total, so "how many are blocked" is one call. With group_by it also returns each value's count, largest first: any filterable field (people, products, modules and types come back as names), lead for features, or assignee for 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 a snippet around 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. limit sets the results per source on a page (10 by default, 50 at most). Call again with only next_cursor until it is null; the cursor carries the query, sources and filters, and limit may change between pages. The GitHub pages walk the whole index in order, with no repeats or gaps.
  • GitHub filters. repo (as owner/name, or a name only one connected repository has), state (open, closed for closed without merging, or merged), author (a GitHub login, such as octocat or dependabot[bot]) and kind (pull_request or issue) narrow the results. since and until bound date_field: opened_at by default, merged_at for pull requests, closed_at or updated_at, which also sets the order. Any of them searches GitHub alone, and with one the query can be left out: state: "merged" with date_field: "merged_at" and since lists every pull request merged since that date.
  • Rows. A GitHub result carries repo, number, state, author and its opened_at, merged_at (pull requests), closed_at and updated_at dates along with its title and url.
  • When a source cannot answer. A source that cannot answer is listed under unavailable with 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.github says 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 empty results with nothing under unavailable means nothing matched.
  • What is indexed. GitHub results come from the index the GitHub App crawl fills, not a live call to GitHub. sync_repo_connection refreshes it and carries on a crawl that stopped.

Read

ToolWhat it does
whoamiWho 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_featureLoads 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_threadWalks 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_featuresKeyword 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_knowledgeReturns 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_entitiesFind 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_similarThe 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_workspaceOne 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_entitiesEvery 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_entitiesHow many rows match the same filters, optionally split by a field, a feature's Lead, or assignees.
get_entityRead 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_entitiesUp 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_entityAn 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_actionsThe 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_sessionsCoding 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_sessionOne 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_messagesA 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_diffThe 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_workspaceThe 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_commentsThe 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_notificationsYour notifications, newest first, with links and the unread count.
list_saved_viewsYour saved views on list pages.
get_ai_usageAI usage and cost over a window, by source and by day, as Settings shows it.
export_workspaceThe 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_attachmentsAn 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_attachmentOne 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_documentsGitHub 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

ToolWhat it does
execute_actionApply 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_actionThe 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_statusAdvance 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_sessionOpen 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_sessionRecord 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_progressPost 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_eventAdd 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_questionSurface 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_repoAsk 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_requestOpen 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_outcomeRecord 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_completeClose 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_artifactLink 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_attachmentAttach 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_uploadStart 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_uploadFinish an upload started with create_attachment_upload once the bytes are there; refused until they are. Takes append_to like upload_attachment.
import_meeting_transcriptBring 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_transcriptRead 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_proposalsA 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_meetingRun 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_meetingStore 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_proposalsAccept, 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_connectionRefresh 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.

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_feature returns every link as external_links, beside linked since a link's url is the outside page rather than a ProductOS one, each with its id, kind, url, external_id, status, metadata and created_at. list_entities and count_entities on external-links filter by feature_id (a UUID, sequence id or link), kind, url, external_id and status, and each row names its Work item as feature and feature_url. get_entity reads one by id.
  • Attach. attach_artifact, or attach_external_link through execute_action for any kind. A link the feature already has (same url, or same kind and external_id) comes back unchanged with existing: true and 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_link sets url, external_id, status or metadata (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_link through execute_destructive_action. Links have no trash; the inverse is an attach_external_link with 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_entity on a product or module returns code, with orgs (each login, repo_count and connection_id) and repos (each full_name and repo_id). get_workspace_knowledge names the products and modules each connected repository belongs to.
  • Attach. attach_code through execute_action, with entity_type (products or modules), id, and orgs, repos or both, up to 50. An organization is its login, a github.com link or its connection UUID; a repo is owner/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 under detail.existing and are not written again; the inverse detaches only what this call added.
  • Remove. detach_code through execute_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_feature returns tasks in list order, each with id, title, is_done, done_at, position, assignee_id (a team member) and due_date, plus tasks_progress (done, total, percent).
  • Add. add_feature_tasks through execute_action takes feature_id and up to 100 tasks, each with a title and optionally assignee_id, due_date (YYYY-MM-DD), is_done and position. Without a position they go to the end, in the order given. The inverse deletes them.
  • Change. update_feature_task takes the task's id and any of title, is_done, assignee_id, due_date (null clears either) and position. To move a task between two others, send the midpoint of their positions. The inverse puts the old values back.
  • Remove. delete_feature_tasks through execute_destructive_action takes ids on one Work item. Tasks have no trash; the inverse is an add_feature_tasks with 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_entity on an outcome returns metrics, each with id, role (primary, guardrail or supporting), name, direction (increase or decrease: which way is good), target, source (manual or amplitude), event, kind, baseline, actual, the windows and days measured, and metrics_suggestion: hit, mixed, missed or null, with the reason. The suggestion is never applied on its own; set it with set_outcome_result.
  • Add. add_outcome_metric through execute_action takes outcome_id, role, name, direction, an optional target, and source: manual with baseline and actual, or amplitude with event and kind (event_count or unique_users) and optionally baseline_days and measure_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_metric takes outcome_id, the metric's id, 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_metric through execute_destructive_action takes outcome_id and id. The inverse adds it back with the same settings and a new id.
  • Refresh. refresh_outcome_metrics takes outcome_id and 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, ![name](/api/v1/attachments/<id>/file); a file linked there uses ?download=1. Pass that url to get_attachment to see the image. To put a file in the text, use upload_attachment with append_to, or write its markdown into the field yourself with update_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_attachment and field: "comment", then pass its id in add_comment's images (up to 10; body may then be empty). The images show below the comment's text, as one pasted in the app does. get_comments lists each comment's images.
  • Kinds. inline is an image that sits in a text field or a comment (field names which); attachment is listed on the Attachments tab. Both appear in list_attachments and in get_feature under attachments.
  • Remove. delete_attachment through execute_destructive_action moves 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 need member.

Uploads are refused while the attachments feature flag is off for the workspace; reading files that exist never is.

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:

JunctionFieldsTable
blocks and depends-on on featureskind: blocks (a hard dependency, the default) or relates-to (informational)feature_dependencies
themes on feedback, feedback on themesconfidence: 0 to 1theme_feedback
members on features and initiatives, features and initiatives on team membersrole (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.

ActionWhat it doesLowest role
set_vision_missionSets the workspace's vision or mission statement.member
set_workspace_identitySets 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_surfacesTurns 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

ToolWhat it does
complete_with_workspace_modelGenerate 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:

  1. start_session with the feature opens the session running, 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 check list_sessions first.
  2. report_progress posts milestones, and update_session records the branch and the PR as they appear.
  3. When a decision belongs to the PM, ask_user_question puts it in the Sessions view. The PM answers there, and the session reads the answer with get_session_messages.
  4. record_session_outcome stores what changed, and mark_session_complete closes it; with an outcome of merged the 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.

  1. When the work needs a repository the session doesn't have, call request_repo with why. Unless the run may add repositories on its own, the request waits on the session as a question with Add and Decline.
  2. Once it is attached, clone it with the clone command, change it on the same branch and push.
  3. 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_at as well, with status (succeeded, the default, or cancelled), an outcome (merged, draft or abandoned, paired with the status as mark_session_complete requires) and a summary. The row is inserted already closed. repo, branch and pr_url go in the same call, and the PR attaches to the feature as it would through update_session.
  • In two calls. Pass started_at alone and the session opens running from that time; close it with mark_session_complete and its ended_at. Only a session opened this way accepts ended_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:

InputRule
started_atISO 8601 with a zone, like 2026-05-04T18:20:00Z; in the past; not before 2026-01-01.
ended_atThe 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_sessionOnly 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.

ToolsLowest role
The read toolsviewer
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_requestcommenter
update_feature_status, attach_artifact, complete_with_workspace_modelmember
sync_repo_connectionadmin
execute_action, execute_destructive_actionmember for most actions; each action states its own requirement
execute_action with update_workspace_public_surfaces or set_workspace_identityadmin
execute_action with attach_external_link or update_external_link, execute_destructive_action with detach_external_linkmember
execute_action with attach_code, execute_destructive_action with detach_codemember

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); whoami says 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/mcp and that https://productos.ca/.well-known/oauth-authorization-server loads 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 unauthorized with a static token means the bearer header is missing the Bearer prefix, 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_model returns "workspace has no Anthropic key configured": paste a key under Settings → AI in ProductOS first.

Next

Last reviewed 2026-10-08