TL;DR
- A velocity report is two reads (tasks shipped in a window, plus open tasks grouped by status), so the whole engineering problem is making those reads run as the right user, at the right scope, for the right tenant, on every scheduled run.
- A ClickUp personal token is scoped to the user who generated it and breaks silently for every integration using it once that user is offboarded; a single shared service token collapses attribution, so every close and comment logs as the bot and per-engineer velocity becomes uncomputable.
- Scoping ClickUp from its 49 clickup_* tools down to the 3 a velocity agent needs is the lever for tool-calling accuracy and token cost. Surface reduction is the lever; model upgrades help, they are not the lever.
- Scalekit's Google ADK adapter returns native ADK tool objects already bound to the acting user's connected account, so isolation is structural: a tool set built for one user cannot execute as another.
- Scalekit vaults the ClickUp token (AES-256, namespaced per tenant), resolves it server-side on each call so credentials never touch the agent runtime or the LLM context, and records a 90-day audit trail of who triggered which call.
The weekly velocity report is two reads. Fetch the tasks that closed this week; fetch the tasks that are still open and group them by status. A junior engineer can write that against the ClickUp REST API in an afternoon, paste a personal token into .env, and demo a clean report to the team by Friday.
Then it ships to production, runs on a schedule across ten customer workspaces, and the numbers go wrong. Not loudly. The report still renders. It just stops being true: tasks silently missing, every status change attributed to a bot, one engineer's offboarding quietly zeroing out a whole team's history. The reasoning was never the hard part. The identity behind each read was.
What the report actually is, and why the numbers are only as honest as the identity behind them
A velocity report answers two questions, and both map to fields ClickUp already tracks on every task.
Tasks that entered a closed-type status inside the window; date_closed, status.type
clickup_task_list with include_closed and a date-closed window
Non-closed tasks grouped by their status name; assignees for per-person breakdown
clickup_task_list filtered to non-closed statuses
ClickUp statuses are typed: every status resolves to open, closed, or a custom grouping, and the filtered task endpoint accepts statuses, assignees, and include_closed. That makes both computations mechanically simple.
The complication is not the query. It is that a task's date_closed, its assignees, and the identity that moved it to closed are all recorded against whoever's token made the call. A report built on the wrong identity is not a slightly-off report; it is a confident, well-formatted, wrong one.
Why the single-token build breaks
The naive build uses one credential for all reads: a personal token, or a shared service account. It works in demos; it does not survive production scale. Three specific failures, none of them loud.
- Silent scope gaps. A ClickUp personal token sees exactly what its owner sees. Run velocity for a squad the token owner isn't a member of and the read returns partial or empty data with a 200, not an error. The report is wrong and nothing flags it.
- Attribution collapse. With a shared service token, every status change, every comment the agent posts, every close it records logs as the service account, not the engineer. Per-engineer velocity is now uncomputable, and the audit trail attributes the whole team's week to bot.
- Offboarding breaks it, quietly. A personal token is bound to the account that generated it. When that person leaves and their account is deactivated, every integration on that token stops working silently; a documented, common failure in service-account-light setups.
There is an operational trap specific to scheduled reporters too. ClickUp's official MCP server caps at 50 calls per 24 hours on Free Forever and 300 on paid plans without the Everything AI add-on. A background agent making frequent small reads exhausts a per-24-hour cap fast. The REST surface that Scalekit's ClickUp connector wraps uses per-minute limits instead (100 to 10,000 requests per minute per token by plan), which is the correct shape for a headless, high-frequency reporter.
What production needs is not a safer place to hide one token. It needs each read to run under the identity of the person who owns the work, scoped to exactly what that person can see. What the user can't do, the agent can't do.
Understanding who holds the token across agent tool-calling patterns is the first architectural decision that separates a demo from a production system.
Shared PAT / service token
Scalekit connected account
Whose data the read returns
The token owner's visibility, for everyone
The acting user's visibility, per call
Everything logs as the bot
Every action logs as the real user
Token dies, integration breaks silently
That user's account is revoked; others unaffected
In your .env, in agent code
AES-256 vault, resolved server-side, never in agent or LLM
One token cannot express per-workspace scope
One connected account per identifier, isolated namespace
Prerequisites
Install the Scalekit Python SDK and Google ADK:
pip install scalekit-sdk-python google-adk
Set your Scalekit control-plane credentials (these authenticate your app to Scalekit; they are never a ClickUp token):
# .env
SCALEKIT_ENV_URL=
SCALEKIT_CLIENT_ID=
SCALEKIT_CLIENT_SECRET=
Create a ClickUp connection in the Scalekit dashboard before you write any code. The connection name you pass in code must match that dashboard connection exactly; a mismatched connection_name is the single most common integration error, and the SDK cannot guess it for you.
Connect the user
Every read runs under a per-user connected account. Before an agent can call a ClickUp tool for someone, that someone has to authorize ClickUp once. get_or_create_connected_account is idempotent: it returns the existing account if present, or a fresh PENDING_AUTH one if not.
import os
from scalekit.client import ScalekitClient
# Control-plane credentials: your app -> Scalekit. Not a ClickUp credential.
scalekit_client = ScalekitClient(
client_id=os.environ["SCALEKIT_CLIENT_ID"],
client_secret=os.environ["SCALEKIT_CLIENT_SECRET"],
env_url=os.environ["SCALEKIT_ENV_URL"],
)
actions = scalekit_client.actions
# MUST match the ClickUp connection name configured in your Scalekit dashboard.
CLICKUP_CONNECTION = "clickup"
def connect_user(identifier: str) -> bool:
"""Ensure `identifier` has an ACTIVE ClickUp connected account.
Returns True when the user is ready for tool calls, False when they still
need to complete the one-time OAuth consent.
`identifier` is your stable per-user id (internal user id, email, etc.).
"""
account = actions.get_or_create_connected_account(
connection_name=CLICKUP_CONNECTION,
identifier=identifier,
)
if account.connected_account.status != "ACTIVE":
# Not yet authorized: hand the user a branded consent link.
link = actions.get_authorization_link(
connection_name=CLICKUP_CONNECTION,
identifier=identifier,
)
print(f"Authorize ClickUp for {identifier}: {link.link}")
return False
return True
ClickUp OAuth access tokens currently do not expire, which removes proactive refresh from your problem list, but they remain user-revocable. Scalekit stores the token in its vault and detects revocation, so your code never holds or refreshes a ClickUp credential.
Scope the ClickUp surface, then run the agent
Before the agent calls anything, it needs its tool surface; and this is where most builds quietly go wrong. The ClickUp connector exposes 49 clickup_* tools across tasks, lists, folders, spaces, goals, comments, checklists, time entries, and webhooks. Handing all 49 to the model is tool bloat: it degrades tool selection and burns context tokens before the agent does any work. A velocity reporter needs three.
actions.google.get_tools does the scoped fetch and returns native ADK tool objects, so there is no schema reshaping. It retrieves only the tools the current user's connected account is authorized to call, and the tool_names filter narrows that surface to exactly the velocity set.
This surface-reduction principle is central to production tool-calling auth patterns — fewer exposed tools means fewer failure modes and lower token costs.
# Velocity needs a tiny slice of the 49 clickup_* tools.
# Surface reduction is the accuracy and cost lever, not the model.
VELOCITY_TOOLS = [
"clickup_task_list", # list tasks by list/folder/space, filter status + date
"clickup_task_get", # fetch one task's full status and timestamps
"clickup_user_get", # resolve the acting user (attribution sanity check)
]
tools = actions.google.get_tools(
identifier="user_123",
connection_names=[CLICKUP_CONNECTION],
tool_names=VELOCITY_TOOLS, # 49 -> 3
page_size=100, # avoid truncating a connector's tool page
)
Here is the detail that makes per-user correctness structural rather than a convention you have to remember: each object get_tools returns is bound to this user's connected_account_id, and executes through Scalekit's server-side callback using that account's vaulted token. A tool set built for user_123 cannot execute as anyone else. That is why the agent is built per identifier, per run, and never cached across users.
Now the execution loop. Google ADK drives the reasoning; the Runner yields events (tool calls, tool results, partial text), and the report is the final, non-partial model turn.
import asyncio
from google.adk.agents import Agent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
def build_agent(identifier: str) -> Agent:
"""Build a velocity-reporter agent scoped to one user's ClickUp access."""
tools = actions.google.get_tools(
identifier=identifier,
connection_names=[CLICKUP_CONNECTION],
tool_names=VELOCITY_TOOLS,
page_size=100,
)
return Agent(
name="clickup_velocity_reporter",
model="gemini-2.5-flash", # any Gemini model works; flash is enough here
instruction=(
"You produce a weekly engineering velocity report for one ClickUp "
"scope. Follow this procedure exactly:\n"
"1. Call clickup_task_list for tasks CLOSED in the last 7 days "
" (include_closed = true, restrict to closed-type statuses, use a "
" date-closed window). This is 'what shipped'.\n"
"2. Call clickup_task_list for tasks that are NOT closed, and group "
" them by status name. This is the open-work breakdown.\n"
"3. Return: (a) count and titles of shipped tasks, (b) open-task "
" count per status, (c) one line flagging any status holding a "
" disproportionate share of open work.\n"
"Use only the provided tools. Never fabricate task data."
),
tools=tools,
)
async def run_report(identifier: str, scope_label: str) -> str:
"""Run the weekly velocity report as `identifier`, over `scope_label`."""
if not connect_user(identifier):
return "User must authorize ClickUp before a report can run."
agent = build_agent(identifier)
session_service = InMemorySessionService()
runner = Runner(
agent=agent,
app_name="velocity_reporter",
session_service=session_service,
)
session = await session_service.create_session(
app_name="velocity_reporter",
user_id=identifier,
)
prompt = (
f"Generate this week's velocity report for {scope_label}. "
f"Report both what shipped and the open-task breakdown by status."
)
message = types.Content(role="user", parts=[types.Part(text=prompt)])
final_text = "No report produced."
async for event in runner.run_async(
user_id=identifier,
session_id=session.id,
new_message=message,
):
# The Runner yields tool-call and tool-result events along the way.
# The report is the final, non-partial model turn.
if event.is_final_response():
if event.content and event.content.parts:
final_text = event.content.parts[0].text
return final_text
if __name__ == "__main__":
# Runs as user_123: the report reflects exactly what that person can see
# in ClickUp, nothing more. The token never enters this process.
print(asyncio.run(run_report("user_123", "the Platform sprint list")))
The token for user_123 is never in this process. Scalekit resolves it server-side on each clickup_task_list call, executes against ClickUp as that user, and returns structured data to the agent. Credentials never touch the agent runtime, and they never appear in your logs.
Running it for every engineer and every tenant
A single report is not the product. The product is a report per person, or per customer workspace, on a schedule. Because identity is carried by the tool objects, scaling is the same mechanism applied N times: one get_tools call per identifier, one agent per run.
async def run_team_reports(identifiers: list[str], scope_label: str) -> dict[str, str]:
"""Run the report for many users. Each runs under their own connected
account, so no two reports can read across each other's ClickUp scope."""
reports = {}
for identifier in identifiers:
# A fresh scoped tool set + agent per identifier. Never reuse one
# user's tools for another; the connected_account_id is baked in.
reports[identifier] = await run_report(identifier, scope_label)
return reports
The tradeoff is explicit: fetching tools per identifier per run costs one extra control-plane round trip per report. That call is the isolation guarantee. In a multi-tenant deployment across separate customer ClickUp workspaces, each tenant's credentials sit in an isolated vault namespace, so a misrouted call cannot reach another tenant's tasks or goals. Every call is logged with attribution (who triggered it, which workspace, what returned) for 90 days.
This is the same isolation model described in access control for multi-tenant AI agents — per-user identity carried structurally, not by convention. When a user revokes ClickUp access from inside ClickUp's settings, their next tool call fails closed with a clear error; other users are unaffected, and the event is recorded.
When a velocity signal isn't a named tool yet
Goals and time entries are already named clickup_* tools, so cycle-time and goal-progress signals are in reach without leaving the tool surface. If you need a REST capability that has no named tool yet, actions.request proxies any ClickUp endpoint through the same vaulted, per-user token, so you are never blocked on a roadmap.
# Pull raw time entries for cycle-time analysis, still as the acting user,
# still without the token entering your process.
resp = actions.request(
connection_name="clickup",
identifier="user_123",
path="/api/v2/team/{team_id}/time_entries",
method="GET",
query_params={"start_date": "1719792000000"}, # epoch ms window start
)
print(resp.status_code, resp.json())
If you would rather hard-wire the pipeline in Python than let the agent choose, actions.execute_tool(tool_name="clickup_task_list", tool_input={...}, identifier="user_123", connection_name="clickup") runs a single named tool directly; the tradeoff is you own the sequencing and the exact input schema (see the connector docs) instead of the model's reasoning.
For a deeper look at how secure token management works at scale for AI agents, the vault pattern here generalises across connectors.
FAQs
The report runs on a nightly schedule with nobody present to click OAuth. Does that work?
Yes. Consent is one-time; after a user authorizes once, get_or_create_connected_account returns ACTIVE and the agent runs headless. ClickUp OAuth tokens currently do not expire, so there is no refresh to orchestrate; you only need to detect and re-prompt if a user revokes access.
Do I need to call list_scoped_tools separately in ADK?
No. actions.google.get_tools is the scoped fetch. It calls the scoped-tools API under the hood, filters to the current user's connected account (and your tool_names), and wraps the result as native ADK tool objects.
How is one tenant prevented from reading another tenant's ClickUp data?
Each identifier has its own connected account in an isolated vault namespace, and each tool object is bound to one connected_account_id. You pass the identifier per run; there is no shared team_id token that could bridge workspaces. Cross-tenant tool calling requires per-tenant authorization, and there is no shortcut.
What happens to a running report when an engineer revokes ClickUp access?
The connection is invalidated on the next tool call for that identifier, which fails closed with a clear error rather than falling back to a shared token. Other users keep working, and the revocation is in the audit log for agent auth.
Why the connector instead of ClickUp's official MCP server for this agent?
A scheduled reporter makes frequent small reads. ClickUp's MCP caps at 50 calls per 24 hours (Free) or 300 (paid) without the add-on; the REST surface the connector wraps uses per-minute limits that scale far higher. The connector also exposes Goals, webhooks, and task deletion that the MCP beta does not.
Next steps to start building your ClickUp velocity agent