Developer documentation

Your team's priorities, from your own tools

A personal API token lets your scripts and AI assistants read your lists, suggest items and request done, and, where you hold the pen, add, order and finish. A token can do what you can do in the app, and nothing more.

API tokens

  1. Open your profile, then API tokens, then Create a token.
  2. Name it after what will use it, so you know which one to revoke later.
  3. Copy it straight away. It is shown once; Penholder keeps only a hash of it.
Up to 10 tokens per person, each revoked from the same list, and a revoked token stops at once. Resetting your password by email revokes every token. Signing out everywhere does not.

Calling the API

The base URL is https://penholder.app/api. Send the token as a bearer token; token calls need no other header. Bodies are JSON and every id is a string. The examples below keep the token in PENHOLDER_TOKEN.

Shell
export PENHOLDER_TOKEN="ph_YOUR_TOKEN"

AI assistants (MCP)

Penholder is also a Model Context Protocol server, so assistants like OpenCode, Claude and Cursor can use your lists directly. Connect to https://penholder.app/api/mcp/ with the same token. Each tool calls the API above as you, so the pen holder rules apply exactly as they do in the app.

ToolWhoWhat it does
list_teamsAnyoneYour teams
get_teamAnyone on the teamMembers, the pen holder, projects
get_projectAnyone on the teamA project and its topics
get_topicAnyone on the teamThe ordered list, fields, suggestions
list_assignedAnyoneOpen items assigned to you
suggest_itemAnyone on the teamSuggest an item
flag_itemAnyone on the teamAsk the pen holder to look again
request_doneAnyone on the teamSay an item is done, with a note
add_itemPen holderAdd an item at the bottom
move_itemPen holderMove an item to a position
finish_itemPen holderFinish, or confirm done
accept_suggestionPen holderPut a suggestion on the list
decline_suggestionPen holderDecline a suggestion

OpenCode

Save the token in a file of its own, then add Penholder to opencode.json:

opencode.json
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "penholder": {
      "type": "remote",
      "url": "https://penholder.app/api/mcp/",
      "headers": { "Authorization": "Bearer {file:~/.penholder/token}" }
    }
  }
}

Claude Code

Shell
claude mcp add --transport http penholder https://penholder.app/api/mcp/ \
  --header "Authorization: Bearer ph_YOUR_TOKEN"

Cursor and other clients

mcp.json
{
  "mcpServers": {
    "penholder": {
      "url": "https://penholder.app/api/mcp/",
      "headers": { "Authorization": "Bearer ph_YOUR_TOKEN" }
    }
  }
}

Postman

The Postman collection holds every call on this page with the answer it gave. Import it, set api_token and run the Reading folder: it fills in the ids of your first team, project and topic, and every request checks the status it answers. The other folders change real data, so try them on a team made for it.

Reading

GET/api/meAnyone

You

The person the token belongs to.

Request
curl -s https://penholder.app/api/me \
  -H "Authorization: Bearer $PENHOLDER_TOKEN"
Response200 OK
{
  "user": {
    "id": "2c539862-0ceb-4095-836d-0ebd94711798",
    "full_name": "Rob Mills",
    "initials": "RM",
    "avatar_url": null,
    "email": "rob@example.com",
    "pending_email": null
  }
}

GET/api/teamsAnyone

Your teams

Your teams, with their ids and plans.

Request
curl -s https://penholder.app/api/teams \
  -H "Authorization: Bearer $PENHOLDER_TOKEN"
Response200 OK
{
  "teams": [
    {
      "id": "62c99d17-5c7f-443c-b4ea-46df198b20b5",
      "name": "Northwind",
      "initials": "N",
      "logo_url": null,
      "role": "member",
      "member_count": 2,
      "project_count": 1,
      "faces": [
        {
          "id": "72b37a72-42a5-4529-8cff-928a5db6757e",
          "full_name": "Dana Kim",
          "initials": "DK",
          "avatar_url": null
        },
        {
          "id": "2c539862-0ceb-4095-836d-0ebd94711798",
          "full_name": "Rob Mills",
          "initials": "RM",
          "avatar_url": null
        }
      ],
      "plan_status": "beta",
      "locked": false,
      "plan": null
    }
  ],
  "billing_configured": false
}

GET/api/teams/TEAM_IDAnyone on the team

A team

