Skip to content
Product Docs

Provision a team

Create a project, mint a key for it, give an existing member access, and add someone who is not yet a member — end to end in Python and TypeScript.

Three goals, in the order you would actually run them:

  1. Start from an organization and a user-scoped API key.
  2. Create a project, mint a key for it, and give users access.
  3. Add users to the organization as an admin.

Every code block is given in both languages. Pick one at the first tab and the rest of the page follows it.

The API key page carries two tables — Project API Keys and User API Keys. You need one from User API Keys.

If you do see the table, this is the procedure:

  1. Open the key page for your region:

    • North America — https://cloud.llamaindex.ai/organization/<organization_id>/settings/api-keys
    • Europe — https://cloud.eu.llamaindex.ai/organization/<organization_id>/settings/api-keys

    If you do not have the organization id handy, https://cloud.llamaindex.ai/?target=api-key resolves it for you (Europe: the same path on cloud.eu.llamaindex.ai). In the sidebar it is the API Key entry.

  2. Create a key under User API Keys — not Project API Keys.

  3. Export it as LLAMA_CLOUD_API_KEY. Both clients read that exact variable and raise at construction if it is missing.

RegionAPI base URL
North Americahttps://api.cloud.llamaindex.ai (the client default)
Europehttps://api.cloud.eu.llamaindex.ai

Both clients read LLAMA_CLOUD_ADMIN_BASE_URL for the override, or you can pass it to the constructor.

Terminal window
pip install git+https://github.com/run-llama/llamacloud-admin-python.git

This is the single most common reason the recipes below fail in a confusing way, and the check is read-only — it writes nothing either way. Call projects.list with no organization_id:

  • A user-scoped key gets 400 organization_id is required when not using a project-scoped API key.
  • A project-scoped key gets 200 and exactly the one project it is pinned to.
from llama_cloud_admin import BadRequestError, LlamaCloudAdmin
client = LlamaCloudAdmin()
try:
page = client.projects.list()
except BadRequestError:
print("User-scoped key. Continue.")
else:
pinned = page.items[0].name if page.items else "(none)"
print(f"Project-scoped key, pinned to {pinned!r}. Stop — get a user key.")

Two corroborating signals, if you skipped the probe: projects.create returns 403 API key is restricted to a single project, and any project route naming a different project returns 401 Project ID specified in the URL does not match the project ID associated with the API key.

Everything below assumes this block has run.

import os
from llama_cloud_admin import APIStatusError, LlamaCloudAdmin
# api_key comes from LLAMA_CLOUD_API_KEY; base_url from LLAMA_CLOUD_ADMIN_BASE_URL,
# defaulting to https://api.cloud.llamaindex.ai. For Europe, either export that
# variable or pass base_url="https://api.cloud.eu.llamaindex.ai" here.
client = LlamaCloudAdmin()
MY_EMAIL = os.environ["LLAMA_CLOUD_ADMIN_EMAIL"] # your own login address
PROJECT_NAME = os.environ.get("DEMO_PROJECT_NAME", "admin-sdk-demo")
TEAMMATE_EMAIL = "teammate@example.com" # already a member of the organization
NEWHIRE_EMAIL = "newhire@example.com" # not a member yet
def describe(err: APIStatusError) -> str:
"""Status, the API's own message, and the id support will ask for."""
detail = err.body.get("detail") if isinstance(err.body, dict) else None
request_id = err.response.headers.get("x-request-id")
return f"{err.status_code} {detail or err.message} (x-request-id: {request_id})"
ORG_ID = os.environ.get("LLAMA_CLOUD_ORG_ID")
if not ORG_ID:
# `name` here is a case-insensitive PARTIAL match, applied server-side.
page = client.organizations.list(name="acme")
if not page.items:
raise SystemExit("No match. You are not a member of an organization by that name.")
ORG_ID = page.items[0].id
print(ORG_ID)

organizations.list is membership-scoped: an organization you have no grant in is simply absent from the page. There is no 403 and no 404 to catch — an empty result and a permissions problem look identical, which is why the verification step at the end resolves your own membership explicitly.

Both list methods are cursor-paginated, and the code above reads only the first page. To walk every page, iterate the call itself — for org in client.organizations.list(...) in Python, for await (const org of client.organizations.list(...)) in TypeScript — and the client follows the cursor for you.

Role ids are generated per deployment. An id copied from another environment — or hardcoded from a previous run against a different deployment — resolves to nothing. Resolve them by name at runtime, once, and cache the map.

roles = client.organizations.roles.list(ORG_ID)
ROLE_IDS = {role.name: role.id for role in roles}
print(sorted(ROLE_IDS)) # e.g. ['Admin', 'AgentViewer', 'Viewer', 'ViewerV2']
# Resolve the one role the rest of this page grants, and stop if this
# organization does not offer it rather than sending an unresolvable id.
VIEWER_V2 = ROLE_IDS.get("ViewerV2")
if VIEWER_V2 is None:
raise SystemExit("No ViewerV2 role here. Available: " + ", ".join(sorted(ROLE_IDS)))

