Team
Package team is your org's shared workspace: documents edited together, files, seats, and agents as teammates.
Package team is your org's shared workspace: documents edited together, files, seats, and agents as teammates.
| Base URL | https://api.hanzo.ai |
| Operations | 18 |
| 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 | 18 operations |
| CLI | hanzo team … | 16 of 18 — the CLI pins the document on its own clock |
| SDK | TeamApi in every published client | 16 of 18 — the clients are generated at their own release |
| MCP | tool team on https://api.hanzo.ai/v1/mcp | 18 operations, 3 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/bots, operation get_team_bots:
hanzo team bots getimport { Configuration, TeamApi } from 'hanzoai';
const api = new TeamApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getTeamBots();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_bots()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.TeamAPI.GetTeamBots(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::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_bots(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.TeamApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new TeamApi(client).getTeamBots();curl https://api.hanzo.ai/v1/team/bots \
-H "Authorization: Bearer $HANZO_API_KEY"The door reaches team through the team tool, which names its 18 operations with its own verbs — this one among them, under a name only the door 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": "get_collaborator"
}
}
}'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 workspace |
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/bots/sync | SyncBots re-projects the caller org's agents as workspace members into EVERY workspace of the org, and removes the ones whose agent is gone. |
GET /v1/team/bots | Returns the caller org's bot members — the org's agents projected as the workspace Employees they become, each with the member account uuid and… |
POST /v1/team/collaborator/rpc/{documentId} | CollabRPC is the collaborative-markup snapshot plane the Team front's editor speaks: createContent stores a document field's markup at a fresh,… |
GET /v1/team/collaborator | Open the live collaborative-editing socket |
GET /v1/team/files/{workspace}/{filename} | Download a workspace file |
DELETE /v1/team/files/{workspace}/{filename} | Removes one blob from a workspace's file store. |
POST /v1/team/files/{workspace} | Upload a file into a workspace |
GET /v1/team/transactor/{token} | Open the workspace data-plane socket |
GET /v1/team/transactor/api/v1/statistics | Statistics returns the transactor's live sessions for the workspace the caller's credential names — the endpoint the front's workspace switcher and… |
GET /v1/team/transactor/statistics | Statistics returns the transactor's live sessions for the workspace the caller's credential names — the endpoint the front's workspace switcher and… |
How is this guide?
Wallet
Package wallets is blockchain key custody: create wallets, rotate their keys, and sign with them.
AI
Package ai is Hanzo AI — the model API on /v1 (/v1/chat/completions, /v1/messages, /v1/models and the rest of hanzoai/ai's surface) — mounted into a cloud binary with the money, ingest and telemetry…