docs: add the easy teams recipe and playground examples

Covers the read-only surface an app actually has: list(), listMembers()
and listDirectory(). Adds the teams recipe tag the build validates
against, and a Teams section to the playground.

Each example handles the two states that are easy to confuse: teams
turned off on the deployment (not_found) and a team that has not opened
its directory to apps (team_not_found).
This commit is contained in:
Juan Castro committed 2026-09-29 09:11:18 -04:00
1 parent 27c4873042
commit a145df46a2
6 files changed
+298

No files matched your search

+23
View File
@@ -941,6 +941,29 @@ const examples = [
},
],
},
{
title: 'Teams',
children: [
{
title: "Find the user's team",
description: "Check whether the signed-in user belongs to a team with Puter.js teams API. Run and experiment with this example directly in the playground.",
slug: 'teams-list',
source: '/playground/examples/teams-list.html',
},
{
title: 'List team members',
description: 'List the accounts belonging to a team with Puter.js teams API. Run and modify this example instantly in your browser.',
slug: 'teams-list-members',
source: '/playground/examples/teams-list-members.html',
},
{
title: 'Offer colleagues in a picker',
description: 'Build a colleague picker from a team directory with Puter.js teams API. Run and experiment with this example in the playground.',
slug: 'teams-directory',
source: '/playground/examples/teams-directory.html',
},
],
},
{
title: 'Workers',
children: [
@@ -0,0 +1,48 @@
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<select id="colleagues"><option>Loading…</option></select>
<button id="pick">Pick</button>
<script>
const select = document.getElementById('colleagues');
(async () => {
let teams;
try {
teams = await puter.teams.list();
} catch (e) {
if (e.code !== 'not_found') throw e;
select.innerHTML = '<option>Teams are not available here</option>';
return;
}
const team = teams[0];
if (!team) {
select.innerHTML = '<option>You are not on a team</option>';
return;
}
// Off by default, and the usual reason the list comes back empty.
if (!team.directoryEnabled) {
select.innerHTML = '<option>Directory is closed to apps</option>';
return;
}
const entries = await puter.teams.listDirectory(team.uid);
select.innerHTML = '';
for (const { username, uuid } of entries) {
const option = document.createElement('option');
// Store the uuid: it survives a username change.
option.value = uuid;
option.textContent = username;
select.append(option);
}
})();
document.getElementById('pick').addEventListener('click', () => {
const option = select.selectedOptions[0];
if (option) puter.print(`${option.textContent} → ${option.value}<br>`);
});
</script>
</body>
</html>
@@ -0,0 +1,33 @@
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
let teams;
try {
teams = await puter.teams.list();
} catch (e) {
if (e.code !== 'not_found') throw e;
puter.print('Teams are not available on this Puter.');
return;
}
const team = teams[0];
if (!team) {
puter.print('You are not on a team.');
return;
}
try {
const members = await puter.teams.listMembers(team.uid);
puter.print(`${members.length} member(s) of ${team.name}:<br>`);
// An app gets `username` only — no email or activation state.
for (const member of members) puter.print(`${member.username}<br>`);
} catch (e) {
if (e.code !== 'team_not_found') throw e;
puter.print('This team has not opened its directory to apps.');
}
})();
</script>
</body>
</html>
@@ -0,0 +1,31 @@
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
let teams;
try {
teams = await puter.teams.list();
} catch (e) {
// No /teams route at all when the feature is off, so this
// rejects rather than returning an empty list.
if (e.code !== 'not_found') throw e;
puter.print('Teams are not available on this Puter.');
return;
}
if (teams.length === 0) {
puter.print('You are not on a team.');
return;
}
for (const team of teams) {
puter.print(`${team.name ?? team.handle ?? 'Unnamed team'}<br>`);
puter.print(`uid: ${team.uid}<br>`);
puter.print(`owner: ${team.isOwner}<br>`);
puter.print(`directory open to apps: ${team.directoryEnabled}<br><br>`);
}
})();
</script>
</body>
</html>
+1
View File
@@ -12,6 +12,7 @@ const recipeTags = {
hosting: 'Hosting',
workers: 'Workers',
ui: 'UI',
teams: 'Teams',
performance: 'Performance',
'data-modeling': 'Data Modeling',
};
@@ -0,0 +1,162 @@
---
title: Read the User's Team
description: "Find out whether the signed-in user belongs to a team, show which one, and list their colleagues so your app can suggest them."
tags: [teams, auth]
order: 60
---
**Use this when** your app should adapt to the team the signed-in user belongs
to, such as labelling their workspace or offering colleagues in a picker
instead of asking them to type usernames.
Everything here is read-only. Creating a team, adding accounts to it and paying
for them are done by the team owner from their own Puter account, never from an
app, so `puter.teams` gives an app three calls: [`list()`](/Teams/list/),
[`listMembers()`](/Teams/listMembers/) and
[`listDirectory()`](/Teams/listDirectory/).
## Check whether the user is on a team
`list()` returns the teams the signed-in user belongs to:
```js
const teams = await puter.teams.list();
if (teams.length === 0) {
console.log('Not on a team');
} else {
console.log(`On ${teams.length} team(s)`);
}
```
An empty array means *this user has no team*. It does not mean the deployment
has no teams: where the feature is turned off there is no `/teams` route at
all, so the call rejects with `not_found` instead of resolving. Handle the two
separately, or a user on a Puter without teams looks identical to one who
simply has not joined anything:
```js
let teams = [];
try {
teams = await puter.teams.list();
} catch (e) {
if (e.code !== 'not_found') throw e;
// Teams aren't available here — hide the team parts of your UI.
}
```
Failures reject with an error carrying a `code`, so `e.code` is what you branch
on.
## Show which team
A team carries a `name` and a `handle`, and either may be `null`:
```js
const [team] = await puter.teams.list();
console.log(team.name ?? team.handle ?? 'Unnamed team');
console.log(team.createdAt); // '2026-09-28T10:04:00Z'
console.log(team.isOwner); // true if this user owns the team
```
Pass `team.uid` to the other two methods, and store that if you store anything.
Never key on `handle`: it is a label the owner can rename, and deleting a team
releases it for someone else to take, so a saved handle can later resolve to a
different team. `uid` is the stable reference.
## List the user's colleagues
`listMembers()` takes the team's `uid`. Any member may call it:
```js
const [team] = await puter.teams.list();
const members = await puter.teams.listMembers(team.uid);
for (const member of members) {
console.log(member.username);
}
```
From an app each entry carries `username` and nothing else — no email, no
activation state, no usage. The extra fields you will see in the type
(`orgOwned`, `createdAt`, `uuid`) are filled in only when the user's own Puter
session makes the call, so write your UI against `username` alone.
## Offer colleagues in a picker
`listDirectory()` is the one built for suggesting people. It returns each
colleague's `username` together with a `uuid` that survives a rename, which is
what you want to store against a share or an assignment:
```js
const entries = await puter.teams.listDirectory(team.uid);
for (const { username, uuid } of entries) {
addOption(username, uuid);
}
```
Accounts that are suspended, or that never signed in for the first time, are
left out, so the list is people your user can actually reach.
## The team has to opt in
An app sees a team only once that team's owner has turned its directory on.
This is off by default, and it is the single most common reason these calls
come back empty from an app but full from the user's own Puter session.
The behaviour differs per call, which is worth knowing when you are debugging:
- `list()` **omits** teams that have not opted in. It does not throw — a user
on one closed team gets `[]`.
- `listMembers()` and `listDirectory()` **reject** with `team_not_found`.
`directoryEnabled` on the team tells you which case you are in, so you can say
something useful instead of showing an empty list:
```js
const [team] = await puter.teams.list();
if (!team.directoryEnabled) {
console.log('Ask the team owner to turn on the directory.');
} else {
const entries = await puter.teams.listDirectory(team.uid);
}
```
## Longer lists
All three methods share the same three forms. By default you get the whole list
as an array, which is what every example above uses:
```js
const members = await puter.teams.listMembers(team.uid);
```
Passing `cursor` or `includeTotal` switches to a page envelope instead:
```js
const page = await puter.teams.listMembers(team.uid, { limit: 50, cursor: null });
page.items; // this page's members
page.cursor; // present only while more pages remain
```
And `stream: true` hands back an async iterator of those envelopes, for walking
a large team without holding it all at once:
```js
for await (const page of puter.teams.listMembers(team.uid, { stream: true })) {
for (const member of page.items) console.log(member.username);
}
```
## Notes
- These routes are keyset-paginated, so `offset` is rejected rather than
silently ignored. Pass `cursor` to resume from a position.
- Everything is scoped to the signed-in person's own membership, so an app
installed by a member of one team can never read another team.
- A user may belong to more than one team. `list()[0]` is fine for a demo, but
let the user choose if your app acts on a specific one.