Announcing CIMD support for MCP Client registration
Learn more

How to Build a Multi-User Asana Agent with Claude and Scalekit

Saif Ali Shaik
Founding Developer Advocate

TL;DR

  • A shared Asana service account carries admin-level scope. Users querying through it see data they were never supposed to access. Shared credentials do not enforce per-user visibility.
  • The challenge is not tool schemas. It is ensuring every execute_tool call runs under the correct user's connected account, not an over-provisioned bot.
  • Scalekit handles the Asana OAuth per user, stores tokens per identifier, refreshes automatically, and surfaces only the tools that user is authorized to call.
  • "What the user cannot do in Asana, the agent cannot do." Scope is derived from identity, not connector configuration.
  • The identifier maps your user to their Asana connection. Resolve it server-side. Never from client input.

A user asks the agent: "What's in the Leadership OKR board?"

The agent retrieves it. Shares it in full.

The user has no access to that board. The service account does. The agent acted as itself, and surfaced data the person querying it could not have accessed directly in Asana.

This is the less visible failure of shared-credential agents. Wrong assignees are obvious. Scope inflation is not. A service account provisioned with admin access does not inherit the user's visibility boundaries. Every query runs with the bot's permissions, not the user's.

Connected accounts close this at the tool-execution layer. Queries run under each user's own Asana permissions. What they cannot see, the agent cannot retrieve on their behalf.

Why Scope Inflation Is the Harder Problem

Wrong assignees are fixable. Scope inflation is silent.

A service account provisioned with admin access lets every user query every project, retrieve every task, and read every comment — regardless of what they are actually allowed to see. The agent does not restrict. It inherits the credential's permissions wholesale.

The blast radius of one token is the entire workspace. No per-user visibility enforcement. No permission boundary the agent respects.

How execute_tool Knows Who Is Asking

In the Anthropic messages loop, every tool_use block Claude returns needs to be executed as someone. Without an identifier, that someone is always the service account.

Scalekit resolves the right connected account from the identifier, retrieves the correct OAuth token from the token vault, and executes the Asana call under that user's permissions. "Credentials never touch the agent runtime."

Recommended Reading: Scalekit AgentKit Quickstart — connected accounts, scoped tools, and the execution model in full.

Prerequisites

  • Python 3.10+
  • A Scalekit account with an Asana connection created under Agent Auth > Connections. The connection name must match exactly — for example, asana.
  • Anthropic SDK and Scalekit Python client.
  • An Anthropic API key.
  • Working familiarity with Anthropic tool calling.

Step 1: Install and Set Up Clients

pip install anthropic scalekit

Create a .env file:

SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.cloud SCALEKIT_CLIENT_ID=skc_... SCALEKIT_CLIENT_SECRET=sks_... ANTHROPIC_API_KEY=sk-ant-... USER_IDENTIFIER=user_123

Initialize both clients:

# agent.py from scalekit import Scalekit from dotenv import load_dotenv import os load_dotenv() scalekit = Scalekit( os.getenv("SCALEKIT_ENVIRONMENT_URL"), os.getenv("SCALEKIT_CLIENT_ID"), os.getenv("SCALEKIT_CLIENT_SECRET"), ) # In production: resolve from your auth context, never from client input. IDENTIFIER = os.getenv("USER_IDENTIFIER") CONNECTION = "asana" # must match the connection name in your Scalekit dashboard

Step 2: Connect the User's Asana Account

get_or_create_connected_account returns the current state. If not active, generate a one-time authorization link for that user.

response = scalekit.connect.get_or_create_connected_account( connection_name=CONNECTION, identifier=IDENTIFIER, ) if response.connected_account.status != "ACTIVE": link = scalekit.connect.get_authorization_link( connection_name=CONNECTION, identifier=IDENTIFIER, ) print("Authorize Asana:", link.link) input("Press Enter after authorizing...")

First run prompts the user to authorize. Every subsequent run uses the stored, automatically refreshed connection.

Step 3: Retrieve the Authorized Tool Surface

The agent should not receive a flat connector catalog. list_scoped_tools returns only what this user's connected account is permitted to call, already formatted for Claude's native tool input.

scoped_response = scalekit.tools.list_scoped_tools( identifier=IDENTIFIER, filter={"connection_names": [CONNECTION]}, page_size=100, ) llm_tools = [ { "name": tool.definition.name, "description": tool.definition.description or tool.definition.name, "input_schema": tool.definition.input_schema, } for tool in scoped_response.tools ] print(f"Discovered {len(llm_tools)} Asana tools")