A team: members, who holds the pen, projects and the first page of history. More history: GET /api/teams/TEAM_ID/history?offset=20.

Request
curl -s https://penholder.app/api/teams/TEAM_ID \
  -H "Authorization: Bearer $PENHOLDER_TOKEN"
Response200 OK
{
  "team": {
    "id": "62c99d17-5c7f-443c-b4ea-46df198b20b5",
    "name": "Northwind",
    "initials": "N",
    "logo_url": null,
    "created_at": "2026-09-15T17:40:30Z"
  },
  "role": "member",
  "is_owner": false,
  "you": "2c539862-0ceb-4095-836d-0ebd94711798",
  "pen": {
    "id": "72b37a72-42a5-4529-8cff-928a5db6757e",
    "full_name": "Dana Kim",
    "initials": "DK",
    "avatar_url": null
  },
  "you_hold_pen": false,
  "members": [
    {
      "id": "72b37a72-42a5-4529-8cff-928a5db6757e",
      "full_name": "Dana Kim",
      "initials": "DK",
      "avatar_url": null,
      "email": "dana@example.com",
      "role": "owner",
      "joined_at": "2026-09-15T17:40:30Z",
      "holds_pen": true
    },
    {
      "id": "2c539862-0ceb-4095-836d-0ebd94711798",
      "full_name": "Rob Mills",
      "initials": "RM",
      "avatar_url": null,
      "email": "rob@example.com",
      "role": "member",
      "joined_at": "2026-09-15T17:40:30Z",
      "holds_pen": false
    }
  ],
  "invites": [],
  "projects": [
    {
      "id": "00e14274-52d0-4a09-a32b-83cba87de735",
      "name": "Q4 launch",
      "description": "Everything that ships before the quarter closes.",
      "image_url": null,
      "topic_count": 1
    }
  ],
  "live_total": 2,
  "waiting": 0,
  "plan": {
    "status": "beta",
    "interval": null,
    "current_period_end": null,
    "billing_configured": false,
    "price_month": 5,
    "price_year": 50,
    "locked": false,
    "ending": false
  },
  "history": [
    {
      "id": "666ff3c2-7722-4bed-aa6b-8884ed60bf86",
      "who": {
        "id": "2c539862-0ceb-4095-836d-0ebd94711798",
        "full_name": "Rob Mills",
        "initials": "RM",
        "avatar_url": null
      },
      "action": "joined the team",
      "subject": "Northwind",
      "details": null,
      "at": "2026-09-15T17:40:30Z"
    },
    {
      "id": "e8955d1e-5fe7-494c-b213-eef7059f5b82",
      "who": {
        "id": "72b37a72-42a5-4529-8cff-928a5db6757e",
        "full_name": "Dana Kim",
        "initials": "DK",
        "avatar_url": null
      },
      "action": "invited",
      "subject": "rob@example.com",
      "details": null,
      "at": "2026-09-15T17:40:30Z"
    }
  ],
  "history_more": false
}

GET/api/projects/PROJECT_IDAnyone on the team

A project

A project and its topics.

Request
curl -s https://penholder.app/api/projects/PROJECT_ID \
  -H "Authorization: Bearer $PENHOLDER_TOKEN"
Response200 OK
{
  "project": {
    "id": "00e14274-52d0-4a09-a32b-83cba87de735",
    "name": "Q4 launch",
    "description": "Everything that ships before the quarter closes.",
    "image_url": null
  },
  "team": {
    "id": "62c99d17-5c7f-443c-b4ea-46df198b20b5",
    "name": "Northwind",
    "initials": "N",
    "logo_url": null
  },
  "you": "2c539862-0ceb-4095-836d-0ebd94711798",
  "you_hold_pen": false,
  "is_owner": false,
  "pen": {
    "id": "72b37a72-42a5-4529-8cff-928a5db6757e",
    "full_name": "Dana Kim",
    "initials": "DK",
    "avatar_url": null
  },
  "members": [
    {
      "id": "72b37a72-42a5-4529-8cff-928a5db6757e",
      "full_name": "Dana Kim",
      "initials": "DK",
      "avatar_url": null
    },
    {
      "id": "2c539862-0ceb-4095-836d-0ebd94711798",
      "full_name": "Rob Mills",
      "initials": "RM",
      "avatar_url": null
    }
  ],
  "topics": [
    {
      "id": "e751eacf-7f0c-45f5-a9c5-74b6af8cbf53",
      "name": "Revenue",
      "description": null,
      "line": 5,
      "image_url": null,
      "above": 2,
      "below": 0,
      "done": 0,
      "filters": 1,
      "flagged": 0,
      "waiting": 0
    }
  ]
}

