sema-asana
0.1.0Asana REST API (v1) client: tasks, projects, workspaces, comments, and pagination
sema-asana
Asana REST API (v1) client: tasks, projects, workspaces, comments, and pagination.
Install
sema pkg add sema-asana
Authentication
The client uses an Asana personal access token (PAT):
- Create one at https://app.asana.com/0/my-apps (My apps → Personal access tokens).
- Export it —
asana/clientreadsASANA_ACCESS_TOKEN, thenASANA_TOKEN:
export ASANA_ACCESS_TOKEN="1/1234567890:abcdef..."
Or pass it explicitly: (asana/client {:token (env "ASANA_ACCESS_TOKEN")}).
Never put a literal token in source. If no token is found the constructor
raises an error naming both env vars.
Quick start
(import "sema-asana")
(define client (asana/client))
;; Who am I, and where?
(define me (asana/me client))
(define ws (first (asana/workspaces client)))
(println (:name me) "in" (:name ws))
;; Create a task, comment on it, complete it.
(define task
(asana/create-task! client
{:name "Ship sema-asana"
:notes "Created from Sema"
:workspace (:gid ws)}))
(asana/comment! client (:gid task) "On it — tests are green.")
(asana/complete-task! client (:gid task))
; => {:gid "12007..." :completed #t ...}
Data unwrapping
Asana wraps every payload in a {"data": ...} envelope. This client unwraps it
for you: functions return the payload itself (a keyword-keyed map, or a list of
them), not the envelope. Request bodies go the other way — pass plain field
maps ({:name "..."}) and the client wraps them as {:data {...}}
automatically (see asana/wrap-body). Use :raw #t on asana/request when
you need the full response envelope (e.g. for pagination cursors).
API
| Function | Description |
|---|---|
(asana/client opts?) |
Build a client map. :token, :base-url, :timeout |
(asana/request client method path opts?) |
Raw API call — the escape hatch. :query, :body, :raw |
(asana/paginate client path opts?) |
Follow next_page offsets, return all items. :query, :limit, :max-pages |
(asana/query-string params) |
Build an encoded query string from a map |
(asana/wrap-body body) |
Wrap a body as {:data body} — what asana/request sends |
(asana/me client) |
The authenticated user |
(asana/workspaces client opts?) |
List workspaces |
(asana/projects client workspace-gid opts?) |
List projects in a workspace |
(asana/project client project-gid opts?) |
Fetch one project |
(asana/sections client project-gid opts?) |
List a project's sections |
(asana/tasks client filters opts?) |
List tasks by :project, or :assignee + :workspace |
(asana/task client task-gid opts?) |
Fetch one task |
(asana/create-task! client fields) |
Create a task |
(asana/update-task! client task-gid fields) |
Update task fields |
(asana/complete-task! client task-gid) |
Mark a task complete |
(asana/delete-task! client task-gid) |
Delete a task |
(asana/comment! client task-gid text) |
Comment on a task |
(asana/stories client task-gid opts?) |
List a task's stories (comments/activity) |
(asana/typeahead client workspace-gid resource-type query opts?) |
Search a workspace |
asana/client
(asana/client)
(asana/client {:token "..." :base-url "https://app.asana.com/api/1.0" :timeout 30000})
Returns a plain client map {:token ... :base-url ... :timeout ...} passed as
the first argument to every other function. Token resolution: :token option →
ASANA_ACCESS_TOKEN → ASANA_TOKEN → error. Defaults: base URL
https://app.asana.com/api/1.0, timeout 30000 ms.
asana/request
(asana/request client "GET" "/users/me")
(asana/request client "POST" "/tasks" {:body {:name "Task" :workspace "123"}})
(asana/request client "GET" "/tasks" {:query {:project "456" :limit 5} :raw #t})
The escape hatch for any endpoint the wrappers don't cover. Options:
:query— map of query params (seeasana/query-string):body— map, automatically wrapped as{:data body}and JSON-encoded:raw—#tto return the full decoded response (:dataand siblings like:next_page) instead of just the unwrapped:data
On a non-2xx status it raises an error containing the method, path, status, and Asana's first error message:
(try
(asana/task client "999999999")
(catch e (println (:message e))))
; asana: GET /tasks/999999999 → 404: task: Unknown object: 999999999
asana/paginate
(asana/paginate client "/tasks" {:query {:project "456"} :limit 100 :max-pages 5})
Asana paginates with offset cursors: a page is
{"data": [...], "next_page": {"offset": "...", ...}}. asana/paginate
requests pages with ?limit=N, follows next_page.offset until it runs out,
and returns the concatenated :data items as one list. Defaults: :limit 50,
:max-pages 10 (a safety cap — raise it for big result sets). All list
wrappers (asana/workspaces, asana/projects, asana/tasks,
asana/sections, asana/stories) accept the same options and paginate for
you — callers never touch offsets.
asana/query-string
(asana/query-string {:workspace "123" :limit 50})
; => "limit=50&workspace=123"
(asana/query-string {:opt_fields (list "name" "completed" "assignee.name")})
; => "opt_fields=name%2Ccompleted%2Cassignee.name"
Keys are keywords or strings; values are percent-encoded. Lists join with
commas (handy for opt_fields), booleans become true/false, keywords use
their name. Returns "" for an empty map — no leading ?.
Tip: every GET accepts an opt_fields param controlling which fields Asana
returns — e.g. (asana/task client gid {:query {:opt_fields "name,completed,due_on"}}).
asana/me
(asana/me client)
; => {:gid "1200..." :name "Helge Sverre" :resource_type "user" ...}
asana/workspaces
(asana/workspaces client)
; => ({:gid "1200..." :name "My Workspace" :resource_type "workspace"} ...)
asana/projects / asana/project / asana/sections
(asana/projects client workspace-gid)
(asana/projects client workspace-gid {:query {:archived #f}})
(asana/project client project-gid {:query {:opt_fields "name,notes"}})
(asana/sections client project-gid)
asana/tasks / asana/task
(asana/tasks client {:project "456"})
(asana/tasks client {:assignee "me" :workspace "123" :completed_since "now"})
(asana/task client task-gid)
Asana requires either :project, or :assignee together with :workspace —
asana/tasks validates this up front and raises a helpful error otherwise.
Extra filter keys (:completed_since, :modified_since, :section,
:opt_fields, ...) pass straight through as query params.
asana/create-task! / asana/update-task! / asana/complete-task! / asana/delete-task!
(asana/create-task! client {:name "Task" :workspace "123" :notes "..."})
(asana/create-task! client {:name "Task" :projects (list "456")})
(asana/update-task! client task-gid {:due_on "2026-08-01"})
(asana/complete-task! client task-gid)
(asana/delete-task! client task-gid)
asana/create-task! requires a non-empty :name plus a location — one of
:workspace, :projects (list of project gids), or :parent. Mutating
functions return the created/updated resource.
asana/comment! / asana/stories
(asana/comment! client task-gid "Looks good — shipping it.")
(asana/stories client task-gid)
Comments are Asana stories; asana/stories returns all of a task's stories,
including system activity (:type is "comment" for comments).
asana/typeahead
(asana/typeahead client workspace-gid "task" "quarterly report")
(asana/typeahead client workspace-gid "project" "website" {:query {:count 5}})
Workspace search. resource-type is one of "task", "project", "user",
"tag", "team", "portfolio", "goal", "custom_field".
Error handling
Non-2xx responses raise (error ...) with the pattern
asana: <METHOD> <path> → <status>: <Asana's first error message>. Argument
mistakes (bad gid, missing filters, tokenless client) are validated locally and
raise before any request is made. Catch with try/catch; the message is at
(:message e).
Testing
sema pkg add sema-test # once
sema tests.sema
Offline tests (client construction, query strings, body wrapping, validation)
always run. Live tests against the real API run only when ASANA_ACCESS_TOKEN
or ASANA_TOKEN is set.
License
MIT
| Version | Size | Published |
|---|---|---|
| 0.1.0 | 8 KB | 2026-07-07 21:27:52 |