A shared Asana service account makes the agent's "me" resolve to a bot, not the user. Works in demos. Doesn't survive the first real task assignment.
The real challenge is not Asana's tool schemas. It is ensuring "assign to me" means the right person — which requires each user's own connected account.
Scalekit handles the Asana OAuth per user, stores tokens per identifier, refreshes automatically, and returns 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 accept it from client input.
A user types: "Create a task and assign it to me."
The agent creates it. Assigns it to the service account.
No error. No warning. The task exists — owned by a bot, invisible in the actual user's My Tasks, and attributed to a machine in the project audit trail.
This is the Asana shared-credential problem in its most concrete form. "Me" resolves to the bot identity, because that is the only identity the agent has. Task creators, comments, assignments — all bot. The real user's Asana identity never appears.
Connected accounts fix this at the architecture level. Each user authorizes their own Asana identity once. The agent acts as that person: their workspace, their permissions, their assignee field resolving to an actual human.
Why the Bot Identity Breaks Real Workflows
When the agent runs as a service account, it has no concept of the current user. Asana's assignee: me field, user-specific project memberships, and personal task views all resolve to the bot's identity — silently, without errors.
The task gets created. It is assigned to the wrong person. Every time.
Connection time. The user authorizes Asana through Scalekit's OAuth flow. Scalekit stores the token under a stable identifier you control. You verify server-side that the authenticated user completed the OAuth. Scalekit owns the token lifecycle from that point.
Runtime. Your code resolves the identifier for the currently authenticated user. Scalekit returns only the tools that user's connected account is authorized to call. "Credentials never touch the agent runtime."
Recommended Reading:Scalekit AgentKit Quickstart — connected accounts, scoped tools, and the execution model in full.
Prerequisites
Node.js 18+
A Scalekit account with an Asana connection created under Agent Auth > Connections. The connection name must match exactly — for example, asana.
Mastra installed (npm create mastra@latest or an existing project).
An OpenAI API key (or swap for your preferred LLM provider).
Initialize the Scalekit client in a shared module:
// lib/scalekit.ts
import { Scalekit } from '@scalekit-sdk/node';
import 'dotenv/config';
export const scalekit = new Scalekit(
process.env.SCALEKIT_ENV_URL!,
process.env.SCALEKIT_CLIENT_ID!,
process.env.SCALEKIT_CLIENT_SECRET!
);
// In production: resolve from your auth context, never from client input.
export const IDENTIFIER = process.env.USER_IDENTIFIER!;
export const CONNECTION = 'asana'; // must match connection name in Scalekit dashboard
Step 2: Connect the User's Asana Account
getOrCreateConnectedAccount returns the current state. If not active, generate a one-time authorization link for that user.
// agent.ts
import { scalekit, IDENTIFIER, CONNECTION } from './lib/scalekit';
async function ensureConnected() {
const account = await scalekit.connectedAccounts.getOrCreateConnectedAccount({
connectionName: CONNECTION,
identifier: IDENTIFIER,
});
if (account.connectedAccount.status !== 'ACTIVE') {
const link = await scalekit.actions.getAuthorizationLink({
connectionName: CONNECTION,
identifier: IDENTIFIER,
});
console.log('Authorize Asana:', link.link);
console.log('After completing OAuth, re-run this script.');
process.exit(0);
}
console.log(`Connected account for ${IDENTIFIER} is active.`);
}
await ensureConnected();
"What the user cannot do in Asana, the agent cannot do." Scope is derived from their connected account, not a service credential provisioned with admin-level access for convenience.
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 — add a comment to a task
asana_get_project — retrieve project details and members
Step 4: Wrap as Mastra Tools
Each Scalekit tool becomes a native Mastra tool. Every executeTool call resolves to the user's token inside Scalekit's token vault — the agent calls a tool, gets a result, and never sees a credential.
import { createTool } from '@mastra/core';
import { z } from 'zod';
const mastraTools: Record> = {};
for (const tool of toolsResponse.tools) {
const def = tool.definition as Record | undefined;
if (!def?.name) continue;
const toolName: string = def.name;
mastraTools[toolName] = createTool({
id: toolName,
description: def.description || toolName,
inputSchema: z.object({}).passthrough(),
execute: async ({ context }) => {
return scalekit.tools.executeTool({
toolName,
identifier: IDENTIFIER,
params: context as Record,
});
},
});
}
console.log(`Created ${Object.keys(mastraTools).length} Mastra tools.`);
Step 5: Build and Run the Agent
import { Agent } from '@mastra/core';
const agent = new Agent({
name: 'asana-agent',
instructions:
'You are a helpful Asana assistant. Manage tasks and projects using the available tools. Confirm each action after completing it.',
model: 'openai/gpt-4o',
tools: mastraTools,
});
const prompt =
process.argv[2] ||
'Create a high-priority task "Review Q3 roadmap" in the "Website" project and assign it to me';
const result = await agent.generate(prompt);
console.log(result.text);
Now when the user says "assign this to me," the identifier resolves to a real Asana user — not a bot.
The Identifier Is Not a Config Value
The identifier is the live security boundary between users. A misconfigured identifier does not crash. It silently acts as the wrong user.
// Correct: resolve server-side after authenticating
const identifier = getUserIdFromSession(req);
// Incorrect: accept from client input
const identifier = req.body.userId; // never do this
"Credentials never touch the agent runtime." The identifier must come from your authentication layer: your session, your JWT claims, your database. Never from anything the client controls.
Running It
npx tsx agent.ts
Or with a custom prompt:
npx tsx agent.ts "List my open tasks and add a comment to the first one: 'Updated via agent'"
Expected output:
Connected account for user_123 is active.
Discovered 12 tools
Created 12 Mastra tools.
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
Assignee resolution
Resolves to the bot
Correct, if built correctly
Correct — acts as the real user
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 to the userVerifyUrl you provided (or you call the equivalent in a protected endpoint). You must then call verifyConnectedAccountUser({ authRequestId, identifier }) after confirming (via your own auth) that the current logged-in user actually owns this identifier.
If you never call the verify step, or you call it with the wrong values, the account stays in a pending state.
Why do the tools return permission errors or empty results?
The connected account for that identifier either never received the required Asana scopes, or the token was later revoked / the scopes were changed on the Asana side.
Re-authorize the same identifier by generating a fresh link with getAuthorizationLink. The user will see the current consent screen based on the scopes you configured on the "asana" connection in the Scalekit dashboard.
Why is the identifier "not found" or why does listTools return an empty list?
Two very common causes:
The identifier you are using has no active connected account for the connection you asked for.
You are accepting the identifier from the client (e.g. from req.body or localStorage) instead of resolving it server-side after authenticating the request.
Rule: The identifier is sensitive. Always look it up from your database or session after you have verified that the current caller is allowed to act as that user. Never trust an identifier supplied by the frontend.
How do I handle re-auth or expired connections?
Just call getAuthorizationLink again with the same connectionName + identifier. Scalekit will detect the existing account and let the user re-authorize (or refresh) it. The identifier stays the same.
Can one user connect multiple services (Asana + GitHub, etc.)?
Yes. Use the exact same identifier value but pass different connectionNames when calling listTools / executeTool (or filter in the Mastra agent).
What Changes When the Agent Knows Who It Is
A shared-credential agent cannot resolve user-specific Asana fields. Its task history is a bot's history. It cannot be revoked per user without breaking all others.
Per-user connected accounts make the agent act as the person who asked. "Assign this to me" resolves correctly. Audit trails show real users. Accounts disconnect individually without affecting anyone else.
That is what makes the agent usable in a real Asana workspace — not just in a demo.