Entity reference · v1
features
FEAT-NNNNNNtable featuressoft deleteAtomic shipping units. Tracks status, priority, effort, dates, dependencies.
Features are the atomic units of shipping work. Every initiative rolls features up; every release ships features. This page covers the semantics that aren't obvious from the field table.
Work-item fields
A feature is also the unit of engineering work, so it carries tracker fields alongside its roadmap ones.
type_id references the workspace's work-item type catalog. Omit it on create and ProductOS fills in the workspace default, you never need to resolve the catalog yourself just to post a feature.
estimate_hours and hours_spent are optional non-negative numerics. They sit alongside the t-shirt effort field rather than replacing it: effort is for relative sizing during planning, hours for tracking actual capacity within a sprint.
status has nine values. The linear path is discovery → planned → in-progress → in-review → testing → shipped; blocked and parking-lot are side states that a feature can enter and leave from anywhere, and wont-do is terminal. The GitHub pull request handler moves a Claude Code session's feature to in-review when its PR opens for review and to testing, not shipped, when it merges: shipping stays a human decision after QA.
Status transitions
The status enum has the nine values listed above. Through the REST API you can move from any status to any other; the conventions are:
discovery: exploring the problem; nothing committedplanned: committed to a release / sprintin-progress: actively being builtin-review: a pull request is open and waiting on review or CI; the GitHub pull request handler lands a session's feature here when the PR opens for review, and moves it back toin-progressif the PR goes back to draft or closes unmergedtesting: merged and in QA; the PR-merge handler and the MCP session tools land hereblocked/parking-lot: side states, entered and left from anywhereshipped: code is in productionwont-do: explicitly declined, with a reason in the description
Automated moves (the PR-merge handler, update_feature_status over MCP) refuse to move a feature backwards along the linear path when a person set its current status, so an automation can be called optimistically without undoing a PM's decision.
Feature status and release status are independent. Shipping every feature linked to a release doesn't change the release's status, and publishing a release doesn't move its features.
Releases (releases junction)
A feature ships in a release through the releases junction, stored in release_features. GET /api/v1/features/{id}/links/releases answers "which release ships this feature?", and the same link is writable from either side. See the Releases entity for the release side, and for why a feature title in a release's items field is not a link.
Outcomes (outcomes junction)
A feature names the outcomes it is meant to move through the outcomes junction, stored in outcome_features. GET /api/v1/features/{id}/links/outcomes lists them, POST with { "id": "OUTC-000012" } adds one, and the same link is writable from the outcome side as /api/v1/outcomes/{id}/links/features. This is the direct link only: an outcome also reaches the features of its initiative through initiative_id, and those are not repeated here. Both ends must be in the same workspace; an id from another workspace is answered with a 404.
Blocked deals (deal-blockers junction)
GET /api/v1/features/{id}/links/deal-blockers answers "which deals are blocked on this?", through deal_blocker_features. Each linked blocker carries the account and the revenue_impact that is waiting on this feature, which is what the AI grounding for ProductSense and Copilot weighs when it ranks work. Writable from either side; see the Deal blockers entity for why that link is a junction and not a column.
Priority vs. effort
priority(critical | high | medium | low) is the team's scheduling decision: how important to ship next, not how customer-painful it is. Customer pain lives on the upstream feedback'surgencyfield.effort(xs | s | m | l | xl) is a t-shirt sizing of build cost. ProductOS doesn't compute story-points or person-days, those rot. T-shirt is sticky.priority×effortis the implicit prioritization quadrant: high priority and low effort first.
Don't import a 1–100 priority score and store it in metadata.score. Use the enum. The UI, filters, and AI prompts all assume the enum.
Dependencies (blocks / depends-on junctions)
The two reciprocal junction tables blocks and depends-on model feature dependencies as a DAG. A feature can declare:
- Depends on: features that must ship first
- Blocks: features that can't ship until this one does (the inverse, auto-mirrored)
ProductOS does not block planning on these. You can plan a feature that depends on an unshipped one; the dependency is information for the team, not a gate. This is intentional: rigidly forbidding the case breaks the ability to plan in parallel.
The kind field on the junction is either blocks (a hard dependency, the default) or relates-to (informational). Set it when you link: over REST, POST /api/v1/features/{id}/links/depends-on with { "id": "FEAT-000098", "fields": { "kind": "relates-to" } }; over MCP, the same fields object on link_entities, link_entities_bulk or a create_and_link link. Any other value is refused with a 422, and linking two features that are linked already updates the kind.
Team member allocation
The members junction carries two extra fields: role (free text, e.g. "tech lead", default contributor) and allocation_pct (integer 0 to 100, default 50), alongside each team member's own capacity field. Both are set through fields on the link, { "id": "TEAM-000004", "fields": { "role": "tech lead", "allocation_pct": 40 } } over REST or the same object on link_entities; a value outside 0 to 100 is refused with a 422.
There's no enforcement that allocations sum to 100%; a team member can be allocated 30% to feature A and 30% to feature B with the other 40% un-tracked.
Lead
owner_id is the feature's Lead: the one accountable team member, picked in the feature dialog. It holds a team-members id and is separate from the members junction: a Lead need not be allocated to the feature. Filter by it with ?owner_id=<team member id>. The MCP connector shows it as lead (the member's name) and filters it by name, email or me.
Design status
design_status (not-started | in-design | in-review | approved | na) records the handoff point between design and engineering. It defaults to not-started and is filterable, so ?design_status=in-design lists what is still with design.
figma_url is a link, not an embed. To attach a Figma file with its metadata cached on the feature, use the documents sub-route below (kind: figma_file) with the Figma integration connected.
Progress
progress is an integer 0–100. Hand-edited; not auto-computed from PR count or AC checklist completion. The intent: PMs report progress as a sanity check against engineering's reality, not as a precise tracking signal. The one automatic move: setting status to shipped through the MCP update_feature_status tool settles progress to 100.
Claude Code integration
If GitHub is connected and the workspace has linked a repo, every feature with a spec gains a Send to Claude Code action. Clicking it dispatches a GitHub Action workflow with the feature's spec and a 24-hour session-scoped token, so the run can load the full feature context and report progress back through the MCP server.
The same dispatch is POST /api/v1/features/{id}/dispatch-claude-code with { repo, branch?, prompt_override?, model? }. model is a Claude Code alias (opus, sonnet, haiku) or a full model id; leave it out for the default model, and a value with spaces or quotes returns 422. Before it creates a session it checks the repo: a missing workflow or a missing ANTHROPIC_API_KEY secret returns 400 with error.details.reason set to workflow_not_installed or anthropic_secret_missing. branch is cleaned into a name git accepts (spaces become -). The 201 response carries session_id, the branch actually used, the model sent (null for the default), anthropic_secret (present, missing or unknown), workflow_version and a warnings list.
See Connecting GitHub for setup.
Common patterns
List features blocked on a specific feature
curl "https://productos.ca/api/v1/features/FEAT-000123/links/blocks" \
-H "Authorization: Bearer $TOKEN"Mark a dependency as informational
curl "https://productos.ca/api/v1/features/FEAT-000123/links/depends-on" \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "id": "FEAT-000098", "fields": { "kind": "relates-to" } }'The same call on a pair that is linked already changes the kind of the existing link.
List features ready to ship (planned + 100% progress + design approved)
curl "https://productos.ca/api/v1/features?status=planned&design_status=approved&sort=target_date&order=asc" \
-H "Authorization: Bearer $TOKEN"Then filter client-side by progress >= 100. There's no operator filter syntax in the URL, it's intentional, see Pagination.
Bulk update progress on a sprint's features
# For each feature, PATCH the progress field. A PATCH is safe to retry.
curl "https://productos.ca/api/v1/features/FEAT-000123" \
-X PATCH \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"progress": 80}'To set the same value on many features in one call, use POST /api/v1/features/bulk with { "ids": [...], "action": "update", "patch": { "progress": 80 } } (up to 200 ids).
Rich-text spec fields (description_doc, specifications_doc)
The in-app spec editor is collaborative and renders from its own editor-state columns, while description and specifications hold the markdown text that the API, exports and AI prompts read.
Write through the API by PATCHing description / specifications as plain markdown. Whenever a write supplies the text without editor state, ProductOS seeds the editor state from the markdown, so what you send is what the editor shows. The description_doc / specifications_doc JSON columns are accepted for compatibility, but the API does not derive the text columns from them; send the markdown.
Sub-routes
Documents
Linked documents are exposed as a sub-resource. These routes take the in-app session only (not API tokens); API tokens read links through GET /api/v1/external-links, and connected clients write them with the MCP attach_artifact tool and the link actions:
GET /api/v1/features/{id}/documents: list every linked document for the featurePOST /api/v1/features/{id}/documents: attach a document. Send{ url }and ProductOS detects Notion, Figma and GitHub links (pull requests, issues and commits) and caches Notion and Figma metadata, or send{ kind, external_id, url, metadata }explicitly. A link the feature already has (same url, or same kind andexternal_id) comes back with200andexisting: trueinstead of a second rowDELETE /api/v1/features/{id}/documents/{linkId}: detach
The kind field is one of: notion_page, figma_file, github_pr, github_commit, github_issue, github_branch, repo_path, claude_session, claude_artifact, other_link. The feature dialog groups them as Documentation, Design, Code, Artifacts and Other links. A POST with a URL the endpoint doesn't recognise stores it as other_link; a Claude artifact URL (claude.ai/artifact/<id>, claude.ai/public/artifacts/<id>, claude.site/artifacts/<id>) as claude_artifact. Both take an optional title, which the row shows instead of the URL.
A github_commit link's url is the commit page, https://github.com/<owner>/<repo>/commit/<sha>, and its external_id is always the full 40-character SHA. ProductOS reads the SHA from the URL; if the URL abbreviates it, send the full SHA as external_id or the request is refused with a 400.
Tasks
A feature's checklist of tasks. These routes take the in-app session, a bearer token or an API token. Reading needs any role; writing needs member or higher.
GET /api/v1/features/{id}/tasks: every task, in list order (position, then oldest first)POST /api/v1/features/{id}/tasks: add to the end of the list. Send{ title }, optionally withassignee_id(a team member id),due_date(YYYY-MM-DD),is_doneandposition, or{ titles: [...] }to add up to 100 at once in the order given (the other fields apply to each). Returns201withdataas an array of the new tasksPATCH /api/v1/features/{id}/tasks/{taskId}: change any oftitle,is_done,assignee_id,due_date(sendnullto clear either) andposition. To move a task between two others, send the midpoint of their positionsDELETE /api/v1/features/{id}/tasks/{taskId}: remove the task
A task carries id, feature_id, title (1 to 500 characters), is_done, done_at (set when it is ticked, cleared when it is unticked), position, assignee_id, due_date, created_at and updated_at. Adding, renaming, ticking and removing a task are recorded on the feature's audit trail. A bad value returns 422; a feature outside your workspace, in the Trash, or someone else's draft returns 404.
Notify-event
POST /api/v1/features/{id}/notify-event: emit a workspace notification tied to this feature, to the feature's Lead and assignees. The app calls it after its own client-side mutations; it takes the in-app session only. Body: { kind, recipient_ids?, metadata?, title?, body? }, where kind is one of feature.assigned_to_me, feature.unassigned_from_me, feature.status_changed, feature.target_date_changed, pr.linked_to_my_feature. Writes made through the REST API emit feature.status_changed, feature.target_date_changed and feature.assigned_to_me on their own.
The auto-generated endpoint, field, and junction reference is below.
Endpoints
/api/v1/features/api/v1/features/api/v1/features/{id}/api/v1/features/{id}/api/v1/features/{id}/api/v1/features/{id}/restore/api/v1/features/{id}/links/themes/api/v1/features/{id}/links/themes/api/v1/features/{id}/links/themes/{targetId}/api/v1/features/{id}/links/initiatives/api/v1/features/{id}/links/initiatives/api/v1/features/{id}/links/initiatives/{targetId}/api/v1/features/{id}/links/feedback/api/v1/features/{id}/links/feedback/api/v1/features/{id}/links/feedback/{targetId}/api/v1/features/{id}/links/personas/api/v1/features/{id}/links/personas/api/v1/features/{id}/links/personas/{targetId}/api/v1/features/{id}/links/releases/api/v1/features/{id}/links/releases/api/v1/features/{id}/links/releases/{targetId}/api/v1/features/{id}/links/outcomes/api/v1/features/{id}/links/outcomes/api/v1/features/{id}/links/outcomes/{targetId}/api/v1/features/{id}/links/deal-blockers/api/v1/features/{id}/links/deal-blockers/api/v1/features/{id}/links/deal-blockers/{targetId}/api/v1/features/{id}/links/members/api/v1/features/{id}/links/members/api/v1/features/{id}/links/members/{targetId}/api/v1/features/{id}/links/blocks/api/v1/features/{id}/links/blocks/api/v1/features/{id}/links/blocks/{targetId}/api/v1/features/{id}/links/depends-on/api/v1/features/{id}/links/depends-on/api/v1/features/{id}/links/depends-on/{targetId}Writable fields
* required on create. PATCH treats every field as optional.
| Field | Type | Constraints | Notes |
|---|---|---|---|
| title* | string | ||
| description | text | ||
| description_doc | json | ||
| description_yjs_state | bytea | ||
| specifications | text | ||
| specifications_doc | json | ||
| specifications_yjs_state | bytea | ||
| acceptance_criteria | string[] | ||
| status | string | discovery | planned | in-progress | in-review | testing | blocked | parking-lot | shipped | wont-do | |
| priority | string | critical | high | medium | low | |
| effort | string | xs | s | m | l | xl | |
| estimate_hours | float | ≥ 0 | |
| hours_spent | float | ≥ 0 | |
| type_id | uuid | ||
| roadmap_audience_ids | uuid[] | The roadmap audiences this Work item is shown to, as roadmap-audiences ids (the workspace's Leadership, Customers, Investors and its own). [] means Internal Only: the internal roadmap shows it, no audience view, share link or public roadmap does. An audience only shows items whose effort is at least its min_effort; unsized items always pass. Every id must be an audience in this workspace; duplicates are dropped. | |
| progress | integer | ≥ 0 · ≤ 100 | |
| team | string | ||
| owner_id | uuid | The feature's Lead: the one accountable team member (a team-members id). Separate from its assignees, who are linked through the members junction. | |
| product_id | uuid | ||
| module_id | uuid | ||
| design_status | string | not-started | in-design | in-review | approved | na | |
| figma_url | string | ||
| start_date | date | ||
| target_date | date | ||
| original_target_date | date | ||
| is_draft | boolean | ||
| metadata | json |
Listing & filtering
- Pagination:
?limit=50&offset=0· max limit 200 - Default sort:
?sort=created_at&order=desc - Sortable fields: created_at · updated_at · title · priority · progress · target_date · estimate_hours · hours_spent · id_number
- Search (?q=): title, description, specifications
- Filters: ?status= ?priority= ?effort= ?type_id= ?team= ?owner_id= ?product_id= ?module_id= ?design_status= ?is_draft= , repeat any filter to OR multiple values
- Trash: hidden by default; pass
?include_deleted=true - Expand:
?expand=themes,initiatives
Examples
curl 'https://productos.ca/api/v1/features?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/features. 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/features?limit=3