Agent-first CLI for Asana. Single-file entry point (aslan.py, Python 3
stdlib only), run as aslan once symlinked onto your PATH. Output is plain
text โ one item per line (<name> <gid>), stable and trivial for an agent to
parse; read commands take --json for JSON output.
aslan authenticates with an Asana Personal Access Token (PAT). Create one in Asana: Settings โ Apps โ Developer console โ Personal access tokens โ Create new token (direct link: https://app.asana.com/0/my-apps).
Don't paste the raw token into a dotfile or export โ resolve it from a
secret manager at runtime instead. Two supported ways:
Command hook (recommended, manager-agnostic). Point $ASANA_PAT_CMD at
any command that prints the token to stdout; aslan runs it fresh each call:
export ASANA_PAT_CMD="op read op://Private/Asana/token" # 1Password
export ASANA_PAT_CMD="pass show asana/pat" # pass
export ASANA_PAT_CMD="vault kv get -field=token secret/asana" # VaultInjected env var. Let your manager populate $ASANA_PAT at exec time โ
e.g. with 1Password, keep a reference (not the secret) in an env file and
run under op run:
# asana.env โ holds a secret reference, not the token
ASANA_PAT=op://Private/Asana/tokenop run --env-file=asana.env -- aslan whoamiResolution order: $ASANA_PAT first, then $ASANA_PAT_CMD. The token is
never written to disk by aslan.
Remember that plain-text tokens on the filesystem are bad for your health.
ln -sfn "$(pwd)/aslan.py" ~/.local/bin/aslan
aslan whoamiSet your workspace once in your shell (required for my-tasks, search, and
digest). It's a global flag too โ --workspace <gid> before the subcommand:
export ASANA_WORKSPACE=<your-workspace-gid>| Var | Default | Meaning |
|---|---|---|
ASANA_PAT |
(unset) | token; populate securely at runtime (see Auth) |
ASANA_PAT_CMD |
(unset) | command that prints the token to stdout, used if ASANA_PAT is unset |
ASANA_WORKSPACE |
(unset) | workspace gid; required for my-tasks / search / digest |
ASANA_USER |
me |
task owner for my-tasks / digest |
ASANA_GOAL_FIELD |
(unset) | custom-field name surfaced inline by my-tasks / task; off if unset |
aslan whoami # token owner (name, gid, email)
aslan my-tasks # tasks assigned to me
aslan my-tasks --open # incomplete only
aslan task <gid> # one task's detail
aslan create "name" # create a top-level task (unassigned)
aslan create "name" --assignee me --due 2026-07-15 # assign + due date
aslan create "name" --parent <gid> # create as a subtask
aslan subtask <parent-gid> "name" # create a subtask under a parent
aslan create "name" --dry-run # print the request body, send nothing
aslan create "name" --field <field-gid>:<value> # set a custom field
aslan create "name" --goal "Lead the Category" # set the goal field by name
aslan subtasks <gid> # list a task's subtasks
aslan set <gid> --name "new" --assignee me --due 2026-07-20 # edit an existing task
aslan set <gid> --assignee "" # clear a field ("" unassigns; --due "" clears due)
aslan set <gid> --goal "Campaigns" # set the goal field on a task
aslan delete <gid> --yes # delete a task (--yes required; --dry-run to preview)
aslan add-project <gid> <proj> # add a task to a project
aslan remove-project <gid> <proj> # remove a task from a project
aslan done <gid> # mark completed (echoes before/after)
aslan reopen <gid> # mark not completed
aslan comment <gid> "text" # add a comment (story)
aslan comments <gid> # read a task's comments (--all for system events)
aslan due <gid> 2026-07-01 # set due date (YYYY-MM-DD)
aslan search "query" # typeahead task search
# Resolve names to gids (creates need gids for --project/--assignee/--field):
aslan projects # list projects (name -> gid); "query" to filter
aslan users "patrick" # find users (name -> gid, email)
aslan fields # workspace custom fields + enum options (+ gids)
aslan fields <project-gid> # custom fields attached to a project
aslan digest # recent activity on your assigned tasks (Inbox subset)
aslan digest --days 2 # tighter look-back window
aslan digest --comments-only # human comments only, skip system events
aslan digest --all # include completed tasks tooEvery write command (create, subtask, set, delete, add-project,
remove-project) takes --dry-run to print exactly what it would send without
touching Asana. --dry-run is fully offline (no network) except when combined
with --goal, which must read the field definitions to resolve the option name.
Stdlib unittest, no network or token needed:
python3 -m unittest discover -s tests- Asana tasks carry no built-in status field beyond the
completedcheckbox, so intermediate status (e.g. "in progress") is usually tracked in comments. createmakes a top-level task in$ASANA_WORKSPACE(or the subtask endpoint when--parentis given);subtaskis the ergonomic front door for the same subtask path. Both default to unassigned โ pass--assignee me(or a user gid) to assign.--dry-runprints the request body and sends nothing, so an agent can preview a write before committing to it.--field <field-gid>:<value>sets a custom field by raw gid. For enum fields the value must be the enum-option gid, not the visible label โ runaslan fieldsto list fields and their option gids.--goal "value"is the friendly path for the field named by$ASANA_GOAL_FIELD: it resolves the field by name (exact match wins over substring) and, for enum/multi_enumfields, matches your value to an option name (exact, then case-insensitive substring), erroring with the list of valid options if there's no unique match.dateandnumbergoal fields get the right JSON shape;textfields take the raw string; an empty value is an error. Works oncreate,subtask, andset, and needs a workspace. It resolves workspace/org-shared custom fields only โ a project-local goal field won't be found by name; useaslan fields <project>to get its gid and set it with--field <gid>:<value>.- Attaching vs. updating a custom field: Asana lets you update a custom
field a task already carries, but the API won't attach a workspace field to
a task that lacks it. So
--goal/--fieldwork when the task already has the field (e.g. mirroring status on tasks the sheet already tagged), but setting a goal on a brand-new bare task returns HTTP 400 ("is not on given object"); aslan turns that into a hint. Attach the field once in the Asana UI (or via a project that includes it), then aslan can set it.create/subtasksucceed regardless โ only the custom-field write is subject to this. setedits an existing task in one PUT. It uses presence, not truthiness, so you can explicitly clear:--assignee ""unassigns,--due ""clears the due date,--notes ""empties the description.--namecan't be empty.deleterefuses to run without--yes(the task moves to Asana's trash).projects,users, andfieldsexist so an agent can resolve human names to the gids thatcreate/setneed for--project,--assignee, and--field.my-taskssurfaces the custom field named byASANA_GOAL_FIELDinline when the task has one.digestaggregates the activity feed of each task assigned to you into one recent-activity view. It is deliberately not the Asana Inbox โ Asana's API exposes no notifications/Inbox endpoint, sodigestcan't see tasks you only follow, @mentions on tasks you aren't assigned, project messages, or read/unread state. It makes one API call per task, so it's scoped to open tasks and a look-back window (--days, default 7).