Entity reference · v1
external-links
tablefeature_external_linksLinks from a feature (Work item) to things outside ProductOS: GitHub pull requests, issues and branches, Claude Code sessions, Notion pages, Figma files and pinned repo paths. The same feature and url, or the same feature, kind and external_id, is one link. Written only by attach_artifact and the attach_external_link, update_external_link and detach_external_link actions, never by POST, PATCH or DELETE here.
An external link ties a feature (a Work item) to something outside ProductOS: a GitHub pull request, issue, commit or branch, a Claude Code session, a Notion page, a Figma file, or a pinned repo path. The feature dialog shows them in its Documentation, Design and Code groups.
One link, once
A link is the same link when it has the same feature and url, or the same feature, kind and external_id. Two unique indexes hold that line, and every way of attaching a link returns the row already there instead of adding a second one:
- the MCP
attach_artifacttool and the catalog'sattach_external_linkaction, which answer withexisting: true; update_sessionandstart_sessionwhen they attach a session's pull request or the session itself;POST /api/v1/features/{id}/documents, which answers200withexisting: truerather than201;- the workbook import, which skips a link the feature already has.
One pull request can still be on two Work items: each feature holds its own link to it.
external_id
| Kind | external_id |
|---|---|
github_pr, github_issue | owner/repo#number, such as ProductOS-Inc/ProductOS#123. Taken from the url when you leave it out; a bare 123 is completed from the url; an id that names a different pull request than the url is refused. |
github_commit | The full 40-character SHA, lowercased. Read from the url when it carries all 40 characters; an abbreviated SHA in the url needs the full one as external_id. |
claude_session | The session's UUID. |
notion_page | The page id. |
figma_file | The file key. |
repo_path | The path, which the app also shows as the link's title. |
github_branch | Whatever you pass, or nothing. |
What the app shows
A GitHub link's title comes from its url (owner/repo #123), so metadata on GitHub kinds is kept and returned but not displayed. The app reads 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 ("synced 3h ago"). status on a pull request (open, merged or closed) feeds the pull request counts on the Work list.
Reading and writing
This endpoint only reads: GET /api/v1/external-links filters by feature_id, kind, url, external_id and status, and POST, PATCH and DELETE are refused with a 403. Links are written by their own actions, which apply the rules above:
| Where | Attach | Change | Remove |
|---|---|---|---|
| MCP | attach_artifact, or attach_external_link through execute_action | update_external_link through execute_action | detach_external_link through execute_destructive_action |
| The app | The feature dialog's link picker | The link's remove button |
Over MCP, get_feature returns every link on a feature as external_links, and list_entities or count_entities on external-links finds them by feature (its sequence id or link works as well as the UUID), url or external_id. See MCP server.
Endpoints
/api/v1/external-links/api/v1/external-links/{id}Writable fields
This endpoint only reads. The fields below are what the type’s own actions take over MCP and the Copilot (see its description above).
| Field | Type | Constraints | Notes |
|---|---|---|---|
| feature_id* | uuid | The feature (Work item) the link is on. | |
| kind* | string | github_pr | github_issue | github_branch | github_commit | claude_session | notion_page | figma_file | repo_path | claude_artifact | other_link | |
| url* | string | The link itself, http or https. | |
| external_id | string | The provider's id for the link, one format per kind: owner/repo#number for github_pr and github_issue (ProductOS-Inc/ProductOS#123; derived from the url when left out, and a bare number is completed from it), the full 40-character SHA for github_commit (read from the url when it carries all of it), the session UUID for claude_session, the page id for notion_page, the file key for figma_file, the path for repo_path, the artifact id for claude_artifact (read from the url when left out). github_branch and other_link keep what you pass, usually nothing. | |
| status | string | Where the linked item stands. For github_pr the app counts open, merged and closed pull requests on the Work list; other kinds leave it empty. | |
| metadata | json | Extra JSON kept on the link and returned by every read. The app shows title, snippet and icon_emoji on a notion_page, file_name and thumbnail_url on a figma_file, title on a claude_artifact or other_link (the name the row shows instead of its url), and last_synced_at on any kind as "synced 3h ago". A GitHub link's title comes from its url, so metadata on github kinds is stored but not displayed. |
Listing & filtering
- Pagination:
?limit=50&offset=0· max limit 200 - Default sort:
?sort=created_at&order=desc - Sortable fields: created_at · updated_at · kind
- Search (?q=): url, external_id
- Filters: ?feature_id= ?kind= ?url= ?external_id= ?status= , repeat any filter to OR multiple values
Examples
curl 'https://productos.ca/api/v1/external-links?limit=10' \
-H "Authorization: Bearer $TOKEN"Try it out
Paste a token and hit a real endpoint. The request goes from your browser straight to /api/v1/external-links. The token is held in this page only for the request and is never stored.
Issue tokens in Settings → API tokens. Token never leaves this page.
GET/api/v1/external-links?limit=3