Match on the name and treat the id as an opaque per-deployment value. Which names come back can vary — the organization id filters which roles are visible to you — so read the list you actually get rather than assuming a fixed set. Usually it is Admin, Viewer, ViewerV2, and AgentViewer. If a deployment ever offers two roles under one name, a name-keyed map keeps an arbitrary one of them — check len(roles) against the number of distinct names if that matters to you.

This call needs only a valid key — the organization id filters which roles are visible, it does not authorize you. Succeeding here is not evidence that you are an admin of that organization.

Read-only, needs only read access to the organization, and it tells you the two numbers behind two of the four different 429s on this API.

usage = client.organizations.get_usage(ORG_ID)
limits = usage.plan.limits
print(usage.plan.name)
print("projects:", limits.max_projects) # None = unlimited
print("seats:", usage.usage.total_users, "of", limits.max_users)

max_projects is the cap that turns projects.create into a 429, and max_users is the cap that turns users.add into one. Pending invites count toward total_users. On a BYOC or self-hosted deployment neither cap applies.

from llama_cloud_admin import ConflictError, RateLimitError
def find_project(name: str):
# `name` on projects.list is an EXACT match.
page = client.projects.list(organization_id=ORG_ID, name=name)
return page.items[0] if page.items else None
try:
# projects.create is fully keyword-only — there is no positional org id.
project = client.projects.create(organization_id=ORG_ID, name=PROJECT_NAME)
except ConflictError:
project = find_project(PROJECT_NAME) # 409: the name is taken in this organization
if project is None:
raise
except RateLimitError as err:
raise SystemExit(f"Plan project cap reached — {describe(err)}")
# is_default is optional AND nullable here; created_at is a datetime, not a string.
print(project.id, project.name, "default" if bool(project.is_default) else "regular")
print(project.created_at.isoformat() if project.created_at else "unknown")

Creating a project needs write access to the organization. Lacking it returns 404 Organization <id> not found, not 403.

A project-scoped key is what you hand to the application or teammate that will work in this project. It reaches that project and no other — unlike the user-scoped key driving this script, which reaches everything you can.

Inside that project it is not a reduced key. Like every key it authenticates as the account that created it, so the holder acts with your rights there, up to deleting the project. Scope the account, not the key: if that is more than the holder should have, create the key from an account whose access matches.

from datetime import datetime, timedelta, timezone
key = client.api_keys.create(
project_id=project.id,
name=f"{PROJECT_NAME} deploy key",
# Optional, and worth setting. Omit it and the key never expires.
expires_at=datetime.now(timezone.utc) + timedelta(days=90),
)
# Named redacted_api_key, but on the create response it is the live secret.
print("store this now, it is not shown again:", key.redacted_api_key)
print("key id, for revoking it later:", key.id)

api_keys.list(project_id=...) shows every key on that project — its members share them — with the secrets masked. Pass the project: called bare it lists your own keys instead, which is a different question and easy to mistake for an empty project. api_keys.delete revokes one, taking access away from everyone using it.

4. Goal B — give an existing member access to that project

Section titled “4. Goal B — give an existing member access to that project”

First resolve the person to a user id. A member who has been invited but has not signed up yet has no user_id, so this step doubles as the check for whether they are really a member.

members = client.organizations.users.list_members(ORG_ID)
target = next(
(m for m in members if (m.email or "").lower() == TEAMMATE_EMAIL.lower()), None
)
if target is None or not target.user_id:
raise SystemExit(f"{TEAMMATE_EMAIL} is not an active member yet — see Goal C.")
client.organizations.users.add_to_project(
target.user_id, # positional: the USER id, not the organization id
organization_id=ORG_ID, # keyword
project_id=project.id, # ALWAYS pass this
)
granted = client.organizations.users.list_projects(target.user_id, organization_id=ORG_ID)
print([p.name for p in granted])

This call needs write access to the organization (404 Organization <id> not found if you lack it) and read access to the named project (404 Project <id> is not found). The organization is checked first, so a 404 naming the organization means the project id was never reached.

5. Goal C — add someone who is not yet a member

Section titled “5. Goal C — add someone who is not yet a member”

users.add takes the organization id positionally and the members as a body array. project_ids is a required key in each entry — omitting it is a 422, not a default.

added = client.organizations.users.add(
ORG_ID, # positional
body=[ # one member per call — see the warning below
{
"email": NEWHIRE_EMAIL,
"project_ids": None, # required key; None = organization-wide
"role_id": VIEWER_V2,
}
],
)
print([(m.email, m.pending) for m in added])

The value of project_ids picks between two genuinely different outcomes. Both make the person a member — they appear in list_members as pending — and both send the invitation email. What differs is what they can act on.

project_idsWhat they getAppears in their invites.list_mine()
nullMembership in the organization, with the role you namedYes. A real invite, with an id they can accept() or decline()
A list of project ids, with Admin, ViewerV2, or AgentViewerAccess to exactly those projects. No organization-wide grantNo. Nothing to accept

