Hundreds of projects, zero manual staffing: AI Studio + Script Actions

Hi all :waving_hand:

I’m Arthur a Forum leader, and passionate about AI and automations in Asana.
Please me know what you think about this use-case, and happy to answer questions. :wink:

Where Asana meets automation and scalability

Many teams live in the same loop: lots of similar requests come in (software updates, campaigns, onboardings, process changes…), and each one really deserves a proper project with tasks assigned to the right people.

That’s still manageable when you only have a few projects. But when you’re dealing with hundreds of similar requests, manually staffing each one and creating projects from templates doesn’t scale anymore.

This article shows a simple way to scale it with full automation by combining two Asana features:

  • AI Studio, to choose the right people based on skills and workload
  • Script Actions, to create a project from a template and assign those roles automatically

Please note that Script Actions are available exclusively on Enterprise plans

The result: one request converted to a fully staffed, fully structured project, fully automated!


Part 1 - The use-case

A- What is currently possible, when done by hand

1- Store requests in a project and analyze them manually

2- Visually identify the right ressources based on their workload

3- Create a project from a template and assign the roles manually

3bis- (Alternative) Use a rule to convert your task to a project, but loose the benefits of the template’s role
As template’s roles are not natively compatible with rules, tasks will need to be assigned manually after project creation

:hourglass_not_done: The pain is the estimated effort: 5 to 20 minutes by project, based on complexity and team size. That’s could represent 20 hours for 100 projects, or more.

B. The automated workflow solution :sparkles:

📼 Click if you prefer to watch the use-case in video

Sorry for sound quality, the wrong microphone was selected :person_shrugging:

End-to-end automated workflow is:

1- A request arrives in an intake project
A task is created in a project like “Requests” via a form or manually. It has any desired fields (type, priority…) but at least “due date”, and the project contains empty People custom fields for the roles you care about (e.g. Project Manager, Specialist…).

2- AI Studio staffs the task

The AI Studio rule:

3- Script Actions creates the project and assign tasks

A Script Action triggers when the task enters that section, and:


Part 2. How to implement it (prompt & script included)

Disclaimer: Some parts of that guide are for advanced users.

Click here to see the step-by-step guide (including prompts)

2.1. Prerequisites

You’ll need:

  1. Access to AI Studio
  2. (Optional) Access to Script Actions
  3. An intake project (e.g. “Requests”) with:
    1. A due date field
  • One People custom field per role (Project Lead, Specialist, etc.)
  • Sections such as “Pending assignment”, “:sparkles:Staffing”, “:robot: Create project with roles”..
  1. A project template that represents the typical work for one request, with:
  2. Defined ** template’s roles** that match your People fields
  3. Tasks of the template assigned to the roles

2.2. Step by step

Step 1 – Configure the intake workflow

  1. Create or reuse the intake project and its form.
  2. Add the People custom fields for each role you want to staff.
  3. Set up the sections you’ll use to track progress

Step 2 – Build the AI Studio staffing agent

  1. Trigger it with a rule when a new request is created or updated.
  2. In the agent instructions, tell it to:
  3. Filter the team by role (and optionally skills).
  4. Use a workload / capacity view to avoid overloading people.
  5. Pick 1 person per role and write a short explanation in a comment.
  6. Fill the People fields on the task.
  7. Move the task to:
  8. **“:robot: Create project with roles (by Script Actions)”***when all roles are staffed, or
  9. “Pending assignment” if something is missing or ambiguous.
📋 Click here to see the full prompt

You are an Asana staffing assistant for IT requests.

**Task**

Fill BOTH People custom fields on the current task:

- 🧑‍🍳Project Manager [IT]

- 🧑‍💻Technical Engineer [IT]

**How to pick people**

1) Open the IT Team page and find candidates using the field “Role [IT]”:

- For PM: pick from people whose Role [IT] = Project Manager

- For Tech: pick from people whose Role [IT] = Technical Engineer

Team page: [IT](https://app.asana.com/0/1202805238464599/1209002045072288)

2) Open the IT Workload page and pick, among the candidates, the person with the MOST capacity during the task timeframe (start date and end date).

