Team
Your org's shared space: documents edited together, files, seats, and agents as teammates.
Also for this capability: API · CLI · MCP · SDKs
Your org's shared space: documents edited together, files, seats, and agents as teammates.
| Base URL | https://api.hanzo.ai |
| Operations | 45 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1048 · Team — Draft · read the specification →
/v1/team is the shared workspace: the login and membership control plane, the
live document plane clients connect to, the workspace file store, and the roster
that includes an organization's agents. It is served by apps/team in
hanzoai/cloud.
Its identity domain membership is not a filing accident. The thing this capability actually owns is WHO IS IN A WORKSPACE — the rows that authorize every other plane under this address — and the org each workspace belongs to.
Motivation
A workspace surface accretes credentials: it has its own sessions, its own tokens, its own idea of who a member is. Every one of those is a second identity system, and a second identity system is a second place a member can be admitted after the first one revoked them.
So there is ONE identity seam here, one answer to "who is calling and what may they
touch", shared by every surface under the address (apps/team/account.go:943). No
surface reads a claim off a credential for itself, and therefore no surface can
disagree with another about who is calling.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
Tenancy is a column, enforced on every query
A workspace belongs to exactly one org, and that org is a column on the row. Every
read and every write MUST be scoped to the caller's VERIFIED org
(apps/team/account_store.go:32, :65). Composite uniqueness is on
(org, slug) and (workspace, user), so two orgs may hold the same workspace name
and neither can address the other's.
The membership rows ARE the authorization. On the identity lane nothing about a
workspace is signed, so what a caller may touch is decided per request against
those rows and never by a claim the caller carries
(apps/team/account.go:971-975).
Two lanes, and one of them carries no credential out
A caller resolves either from a verified identity token — narrowed to this deployment's own audience — or from a token this service minted itself.
On the identity lane the credential MUST NOT leave the resolution: the session RPC
echoes a token back to page script, and an identity access token is an
estate-wide bearer that reaches every other service. The login flow puts it in an
HttpOnly cookie precisely so script cannot read it; echoing it would hand it back
to the script the flag exists to keep it from. The lane therefore carries no
credential out at all, structurally, so a future echo site cannot reintroduce the
leak by forgetting (apps/team/account.go:976-992).
The minted arm exists because one read still answers for "the workspace the credential pins", and the identity lane pins none. Removing it is a client change: the client names the workspace and the server authorizes it against the rows, the way the document and file planes already do.
No signing secret means health only
When the signing secret is unset or is the public default, the subsystem serves
HEALTH ONLY: every route under the address answers 503 and NO token is ever
decoded or accepted (apps/team/team.go:293). A forged token cannot be used
because nothing verifies one.
Mount MUST still SUCCEED. Erroring at mount fails the whole composition and takes every other subsystem in the binary down with it; refusing at the route is the same safety with none of the blast radius.
The refusal MUST be applied per route and MUST NOT wrap the liveness probe. A prefix-wide middleware risks catching it, and a degraded subsystem that also reports itself dead cannot be diagnosed.
The file plane repeats the boundary physically
A workspace blob's key embeds org, workspace and blob id
(apps/team/files.go:289), so an identifier from another org or another workspace
does not resolve. The caller MUST additionally be asserted a MEMBER of the named
workspace — not merely same-org — before anything is stored or served, and every
denial is a 404, so the plane is neither a membership oracle nor an existence
oracle.
Three independent layers for one property is deliberate: the verified org, the membership assertion, and the key shape.
Login's billing check fails open, and says so
The single chokepoint every client passes on its way into a workspace asks whether
the org's plan licenses this product. Two kinds of "no" are distinguished
(apps/team/entitle.go):
- Cannot verify — an infrastructure absence — ADMITS. A login gate that fails closed during an outage bricks every session mid-rollout.
- A definitive "no entitlement" currently OBSERVES: it logs the denial and admits, because self-serve checkout does not exist yet and refusing sends a person to a dead end. Enforcement returns when the self-serve path ships.
This is stated in the specification rather than left in a comment because it is a live posture with a date-stamped reason, and a reader MUST NOT assume the presence of a gate implies enforcement.
Agent replies are off unless an operator opts in
The workspace's automated responder is DISABLED by default, and the model seam is
wired only when explicitly enabled (apps/team/team.go:113-119). An
unconfigured or misconfigured binary is provably inert: with no responder wired,
no outbound model call can fire.
The document plane's wire is fixed
The live document transport carries one message per frame in the platform's own envelope, negotiates a text encoding, and pins the model version it reports. It is a data plane over an already-resolved caller, and it MUST NOT re-derive identity of its own.
Addresses, and which operations cannot be values
The capability answers at two prefixes, and the second is deliberate:
/v1/team — the control plane, bots, files, billing reads and the transactor
door — and /collaborator, the live document lane, app-level because the
front derives its WebSocket address from an origin, not from the /v1/team
base (manifest/apps.go:339-346). Operations are typed by default; the closed
exception list names each one that cannot be a value, with its wire fact —
the two WebSocket upgrades, the account plane's JSON-RPC envelope, the
cookie write whose unparseable body is ignored rather than 400'd, the two
browser redirects, the embedded billing page's bytes, and the file plane's
multipart-in and raw-bytes-out (apps/team/typed_wire_test.go:152-178).
Store, money, events, stage, upstreams
The store this capability owns is the workspace store §1 describes: one
encrypted SQLite file for the deployment (cek.Open(namespace.System(), "account", dir), apps/team/account_store.go:83), org isolation the column
§1 states, holding workspaces, memberships and the roster — plus the workspace blob store §4
bounds. It is FREE: no meter, no debit through any plane (plugin/team/main.go,
Price: cloud.Free); §5's login check is a licence read, not a meter. It
publishes no event, so a customer's webhooks receive nothing from it, and it
emits nothing to observability beyond the request span every route already
gets. Its stage is ga.
It derives from no forked upstream. The live lane SPEAKS the Y.js sync
protocol — the server is a relay and update log, not an embedded CRDT engine
(apps/team/collabws.go:13) — and the document contract is the one the Team
front's own collaborator client states (apps/team/collab.go:3-7).
Rationale
Membership could be read from a signed claim in the workspace token, which is faster and needs no store lookup. It is also stale by construction: a member removed from a workspace keeps their claim until it expires. Rows read per request cost a query and revoke immediately.
Holding one file for the deployment rather than one per workspace keeps a
single writer and lets the org column carry isolation. The store is encrypted at rest
by a handle the caller opens, rather than by the object mapper opening its own —
the mapper's own configuration carries no master key, so letting it open the file
would write every workspace, membership and display name as plaintext
(apps/team/account_store.go:43-49).
Security Considerations
The identity lane's no-echo rule is the sharpest edge here: one unauthenticated- looking RPC that returned the caller's identity token would put an estate-wide bearer into page script. The property is structural — the lane's credential field is empty by construction — and MUST stay structural rather than becoming a rule each new surface remembers.
The degraded posture is a security feature, not an outage behaviour: without a real secret, accepting a token is accepting a forgery, so the subsystem refuses to decode one at all.
Tenant isolation rests on the verified org, never on a client-supplied header, and is repeated in the key shape of the file plane so a single missed predicate is not a cross-tenant read. Every denial across these planes is the same 404 for the same reason: a distinguishable refusal is an oracle for what exists and who belongs.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | team at its own prefix | 45 operations |
| CLI | hanzo team … | 21 of 45 — the CLI pins the document on its own clock |
| SDK | TeamApi in every published client | 45 methods |
| MCP | tool team on https://api.hanzo.ai/v1/mcp | 33 operations, 4 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/team/docs, operation get_team_docs:
hanzo has no subcommand for this operation — the CLI serves only what cloud's live route table confirms. Use HTTP or an SDK.
import { Configuration, TeamApi } from 'hanzoai';
const api = new TeamApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getTeamDocs();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import TeamApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = TeamApi(client).get_team_docs()cfg := hanzoai.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := hanzoai.NewAPIClient(cfg)
resp, _, err := client.TeamAPI.GetTeamDocs(context.Background()).Execute()
if err != nil {
return err
}use hanzo_client::apis::{configuration::Configuration, team_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = team_api::get_team_docs(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.TeamApi;
ApiClient client = new ApiClient();
client.setBearerToken(System.getenv("HANZO_API_KEY"));
var result = new TeamApi(client).getTeamDocs();curl https://api.hanzo.ai/v1/team/docs \
-H "Authorization: Bearer $HANZO_API_KEY"MCP reaches team through the team tool, which names its 33 operations with its own verbs — this one among them, under a name only MCP declares. describe explains any of them:
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "describe",
"arguments": {
"op": "list_team_account_providers"
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/team/account/auth/{provider}/callback | Complete a sign-in and hand the browser its session |
GET /v1/team/account/auth/{provider} | Start a sign-in at hanzo.id |
PUT /v1/team/account/cookie | Store the session token as this browser's cookie |
DELETE /v1/team/account/cookie | Signs this browser out of team by expiring the HttpOnly account-token cookie the OAuth callback set. |
GET /v1/team/account/providers | Returns the identity providers this deployment starts a login with. |
POST /v1/team/account | Read the caller's account and switch space |
GET /v1/team/billing/plan | Returns the plan and seat counts for the caller's OWN org, resolved from the VERIFIED team session token — never a client header. |
GET /v1/team/billing/ui | Open the wallet page |
POST /v1/team/collaborator/rpc/{documentId} | The collaborative-markup snapshot plane the Team front's editor speaks: createContent stores a document field's markup at a fresh, immutable blob ref and returns it, updateContent stores a new snapshot and answers nothing, and getContent reads back the exact snapshot a ref names. |
GET /v1/team/collaborator | Open the live collaborative-editing socket |
POST /v1/team/dms | Opens the direct message between the caller and the people named, or answers the one that already exists — there is one conversation per set of people, however many times it is asked for. |
GET /v1/team/docs/{id}/comments | Returns the comments on a document, oldest first — the same message shape a room's conversation answers. |
POST /v1/team/docs/{id}/comments | Comments on a document, as the caller. |
GET /v1/team/docs/{id} | Returns one document the caller may see. |
PATCH /v1/team/docs/{id} | Renames a document or moves it — under another document, to the top of its teamspace, or to another teamspace with everything nested under it. |
DELETE /v1/team/docs/{id} | Removes a document with everything nested under it and every comment on any of them — the Team client's own delete, which takes the subtree with it. |
GET /v1/team/docs | Returns the documents of a space the caller may see, with the teamspaces they are grouped in. |
POST /v1/team/docs | Creates a document, as the caller, after its siblings. |
GET /v1/team/events | Stream live changes to what the caller may see, as Server-Sent Events |
GET /v1/team/files/{space}/{filename} | Download a space file |
DELETE /v1/team/files/{space}/{filename} | Removes one blob from a space's file store. |
POST /v1/team/files/{space} | Upload a file into a space |
POST /v1/team/inbox/{id}/archive | Archives one of the caller's notifications — read, and out of the live inbox — and answers it. |
POST /v1/team/inbox/{id}/read | Marks one of the caller's notifications read and answers it. |
POST /v1/team/inbox/read | Marks every live notification of the caller's read, and says how many it changed. |
GET /v1/team/inbox | Returns the caller's notifications, newest first, each with the room or document it is about and the message that caused it. |
GET /v1/team/members | Returns the people and agents in a space, with the name, avatar, role and presence a client draws them with. |
PUT /v1/team/messages/{id}/reactions/{emoji} | Adds the caller's reaction to a message and answers the message with its reactions as they now stand. |
DELETE /v1/team/messages/{id}/reactions/{emoji} | Takes back the caller's reaction to a message and answers the message with its reactions as they now stand. |
GET /v1/team/messages/{id}/replies | Returns a message's thread, oldest first, each reply with its reactions and files. |
POST /v1/team/messages/{id}/replies | Answers a message in its thread, as the caller. |
PATCH /v1/team/messages/{id} | Rewrites what a message says. |
DELETE /v1/team/messages/{id} | Removes a message, with its replies, reactions, files and the inbox notifications that point at it. |
GET /v1/team/public | Lists the rooms orgs have published, across every org. |
DELETE /v1/team/rooms/{id}/members/{account} | Takes one person out of a room — the caller leaving, when the account is their own. |
GET /v1/team/rooms/{id}/members | Returns the people and agents in one room, as the roster describes them. |
POST /v1/team/rooms/{id}/members | Adds people to a room and answers the room as it now stands. |
GET /v1/team/rooms/{id}/messages | Returns the tail of one room's conversation, oldest first, each message with its reactions, files and thread count. |
POST /v1/team/rooms/{id}/messages | Says one thing in a room, as the caller. |
PUT /v1/team/rooms/{id} | States what a room is for: its lifecycle intent, and what it is about. |
PATCH /v1/team/rooms/{id} | Renames a channel, sets its topic, or archives or reopens it, and answers the room as it now stands. |
GET /v1/team/rooms | Returns the rooms the caller may see, with the kind and work facet each carries. |
POST /v1/team/rooms | Opens a named room and answers it as the store now holds it. |
GET /v1/team/transactor/{token} | Open the space data-plane socket |
GET /v1/team/transactor/statistics | Returns the transactor's live sessions for the space the caller's credential names — the endpoint the front's space switcher and server panel poll on the transactor base. |