The project-scoped shape grants access to the named projects only, and the invite list surfaces organization-scope invites — so the person receives the email, shows as pending to you, and has an empty invite list. Their access materializes when they sign up.

Use this one when:

  • You want them in the organization — project_ids: null. This is the only shape that produces an invite they can accept, so it is the right default.
  • You want an outside collaborator confined to named projects — pass the project ids. Accept that there is nothing for them to accept, and tell them to sign up rather than to look for an invite.
  • You want both — add them organization-wide with project_ids: null first, then grant the project with add_to_project as in Goal B. Two calls, and the invite still works.

Two of the four 429s live on this call: an invite rate limit of 10 requests per 60 seconds per organization, and the plan’s seat cap. They are told apart by detail, not by status — see Troubleshooting. One more thing the response cannot tell you: an email-only request always comes back reading as pending, whether or not that address already had an account.

Invites are read and acted on by the invitee, with their own key. There is no way to accept on someone else’s behalf — the API returns 403 Invite <id> is not addressed to user <id>.

invitee = LlamaCloudAdmin(api_key=os.environ["DEMO_INVITEE_API_KEY"])
page = invitee.invites.list_mine()
for invite in page.items:
print(invite.id, invite.organization_name, invite.role)
if page.items:
invitee.invites.accept(page.items[0].id) # or .decline(page.items[0].id)

Accepting converts the pending grants for that address into grants on their user account, across the organization and the projects under it. Declining deletes them, so a later re-invite issues fresh ones. An invite that has already been redeemed returns 404 Invite <id> not found or already redeemed.

Signing up also redeems whatever is pending for that address, which is how the project-scoped shape from Goal C delivers access without an invite to accept.

from llama_cloud_admin import BadRequestError
try:
assigned = client.organizations.users.assign_role(
ORG_ID, # path_organization_id, positional
body_organization_id=ORG_ID, # the same id again, in the body
role_id=VIEWER_V2,
user_id=target.user_id,
)
print(assigned.role.name, assigned.project_ids)
except BadRequestError as err:
print(describe(err)) # e.g. targeting yourself

The organization id is sent twice — once in the path and once in the body. The Python client names them path_organization_id and body_organization_id so they cannot silently collide; pass the same value to both. Leaving it out of the body is a 422, not an inherited path value.

You cannot target yourself: 400 User cannot assign role to themselves. There is no path through this API to change your own role.

The denial here is 404, not 403 — see the permission table for the one rare case that does return 403.

members = client.organizations.users.list_members(ORG_ID)
me = next((m for m in members if (m.email or "").lower() == MY_EMAIL.lower()), None)
# A role row WITH project_ids is a project grant. Only a row with no project_ids
# is organization-wide — and that is what every write on this page requires.
i_am_org_admin = me is not None and any(
r.role.name == "Admin" and not r.project_ids for r in me.roles
)
print("organization admin:", i_am_org_admin)
watch = {TEAMMATE_EMAIL.lower(), NEWHIRE_EMAIL.lower()}
for m in members:
if (m.email or "").lower() in watch:
print(
m.email,
"pending" if m.pending else "active",
[(r.role.name, r.project_ids) for r in m.roles],
)
StepPythonTypeScriptHTTP
Probe the key’s scopeprojects.list()projects.list()GET /api/v2/projects
Find your organizationorganizations.list()organizations.list()GET /api/v2/organizations
Resolve role idsorganizations.roles.list()organizations.roles.list()GET /api/v1/organizations/{organization_id}/roles
Read the plan’s capsorganizations.get_usage()organizations.getUsage()GET /api/v1/organizations/{organization_id}/usage
Create a projectprojects.create()projects.create()POST /api/v2/projects
Find a project by nameprojects.list()projects.list()GET /api/v2/projects
List membersorganizations.users.list_members()organizations.users.listMembers()GET /api/v1/organizations/{organization_id}/users
Grant project accessorganizations.users.add_to_project()organizations.users.addToProject()PUT /api/v1/organizations/{organization_id}/users/{user_id}/projects
Confirm project accessorganizations.users.list_projects()organizations.users.listProjects()GET /api/v1/organizations/{organization_id}/users/{user_id}/projects
Add a non-memberorganizations.users.add()organizations.users.add()PUT /api/v1/organizations/{organization_id}/users
See your own invitesinvites.list_mine()invites.listMine()GET /api/v2/invites
Accept an inviteinvites.accept()invites.accept()POST /api/v2/invites/{invite_id}/accept
Change a roleorganizations.users.assign_role()organizations.users.assignRole()PUT /api/v1/organizations/{organization_id}/users/roles
Mint a project keyapi_keys.create()apiKeys.create()POST /api/v1/beta/api-keys
List a project’s keysapi_keys.list()apiKeys.list()GET /api/v1/beta/api-keys
Revoke a keyapi_keys.delete()apiKeys.delete()DELETE /api/v1/beta/api-keys/{api_key_id}

When something on this page returns a status you did not expect, the decoder is Troubleshooting.