Skip to content

Teams

Most software people pay for is used by more than one person at a time: a workspace, an account, a tenant. Building that yourself means a teams table, a membership table, an invite flow, and — the part that goes wrong — a teamId on every record and a filter on every query. Miss one filter and one customer reads another’s data.

Teams removes that job. Turn it on, declare a collection with "scope": "team", and the records inside it belong to the team the caller is in. There is no team id to pass and none to store.

Teams requires Sign-in, and turns it on with you if it isn’t already — a team needs a signed-in person to belong to it.

data.json
{
"collections": {
"projects": { "scope": "team", "fields": { "title": "string", "status": "string" } }
}
}
import { collection } from "data";
const projects = collection("projects");
await projects.list(); // this team's projects
await projects.create({ title: "" }); // adds one to this team

Two people in different teams run that exact code and never see each other’s rows. The partition is decided by the platform from the session, so a query cannot forget it.

The app never hand-writes a request to the team endpoints. It imports them:

import { currentTeam, teamMembers, isTeamOwner, inviteMember, TeamError } from "team";
const team = await currentTeam(); // {id, name, role} — or null when signed out
const members = await teamMembers(); // [{id, email, name, role, joinedAt}]
if (await isTeamOwner()) {
await inviteMember("kim@acme.com"); // or inviteMember(email, "owner")
}

Each call answers the thing itself. The wire wraps a team in {"team": …} and an invite in {"invite": …}; the import unwraps it, so it is team.name, never result.team.name.

currentTeam() the active team, or null when signed out
myTeams() every team this user is in
teamMembers() who is in the active team
teamInvites() invites this team has sent and nobody has accepted yet
pendingInvites() invites waiting for this user
isTeamOwner() whether they own the active team
refreshTeam() re-read all of the above, and answer the active team
createTeam(name) a new team they own, and it becomes the active one
renameTeam(name) rename the active team
switchTeam(team) make another of their teams the active one
acceptInvite(team) join a team that invited them, and switch to it
inviteMember(email, role?) invite an address
revokeInvite(email) take a sent invite back
setMemberRole(id, role) promote or demote somebody
removeMember(id) an owner removes somebody
leaveTeam() the caller leaves

The six reads share one request — a whole team screen can call every one of them. Each write drops that cached answer, so the next read is fresh; refreshTeam() re-reads now and is what a screen repaints from after a change.

The calls that name a team, a member or an invite take the object you just rendered as happily as its id, so acceptInvite(entry) works straight off an entry from pendingInvites().

A call that fails throws a TeamError carrying .status and the server’s own message — 403 you are not an owner, 409 a limit or the last-owner rule, 429 slow down. Catch it and show the message.

A user who belongs to no team gets one of their own the moment they sign in, named after them, and they own it. So a team collection works for a single user on day one, and the same code keeps working when they invite four colleagues — which is the point.

This mirrors how the rest of the platform behaves: a scope always means something, even before the feature that splits it is used.

A member of a team is an owner or a member. That is separate from the account role (member or staff) that decides who runs the whole app — a person can own one team and be a plain member of another.

owner member
Read and write the team’s records yes yes
Rename the team yes no
Invite someone, or revoke an invite yes no
Change a member’s role yes no
Remove a member yes no
Leave yes, if another owner remains yes

A team always keeps at least one owner: the last one cannot leave or be demoted. To hand a team over, promote someone else first — setMemberRole(them, "owner"), then leaveTeam().

Gate the owner-only controls on await isTeamOwner() rather than letting somebody press a button that can only answer 403.

inviteMember("kim@acme.com") creates no account and mails no token to paste. The next time Kim signs in — with that address — the invitation is waiting in pendingInvites(), and acceptInvite(it) joins them.

That is deliberate. A forwarded invite link is a security hole in most products; here the only person who can accept is the one who proved they own the address at sign-up. An invite lasts 14 days, and inviting the same address again just refreshes it.

If Email is on, the invited person also gets a note telling them to sign in. If it isn’t, the invite still works — they just won’t hear about it from your app.

The team import calls these, and they are the whole surface. The app has no reason to name one — but server.mjs is a lone module with no module graph, so it cannot import anything; there, reach the same routes through env.PLATFORM.fetch(), which takes the same paths and carries the caller’s session.

Method & path Body Returns
GET /api/_team {"team","teams","members","invites","pending"} — the whole team screen in one call
POST /api/_team {"name":"Acme"} 201 {"team"} — creates it, you own it, and it becomes the active one
PATCH /api/_team {"name":"Acme Inc"} {"team"} — renames the active team (owner only)
POST /api/_team/switch {"team":"team_..."} {"team"} — makes another of your teams the active one
POST /api/_team/invites {"email":"kim@acme.com","role?":"member"} 201 {"invite"} — owner only; emails them if this app can send
DELETE /api/_team/invites/:email {"revoked":true} — owner only
POST /api/_team/accept {"team":"team_..."} {"team"} — joins a team that invited you, and switches to it
PATCH /api/_team/members/:id {"role":"owner"} {"member"} — owner only; this is how ownership is handed over
DELETE /api/_team/members/:id {"removed":true} — an owner removes anyone; anyone removes themselves

Every one of them needs a signed-in caller. GET /api/me carries the active team too, so currentUser() already knows it:

{ "id": "usr_…", "email": "kim@acme.com", "name": "Kim", "role": "member",
"team": { "id": "team_…", "name": "Acme", "role": "owner" } }

That inner role is their standing in the team. currentUser().role is the account role that decides who runs the whole app; currentTeam().role is this one.

The active team is remembered on the server, per user — not in the tab. It follows someone to their phone and survives a sign-out, and every read and write of every team collection follows it. So a team switcher has to repaint: await switchTeam(other), then re-read the records, and never leave the previous team’s rows on screen.

A staff account reading a team collection gets every team’s records, the same widening they get on a user collection. Each record carries its owner — the team it belongs to — so a back office can say whose it is. That is what makes support tooling possible, and it is why a staff screen should never be the screen an ordinary member uses.

A file key beginning team/ lands in the same tenancy: every member of the team reads and writes it, and no other team can. It sits alongside the public/ and shared/ prefixes Files already understands.

Members per team 200 (invites count against it)
Teams one person can be in 20
Invite lifetime 14 days
Calls to /api/_team 30/min per caller