GET/api/topics/TOPIC_IDAnyone on the team

A topic's ordered list

A topic's ordered list: above the line (the shortlist), below it, recently done, its fields, waiting suggestions and recent history. More history: GET /api/topics/TOPIC_ID/history?offset=20.

Request
curl -s https://penholder.app/api/topics/TOPIC_ID \
  -H "Authorization: Bearer $PENHOLDER_TOKEN"
Response200 OK
{
  "topic": {
    "id": "e751eacf-7f0c-45f5-a9c5-74b6af8cbf53",
    "name": "Revenue",
    "description": null,
    "line": 5,
    "created_at": "2026-09-15T17:40:30Z",
    "image_url": null
  },
  "project": {
    "id": "00e14274-52d0-4a09-a32b-83cba87de735",
    "name": "Q4 launch"
  },
  "team": {
    "id": "62c99d17-5c7f-443c-b4ea-46df198b20b5",
    "name": "Northwind",
    "initials": "N",
    "logo_url": null
  },
  "you": "2c539862-0ceb-4095-836d-0ebd94711798",
  "you_hold_pen": false,
  "is_owner": false,
  "pen": {
    "id": "72b37a72-42a5-4529-8cff-928a5db6757e",
    "full_name": "Dana Kim",
    "initials": "DK",
    "avatar_url": null
  },
  "members": [
    {
      "id": "72b37a72-42a5-4529-8cff-928a5db6757e",
      "full_name": "Dana Kim",
      "initials": "DK",
      "avatar_url": null,
      "holds_pen": true,
      "joined_at": "2026-09-15T17:40:30Z"
    },
    {
      "id": "2c539862-0ceb-4095-836d-0ebd94711798",
      "full_name": "Rob Mills",
      "initials": "RM",
      "avatar_url": null,
      "holds_pen": false,
      "joined_at": "2026-09-15T17:40:30Z"
    }
  ],
  "above": [
    {
      "id": "6d217ccd-7a0e-4e47-99c4-be6b28840a9e",
      "title": "Invoice the retainer",
      "reason": "It pays **next month's** payroll.",
      "status": "open",
      "position": 1,
      "assignee": null,
      "image_url": null,
      "thumb_url": null,
      "stale": false,
      "last_moved_at": "2026-09-15T17:40:30Z",
      "created_at": "2026-09-15T17:40:30Z",
      "solved_at": null,
      "flag": null,
      "values": {},
      "done_request": null,
      "done_proof": null
    },
    {
      "id": "aea0d9b2-2ac8-4578-a39f-a2e0fbf5165d",
      "title": "Chase the late client",
      "reason": "It pays **next month's** payroll.",
      "status": "open",
      "position": 2,
      "assignee": null,
      "image_url": null,
      "thumb_url": null,
      "stale": false,
      "last_moved_at": "2026-09-15T17:40:30Z",
      "created_at": "2026-09-15T17:40:30Z",
      "solved_at": null,
      "flag": null,
      "values": {},
      "done_request": null,
      "done_proof": null
    }
  ],
  "below": [],
  "done": [],
  "suggestions": [],
  "filters": [
    {
      "id": "76bd080c-2b7c-46f8-b8f0-60349c60347b",
      "name": "Brings money?",
      "kind": "yes_no",
      "created_at": "2026-09-15T17:40:30Z",
      "born_from": null
    }
  ],
  "activity": [
    {
      "id": "575b6335-d0f9-4617-af4d-1aca563c7b5d",
      "who": {
        "id": "72b37a72-42a5-4529-8cff-928a5db6757e",
        "full_name": "Dana Kim",
        "initials": "DK",
        "avatar_url": null
      },
      "action": "put on the list",
      "subject": "Chase the late client",
      "details": "It pays **next month's** payroll.",
      "at": "2026-09-15T17:40:30Z"
    },
    {
      "id": "3a52aab4-4d88-4a6e-a102-8df9e0c0184c",
      "who": {
        "id": "72b37a72-42a5-4529-8cff-928a5db6757e",
        "full_name": "Dana Kim",
        "initials": "DK",
        "avatar_url": null
      },
      "action": "put on the list",
      "subject": "Invoice the retainer",
      "details": "It pays **next month's** payroll.",
      "at": "2026-09-15T17:40:30Z"
    }
  ],
  "activity_more": false,
  "last_ordered_at": null,
  "flagged": 0,
  "park_days": 30,
  "line_min": 1,
  "line_max": 50
}

GET/api/assignedAnyone

Assigned to you

Open items assigned to you, across all your teams.

Request
curl -s https://penholder.app/api/assigned \
  -H "Authorization: Bearer $PENHOLDER_TOKEN"
Response200 OK
{
  "items": [],
  "total": 0
}

Anyone on the team

POST/api/suggestionsAnyone on the team

Suggest an item

Suggest an item; it waits for the pen holder to accept or decline. reason is Markdown, assigned_to a member's id, values maps field ids to answers. Edit it while it waits with PATCH /api/suggestions/SUGGESTION_ID, or take it back with POST /api/suggestions/SUGGESTION_ID/withdraw.

Request
curl -s -X POST https://penholder.app/api/suggestions \
  -H "Authorization: Bearer $PENHOLDER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "topic_id": "TOPIC_ID",
    "title": "Renew the domain",
    "reason": "It expires on the **30th**.",
    "assigned_to": "MEMBER_ID",
    "values": {
      "FIELD_ID": "no"
    }
  }'
Response201 Created
{
  "suggestion": {
    "id": "47ac734d-527a-4202-ac28-6c030198ad62",
    "title": "Renew the domain",
    "reason": "It expires on the **30th**.",
    "assignee": {
      "id": "2c539862-0ceb-4095-836d-0ebd94711798",
      "full_name": "Rob Mills",
      "initials": "RM",
      "avatar_url": null
    },
    "by": {
      "id": "2c539862-0ceb-4095-836d-0ebd94711798",
      "full_name": "Rob Mills",
      "initials": "RM",
      "avatar_url": null
    },
    "created_at": "2026-09-15T17:40:31Z",
    "values": {
      "76bd080c-2b7c-46f8-b8f0-60349c60347b": "no"
    }
  }
}

POST/api/priorities/ITEM_ID/flagAnyone on the team

Ask for another look

Ask the pen holder to look at an item again. Nothing moves. POST /api/priorities/ITEM_ID/unflag removes your flag.

Request
curl -s -X POST https://penholder.app/api/priorities/ITEM_ID/flag \
  -H "Authorization: Bearer $PENHOLDER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "note": "The client moved the deadline."
  }'
Response200 OK
{
  "ok": true
}

POST/api/priorities/ITEM_ID/request-doneAnyone on the team

Request done

Say an item is done, as a form with note and an optional file image. It stays on the list until the pen holder confirms. POST /api/priorities/ITEM_ID/withdraw-done takes it back.

Request
curl -s -X POST https://penholder.app/api/priorities/ITEM_ID/request-done \
  -H "Authorization: Bearer $PENHOLDER_TOKEN" \
  -F "note=Sent and paid on Friday."
Response200 OK
{
  "ok": true
}

Pen holder

These answer 403 unless the token's owner holds the pen on that team. Projects, topics and fields are the pen holder's too: POST /api/projects, POST /api/topics and POST /api/topics/TOPIC_ID/filters with {"name", "kind"}, where kind is yes_no or number.

POST/api/prioritiesPen holder

Add an item

Put an item at the bottom of a topic's list. similar lists open items with close titles, so you can spot a duplicate. Edit with PATCH /api/priorities/ITEM_ID.

Request
curl -s -X POST https://penholder.app/api/priorities \
  -H "Authorization: Bearer $PENHOLDER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "topic_id": "TOPIC_ID",
    "title": "Plan the offsite",
    "reason": "- book the venue\n- send the agenda",
    "assigned_to": "MEMBER_ID"
  }'
