TL;DR
- Vimeo's Scalekit connector exposes 59 OAuth 2.0 tools; a metadata agent needs 9 of them, so the entire catalog in context is both a token tax and a tool-selection hazard.
- vimeo_video_edit takes 11 parameters. Three are descriptive metadata (video_id, name, description). Eight change who can see, embed, or download the video. The tool that writes the title is the tool that can publish a private cut.
- Neither an OAuth scope nor a tool-name allowlist can express "title yes, privacy no," because both permissions live on the same tool and the same edit scope. Authorization for this agent has to operate on tool arguments.
- In the Claude Agent SDK, allowed_tools auto-approves; it does not restrict availability, and can_use_tool is never invoked for calls it auto-approved. Argument-level policy belongs in a PreToolUse hook, which runs before every other step and whose deny holds even under bypassPermissions.
- Identity is per user, per tenant: one Scalekit connected account keyed by (organization_id, user_id), with the Vimeo token vaulted outside the agent runtime and outside the model context.
- The connector cannot read caption text. Metadata has to be derived from filenames, existing descriptions, folder context, and tags; plan the prompt around that ceiling rather than discovering it in staging.
Video libraries rot in a specific way. Uploads land as Final_v3_ACTUAL_final.mp4, descriptions stay empty, tags never get added, and everything sits in the account root. At 40 videos a human fixes it on a Friday. At 4,000 across 60 client organizations, nobody does, and search stops working. That is a good agent problem: high volume, low per-item judgment, clear success criteria. It is also an agent problem where a single careless write is a client-confidentiality incident, which is what makes the authorization design the hard part rather than the prompt.
What the Vimeo connector actually gives you, and what it does not
Scalekit's Vimeo connector wraps Vimeo API v3.4 with 59 tools behind a single OAuth 2.0 connection. The nine that matter for library metadata:
sort, direction, per_page, query
Write title + description
11 params, only 3 descriptive
batch tags array, PUT semantics
Four properties of that surface drive the whole design, and three of them are constraints you cannot prompt your way out of.
The parameter collision. vimeo_video_edit accepts name and description alongside privacy_view, privacy_embed, privacy_download, privacy_add, privacy_comments, password, license, and content_rating. Vimeo did not ship a metadata-only write endpoint. So the granularity your agent needs (descriptive fields, never distribution fields) is finer than the granularity either OAuth or MCP tool names can express.
Tags are asymmetric and capped. Vimeo caps a video at 20 tags total and returns error code 2501 past that, per Vimeo's published OpenAPI spec. Adding is one batch call; removing is one call per tag. A model that emits 12 tags on two passes silently hits the ceiling on the second. Reconciliation therefore costs 1 + N calls: read current tags, remove the N that no longer apply, add the rest in one batch.
Showcase adds do not reverse. The connector has vimeo_showcase_video_add and no corresponding remove. A folder mistake is repairable, because vimeo_folder_video_add is move-or-add and a second call relocates the video. A showcase mistake is not repairable through this surface. That asymmetry is a reason to order writes deliberately and to treat showcase placement as the step worth gating, not the metadata write.
There is no transcript. vimeo_video_texttracks_list tells you a caption track exists and what language it is in; vimeo_video_texttrack_create returns an upload link for VTT content. No tool returns caption text. An agent that was designed to "summarize the video" has nothing to summarize. The available signal is the existing filename or title, any existing description, the folder the video already sits in, current tags, and whether captions exist at all. That is enough to normalize Final_v3_ACTUAL_final.mp4 into a titled, described, tagged, filed asset. It is not enough to write a content summary, and a prompt that asks for one will produce confident fiction.
One connected account per user, per tenant
The studio in the opening has 60 client organizations. Editors in each one authorize their own Vimeo account. The agent acts as whichever editor triggered the run, and it must never see another tenant's library.
A shared service account collapses that. One Vimeo token with private and edit across the whole library means every run can read and rewrite every organization's footage, and the audit trail records one identity for all of it. Scalekit's model is the opposite: a connected account per user, tagged with your tenant identifiers, with the token held in a vault the agent runtime never reads.
# identity.py
import os
from scalekit import ScalekitClient
# Verified against scalekit-sdk-python 2.17.0 (pip install scalekit-sdk-python).
# Note: the connector quickstart shows `pip install scalekit`, which does not
# resolve on PyPI. `scalekit-sdk-python` is the published package.
scalekit = ScalekitClient(
env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"],
client_id=os.environ["SCALEKIT_CLIENT_ID"],
client_secret=os.environ["SCALEKIT_CLIENT_SECRET"],
)
# The dashboard Connection name, NOT the provider slug. If you created the
# connection as "vimeo-prod", this must read "vimeo-prod". A wrong value here
# fails at account resolution, not at import, so it surfaces as a runtime
# 404 on the first execute_tool call.
VIMEO_CONNECTION = os.environ.get("SCALEKIT_VIMEO_CONNECTION", "vimeo")
def ensure_vimeo_account(*, org_id: str, user_id: str, email: str) -> str:
"""Bind a Vimeo connected account to one user inside one tenant.
`identifier` is the routing key every later call uses. Namespacing it by
org prevents two tenants that share an email domain from colliding, and it
makes tenant scope readable in the audit trail.
"""
identifier = f"{org_id}:{email}"
scalekit.actions.get_or_create_connected_account(
connection_name=VIMEO_CONNECTION,
identifier=identifier,
organization_id=org_id, # tenant boundary, recorded on the account
user_id=user_id, # your app's user id, for attribution
)
return identifier
def vimeo_authorization_link(identifier: str) -> str:
"""One-time OAuth link for the editor. Scalekit runs the redirect flow,
stores the token, and refreshes it. The agent process never holds it."""
return scalekit.actions.get_authorization_link(
connection_name=VIMEO_CONNECTION,
identifier=identifier,
).link
def vimeo_account_status(identifier: str) -> str:
"""Metadata only, no credentials in the response. Check this before a run
so a revoked grant fails as a clear precondition rather than as a 401
halfway through a batch of writes."""
details = scalekit.actions.get_connected_account_details(
connection_name=VIMEO_CONNECTION,
identifier=identifier,
)
return details.connected_account.status
Two properties worth naming, because they are what make the rest of the design enforceable:
- The Vimeo access token never enters the agent process or the model context. The agent names a tool and an identifier; Scalekit resolves the account, injects credentials, and proxies the call. See why the token vault is the security boundary for agent workflows.
- Vimeo's own permissions still apply on every call. A token minted for one editor cannot read another team member's private videos, so tenant isolation is enforced twice: once by your identifier routing and once by Vimeo.
The tradeoff is real. Per-user accounts mean per-user authorization, so a new editor cannot be served until they complete the OAuth flow, and a revoked grant pauses that editor's runs specifically. That is the cost of not having a shared credential, and it is the correct cost; access control for multi-tenant AI agents requires this friction precisely because shared credentials trade it for unbounded blast radius.
Scope the tool surface before the model sees it
vimeo_me_get is the cheapest tenancy assertion available: it returns the authenticated Vimeo profile, so a run can confirm it is operating as the expected account before it writes anything.
The bigger question is how many of the 59 tools reach the model. Handing over the full catalog costs roughly 200 tokens per tool definition, so about 11,800 tokens burned before the agent reads a single video. It also degrades selection: given vimeo_video_delete, vimeo_user_update, and vimeo_video_edit side by side, a model asked to "clean up this video" has three plausible-looking writes to choose between, and only one is correct.
list_scoped_tools returns the surface the current identifier is actually authorized to call, filtered further to the tools this agent needs:
# surface.py
from scalekit.v1.tools.tools_pb2 import ScopedToolFilter
from identity import scalekit, VIMEO_CONNECTION
# The nine tools this agent is allowed to know about. Anything absent here is
# not a "tool the model shouldn't pick"; it is a tool the model never sees.
# vimeo_video_delete and vimeo_user_update are deliberately excluded: both are
# reachable with the same `edit`/`delete` grant and neither is recoverable.
METADATA_TOOLS = [
"vimeo_my_videos_list",
"vimeo_video_get",
"vimeo_video_tags_list",
"vimeo_folders_list",
"vimeo_showcases_list",
"vimeo_video_edit",
"vimeo_video_tags_add",
"vimeo_video_tag_remove",
"vimeo_folder_video_add",
]
def scoped_vimeo_tools(identifier: str):
"""Return (tool_definition_dict, connected_account_id) for each tool.
Gotcha: ToolsClient.list_scoped_tools returns a (response, metadata) tuple,
not the response. Indexing [0] is required; without it every attribute read
on the result fails with AttributeError on a tuple.
"""
result = scalekit.tools.list_scoped_tools(
identifier,
ScopedToolFilter(
connection_names=[VIMEO_CONNECTION], # dashboard name, not slug
tool_names=METADATA_TOOLS,
),
page_size=50,
)
response = result[0]
from google.protobuf.json_format import MessageToDict
surface = []
for scoped in response.tools:
# tool.definition is a protobuf Struct holding the JSON tool spec.
# Struct map keys are data, so MessageToDict leaves them snake_case:
# "input_schema" stays "input_schema".
definition = MessageToDict(scoped.tool.definition)
surface.append((definition, scoped.connected_account_id))
return surface
Scoping from 59 tools to 9 removes roughly 10,000 tokens of context per run and, more importantly, makes the destructive tools unreachable by construction rather than by instruction. The tradeoff is one network round trip per run before the agent starts, plus a cache-invalidation question: if an editor narrows their Vimeo grant mid-session, a cached surface goes stale. Resolve the surface per run, not per process. Further reading on the pattern: agent tool calling auth production patterns and tool calling authentication for AI agents.
Bridge the scoped surface into the Claude Agent SDK
The Python SDK ships a LangChain adapter and no Claude Agent SDK adapter, so the bridge is yours to write: turn each scoped tool definition into an in-process MCP tool via @tool, and register them with create_sdk_mcp_server.
The design decision inside the bridge is which identity the execution uses. Passing identifier again at execution time re-resolves the account, which means discovery-time and execution-time identity can drift. Passing the connected_account_id that came back from list_scoped_tools pins execution to exactly the account whose authorization produced the tool.
# bridge.py
import json
from typing import Any
from claude_agent_sdk import tool, create_sdk_mcp_server, ToolAnnotations
from identity import scalekit
from runstate import RunState
# The MCP namespace comes from the mcp_servers dict KEY, not the server name
# argument. Keeping both "vimeo" means tools are addressed mcp__vimeo__
# and the two can never drift apart.
MCP_SERVER_KEY = "vimeo"
def build_vimeo_server(surface, state: RunState):
"""Wrap each scoped Scalekit tool as an in-process MCP tool."""
sdk_tools = []
for definition, connected_account_id in surface:
name = definition["name"]
description = definition.get("description", "")
input_schema = definition.get("input_schema", {"type": "object", "properties": {}})
annotations = definition.get("annotations", {})
# Scalekit publishes snake_case hints; ToolAnnotations accepts camelCase
# on every SDK version, while snake_case attribute names require
# claude-agent-sdk 0.2.140+. Construct with camelCase for portability.
hints: dict[str, Any] = {}
if annotations.get("read_only_hint") is not None:
hints["readOnlyHint"] = bool(annotations["read_only_hint"])
if annotations.get("destructive_hint") is not None:
hints["destructiveHint"] = bool(annotations["destructive_hint"])
def make_handler(tool_name: str, account_id: str):
@tool(
tool_name,
description,
input_schema,
annotations=ToolAnnotations(**hints) if hints else None,
)
async def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
# ActionClient.execute_tool unwraps the proto tuple for you
# and returns .data plus .execution_id. Binding
# connected_account_id (rather than identifier) pins this
# call to the account resolved during discovery.
result = scalekit.actions.execute_tool(
tool_input=args,
tool_name=tool_name,
connected_account_id=account_id,
)
except Exception as exc:
# Surface the failure to the model as text so it can adapt,
# rather than raising and killing the run.
return {
"content": [
{"type": "text", "text": f"{tool_name} failed: {exc}"}
],
"isError": True,
}
payload = dict(result.data) if result.data else {}
# Record real facts from real results. The policy gate reads
# this, never the model's claims about what it saw.
state.observe(tool_name, args, payload)
return {
"content": [
{
"type": "text",
"text": json.dumps(
{"result": payload, "execution_id": result.execution_id}
),
}
]
}
return handler
sdk_tools.append(make_handler(name, connected_account_id))
server = create_sdk_mcp_server(
name=MCP_SERVER_KEY, version="1.0.0", tools=sdk_tools
)
qualified = [f"mcp__{MCP_SERVER_KEY}__{t.name}" for t in sdk_tools]
return server, qualified
An in-process server is the right choice here because the tools are thin wrappers over an SDK call already running in your process; there is no subprocess to manage and no transport to secure. A remote MCP server earns its keep when several agents or several languages share one tool surface, at the cost of another hop to authenticate and observe.
For the same bridge pattern against other providers, see the engineering standup agent for GitHub, GitLab, Jira, and Slack and the production-ready CrewAI agents with role-based identity and tool calling.
The guard that actually holds
Here is where the opening incident gets resolved, and where most implementations of this agent go wrong.
The instinct is to list the nine tools in allowed_tools and consider the agent constrained. allowed_tools does not do that. Per the Claude Agent SDK permissions reference, it adds entries to the allow rule list: listed tools are auto-approved, and unlisted tools remain available and fall through to the permission mode.
The second instinct is can_use_tool. That callback is invoked only when evaluation falls through to a prompt, so any call your own allowed_tools entry approved never reaches it. A privacy check placed there is silently skipped for exactly the tools you meant to check.
Permissions evaluate in a fixed order, and only the first step runs unconditionally:
Can it gate vimeo_video_edit arguments?
Yes. Runs first, sees tool_input, deny holds in every mode
Deny rules (disallowed_tools)
No. Matches tool names and patterns, not JSON arguments
No. Routes to a prompt; headless runs have no approver
No. Approves or denies wholesale
Allow rules (allowed_tools)
No. Auto-approves, which skips step 6
Not reliably. Never reached for auto-approved calls
So the policy lives in a PreToolUse hook, and it inspects arguments:
# policy.py
from typing import Any
# Fields on vimeo_video_edit that change who can see, embed, or download the
# video. A metadata agent is never permitted to set these, regardless of what
# it concluded about the video's purpose.
PRIVACY_FIELDS = frozenset({
"privacy_view", "privacy_embed", "privacy_download",
"privacy_add", "privacy_comments", "password",
"license", "content_rating",
})
# The only fields this agent may write on vimeo_video_edit.
WRITABLE_METADATA_FIELDS = frozenset({"video_id", "name", "description"})
VIMEO_TAG_CEILING = 20 # Vimeo rejects the 21st tag with error code 2501.
def check_video_edit(tool_input: dict[str, Any]) -> str | None:
"""Return a denial reason, or None when the call is permitted."""
offending = sorted(PRIVACY_FIELDS & tool_input.keys())
if offending:
return (
f"vimeo_video_edit may not set {', '.join(offending)}. "
"This agent is scoped to descriptive metadata only."
)
# Allowlist the shape, not just the denylist: an unrecognised field is a
# sign the model is improvising, and Vimeo may add params later.
unknown = sorted(tool_input.keys() - WRITABLE_METADATA_FIELDS)
if unknown:
return f"vimeo_video_edit received unexpected fields: {', '.join(unknown)}."
if not tool_input.get("video_id"):
return "vimeo_video_edit requires a video_id."
return None
def check_tags_add(tool_input: dict[str, Any], existing_tag_count: int) -> str | None:
"""Enforce the 20-tag ceiling before the write, not after a 400."""
tags = tool_input.get("tags") or []
if not isinstance(tags, list):
return "tags must be a list."
if existing_tag_count + len(tags) > VIMEO_TAG_CEILING:
return (
f"Adding {len(tags)} tags would exceed Vimeo's {VIMEO_TAG_CEILING}-tag "
f"ceiling (video already has {existing_tag_count}). Reconcile first."
)
return None
def check_folder_add(tool_input: dict[str, Any], known_folder_ids: set[str]) -> str | None:
"""Folder ids must come from a prior vimeo_folders_list result.
A model that has not listed folders will happily invent a plausible numeric
id. Requiring provenance turns a silent misfile into a denial.
"""
folder_id = str(tool_input.get("folder_id") or "")
if folder_id not in known_folder_ids:
return (
f"folder_id {folder_id!r} was not returned by vimeo_folders_list for "
"this user. Refusing to write to an unverified folder."
)
return None
Wiring it, with a lockdown posture appropriate to an unattended multi-tenant run:
# agent.py
import asyncio
from claude_agent_sdk import (
ClaudeAgentOptions, ClaudeSDKClient, HookMatcher,
AssistantMessage, TextBlock, ResultMessage,
)
from bridge import build_vimeo_server, MCP_SERVER_KEY
from identity import ensure_vimeo_account, vimeo_account_status
from policy import check_video_edit, check_tags_add, check_folder_add
from runstate import RunState
from surface import scoped_vimeo_tools
SYSTEM_PROMPT = """You normalise Vimeo library metadata.
For each video: read it, then write a descriptive title, a 1-2 sentence
description, and up to 8 relevant tags, then file it into the best-matching
existing folder.
Derive metadata ONLY from the filename, existing title and description, the
folder the video already sits in, and existing tags. You cannot read the video
or its captions. Never invent plot, participants, or dates.
Before adding tags, call vimeo_video_tags_list. Before filing, call
vimeo_folders_list and use an id from that result. Never set privacy,
password, licence, or content-rating fields."""
def deny(reason: str) -> dict:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
def make_gate(state: RunState):
"""PreToolUse runs before every other permission step, and its deny is
honoured even under bypassPermissions. This is the only place an
argument-level rule is guaranteed to execute on every call."""
prefix = f"mcp__{MCP_SERVER_KEY}__"
async def gate(input_data, tool_use_id, context):
tool_name = (input_data.get("tool_name") or "").removeprefix(prefix)
args = input_data.get("tool_input") or {}
if tool_name == "vimeo_video_edit":
if reason := check_video_edit(args):
return deny(reason)
elif tool_name == "vimeo_video_tags_add":
seen = state.tag_counts.get(str(args.get("video_id")))
if seen is None:
return deny(
"Call vimeo_video_tags_list for this video before adding tags."
)
if reason := check_tags_add(args, seen):
return deny(reason)
elif tool_name == "vimeo_folder_video_add":
if reason := check_folder_add(args, state.folder_ids):
return deny(reason)
return {} # no opinion; evaluation continues
return gate
async def run(*, org_id: str, user_id: str, email: str, batch_size: int = 10) -> None:
identifier = ensure_vimeo_account(org_id=org_id, user_id=user_id, email=email)
# Fail as a precondition, not as a 401 mid-batch.
status = vimeo_account_status(identifier)
if str(status).upper() not in {"ACTIVE", "CONNECTED"}:
raise RuntimeError(f"Vimeo account for {identifier} is {status}; re-authorise.")
state = RunState()
surface = scoped_vimeo_tools(identifier)
server, qualified_tools = build_vimeo_server(surface, state)
options = ClaudeAgentOptions(
system_prompt=SYSTEM_PROMPT,
mcp_servers={MCP_SERVER_KEY: server},
# Auto-approve exactly the nine scoped Vimeo tools.
allowed_tools=qualified_tools,
# Anything not pre-approved is denied outright instead of prompting.
# There is no human to prompt in a batch run.
permission_mode="dontAsk",
# allowed_tools does not remove built-ins; a bare-name deny rule does.
# Without this, Bash and Write stay in the model's toolset.
disallowed_tools=[
"Bash", "Read", "Write", "Edit", "NotebookEdit",
"WebFetch", "WebSearch", "Glob", "Grep",
"Task", "TodoWrite", "SlashCommand",
],
# Ignore .mcp.json, user settings, and claude.ai connectors, so no
# ambient config can widen this agent's reach on a different host.
strict_mcp_config=True,
# Ignore filesystem settings entirely. Requires SDK 0.2.x; on 0.1.59
# and earlier an empty list was treated as "unset".
setting_sources=[],
hooks={
"PreToolUse": [
HookMatcher(matcher=f"mcp__{MCP_SERVER_KEY}__.*",
hooks=[make_gate(state)])
]
},
max_turns=8 * batch_size, # bounded work per run
max_budget_usd=2.00, # bounded spend per run
)
async with ClaudeSDKClient(options=options) as client:
await client.query(
f"Normalise metadata for the {batch_size} most recently uploaded "
"videos that have an empty description or a filename-style title. "
"Start with vimeo_my_videos_list sorted by date, descending."
)
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(block.text)
elif isinstance(message, ResultMessage):
print(
f"turns={message.num_turns} "
f"cost={message.total_cost_usd} "
f"filed={len(state.filed)} "
f"reason={message.terminal_reason}"
)
if __name__ == "__main__":
asyncio.run(run(org_id="org_4410", user_id="usr_88", email="dana@studio.example"))
The gate costs an in-process function call on every tool invocation, which is negligible against a network round trip. The real cost is that policy now lives in code rather than in a prompt, so adding a field to PRIVACY_FIELDS is a deploy. That is the correct trade: the opening incident happened because the only thing standing between the model and privacy_view was an instruction. Related: implementing least privilege for agent tool calls and agent tool observability.
Two notes on where the gate does not reach. Subagents inherit the parent permission mode, and AgentDefinition.tools restrictions have been reported as not enforced for subagent child processes; if you fan this agent out per video, keep the PreToolUse hook on the parent session, since it is the layer that holds. And Scalekit request modifiers (actions.pre_modifier) can strip fields before a call, which is useful defence in depth, but they run inside your process, so treat them as argument hygiene rather than as the authorization boundary. Their callback takes a single argument (func(tool_input)), despite the two-argument form shown in the SDK docstring.
The reconcile loop and what breaks in production
The agent's write sequence is read-diff-write, in an order chosen so the irreversible step comes last:
- vimeo_my_videos_list sorted by date, descending, to find candidates.
- vimeo_video_get per candidate for current metadata.
- vimeo_video_tags_list, which also populates the tag count the gate requires.
- vimeo_video_edit with name and description only.
- vimeo_video_tag_remove for each stale tag, then vimeo_video_tags_add once.
- vimeo_folders_list, then vimeo_folder_video_add with a verified id.
Step 4 is idempotent, since writing the same title twice is a no-op. Step 5 is not, which is why the count is read first. Step 6 is a move, so a second run corrects a first-run mistake. Showcase placement is deliberately absent from the automated path; with no remove tool, it belongs behind an approval.
Editor revokes Vimeo grant
401 mid-batch, silent partial completion
Check get_connected_account_details status pre-run; subscribe to disconnect events to pause the run
Access token expires mid-run
Intermittent 401s across concurrent videos
Handled in the vault; do not implement 401-retry in the agent, which races across threads
Error code 2501 on the second pass
Gate denies pre-flight using the count from vimeo_video_tags_list
Video filed into a stranger's folder, or a 404
Gate requires provenance from vimeo_folders_list
Private cut becomes embeddable
Gate denies; PRIVACY_FIELDS is an explicit denylist plus a field allowlist
Cost creep, repeated identical edits
max_turns and max_budget_usd; inspect terminal_reason
Every proxied call returns an execution_id, and every connected account carries a connection_id tying actions back to the authorization event that permitted them. Log both alongside your org_id and user_id, and "which editor's grant authorized this title change, and was it valid at the time" becomes a query rather than an investigation. See audit trails for agent auth and the full picture on token refresh for long-running agents — covering why proactive refresh beats reacting to 401s.
FAQs
Can this agent write metadata from the video's spoken content?
No. The connector exposes vimeo_video_texttracks_list and vimeo_video_texttrack_create, which respectively enumerate caption tracks and return an upload link for VTT content. Neither returns caption text. To use transcripts you need a separate source (your own ASR pipeline, or a transcription connector) and then a second write into Vimeo.
Why not put the privacy check in can_use_tool instead of a hook?
Because the nine Vimeo tools are in allowed_tools, so they are auto-approved at step 5 of the permission flow and never reach can_use_tool at step 6. The check would compile, run in tests where you had not listed the tools, and silently stop firing in production. PreToolUse runs at step 1 on every call.
Is disallowed_tools really necessary if permission_mode is dontAsk?
dontAsk denies unapproved calls, so a Bash attempt would be blocked. But the tool definition still sits in the model's context, consuming tokens and inviting attempts that show up as denials in your logs. A bare-name entry in disallowed_tools removes the definition from the request, so the model never sees it.
How do I keep 60 tenants from bleeding into each other?
Namespace identifier by organization (f"{org_id}:{email}"), pass organization_id and user_id on the connected account, resolve the tool surface per run with that identifier, and pin execution to the connected_account_id from discovery. Vimeo's own team permissions then act as a second boundary. How tool calling auth changes when you move from single-tenant to multi-tenant covers the architectural fork.
What OAuth scopes should the Vimeo connection request?
private to list and read the user's own videos and folders, and edit to write metadata, tags, and folder placement. Omit delete, and omit upload unless you are also creating videos. Note that edit is unavoidably what makes the privacy parameters reachable, which is why the argument gate exists rather than a narrower scope.
Can I run this on a webhook instead of a schedule?
Yes. vimeo_webhook_create registers an HTTPS endpoint for upload and transcode events, which is a better trigger than polling vimeo_my_videos_list. Keep the same per-user identifier resolution in the handler; a webhook tells you a video changed, not which tenant's agent may touch it.
Next steps
- Create a Vimeo app in the Vimeo Developer Portal, then add the connection in the Scalekit dashboard under AgentKit, Connections. Select private and edit scopes only.
- pip install scalekit-sdk-python claude-agent-sdk, then confirm the wiring end to end with a single read: vimeo_me_get through actions.execute_tool. If that returns the expected profile, identity and proxying are correct.
- Copy the dashboard Connection name into SCALEKIT_VIMEO_CONNECTION. This is the most common first-run failure, and it surfaces as a 404 on the first tool call rather than at startup.
- Build policy.py first and unit-test it without the model. Assert that a vimeo_video_edit call carrying privacy_view is denied, that a tag batch breaching 20 is denied, and that an unlisted folder_id is denied. Policy you can test offline is policy you can trust online.
- Run against one editor's account with batch_size=1 and permission_mode="default" so unexpected calls surface as prompts, then switch to dontAsk for unattended batches.
- Add showcase placement last, behind an approval step, since vimeo_showcase_video_add has no counterpart remove tool in the connector.