"What the user cannot do in Asana, the agent cannot do." Scope is derived from their connected account, not a service credential with admin visibility across the entire workspace.

Typical tools surfaced for an active Asana connection:

  • asana_create_task — create a task in a project or workspace
  • asana_list_tasks — list tasks by assignee, project, or status
  • asana_update_task — update title, description, due date, or assignee
  • asana_add_comment_to_task — add a comment to a task
  • asana_get_project — retrieve project details and members

Step 4: Run the Agent Loop with Claude

The IDENTIFIER binds each tool execution to the correct user. Claude decides which tool to call; Scalekit determines whose Asana account to use.

import anthropic client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) messages = [ {"role": "user", "content": 'Create a high-priority task "Review Q3 roadmap" in the "Website" project and assign it to me'} ] while True: response = client.messages.create( model=os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6"), max_tokens=1024, tools=llm_tools, messages=messages, ) if response.stop_reason == "end_turn": print(response.content[0].text) break tool_results = [] for block in response.content: if block.type == "tool_use": print(f" -> Calling: {block.name}") result = scalekit.tools.execute_tool( tool_name=block.name, identifier=IDENTIFIER, tool_input=block.input, ) tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": str(result.data), }) messages.append({"role": "assistant", "content": response.content}) messages.append({"role": "user", "content": tool_results})

The Identifier Is Not Metadata

The identifier is the security control that determines which Asana account makes each tool call. Passing the wrong one does not produce an error. It silently acts as the wrong user.

# Correct: resolve from your session after authenticating the request identifier = get_current_user_id_from_session(request) # Incorrect: accept from anything the client controls identifier = request.args.get("user_id") # never do this

"Credentials never touch the agent runtime." The identifier must come from your authentication layer: your session, your JWT claims, your verified database record. This is a fundamental principle of secure token management in agentic systems.

Running It

python agent.py

Or with a custom prompt:

python agent.py "List my open tasks and add a comment to the first one: 'Updated via agent'"

Expected output:

Authorize Asana: https://... # first run only Discovered 12 tools -> Calling: asana_create_task Task created in project "Website". Task ID: 123456789. Assigned to you.

Tradeoffs

Dimension
Shared bot / service account
Build your own OAuth + vault
Scalekit connected accounts
Data visibility scope
Bot's permissions, often admin
User's permissions, if built correctly
User's permissions, always
Per-user permissions
No
Yes
Yes
Token maintenance
Manual
Owned by you
Automatic
Revocation per user
No — revoking breaks everyone
Yes
Yes
When to use
Prototypes only
Rarely worth the maintenance cost
Production multi-user agents

Troubleshooting

Why does my connection never become ACTIVE after the user finishes OAuth?

You probably skipped the verification step.

After the user completes the Asana consent screen, Scalekit redirects or you must explicitly verify in a protected endpoint that the current authenticated user owns the identifier.

If you never verify, or verify incorrectly, the account stays pending.

Why do the tools return permission errors or empty results?

The connected account either never received the required Asana scopes during OAuth, or the token was revoked/changed later.

Re-authorize with a fresh link for the same identifier.

Why is the identifier "not found" or list_scoped_tools empty?

  • No active connection for that identifier + connection name.
  • You are trusting an identifier from the client instead of resolving it server-side after your own auth check.

Rule: Identifier is sensitive. Resolve from your session/DB after authenticating the caller.

How do I handle re-auth or expired connections?

Call get_authorization_link again with the same connection_name + identifier. Scalekit handles re-authorize/refresh.

Can one user connect multiple services?

Yes. Same identifier, different connection_names in the filter.

What Correct Scoping Prevents

A shared-credential agent does not just use the wrong identity. It uses the wrong permissions. A service account with admin access silently makes every user an admin.

Per-user connected accounts enforce Asana's permission model exactly. No user sees data they shouldn't. Audit trails show real people. Revocation affects one account, not all.

That is what makes it a real multi-user agent.

Browse all AgentKit connectors

What's Next

No items found.
Agent Auth Quickstart
On this page
Share this article
Agent Auth Quickstart

Acquire enterprise customers with zero upfront cost

Every feature unlocked. No hidden fees.
Start Free
$0
/ month
1 million Monthly Active Users
100 Monthly Active Organizations
1 SSO connection
1 SCIM connection
10K Connected Accounts
Unlimited Dev & Prod environments