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:
- Start from an organization and a user-scoped API key.
- Create a project, mint a key for it, and give users access.
- 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.
Before you start
Section titled “Before you start”Get a user-scoped key
Section titled “Get a user-scoped key”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:
-
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-keyresolves it for you (Europe: the same path oncloud.eu.llamaindex.ai). In the sidebar it is the API Key entry. - North America —
-
Create a key under User API Keys — not Project API Keys.
-
Export it as
LLAMA_CLOUD_API_KEY. Both clients read that exact variable and raise at construction if it is missing.
Pick your region
Section titled “Pick your region”| Region | API base URL |
|---|---|
| North America | https://api.cloud.llamaindex.ai (the client default) |
| Europe | https://api.cloud.eu.llamaindex.ai |
Both clients read LLAMA_CLOUD_ADMIN_BASE_URL for the override, or you can pass
it to the constructor.
Install
Section titled “Install”pip install git+https://github.com/run-llama/llamacloud-admin-python.gitnpm install git+https://github.com/run-llama/llamacloud-admin-typescript.gitConfirm your key is user-scoped
Section titled “Confirm your key is user-scoped”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.")import LlamaCloudAdmin, { BadRequestError } from "@llamaindex/llama-cloud-admin";
const client = new LlamaCloudAdmin();
try { const page = await client.projects.list(); const pinned = page.items[0]?.name ?? "(none)"; console.log(`Project-scoped key, pinned to ${pinned}. Stop — get a user key.`);} catch (err) { if (!(err instanceof BadRequestError)) throw err; console.log("User-scoped key. Continue.");}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.
Set up the client
Section titled “Set up the client”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 addressPROJECT_NAME = os.environ.get("DEMO_PROJECT_NAME", "admin-sdk-demo")TEAMMATE_EMAIL = "teammate@example.com" # already a member of the organizationNEWHIRE_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})"import LlamaCloudAdmin, { APIError } from "@llamaindex/llama-cloud-admin";
// apiKey comes from LLAMA_CLOUD_API_KEY; baseURL from LLAMA_CLOUD_ADMIN_BASE_URL,// defaulting to https://api.cloud.llamaindex.ai. For Europe, either export that// variable or pass baseURL: "https://api.cloud.eu.llamaindex.ai" here.const client = new LlamaCloudAdmin();
const MY_EMAIL = process.env.LLAMA_CLOUD_ADMIN_EMAIL!; // your own login addressconst PROJECT_NAME = process.env.DEMO_PROJECT_NAME ?? "admin-sdk-demo";const TEAMMATE_EMAIL = "teammate@example.com"; // already a member of the organizationconst NEWHIRE_EMAIL = "newhire@example.com"; // not a member yet
/** Status, the API's own message, and the id support will ask for. */function describe(err: unknown): string { if (!(err instanceof APIError)) return String(err); const detail = (err.error as { detail?: string } | undefined)?.detail; const requestId = err.headers?.get("x-request-id"); return `${err.status ?? "?"} ${detail ?? err.message} (x-request-id: ${requestId})`;}1. Find your organization
Section titled “1. Find your organization”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)let ORG_ID = process.env.LLAMA_CLOUD_ORG_ID;
if (!ORG_ID) { // `name` here is a case-insensitive PARTIAL match, applied server-side. const page = await client.organizations.list({ name: "acme" }); if (page.items.length === 0) { throw new Error("No match. You are not a member of an organization by that name."); } ORG_ID = page.items[0]!.id;}
console.log(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.
2. Look up role ids
Section titled “2. Look up role ids”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)))const roles = await client.organizations.roles.list(ORG_ID);const ROLE_IDS = new Map(roles.map((role) => [role.name, role.id]));
console.log([...ROLE_IDS.keys()]); // e.g. ['Admin', 'Viewer', 'ViewerV2', 'AgentViewer']
// 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.const VIEWER_V2 = ROLE_IDS.get("ViewerV2");if (!VIEWER_V2) { throw new Error(`No ViewerV2 role here. Available: ${[...ROLE_IDS.keys()].join(", ")}`);}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.
3. Goal A — create a project
Section titled “3. Goal A — create a project”Check the plan’s headroom first
Section titled “Check the plan’s headroom first”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 = unlimitedprint("seats:", usage.usage.total_users, "of", limits.max_users)const usage = await client.organizations.getUsage(ORG_ID);const limits = usage.plan.limits;
console.log(usage.plan.name);console.log("projects:", limits.max_projects); // null = unlimitedconsole.log("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.
Create it
Section titled “Create it”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: raiseexcept 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")import { ConflictError, RateLimitError } from "@llamaindex/llama-cloud-admin";
async function findProject(name: string) { // `name` on projects.list is an EXACT match. const page = await client.projects.list({ organization_id: ORG_ID, name }); return page.items[0];}
let project: LlamaCloudAdmin.Project;try { project = await client.projects.create({ organization_id: ORG_ID, name: PROJECT_NAME });} catch (err) { if (err instanceof ConflictError) { const existing = await findProject(PROJECT_NAME); // 409: the name is taken here if (!existing) throw err; project = existing; } else if (err instanceof RateLimitError) { throw new Error(`Plan project cap reached — ${describe(err)}`); } else { throw err; }}
// is_default is optional but never null here; created_at is a string, not a Date.console.log(project.id, project.name, project.is_default ?? false);console.log(project.created_at ?? "unknown");Creating a project needs write access to the organization. Lacking it returns
404 Organization <id> not found, not 403.
Mint a key for it
Section titled “Mint a key for it”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)const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
const key = await client.apiKeys.create({ project_id: project.id, name: `${PROJECT_NAME} deploy key`, // Optional, and worth setting. Omit it and the key never expires. expires_at: expiresAt,});
// Named redacted_api_key, but on the create response it is the live secret.console.log("store this now, it is not shown again:", key.redacted_api_key);console.log("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])const members = await client.organizations.users.listMembers(ORG_ID);const target = members.find( (m) => m.email?.toLowerCase() === TEAMMATE_EMAIL.toLowerCase(),);if (!target?.user_id) { throw new Error(`${TEAMMATE_EMAIL} is not an active member yet — see Goal C.`);}
await client.organizations.users.addToProject(target.user_id, { organization_id: ORG_ID, project_id: project.id, // ALWAYS pass this});
const granted = await client.organizations.users.listProjects(target.user_id, { organization_id: ORG_ID,});console.log(granted.map((p) => p.name));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])const added = await client.organizations.users.add(ORG_ID, { body: [ // one member per call — see the warning below { email: NEWHIRE_EMAIL, project_ids: null, // required key; null = organization-wide role_id: VIEWER_V2, }, ],});
console.log(added.map((m) => [m.email, m.pending]));The two shapes, and which one you want
Section titled “The two shapes, and which one you want”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_ids | What they get | Appears in their invites.list_mine() |
|---|---|---|
null | Membership in the organization, with the role you named | Yes. A real invite, with an id they can accept() or decline() |
A list of project ids, with Admin, ViewerV2, or AgentViewer | Access to exactly those projects. No organization-wide grant | No. 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: nullfirst, then grant the project withadd_to_projectas 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.
6. The invitee’s side
Section titled “6. The invitee’s side”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)const inviteeKey = process.env.DEMO_INVITEE_API_KEY;if (!inviteeKey) { // Without this, the client falls back to LLAMA_CLOUD_API_KEY — your own admin // key — and would list and accept your invites instead of theirs. throw new Error("Set DEMO_INVITEE_API_KEY to the invitee's own user-scoped key.");}const invitee = new LlamaCloudAdmin({ apiKey: inviteeKey });
const page = await invitee.invites.listMine();for (const invite of page.items) { console.log(invite.id, invite.organization_name, invite.role);}
const first = page.items[0];if (first) await invitee.invites.accept(first.id); // or .decline(first.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.
7. Changing a role later
Section titled “7. Changing a role later”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 yourselfimport { BadRequestError } from "@llamaindex/llama-cloud-admin";
try { const assigned = await client.organizations.users.assignRole(ORG_ID, { organization_id: ORG_ID, // the same id again, in the body role_id: VIEWER_V2, user_id: target.user_id, }); console.log(assigned.role.name, assigned.project_ids);} catch (err) { if (!(err instanceof BadRequestError)) throw err; console.log(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.
8. Verify
Section titled “8. Verify”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], )const roster = await client.organizations.users.listMembers(ORG_ID);const me = roster.find((m) => m.email?.toLowerCase() === MY_EMAIL.toLowerCase());
// 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.const iAmOrgAdmin = !!me && (me.roles ?? []).some((r) => r.role.name === "Admin" && !r.project_ids?.length);console.log("organization admin:", iAmOrgAdmin);
const watch = new Set([TEAMMATE_EMAIL.toLowerCase(), NEWHIRE_EMAIL.toLowerCase()]);for (const m of roster) { if (m.email && watch.has(m.email.toLowerCase())) { console.log( m.email, m.pending ? "pending" : "active", m.roles.map((r) => [r.role.name, r.project_ids]), ); }}Everything on this page, in one table
Section titled “Everything on this page, in one table”| Step | Python | TypeScript | HTTP |
|---|---|---|---|
| Probe the key’s scope | projects.list() | projects.list() | GET /api/v2/projects |
| Find your organization | organizations.list() | organizations.list() | GET /api/v2/organizations |
| Resolve role ids | organizations.roles.list() | organizations.roles.list() | GET /api/v1/organizations/{organization_id}/roles |
| Read the plan’s caps | organizations.get_usage() | organizations.getUsage() | GET /api/v1/organizations/{organization_id}/usage |
| Create a project | projects.create() | projects.create() | POST /api/v2/projects |
| Find a project by name | projects.list() | projects.list() | GET /api/v2/projects |
| List members | organizations.users.list_members() | organizations.users.listMembers() | GET /api/v1/organizations/{organization_id}/users |
| Grant project access | organizations.users.add_to_project() | organizations.users.addToProject() | PUT /api/v1/organizations/{organization_id}/users/{user_id}/projects |
| Confirm project access | organizations.users.list_projects() | organizations.users.listProjects() | GET /api/v1/organizations/{organization_id}/users/{user_id}/projects |
| Add a non-member | organizations.users.add() | organizations.users.add() | PUT /api/v1/organizations/{organization_id}/users |
| See your own invites | invites.list_mine() | invites.listMine() | GET /api/v2/invites |
| Accept an invite | invites.accept() | invites.accept() | POST /api/v2/invites/{invite_id}/accept |
| Change a role | organizations.users.assign_role() | organizations.users.assignRole() | PUT /api/v1/organizations/{organization_id}/users/roles |
| Mint a project key | api_keys.create() | apiKeys.create() | POST /api/v1/beta/api-keys |
| List a project’s keys | api_keys.list() | apiKeys.list() | GET /api/v1/beta/api-keys |
| Revoke a key | api_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.