ProductOSDocs

Entity reference · v1

features

FEAT-NNNNNNtable featuressoft delete

Atomic 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 committed
  • planned: committed to a release / sprint
  • in-progress: actively being built
  • in-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 to in-progress if the PR goes back to draft or closes unmerged
  • testing: merged and in QA; the PR-merge handler and the MCP session tools land here
  • blocked / parking-lot: side states, entered and left from anywhere
  • shipped: code is in production
  • wont-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's urgency field.
  • 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 × effort is 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 feature
  • POST /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 and external_id) comes back with 200 and existing: true instead of a second row
  • DELETE /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 with assignee_id (a team member id), due_date (YYYY-MM-DD), is_done and position, or { titles: [...] } to add up to 100 at once in the order given (the other fields apply to each). Returns 201 with data as an array of the new tasks
  • PATCH /api/v1/features/{id}/tasks/{taskId}: change any of title, is_done, assignee_id, due_date (send null to clear either) and position. To move a task between two others, send the midpoint of their positions
  • DELETE /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

GET/api/v1/features
POST/api/v1/features
GET/api/v1/features/{id}
PATCH/api/v1/features/{id}
DELETE/api/v1/features/{id}
POST/api/v1/features/{id}/restore
GET/api/v1/features/{id}/links/themes
POST/api/v1/features/{id}/links/themes
DELETE/api/v1/features/{id}/links/themes/{targetId}
GET/api/v1/features/{id}/links/initiatives
POST/api/v1/features/{id}/links/initiatives
DELETE/api/v1/features/{id}/links/initiatives/{targetId}
GET/api/v1/features/{id}/links/feedback
POST/api/v1/features/{id}/links/feedback
DELETE/api/v1/features/{id}/links/feedback/{targetId}
GET/api/v1/features/{id}/links/personas
POST/api/v1/features/{id}/links/personas
DELETE/api/v1/features/{id}/links/personas/{targetId}
GET/api/v1/features/{id}/links/releases
POST/api/v1/features/{id}/links/releases
DELETE/api/v1/features/{id}/links/releases/{targetId}
GET/api/v1/features/{id}/links/outcomes
POST/api/v1/features/{id}/links/outcomes
DELETE/api/v1/features/{id}/links/outcomes/{targetId}
GET/api/v1/features/{id}/links/deal-blockers
POST/api/v1/features/{id}/links/deal-blockers
DELETE/api/v1/features/{id}/links/deal-blockers/{targetId}
GET/api/v1/features/{id}/links/members
POST/api/v1/features/{id}/links/members
DELETE/api/v1/features/{id}/links/members/{targetId}
GET/api/v1/features/{id}/links/blocks
POST/api/v1/features/{id}/links/blocks
DELETE/api/v1/features/{id}/links/blocks/{targetId}
GET/api/v1/features/{id}/links/depends-on
POST/api/v1/features/{id}/links/depends-on
DELETE/api/v1/features/{id}/links/depends-on/{targetId}

Writable fields

* required on create. PATCH treats every field as optional.

FieldTypeConstraintsNotes
title*string
descriptiontext
description_docjson
description_yjs_statebytea
specificationstext
specifications_docjson
specifications_yjs_statebytea
acceptance_criteriastring[]
statusstringdiscovery | planned | in-progress | in-review | testing | blocked | parking-lot | shipped | wont-do
prioritystringcritical | high | medium | low
effortstringxs | s | m | l | xl
estimate_hoursfloat≥ 0
hours_spentfloat≥ 0
type_iduuid
roadmap_audience_idsuuid[]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.
progressinteger≥ 0 · ≤ 100
teamstring
owner_iduuidThe feature's Lead: the one accountable team member (a team-members id). Separate from its assignees, who are linked through the members junction.
product_iduuid
module_iduuid
design_statusstringnot-started | in-design | in-review | approved | na
figma_urlstring
start_datedate
target_datedate
original_target_datedate
is_draftboolean
metadatajson

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

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

Try it out

Issue tokens in Settings → API tokens. Token never leaves this page.

GET/api/v1/features?limit=3

Generated from the API's entity configuration. This page is rebuilt on every deploy, so it always matches the live API.