Asana
Asana is SABLE's project-management source of truth for tasks. It has two separate paths: a
read-only mirror that keeps SABLE's tasks table current, and a gated write executor that creates
Asana tasks only after a human approves the action. The two paths never overlap — the mirror cannot
write to Asana, and the executor cannot browse it.
How it works
Mirror (asana-sync)
asana-sync authenticates with ASANA_API_KEY and walks the account:
- List workspaces. If the API key can see more than one, the caller (or the scheduled job) must
pass
workspaceGidexplicitly. - List every project in that workspace. A dry run samples up to 200 projects; a real run pages to completion.
- List every task in each project (
completed_sincepinned to the epoch, so completed tasks are included and can be reconciled). - Match each Asana project to a SABLE project, in priority order:
projects.asana_gid(already linked)- A job code embedded in the Asana project name (regex
\d{8}-\d+) - Normalized project name
- Match each task's owner to a SABLE person, in priority order: assignee email, then assignee name. An unmatched owner name is kept as free text rather than defaulted onto the project lead.
- Upsert the result into
tasks, keyed on(org_id, asana_gid). - If — and only if — both the project list and the task scan for a project completed in full (no
partial page), tasks that disappeared from Asana are closed in SABLE with a
task_removed_from_workspaceactivity event. A partial scan never closes tasks, so a timeout or rate limit can't look like mass task completion.
A real (non-dry-run) sync additionally requires confirm:true in the request body — a second gate
on top of admin/lead auth.
Act executor (asana-act)
asana-act executes exactly one approved action per call. A task is created in Asana only when all
of the following hold:
- The caller is an admin/lead (via Supabase JWT), or a cron/internal caller passing
actorPersonIdthat resolves to an admin/lead person. - The approval belongs to the caller's org.
- The approval's
statusisapproved(notpending,denied, or alreadyexecuted). - The approval's
kindisasana_task_create. - The edge secret
ASANA_ACT_WRITES_ENABLEDis exactly"true".
On execution it claims the approval (approved → executing), resolves the intended owner against
project members by email or a text custom field, creates the Asana task via REST, writes the
returned asana_gid back onto the SABLE tasks row, and marks the approval executed (or
failed with the error). If the approval carries a dar_id (a data-access-request kickoff flow),
it also advances that request's status to requested. Every execution is logged to audit.
Configuration
| Setting | Value |
|---|---|
| Secret | ASANA_API_KEY (REST, used by both functions) |
| Write gate | ASANA_ACT_WRITES_ENABLED=true (edge secret; unset by default) |
| Scheduled workspace | ASANA_WORKSPACE_GID (required env for the scheduled sync) |
Cron cadence
| Task | Schedule | Calls |
|---|---|---|
sable.asana.sync | Every 2 hours, :25 (25 */2 * * *) | asana-sync with {confirm:true, limit:50, workspaceGid: <ASANA_WORKSPACE_GID>} |
asana-act has no schedule — it only runs when a human approval triggers it.
Table mapping
| Table | Connector | What's written |
|---|---|---|
tasks | asana-sync | Upsert on (org_id, asana_gid): text, owner, status, due date, source metadata, last-seen/verified timestamps |
tasks.asana_gid | asana-act | Set once a created task's Asana id comes back |
activity_events | asana-sync | task_removed_from_workspace when a full scan finds a task gone |
connector_checkpoints | asana-sync | Health/high-water-mark bookkeeping via the shared checkpoint helper |
approvals | asana-act | Read the approval, then advance its status |
data_access_requests | asana-act | Status bumped to requested when the approval carries a dar_id |
audit | asana-act | One row per execution attempt |
What can go wrong
- A rate limit or timeout mid-scan looks like a stalled mirror, not a broken one. Because
closing missing tasks requires a complete project-and-task scan, a partial run simply skips the
close-out step rather than closing tasks incorrectly. Check
connector_checkpointsfor theasanarow'shealthanderrorbefore assuming data loss. - Two Asana API keys see different workspace sets. If
workspaceGidisn't pinned and the key can see more than one workspace, the sync intentionally requires the caller to disambiguate rather than silently picking one. - A created task never lands in Asana. Check
ASANA_ACT_WRITES_ENABLEDfirst — it ships unset, so writes are off until an operator explicitly flips it. Then confirm the approval'skindis exactlyasana_task_createand itsstatusisapproved. - An owner shows up as free text instead of a linked person. This is intentional: an unmatched
assignee name is kept as-is rather than defaulted onto the project lead, so it doesn't silently
misattribute work. Fix the underlying email/name mismatch in Asana or in
people.
MCP
agents/asana-agent.yaml points the agent's hosted-tool integration at Asana's own MCP endpoint
rather than a SABLE-hosted proxy:
https://mcp.asana.com/v2/mcp
Least privilege here comes from Asana's own user/project permissions plus SABLE's human-approval
gate on asana-act — the MCP endpoint itself has no additional scoping logic.
Where the code lives
supabase/functions/asana-sync/index.ts— mirrorsupabase/functions/asana-act/index.ts— approved write executortrigger/connectors.ts—sable.asana.syncscheduleagents/asana-agent.yaml— MCP endpoint configuration