Workload page: [[IT] Team Workload](https://app.asana.com/0/1212848555821367/1212813398026822)

**Rules**

- If the task has no due date → do NOT assign anyone.

- If you can’t find a valid person for one role → leave it empty.

- Only assign someone if their Role [IT] matches the role.

- Prefer not assigning the same person to both roles unless they clearly match both roles AND have capacity.

**After you attempt staffing (ALWAYS add a comment)**

Post a nicely formatted comment with emojis that includes:

- 📅 Due date (or “missing due date”)

- 🧑‍🍳 PM chosen (name) + short reason (“Role match + highest capacity”)

- 🧑‍💻 Tech chosen (name) + short reason (“Role match + highest capacity”)

- 🔎 Pages used: Team + Workload

- ✅ Result: “Staffing complete” OR “Missing assignment(s): …”

**Move the task based on outcome**

- If BOTH roles are filled → move task to section:

“🤖 Create project with roles (by Script Actions)”

- If ONE or BOTH roles are not filled → move task back to section:

“Pending Assignment”

Step 3 – Build the Script Action that creates the project

  1. Trigger it when a task enters the “Create project with roles” section.
  2. In the script, you:
  3. Read the task and its People custom fields.
  4. Call instantiateProject(...) on your template, passing:
  5. A generated project name
  6. A requested_roles mapping from template roles → user IDs from the People fields
  7. (Optional) requested_dates if your template expects date variables (for example, start = today, due = the request’s due date)
  8. The team, if needed in your workspace
📋 Click here to see the full Script Action

Disclaimer: This script was made for our environement, and requires some update on the variable (GID) to be functional in yours; and if you don’t understand this technical part, you might struggle. But feel free to use an AI Assistant to guide you adapting your script. :wink:

// Code created by Asana Solutions Partner i.DO (ido-clarity.com).

async function run() {
  log('=== SCRIPT START: Instantiate project from template + link back to request ===');
  log(`workspace_gid: ${workspace_gid}`);
  log(`project_gid: ${project_gid}`);
  log(`task_gid: ${task_gid}`);

  const projectTemplateGid = '1207291537152711'; // Software update template

  // People custom fields on the triggering task
  const PM_PEOPLE_CF_GID = '1212848577913693';   // 🧑‍🍳Project Manager [IT]
  const TECH_PEOPLE_CF_GID = '1212848580115291'; // 🧑‍💻Technical Engineer [IT]

  // Template role names (as they exist on the template)
  const ROLE_PM_NAME = 'Project Manager';
  const ROLE_TECH_NAME = 'Technical Engineer';

  const TASK_PREFIX = '[Initial request - converted to project] ';

  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  function getFirstPersonFromPeopleCustomField(task, customFieldGid) {
    const cf = (task.custom_fields || []).find((x) => x.gid === customFieldGid);
    if (!cf) return null;
    const pv = cf.people_value;
    if (!pv || !Array.isArray(pv) || pv.length === 0) return null;
    return pv[0];
  }

  // --- Helpers for color & icon selection ---

  function pickColorFromProjectName(name) {
    const palette = [
      'dark-blue',
      'dark-green',
      'dark-pink',
      'dark-purple',
      'dark-red',
      'dark-teal',
      'dark-orange',
      'dark-brown'
    ];

    if (!name || typeof name !== 'string') {
      log('pickColorFromProjectName: no name provided, defaulting to dark-blue');
      return 'dark-blue';
    }

    const trimmed = name.trim();
    if (!trimmed) {
      log('pickColorFromProjectName: empty name after trim, defaulting to dark-blue');
      return 'dark-blue';
    }

    const firstLetter = trimmed[0].toUpperCase();
    const code = firstLetter.charCodeAt(0);

    if (code < 65 || code > 90) {
      log(`pickColorFromProjectName: first char "${firstLetter}" is not A-Z, defaulting to dark-blue`);
      return 'dark-blue';
    }

    const index = (code - 65) % palette.length;
    const color = palette[index];

    log(`pickColorFromProjectName: name="${name}", firstLetter="${firstLetter}", color="${color}"`);
    return color;
  }

  function pickIconFromProjectName(name) {
    const lower = (name || '').toLowerCase();

    const mappings = [
      { regex: /(security|secur|auth|password|2fa|mfa)/, icon: 'shield' },
      { regex: /(bug|incident|issue|defect|error)/, icon: 'bug' },
      { regex: /(infra|server|hosting|cloud|aws|gcp|azure)/, icon: 'cloud' },
      { regex: /(deploy|release|launch|rollout|go live|golive)/, icon: 'rocket' },
      { regex: /(data|report|kpi|metric|analytics|dashboard)/, icon: 'graph' }
    ];

    for (const m of mappings) {
      if (m.regex.test(lower)) {
        log(`pickIconFromProjectName: matched "${m.regex}" for name="${name}", icon="${m.icon}"`);
        return m.icon;
      }
    }

    log(`pickIconFromProjectName: no thematic match for name="${name}", defaulting to "star"`);
    return 'star';
  }

  // --- Helpers for errors: move task + comment ---

  async function moveTaskToPendingAssignmentSection(taskGid, projectGid) {
    if (typeof sectionsApiInstance === 'undefined') {
      log('sectionsApiInstance is not available; cannot move task to "Pending Assignment".');
      return;
    }

    log(`Looking for section "Pending Assignment" in project ${projectGid}...`);
    const rawSections = await sectionsApiInstance.getSectionsForProject(projectGid, {
      opt_fields: 'gid,name'
    });
    const sections = rawSections && rawSections.data ? rawSections.data : rawSections;

    if (!Array.isArray(sections)) {
      log('getSectionsForProject returned an unexpected payload; cannot move task.');
      return;
    }

    const targetSection = sections.find(
      (s) => (s.name || '').trim().toLowerCase() === 'pending assignment'
    );

    if (!targetSection) {
      log('Section "Pending Assignment" was not found; task will not be moved.');
      return;
    }

    log(`Moving task ${taskGid} to section "${targetSection.name}" (${targetSection.gid})...`);

    // Script Actions env: section_gid first, then opts.body.data
    const addOpts = {
      body: {
        data: { task: taskGid }
      }
    };
    await sectionsApiInstance.addTaskForSection(targetSection.gid, addOpts);

    log('Task moved back to "Pending Assignment" section.');
  }

  async function addErrorCommentToTask(taskGid, message) {
    if (typeof storiesApiInstance === 'undefined') {
      log('storiesApiInstance is not available; cannot add an error comment.');
      return;
    }

    const text =
      '❌ Project not created by script\n\n' +
      message;

    log('Adding error comment to the request task: ' + text.replace(/\n/g, ' '));

    await storiesApiInstance.createStoryForTask(
      { data: { text } },
      taskGid
    );

    log('Error comment added successfully.');
  }

  // 1) Load triggering task with needed fields
  log('Fetching triggering task with People custom fields + due dates...');
  const rawTaskResp = await tasksApiInstance.getTask(task_gid, {
    opt_fields:
      'name,due_on,due_at,custom_fields.gid,custom_fields.name,custom_fields.resource_subtype,custom_fields.people_value.gid,custom_fields.people_value.name'
  });
  const task = rawTaskResp && rawTaskResp.data ? rawTaskResp.data : rawTaskResp;
  log(`Triggering task loaded: "${task.name}" (${task_gid})`);

  const pmPerson = getFirstPersonFromPeopleCustomField(task, PM_PEOPLE_CF_GID);
  const techPerson = getFirstPersonFromPeopleCustomField(task, TECH_PEOPLE_CF_GID);

  log(`🧑‍🍳 PM field -> ${pmPerson ? `${pmPerson.name} (${pmPerson.gid})` : 'NOT SET'}`);
  log(`🧑‍💻 TECH field -> ${techPerson ? `${techPerson.name} (${techPerson.gid})` : 'NOT SET'}`);

  if (!pmPerson || !techPerson) {
    log('STOP: Both roles must be filled on the task before creating the project from template.');

    const errorMessage =
      'Both roles must be filled on this request before a project can be created from the template. ' +
      'Please set both 🧑‍🍳 Project Manager [IT] and 🧑‍💻 Technical Engineer [IT], then re-run the rule. ' +
      'The task was moved back to the "Pending Assignment" section.';

    await moveTaskToPendingAssignmentSection(task_gid, project_gid);
    await addErrorCommentToTask(task_gid, errorMessage);

    log('=== SCRIPT END (no instantiation - missing roles) ===');
    return;
  }

  // 2) Fetch workspace to know if it is an organization (team vs workspace payload differences)
  log('Fetching workspace to check is_organization...');
  const rawWsResp = await workspacesApiInstance.getWorkspace(workspace_gid, {
    opt_fields: 'name,is_organization'
  });
  const ws = rawWsResp && rawWsResp.data ? rawWsResp.data : rawWsResp;
  log(`Workspace: "${ws.name}", is_organization: ${ws.is_organization}`);

  // 3) Fetch project template details (roles + date variables + team)
  log('Fetching project template (requested_roles + requested_dates + team)...');
  const rawTmplResp = await projectTemplatesApiInstance.getProjectTemplate(projectTemplateGid, {
    opt_fields:
      'name,team.gid,team.name,requested_roles.gid,requested_roles.name,requested_dates.gid,requested_dates.name,requested_dates.description'
  });
  const template = rawTmplResp && rawTmplResp.data ? rawTmplResp.data : rawTmplResp;

  log(`Template: "${template.name}" (${projectTemplateGid})`);
  if (template.team) {
    log(`Template team: ${template.team.name} (${template.team.gid})`);
  } else {
    log('Template team: none');
  }

  const requestedRolesFromTemplate = template.requested_roles || [];
  log(`Template requested_roles count: ${requestedRolesFromTemplate.length}`);
  for (const r of requestedRolesFromTemplate) {
    log(`ROLE -> "${r.name}" (${r.gid})`);
  }

  const roleGidByName = {};
  for (const r of requestedRolesFromTemplate) roleGidByName[r.name] = r.gid;

  const pmRoleGid = roleGidByName[ROLE_PM_NAME];
  const techRoleGid = roleGidByName[ROLE_TECH_NAME];

  if (!pmRoleGid || !techRoleGid) {
    log('STOP: Could not find role GIDs on the template by expected role names.');
    log(`Expected role "${ROLE_PM_NAME}" -> ${pmRoleGid || 'NOT FOUND'}`);
    log(`Expected role "${ROLE_TECH_NAME}" -> ${techRoleGid || 'NOT FOUND'}`);

    const errorMessage =
      'The script could not find one or both expected roles ("Project Manager" / "Technical Engineer") on the project template. ' +
      'Please check that both roles exist on the template and are correctly named, then re-run the rule.';

    await addErrorCommentToTask(task_gid, errorMessage);

    log('=== SCRIPT END (no instantiation - template roles missing) ===');
    return;
  }

  // 4) Build requested_dates (if template uses date variables)
  const todayStr = new Date().toISOString().slice(0, 10);
  const taskDueStr = task.due_on
    ? task.due_on
    : task.due_at
      ? String(task.due_at).slice(0, 10)
      : null;

  log(`Today (UTC date): ${todayStr}`);
  log(`Task due date (best effort): ${taskDueStr || 'none'}`);

  const requestedDatesFromTemplate = template.requested_dates || [];
  log(`Template requested_dates count: ${requestedDatesFromTemplate.length}`);

  const requested_dates = [];
  for (const d of requestedDatesFromTemplate) {
    const name = (d.name || '').toLowerCase();

    // Lightweight heuristic:
    // - "start/kickoff" -> today
    // - "due/end/launch" -> task due date if available (else today)
    let value = todayStr;
    if (taskDueStr && /(due|end|finish|deadline|launch|go live|golive)/.test(name)) {
      value = taskDueStr;
    }

    requested_dates.push({ gid: d.gid, value });
    log(`DATE VAR -> "${d.name}" (${d.gid}) => value: ${value}`);
  }

  // 5) Build requested_roles
  const requested_roles = [
    { gid: pmRoleGid, value: pmPerson.gid },
    { gid: techRoleGid, value: techPerson.gid }
  ];

  log(`Mapping role "${ROLE_PM_NAME}" (${pmRoleGid}) -> ${pmPerson.name} (${pmPerson.gid})`);
  log(`Mapping role "${ROLE_TECH_NAME}" (${techRoleGid}) -> ${techPerson.name} (${techPerson.gid})`);

  // 6) Build instantiation payload
  const projectName = `${template.name} - ${task.name}`;

  const instantiateData = {
    name: projectName,
    requested_roles
  };

  if (requested_dates.length > 0) {
    instantiateData.requested_dates = requested_dates;
  }

  if (ws.is_organization) {
    if (template.team?.gid) {
      instantiateData.team = template.team.gid;
      log(`Including team in payload (org workspace): ${template.team.name} (${template.team.gid})`);
    } else {
      log('WARNING: Workspace is org but template.team is missing. Not setting team in payload.');
    }
  } else {
    instantiateData.workspace = workspace_gid;
    log(`Including workspace in payload (non-org workspace): ${workspace_gid}`);
  }

  const instantiateOpts = { body: { data: instantiateData } };

  log(`Instantiating project with name: ${projectName}`);
  log('instantiateProject body: ' + JSON.stringify(instantiateOpts.body));

  // 7) Instantiate project (returns a Job)
  const rawJobResponse = await projectTemplatesApiInstance.instantiateProject(projectTemplateGid, instantiateOpts);
  const jobResponse = rawJobResponse && rawJobResponse.data ? rawJobResponse.data : rawJobResponse;
  log('InstantiateProject job response: ' + JSON.stringify(jobResponse));

  const job = jobResponse || null;
  if (!job?.gid) {
    log('No job gid returned. Cannot poll job.');

    const errorMessage =
      'The project template instantiation did not return a job identifier, so the script could not complete project creation. ' +
      'Please try running the rule again; if the problem persists, check the template configuration.';

    await addErrorCommentToTask(task_gid, errorMessage);

    log('=== SCRIPT END (no instantiation - missing job gid) ===');
    return;
  }

  log(`Job gid: ${job.gid}, status: ${job.status || 'unknown'}`);

  // 8) Poll job to get the created project
  let newProjectGid = null;
  let newProjectName = null;
  let newProjectUrl = null;

  if (typeof jobsApiInstance === 'undefined') {
    log('jobsApiInstance is not available; cannot poll for new_project.');
  } else {
    for (let i = 1; i <= 12; i++) {
      const rawJobCheck = await jobsApiInstance.getJob(job.gid, {
        opt_fields: 'status,new_project.gid,new_project.name,new_project.permalink_url'
      });

      const j = rawJobCheck && rawJobCheck.data ? rawJobCheck.data : rawJobCheck;
      log(`Poll #${i}: status=${j.status}`);

      if (j.new_project?.gid) {
        newProjectGid = j.new_project.gid;
        newProjectName = j.new_project.name;
        newProjectUrl = j.new_project.permalink_url || null;

        log(`✅ New project created: ${newProjectName} (${newProjectGid})`);
        log(`Project URL (from job): ${newProjectUrl || '(none returned)'}`);
        break;
      }

      await sleep(1000);
    }
  }

  // Best effort: if URL missing but we have project gid, fetch permalink_url
  if (newProjectGid && !newProjectUrl && typeof projectsApiInstance !== 'undefined') {
    log('Fetching new project to get permalink_url...');
    const rawProjResp = await projectsApiInstance.getProject(newProjectGid, {
      opt_fields: 'name,permalink_url,color,icon'
    });
    const projResp = rawProjResp && rawProjResp.data ? rawProjResp.data : rawProjResp;
    newProjectName = projResp?.name || newProjectName;
    newProjectUrl = projResp?.permalink_url || null;
    log(`Project URL (from project): ${newProjectUrl || '(none returned)'}`);
  }

  // If we still don't have the created project, stop here
  if (!newProjectGid) {
    log('Could not retrieve new_project from job polling. Skipping prefix/comment/multi-home.');

    const errorMessage =
      'The script could not retrieve the new project after instantiation. ' +
      'The project may not have been created correctly. Please check the IT software update projects and try again.';

    await addErrorCommentToTask(task_gid, errorMessage);

    log('=== SCRIPT END (no instantiation - project not found from job) ===');
    return;
  }

  // 9) Set project color & icon based on project name
  if (typeof projectsApiInstance === 'undefined') {
    log('projectsApiInstance is not available; cannot update project color or icon.');
  } else {
    const finalProjectName = newProjectName || projectName;
    const color = pickColorFromProjectName(finalProjectName);
    const icon = pickIconFromProjectName(finalProjectName);

    const updateBody = {
      data: {
        color,
        icon
      }
    };

    log(`Updating new project ${newProjectGid} with color="${color}" and icon="${icon}"`);

    // IMPORTANT: body first, then project_gid (aligned with Script Actions Gallery example)
    const rawUpdatedProject = await projectsApiInstance.updateProject(updateBody, newProjectGid);
    const safeData = rawUpdatedProject && rawUpdatedProject.data ? rawUpdatedProject.data : rawUpdatedProject;
    log('Project color/icon updated summary: ' + JSON.stringify({
      gid: safeData?.gid || newProjectGid,
      color: safeData?.color,
      icon: safeData?.icon
    }));
  }

  // 10) Prefix the request task name
  const currentName = task.name || '';
  const desiredName = currentName.startsWith(TASK_PREFIX) ? currentName : (TASK_PREFIX + currentName);

  if (desiredName !== currentName) {
    log('Updating request task name with prefix...');
    const updateTaskBody = { data: { name: desiredName } };
    await tasksApiInstance.updateTask(updateTaskBody, task_gid, {});
    log('Task name updated: ' + desiredName);
  } else {
    log('Task name already has prefix; skipping rename.');
  }

  // 11) Multi-home the request task into the created project
  log(`Multi-homing request task into new project (${newProjectGid})...`);
  if (typeof tasksApiInstance === 'undefined') {
    log('tasksApiInstance is not available; cannot multi-home the task into the new project.');
  } else {
    // Body-first convention, aligned avec d'autres scripts qui fonctionnent ici
    const addProjectBody = { data: { project: newProjectGid } };
    await tasksApiInstance.addProjectForTask(addProjectBody, task_gid, {});
    log('Task successfully added to the new project.');
  }

  // 12) Add a comment on the request task with project name + link
  if (typeof storiesApiInstance === 'undefined') {
    log('storiesApiInstance is not available; cannot add a success comment.');
    log('=== SCRIPT END (success but no comment) ===');
    return;
  }

  const linkLine = newProjectUrl ? `🔗 Project link: ${newProjectUrl}` : '🔗 Project link: (not available)';
  const commentText =
    `✅ Project created from this request\n\n` +
    `📌 Project: ${newProjectName || '(unknown)'}\n` +
    `${linkLine}\n\n` +
    `👥 Roles\n` +
    `🧑‍🍳 PM: ${pmPerson.name}\n` +
    `🧑‍💻 Tech: ${techPerson.name}\n\n` +
    `📎 This request was added to the project (multi-homed).`;

  log('Adding comment to the request task:\n' + commentText);

  await storiesApiInstance.createStoryForTask(
    { data: { text: commentText } },
    task_gid
  );

  log('Comment added successfully.');
  log('=== SCRIPT END (success) ===');
}

run();

Once this is working for one team, adapting it is mostly a matter of swapping the project, roles and template.


Arthur | Asana Expert :sparkles:

I help teams get more from Asana + AI - more tips

Asana Expert | Licenses, Training & Consulting Services

12 Likes

Genius !!!

1 Like

Great automation, @Arthur_BEGOU, and beautifully documented…as usual!

Thanks,

Larry

1 Like

The Amazing @Arthur_BEGOU strikes again!
Nicely documented and very creative :ok_hand:

Just curious, roughly how many AI Studio credits are consumed per staffed project, on average? :thinking:

1 Like

Thanks @Richard_Sather :heart_hands:

Good question! :slightly_smiling_face:
=> Depending on the LLM chosen

  • 1500 tokens for Claude Sonnet 4.5
  • 500 for GPT-5 Mini
    And I coudln’t identify any errors with the GTP-5 Mini model.

@lpb @Richard_Sather
As a bonus, I managed for the Script Action to choose a project’s color (instead of having projects all created as grey). That doesn’t look crazy, but when you create 100 projects, those details matter and you end up doing it manually. :grin:

1 Like

Sweet! Do you define the project colour from a single-select field in the ‘Software update queue’ project?
We’ve created a similar script to colour the projects based on the colours of a ‘Workstream/Department’ single-select field!’
It’s crazy what you can do with these scripts.. I’ve spent the past months creating my own scripts using Chat GPT, even though I am no developer! But I do know a thing or two about the API :wink:

2 Likes

Ah, nice!
In my case, the colour was just the first letter of the project name, but I like your idea.

Same here for Script Actions, it’s a super exciting Asana era!
We used a GPT tailored for that purpose, fed with some API documentation and examples of in-house scripts built by Bastien over the last year without AI, like real IT masterminds! (not like us, mortals having to vibecode everything :sweat_smile:).
And now, we even use an Asana AI Teammate for that purpose (instead of the GPT), and that works even better.

What is it we can’t do with Asana in 2026!?
(except maybe Asana, show me your BUTs 🍑 and 🔢 List of technical and data limitations in Asana)
:grin:

2 Likes

Amazing usecase for Script Actions + AI, and really complete guide!

1 Like

Thanks Kevin, appreciated :wink: