- Emacs Lisp 99.8%
- Makefile 0.2%
`CF_' is not a generally recognized abbreviation for "custom field"
(it reads as CompactFlash, Cloudflare, cash flow, ...), and it was the
only property outside the `CLICKUP_' namespace every other managed
property uses. Switch the prefix to `CLICKUP_FIELD_', which is
self-explanatory, consistent with the rest of the drawer, and mirrors
the API's /list/{id}/field endpoint. The prefix now lives in one
constant, `org-clickup-custom-field-property-prefix'. A re-pull
rewrites existing CF_ drawers.
Assisted-by: Claude Code Opus 4.8
|
||
|---|---|---|
| docs | ||
| lisp | ||
| test | ||
| .gitignore | ||
| Makefile | ||
| README.org | ||
org-clickup
Sync ClickUp tasks with org-mode TODO entries from inside Emacs.
Status: pre-release (0.1.0-pre). The package is usable end-to-end for personal task tracking but the API surface may still shift. See the roadmap for what's done and what's coming.
What it does
- Pulls tasks from ClickUp into one org file per List, with watermark-based incremental sync.
- Pushes edits back to ClickUp: name, description, status, priority, due/start dates, time estimate, assignees, tags.
- Renders display names (
alice,bob,Engineering,ACME) alongside the raw IDs so the org buffer reads like a project view rather than a database dump. - Nests subtasks under their parent on pull, and creates subtasks on ClickUp when you write a heading under another task in org.
- Detects server-side conflicts before pushing, with a configurable resolution policy.
- Skips redundant pushes via a content hash, so a
syncafterpushis a no-op. - Lets you read and post comments on demand (no per-task GET on every sync).
Requirements
- Emacs 29.1 or newer (uses built-in
json-parse-string,json-serialize,secure-hash,auth-source, modernorg). - A ClickUp Personal API Token (
pk_...). Generate one at https://app.clickup.com/settings/apps.
Installation
Until the package is on a package archive, clone the repo and add it
to your load-path:
(add-to-list 'load-path "/path/to/org-clickup/lisp")
(require 'org-clickup)
(require 'org-clickup-setup)
(require 'org-clickup-sync)
Setup
Run the first-run wizard once:
M-x org-clickup-setup
It will:
- Prompt you for the
pk_...token (read viaread-passwd). - Validate it against
GET /team. - Let you pick a workspace if you have more than one.
- Persist the token via
auth-source(so~/.authinfo.gpgif you use GPG, otherwise wherever yourauth-sourcechain points). - Save the chosen workspace id as
org-clickup-team-idvia Customize.
Per-file configuration
Each org file that mirrors a ClickUp List gets a small header. Use the discovery wizard to drill down through Workspace → Space → Folder → List and write the header for you:
M-x org-clickup-insert-list-header
Or write it by hand:
#+TITLE: Engineering tasks
#+CLICKUP_TEAM_ID: 1234567
#+CLICKUP_LIST_ID: 901234567890
#+CLICKUP_LIST_ID: is optional — if you omit it, sync hits the
workspace-wide Get Filtered Team Tasks endpoint instead of the
per-List one.
To pull across multiple Lists, or to filter by assignee, use the optional CSV keywords:
#+CLICKUP_LIST_IDS: 901,902,903 ; multi-List filter (workspace-wide)
#+CLICKUP_ASSIGNEES: 12,34 ; filter to those user IDs
#+CLICKUP_LIST_IDS: takes precedence over #+CLICKUP_LIST_ID: and
routes through Get Filtered Team Tasks with list_ids[], since the
per-List endpoint can only scope to one List. #+CLICKUP_ASSIGNEES:
applies to whichever endpoint is selected.
The discovery wizard (org-clickup-insert-list-header) also asks
whether to add an assignee filter and, if yes, lets you multi-select
workspace members; the resulting #+CLICKUP_ASSIGNEES: line is
written alongside the team/list keywords. At the Space picker, the
wizard surfaces a (all Lists in workspace) entry — pick it to skip
the rest of the drill-down and write a workspace-wide header (no
#+CLICKUP_LIST_ID:), useful for a personal "everything assigned to
me" file when combined with #+CLICKUP_ASSIGNEES:.
Workflow
Pull from ClickUp
M-x org-clickup-sync-buffer
Incrementally fetches tasks updated since the last successful sync (per-file watermark). On the first run, fetches everything.
Pull every configured file at once
M-x org-clickup-sync
Sweeps every file in org-clickup-sync-files and pulls each one —
without needing to visit the buffer first. Files org-clickup opens
itself are saved and closed afterwards; a buffer you already had open
is left modified for you to review. One file failing (missing file,
no team id, network error) does not abort the rest; failures are
reported at the end.
org-clickup-sync-files is populated automatically the first time
org-clickup-insert-list-header writes a header into a file. You can
also edit the list via M-x customize-variable RET
org-clickup-sync-files, or drive a periodic background pull from a
timer:
(run-at-time t 900 #'org-clickup-sync) ; every 15 minutes
Edit a task in org
Just edit the headline, description (inside the :CLICKUP: drawer),
DEADLINE, SCHEDULED, EFFORT, etc. — or change the values inside the
property drawer (:CLICKUP_STATUS:, :CLICKUP_PRIORITY:,
:CLICKUP_ASSIGNEE_IDS:, etc.).
Custom fields
Custom field values render into the property drawer on pull, each under
a property named after the field (so Story Points becomes
:CLICKUP_FIELD_STORY_POINTS:). The field's ClickUp id rides alongside
in a :CLICKUP_FIELD_..._ID: companion — that is what identifies the
field on push, so you read and edit by name and never have to know the
raw id:
:PROPERTIES:
,...
:CLICKUP_FIELD_STORY_POINTS: 5
:CLICKUP_FIELD_STORY_POINTS_ID: a1b2c3d4-...
:CLICKUP_FIELD_PRIORITY: High
:CLICKUP_FIELD_PRIORITY_ID: e5f6...
:END:
Edit the value like any property — C-c C-x p on a drop-down or label
field completes its configured options — and push it back with
org-clickup-push-at-point. Each changed field is sent to ClickUp's
POST /task/{id}/field/{id} endpoint (one call per field), encoded for
its type, and only when it actually differs from the server. Custom-field
push needs the pre-push conflict GET, so it is skipped under
org-clickup-trust-local. Removing a property line does not delete the
field on ClickUp.
To override the auto-generated property name on a given List, set
org-clickup-custom-field-map (optional):
(setq org-clickup-custom-field-map
'(("901234567890" ; List ID
("Story Points" . "SP"))))
Push your edits
M-x org-clickup-push-at-point
The pipeline:
- Compares the entry's
:CLICKUP_LOCAL_HASH:to a freshly computed one — if unchanged, skips silently. - Pre-push GET to detect server-side conflicts (unless
org-clickup-trust-localist). - PUTs scalar fields plus an
assigneesadd/remove diff. - Issues one POST/DELETE per added/removed tag.
- Writes the new
:CLICKUP_UPDATED:and:CLICKUP_LOCAL_HASH:back to the entry.
Create a new task
Write a regular org TODO heading anywhere in a configured file, then:
M-x org-clickup-create-from-heading
If the heading sits under an existing ClickUp task (with a
:CLICKUP_TASK_ID:), the new task is created as a subtask of that
parent in ClickUp too.
Comments
M-x org-clickup-fetch-comments-at-point ; pull all comments M-x org-clickup-add-comment-at-point ; post a new one
Comments render as level+1 sub-headings under the task, with
:CLICKUP_COMMENT_ID: for in-place refresh on re-fetch.
Configuration
Common =defcustom=s:
| Variable | Default | Effect |
|---|---|---|
org-clickup-team-id |
nil | Workspace ID. Set by the wizard. |
org-clickup-conflict-strategy |
prompt |
prompt / server-wins / org-wins / abort on push-time conflict. |
org-clickup-trust-local |
nil | Skip the pre-push GET. Saves an API call; loses conflict detection and the assignees/tags diff. |
org-clickup-include-closed |
t | Include closed tasks on pull. |
org-clickup-use-status-as-todo |
nil | When non-nil, use ClickUp status verbatim as the TODO keyword (e.g. IN-PROGRESS); else collapse. |
org-clickup-priority-map |
=((1 . ?A) (2 . ?A) …) = | ClickUp priority int → org priority char. |
org-clickup-drawer-name |
"CLICKUP" |
Name of the drawer holding the task description. |
org-clickup-custom-field-map |
nil | Optional per-List override of custom-field property names; by default the key is derived from the field name. |
See the org-clickup Customize group for the full list.
Roadmap
Implemented (Phase 0–4):
- Pure HTTP/SDK layer with rate-limit governor and pagination
- Read-only sync with watermark-based incremental fetch
- Onboarding wizard (
org-clickup-setup) and List discovery - ID-to-name resolution in the property drawer
- Scalar-field push with conflict detection and hash-skip
- Assignees and tags diff push
- Comments (on-demand fetch and add)
- Subtask nesting on pull, parent-aware task creation
- Registry-wide pull (
org-clickup-syncoverorg-clickup-sync-files, no buffer needed)
Coming (Phase 5+, see docs/design.md):
- Phase 5 — Custom fields (per-List name → property map): rendered on
pull, pushed via
POST /task/{id}/field/{id}with per-type encoding and diff-vs-server, plusC-c C-x pcompletion of drop-down/label options - Phase 6 — UX polish: transient menu, status cycle command,
[[clickup:abc12]]link type - Phase 7 — Verbatim status mapping, property overrides
- Phase 8 — Deletion reconciliation, backup retention, 3-way merge for conflict resolution
- Filter keywords —
#+CLICKUP_ASSIGNEES:(numeric IDs) and#+CLICKUP_LIST_IDS:(multi-List) shipped;meshorthand and#+CLICKUP_STATUSES:still pending - Org-capture and refile hooks
- Buffer-wide push (
org-clickup-push-buffer)
Development
make test # run the full ert suite
make test-quick # skip :slow tagged tests
make compile # byte-compile lisp/*.el with -Werror
No external test dependencies — a tiny property-based testing helper
is bundled in test/test-helper.el.
Architecture and rationale: docs/design.md.
Prior-art studies (org-trello, org-jira, org-gcal) live alongside it
in docs/prior-art/.