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

Source: https://productos.ca/documentation/api/v1/mcp (last reviewed 2026-10-08)

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:

```text title="MCP server URL"
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](#api-tokens-for-scripts-and-ci)).

For the bigger picture of how AI fits into ProductOS, see [The Golden Thread](https://productos.ca/documentation/guides/concepts/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.

> **Reachability**
>
> Claude connects to custom connectors from Anthropic's cloud, not from your machine. `https://productos.ca` is reachable there; a server on `localhost` is not.

## Connect from Claude Code

1. Add the server (CLI ≥ 2.x). `--scope user` makes it available in every project:

```bash title="add the server"
claude mcp add --transport http --scope user productos https://productos.ca/api/mcp/mcp
```

2. Inside Claude Code run `/mcp`, choose **productos**, then **Authenticate**. Approve the connection in the browser tab that opens.
3. 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:

```json title="~/.claude.json (mcpServers)"
{
  "mcpServers": {
    "productos": {
      "type": "http",
      "url": "https://productos.ca/api/mcp/mcp"
    }
  }
}
```

> **Streamable HTTP only**
>
> The endpoint speaks the Streamable HTTP transport (per the 2025-03-26 MCP spec). SSE is not offered. Older clients that can only speak SSE will not connect.

## 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:

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

> **What counts as a connection**
>
> A token counts once it has listed the tools, which every MCP client does when it connects. Scripts and CI jobs that call tools directly are never told to reconnect, and neither are the 24-hour tokens the dispatch flow issues.

## 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:

```bash title="add with a static token"
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 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

| 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](#external-links)), and its task checklist as `tasks` with `tasks_progress` (see [Tasks](#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](#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](#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](#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](#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](#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](https://productos.ca/documentation/pages/settings/import-export#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](#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](#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](#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](#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](#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](https://productos.ca/documentation/api/v1/reference/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](https://productos.ca/documentation/api/v1/reference/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_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`](https://productos.ca/documentation/api/v1/reference/products#code-on-products-and-modules).

### 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](https://productos.ca/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.

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

```json
{
  "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:

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:

| 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](https://productos.ca/documentation/guides/concepts/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](#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

- [Using ProductOS with Claude Code](https://productos.ca/documentation/guides/ai/claude-code) for the in-editor and dispatch workflows.
- [API authentication](https://productos.ca/documentation/api/v1/authentication) for the underlying token model.
- [Workspaces and roles](https://productos.ca/documentation/guides/concepts/workspaces-and-roles) for what each role can do through the MCP tools.
- [The Golden Thread](https://productos.ca/documentation/guides/concepts/golden-thread) for what `get_golden_thread` is actually walking.