Response201 Created
{
  "item": {
    "id": "73908bca-7f88-4430-a3ea-05b2d708e3af",
    "title": "Plan the offsite",
    "reason": "- book the venue\n- send the agenda",
    "status": "open",
    "position": 3,
    "assignee": {
      "id": "2c539862-0ceb-4095-836d-0ebd94711798",
      "full_name": "Rob Mills",
      "initials": "RM",
      "avatar_url": null
    },
    "image_url": null,
    "thumb_url": null,
    "stale": false,
    "last_moved_at": "2026-09-15T17:40:31Z",
    "created_at": "2026-09-15T17:40:31Z",
    "solved_at": null,
    "flag": null,
    "values": {},
    "done_request": null,
    "done_proof": null
  },
  "similar": false
}

POST/api/priorities/ITEM_ID/movePen holder

Move an item

Move an open item to a position, 1 being the top. POST /api/topics/TOPIC_ID/order with {"ids": [...]} sets the whole order at once, and POST /api/topics/TOPIC_ID/line with {"line": 5} moves the line.

Request
curl -s -X POST https://penholder.app/api/priorities/ITEM_ID/move \
  -H "Authorization: Bearer $PENHOLDER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "position": 1,
    "why": "The venue fills up."
  }'
Response200 OK
{
  "ok": true
}

POST/api/priorities/ITEM_ID/donePen holder

Finish, or confirm done

Finish an item, or confirm someone's done request. POST /api/priorities/ITEM_ID/restore reopens it, and POST /api/priorities/ITEM_ID/send-back with {"why"} sends a done request back.

Request
curl -s -X POST https://penholder.app/api/priorities/ITEM_ID/done \
  -H "Authorization: Bearer $PENHOLDER_TOKEN"
Response200 OK
{
  "ok": true
}

POST/api/suggestions/SUGGESTION_ID/acceptPen holder

Accept a suggestion

Put a waiting suggestion on the list. POST /api/suggestions/SUGGESTION_ID/decline with {"why"} declines it.

Request
curl -s -X POST https://penholder.app/api/suggestions/SUGGESTION_ID/accept \
  -H "Authorization: Bearer $PENHOLDER_TOKEN"
Response201 Created
{
  "item": {
    "id": "bcbb1e63-bdf6-44c9-9927-2f624d99e4b5",
    "title": "Renew the domain",
    "reason": "It expires on the **30th**.",
    "status": "open",
    "position": 3,
    "assignee": {
      "id": "2c539862-0ceb-4095-836d-0ebd94711798",
      "full_name": "Rob Mills",
      "initials": "RM",
      "avatar_url": null
    },
    "image_url": null,
    "thumb_url": null,
    "stale": false,
    "last_moved_at": "2026-09-15T17:40:31Z",
    "created_at": "2026-09-15T17:40:31Z",
    "solved_at": null,
    "flag": null,
    "values": {
      "76bd080c-2b7c-46f8-b8f0-60349c60347b": "no"
    },
    "done_request": null,
    "done_proof": null
  }
}

Browser only

A token gets 403 on these, which take a signed-in browser: signing out, changing or resetting the password, changing the email, deleting the account or a team, billing checkout and the billing page, and listing, creating or revoking tokens.

Errors

Errors are JSON with a detail sentence you can show to a person.

StatusMeaning
400Something in the body is missing or not allowed
401No token, an unknown one, or a revoked one
402The team is not paid up: it has no plan, or a payment is due. Everything in it answers 402 until it is paid
403A browser-only call, or it needs the pen
404Not found, or not on a team you are in
409A conflict, like an eleventh token
429Too many requests; wait the seconds in Retry-After

POST/api/priorities

A member cannot add

Without the pen, writes that change the list are refused, and the answer says what to do instead.

Request
curl -s -X POST https://penholder.app/api/priorities \
  -H "Authorization: Bearer $PENHOLDER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "topic_id": "TOPIC_ID",
    "title": "Straight on the list"
  }'
Response403 Forbidden
{
  "detail": "Only the person holding the pen adds to the list. Suggest it instead."
}

GET/api/teams

An unknown or revoked token

No token, an unknown one, or a revoked one.

Request
curl -s https://penholder.app/api/teams \
  -H "Authorization: Bearer $PENHOLDER_TOKEN"
Response401 Unauthorized
{
  "detail": "That API token is not valid. It may have been revoked. Create a new one in your profile."
}

Limits

Each token has its own request budget, wherever it calls from. Reasons and descriptions hold up to 5,000 characters of Markdown, titles 200, and notes 300.