# Move work items through the workflow

Read a work item by its key, find the statuses it can move to, change its status, place it on the board and leave a comment.

This recipe moves a work item through its workflow from your own code. You read the item, find the statuses it may move to, change its status, place it on the board and leave a comment. The examples use the work item `ACM-12`. Every request is a real operation with its own page in the [reference](https://developers.plnnrly.com/en/docs/api/v2/).

Each step comes in three forms: curl, JavaScript and Python. The JavaScript runs on Node 20 or later as an ES module, so save it as a `.mjs` file. The Python uses [httpx](https://www.python-httpx.org/); install it with `pip install httpx`. Run the steps in order: later steps reuse values from earlier ones.

Replace anything in angle brackets. `<your-url>` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain.

## 1. Get a token

In plnnrly, open **API access** in the user menu (top right, under your name) and choose **Create token**. If **API access** is not in your user menu, see [Who may create tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#who-may-create-tokens).

- **Scopes**: tick **Write**, which ticks **Read** for you.
- **Resources**: leave it on **All resources**, or pick **Project workspace** and **Workflows**. **Project workspace** covers work items and their comments. **Workflows** covers the statuses and the moves between them. See [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#resources).

Your role decides the rest:

- **Reading and changing work items** needs the `project_management::view_backlog` and `project_management::manage_work_items` permissions.
- **Looking up statuses, and changing to one**, needs `workflows::read`.

The workflow lists in step 4 are only there while your organisation has the Sprints feature. Without it they answer `404` [endpoint-not-found](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#endpoint-not-found).

## 2. Set up your script

Keep the address and the token out of your code, in two environment variables. Every snippet below uses them.

```bash [curl]
export PLNNRLY_BASE_URL="https://<your-url>/api/v2"
export PLNNRLY_TOKEN="<token>"
```

```js [JavaScript]
const BASE_URL = process.env.PLNNRLY_BASE_URL
const TOKEN = process.env.PLNNRLY_TOKEN

async function api(method, path, { query = {}, body, headers = {} } = {}) {
  const url = new URL(/^https?:/.test(path) ? path : `${BASE_URL}${path}`)
  for (const [name, value] of Object.entries(query)) {
    url.searchParams.set(name, String(value))
  }

  const response = await fetch(url, {
    method,
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
      ...headers,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  })

  const json = response.status === 204 ? null : await response.json()
  if (!response.ok) {
    throw new Error(`${response.status} ${json.type}: ${json.detail} (request ${json.request_id})`)
  }

  return { json, etag: response.headers.get('ETag') }
}
```

```python [Python]
import os

import httpx

client = httpx.Client(
    base_url=os.environ["PLNNRLY_BASE_URL"],
    headers={"Authorization": f"Bearer {os.environ['PLNNRLY_TOKEN']}"},
    timeout=30.0,
)


def api(method, path, *, params=None, json=None, headers=None):
    response = client.request(method, path, params=params, json=json, headers=headers)
    if response.is_error:
        problem = response.json()
        raise RuntimeError(
            f"{response.status_code} {problem['type']}: {problem['detail']} "
            f"(request {problem['request_id']})"
        )
    return response
```

Both helpers stop on any error response and print its `type` and `request_id`. Quote the `request_id` if you ask for help.

## 3. Read the work item

A work item path takes its issue key, such as `ACM-12`, or its numeric id, such as `812`. Both name the same item.

```bash [curl]
curl "$PLNNRLY_BASE_URL/work-items/ACM-12?meta=actions" \
  -i \
  -H "Authorization: Bearer $PLNNRLY_TOKEN" \
  -H "Accept: application/json"
```

```js [JavaScript]
const { json: item, etag } = await api('GET', '/work-items/ACM-12', {
  query: { meta: 'actions' },
})

console.log(item.data.workflow_status.name, item.meta?.actions, etag)
```

```python [Python]
response = api("GET", "/work-items/ACM-12", params={"meta": "actions"})
item = response.json()
etag = response.headers["ETag"]

print(item["data"]["workflow_status"]["name"], item.get("meta", {}).get("actions"), etag)
```

```http
HTTP/1.1 200 OK
ETag: "work-items-812-v7-4be2"
```

```jsonc
{
  "data": {
    "id": 812,
    "issue_key": "ACM-12",
    "title": "Redesign the pricing page",
    "version": 7,
    "project_id": 12,
    "sprint_id": null,
    "workflow_status_id": 27,
    "workflow_status": { "id": 27, "name": "In progress", "color": "#2563eb", "is_resolution": false }
    // 32 more fields: description, priority, due_date, …
  },
  "meta": {
    "actions": ["move", "transition"]
  }
}
```

- `workflow_status_id` is where the item is now. Step 4 starts from it.
- `meta.actions` lists what you may do with this item right now, because you asked with `meta=actions`. If `transition` is missing, your token or your role cannot change its status.
- Keep the `ETag` for step 5. It changes with `version`, which goes up with every change to the item.

## 4. Find where it can move

A workflow only allows the moves it defines. Ask for the moves out of the current status, with the status each one leads to:

```bash [curl]
curl -G "$PLNNRLY_BASE_URL/workflow-transitions" \
  -H "Authorization: Bearer $PLNNRLY_TOKEN" \
  --data-urlencode "filter[from_status_id]=27" \
  --data-urlencode "include=to_status"
```

```js [JavaScript]
const { json: transitions } = await api('GET', '/workflow-transitions', {
  query: { 'filter[from_status_id]': item.data.workflow_status_id, include: 'to_status' },
})

const target = transitions.data.find((transition) => transition.to_status.name === 'In review')
if (target === undefined) {
  throw new Error('ACM-12 cannot move to In review from its current status')
}
```

```python [Python]
transitions = api(
    "GET",
    "/workflow-transitions",
    params={"filter[from_status_id]": item["data"]["workflow_status_id"], "include": "to_status"},
).json()

target = next((t for t in transitions["data"] if t["to_status"]["name"] == "In review"), None)
if target is None:
    raise RuntimeError("ACM-12 cannot move to In review from its current status")
```

```jsonc
{
  "data": [
    {
      "id": 54,
      "name": "Ready for review",
      "from_status_id": 27,
      "to_status_id": 31,
      "to_status": { "id": 31, "name": "In review", "color": "#7c3aed", "is_resolution": false }
      // 6 more fields: workflow_id, created_at, updated_at, workflow, from_status, links
    }
  ]
  // 2 more members: meta, links
}
```

Each row is one move the workflow allows. `name` is the label on the button in plnnrly, and `to_status` is where the move leads. To list every status of a workflow instead, use [GET /workflow-statuses](https://developers.plnnrly.com/en/docs/api/v2/workflow-statuses/list/) with `filter[workflow_id]`.

## 5. Change the status

```bash [curl]
curl "$PLNNRLY_BASE_URL/work-items/ACM-12/actions/transition" \
  -X POST \
  -i \
  -H "Authorization: Bearer $PLNNRLY_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: transition-ACM-12-31-v7" \
  -H 'If-Match: "work-items-812-v7-4be2"' \
  -d '{
    "workflow_status_id": 31
  }'
```

```js [JavaScript]
const transitioned = await api('POST', '/work-items/ACM-12/actions/transition', {
  headers: {
    'Idempotency-Key': `transition-ACM-12-${target.to_status_id}-v${item.data.version}`,
    'If-Match': etag,
  },
  body: { workflow_status_id: target.to_status_id },
})

console.log(transitioned.json.data.workflow_status.name, transitioned.etag)
```

```python [Python]
response = api(
    "POST",
    "/work-items/ACM-12/actions/transition",
    headers={
        "Idempotency-Key": f"transition-ACM-12-{target['to_status_id']}-v{item['data']['version']}",
        "If-Match": etag,
    },
    json={"workflow_status_id": target["to_status_id"]},
)
transitioned = response.json()
transitioned_etag = response.headers["ETag"]

print(transitioned["data"]["workflow_status"]["name"], transitioned_etag)
```

The answer is `200` with the whole work item in its new status, its `version` one higher, and a new `ETag`. Keep that `ETag` for step 6.

- `If-Match` keeps you from overwriting a change you have not seen. If someone changed the item after your read, the answer is `412` [precondition-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#precondition-failed). It carries the current `ETag` and `current_version`: read the item again, check it still needs the change, and retry.
- The `Idempotency-Key` makes a retry safe. A retry with a stale `If-Match` would fail with `412`; with the same key it gets the stored answer instead. The key above names the item, the target and the version, so it stays the same on a retry and changes for the next real move.
- **Moving to the status the item already has** succeeds and changes nothing.

A move the workflow does not define answers `409` [state-conflict](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#state-conflict), naming both ends:

```json
{
  "type": "https://developers.plnnrly.com/api/v2/problems/state-conflict",
  "title": "State conflict",
  "status": 409,
  "detail": "The target status is not reachable from this work item's current status.",
  "instance": "/api/v2/work-items/ACM-12/actions/transition",
  "request_id": "01M4XGKWHGD5NFRBNNG8KE8MRX",
  "conflict": {
    "reason": "illegal_transition",
    "from_workflow_status_id": 27,
    "to_workflow_status_id": 33
  }
}
```

## 6. Place it on the board

```bash [curl]
curl "$PLNNRLY_BASE_URL/work-items/ACM-12/actions/move" \
  -X POST \
  -H "Authorization: Bearer $PLNNRLY_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: move-ACM-12-board-top-v8" \
  -H 'If-Match: "work-items-812-v8-4be2"' \
  -d '{
    "list": "board",
    "after_work_item_id": null
  }'
```

```js [JavaScript]
const moved = await api('POST', '/work-items/ACM-12/actions/move', {
  headers: {
    'Idempotency-Key': `move-ACM-12-board-top-v${transitioned.json.data.version}`,
    'If-Match': transitioned.etag,
  },
  body: { list: 'board', after_work_item_id: null },
})

console.log(moved.json.data.column_order)
```

```python [Python]
moved = api(
    "POST",
    "/work-items/ACM-12/actions/move",
    headers={
        "Idempotency-Key": f"move-ACM-12-board-top-v{transitioned['data']['version']}",
        "If-Match": transitioned_etag,
    },
    json={"list": "board", "after_work_item_id": None},
).json()

print(moved["data"]["column_order"])
```

This puts `ACM-12` at the top of its column, the way dragging it in plnnrly does. The [operation page](https://developers.plnnrly.com/en/docs/api/v2/work-items/move/) has every field; the ones you need most:

- `list` says which order to change: `board` is the item's column on the board, and `backlog` is its place in its sprint, or in the backlog when it is in no sprint.
- `after_work_item_id` names the item to land directly below, by key or by id, such as `"ACM-9"`. `null` puts the item at the top. Leave it out to keep the item's place in its current list, or to put it at the end of a new one.
- `workflow_status_id` and `sprint_id` change the status and the sprint in the same request. The status is checked as in step 5, and `"sprint_id": null` moves the item to the backlog.

On a project that works in sprints, the board shows the running sprint only. Moving an item onto the board that is not in that sprint answers `422` [unprocessable-relationship](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unprocessable-relationship). A move to where the item already is changes nothing, so a retry is safe.

## 7. Leave a comment

```bash [curl]
curl "$PLNNRLY_BASE_URL/work-items/ACM-12/comments" \
  -X POST \
  -H "Authorization: Bearer $PLNNRLY_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: comment-ACM-12-review-v8" \
  -d '{
    "body": "<p>Deployed to staging, ready for review.</p>"
  }'
```

```js [JavaScript]
await api('POST', '/work-items/ACM-12/comments', {
  headers: { 'Idempotency-Key': `comment-ACM-12-review-v${transitioned.json.data.version}` },
  body: { body: '<p>Deployed to staging, ready for review.</p>' },
})
```

```python [Python]
api(
    "POST",
    "/work-items/ACM-12/comments",
    headers={"Idempotency-Key": f"comment-ACM-12-review-v{transitioned['data']['version']}"},
    json={"body": "<p>Deployed to staging, ready for review.</p>"},
)
```

`body` is rich text, written as HTML. The comment is posted as you, the same way as in plnnrly. You cannot create a new mention through the API.

## 8. The same over MCP

An AI assistant connected to the [MCP server](https://developers.plnnrly.com/en/docs/mcp/) can do all of this from one sentence:

> Move ACM-12 to In review, put it at the top of its board column and comment: Deployed to staging, ready for review.

The assistant uses the same operations as this recipe. It acts as you, so it can do only what your role allows. More prompts are in [What you can ask](https://developers.plnnrly.com/en/docs/mcp/examples/).

## Related

- [Actions and state changes](https://developers.plnnrly.com/en/docs/api/v2/guides/actions/): the rules every action shares.
- [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/): `If-Match` in depth.
- [Log time from a script](https://developers.plnnrly.com/en/docs/api/v2/guides/recipes/log-time/): the other recipe.
