Skip to main content
REST surface for teams and everything scoped to a team: team CRUD, agent→team membership, the per-team “Know Your Team” onboarding flags, the durable team-rules text, the server-orchestrated chat surface (ingest, stop, SSE tail) plus its activity snapshot, and the mixed-runtime peer-chat room (read + the explicit exchange kickoff). Teams are stored in the SQLite teams table; agent membership is the nullable agents.teamId FK (one-to-one: an agent belongs to at most one team). Onboarding flags and team rules are NOT tables; they are JSON blobs in the settings key/value table under the keys team-onboarding:<teamId> and team-rules:<teamId>. The peer-chat room is the team_chat table (the same one the TeamChat MCP server writes).
Deleting a team orphans its agents (sets agents.teamId = null) rather than deleting them, and cleans up the team-scoped settings rows. The agents themselves are managed through the agents API.
All POST/PATCH/PUT routes read a JSON body parsed by express.json({ limit: '2mb' }). Two different path-param names appear in this group: the team CRUD, onboarding, chat, and activity routes use :id, while the team-rules routes use :teamId; both are the team id, the difference is purely the registered param name.

Routes


GET /api/teams

Lists every team with a computed agentCount (a COUNT(*) subquery over agents.team_id). Also returns the full agent→team assignment list so a client can patch its fleet store after hydration. Archived teams are excluded unless includeArchived=true.
  • Path params: none.
  • Query params:
  • Request body: none.

Responses

200 OK: the team list plus assignments:
500 Internal Server Error: any DB failure:

Example


POST /api/teams

Creates a team. The server mints a crypto.randomUUID() id unless the body supplies a valid UUID id; the create-team UI seeds a client-side id so the Boo color-palette preview matches the deployed team (per-team color rotation is seeded off the team id). name, icon, and color are required; everything else is optional and stored as supplied (null when omitted).
  • Path/query params: none.
  • Request body:

Responses

400 Bad Request: body is missing or not an object:
400 Bad Request: a required field is missing:
200 OK: the created team (note: 200, not 201):
500 Internal Server Error: the insert failed:

Example


PATCH /api/teams/:id

Partially updates a team. Only the fields present in the body are written; updatedAt is always refreshed. isArchived is coerced to 0 or 1 from the body’s truthiness. Returns the full updated row (including a recomputed agentCount).
  • Path params: id (team id).
  • Request body (all optional):

Responses

400 Bad Request: the :id segment is missing:
400 Bad Request: the body is missing or not an object:
404 Not Found: no team with that id:
200 OK: the updated team:
500 Internal Server Error: the update failed:

Example


DELETE /api/teams/:id

Deletes the team and orphans its agents. In one handler it (1) sets teamId = null on every agent in the team, (2) deletes the team-scoped settings rows (team-rules:<teamId> and team-onboarding:<teamId>), and (3) deletes the team row. The boo_zero_team_briefs table FK-cascades on the team delete, so per-team briefs clean themselves.
  • Path params: id (team id).
  • Request body: none.

Responses

400 Bad Request: the :id segment is missing:
200 OK: the team was deleted and its agents orphaned:
500 Internal Server Error: a DB failure:
The handler does not 404 a non-existent team id. UPDATE … WHERE team_id = ? and DELETE … WHERE id = ? are no-ops when nothing matches, so an unknown id still returns { ok: true }.

Example


POST /api/teams/:id/agents

Assigns an agent to the team. This is an upsert: if the agent row does not exist it is created (with status: 'idle' and gatewayId = agentId); if it exists, only its teamId and updatedAt are set. The optional agentName is used as the display name when creating a new row (it defaults to the agentId).
  • Path params: id (team id).
  • Request body:

Responses

400 Bad Request: the :id segment is missing:
400 Bad Request: the body is missing/non-object or has no agentId:
200 OK: the agent was assigned (created or updated):
500 Internal Server Error: the upsert failed:

Example


DELETE /api/teams/:id/agents/:agentId

Removes an agent from a team by setting its teamId to null; the :id (team) segment is not used in the predicate. The agent row is not deleted.
  • Path params: id (team id, unused in the query), agentId (the agent to orphan).
  • Request body: none.

Responses

400 Bad Request: the :agentId segment is missing:
200 OK: the agent’s teamId was cleared:
500 Internal Server Error: the update failed:

Example


GET /api/teams/:id/onboarding

Reads the per-team “Know Your Team” onboarding state. Both agentsIntroduced and userIntroduced must be true before the normal group-chat composer unlocks. userIntroText is the user’s self-introduction, the source of truth injected into the team context preamble on every group-chat message. When no row exists, the default { false, false, '' } is returned.
  • Path params: id (team id).
  • Request body: none.

Responses

400 Bad Request: the :id segment is missing:
200 OK: the onboarding state (defaults when unset):
500 Internal Server Error: a DB failure:

Example


PATCH /api/teams/:id/onboarding

Merge-updates the onboarding state and returns the full updated state. Only fields with the correct type are applied; a non-boolean agentsIntroduced/userIntroduced or non-string userIntroText is ignored and the current value is kept. userIntroText is truncated to 4000 characters.
  • Path params: id (team id).
  • Request body (all optional, merged with current state):

Responses

400 Bad Request: the :id segment is missing:
400 Bad Request: the body is missing or not an object:
200 OK: the merged, updated state:
500 Internal Server Error: a DB failure:

Example


GET /api/teams/:id/activity-summary

Builds a compact, on-demand snapshot of what a team has been doing, for injection into Boo Zero’s personal chat when the user @-mentions that team. Composed from durable server state in order (brief, board, saved memory, recent chat), so it works regardless of what the browser has loaded. The sections are sized against a 2500-character budget: the Boo-Zero brief is clipped to 900, the board summary and saved memory are kept whole, and the recent-chat section (up to 30 turns, oldest dropped first) gets whatever budget remains.
  • Path params: id (team id).
  • Request body: none.

Responses

400 Bad Request: the :id segment is missing:
200 OK: the summary block, or null when the team has nothing to report:
500 Internal Server Error: a DB failure:
An unknown team id is not a 404: it simply has no brief, board, memory, or chat, so it returns { "content": null }. Saved memory is best-effort (a memory-store failure is swallowed and the section omitted) rather than a 500.

Example


POST /api/teams/:id/chat

Ingests a user message into the team’s server orchestrator and returns 202 immediately. The cascade proceeds detached: req.on('close') is deliberately not wired to abort it, so closing the client (or the request simply ending) never kills the run. Target resolution is by priority: an explicit targetAgentId that is in the roster, then a leading @-mention (the message must start with @<agent name>, and the name must be followed by whitespace or the end of the message; a mention anywhere else in the text is ignored). The user message is persisted under the target’s team session key before that agent’s turn runs. The route is gated on the double-orchestration firewall: a team that has opted out via the team-server-orchestrated:<teamId> setting (value 'false') gets a 404. Only the literal string 'false' closes the gate, and nothing writes that value today: an absent key resolves to on, and the two paths that do write the key (POST /api/teams with serverOrchestrated: true, and the native-team onboarding seed) both write 'true'. So in practice every team is server-orchestrated and the gate passes.
  • Path params: id (team id).
  • Request body:

Responses

400 Bad Request: the :id segment is missing:
400 Bad Request: message is absent, not a string, or empty after trimming:
404 Not Found: the team opted out of server orchestration:
202 Accepted: the message was enqueued (the run has NOT finished):
500 Internal Server Error: an unexpected throw before the enqueue:
The 202 means “accepted”, not “succeeded”. Watch the run on GET /api/teams/:id/chat/stream. A failed delivery is recovered in place rather than returned here: a down OpenClaw operator connection is reconnected and the same turn retried once, and only a still-failing send is persisted into the transcript as a role: 'system', kind: 'meta' entry.

Example


POST /api/teams/:id/chat/stop

User Stop. Bumps the orchestrator’s stop generation synchronously (before any await), then aborts every in-flight run through its runtime adapter. In-flight engine work bails at its next checkpoint, and because the generation changed, the resulting aborted terminals are read as a clean Stop rather than a failure: each claimed task is released back to todo, with no blocked status, no dependent cancellation, and no failure reflection to the delegator. The Stop is made durable in the same call, independently of whether those aborted terminals ever land: queued-but-unsent deliveries are dropped, and every tracked run plus every not-yet-fired ready delegation gets a cancelled execution row. That marker is what keeps the board dispatch pump from re-firing the halted cascade. Same server-orchestration gate as the ingest route.
  • Path params: id (team id).
  • Request body: none.

Responses

400 Bad Request: the :id segment is missing:
404 Not Found: the team opted out of server orchestration:
200 OK: the stop was applied, or there was nothing to stop:
500 Internal Server Error: an unexpected throw:
Stopping a team with no live orchestrator (never started this process, or idle-evicted after 30 minutes with no run in flight) is a no-op that still returns { "ok": true }.

Example


GET /api/teams/:id/chat/stream

Server-Sent Events live-tail of a team’s chat transcript, in two tiers. Tier 1 polls the durable chat_messages rows for the team’s session keys every 750 ms on the monotonic id cursor (up to 500 rows per batch); each row’s stored data is already a serialized TranscriptEntry and goes straight to the wire. Tier 2 forwards ephemeral in-memory signals (assistant token deltas, board-projection changes, agent status) as named frames with no id: line, so they never advance the resume cursor. This is a pure reader: the stream never drives orchestration, so a server-side cascade runs to completion with zero clients connected. It is ungated by design (tailing chat_messages is safe for any team); the double-orchestration firewall lives on the write path. The session-key set (agent:<id>:team:<teamId> for each member, plus Boo Zero’s team-scoped key) is resolved once at connect, so a member added mid-stream is picked up on the next reconnect.
  • Path params: id (team id).
  • Query params:
  • Request body: none.
This is an SSE route, not request/response. There is no JSON response body; the catalog below describes the wire frames. An unknown team id is not a 404: no session keys match, so the stream opens and stays open emitting only keepalives. Resume replays committed rows only: deltas, board changes, and status frames carry no id and are never replayed.

Connection

On open, the handler writes HTTP/1.1 200 with:
then emits a : connected comment frame, flushes any rows already past the cursor, and replays the team’s last-known agent status per agent.

Event catalog

Only the unnamed frames carry an id:, so only they move the resume cursor. A client reconciles any board change it missed across a reconnect with a GET /api/board reload. Example frames:

Example


GET /api/team-rules/:teamId

Reads the durable per-team rules text. The rules are captured either through the maintenance-panel textarea or the /rule <text> slash command in the team-chat composer; either path writes here. The text is injected into the message preamble for every team agent so user corrections survive across sessions. When no row exists, { content: '' } is returned.
This route uses the param name :teamId (not :id like the routes above). The value is still the team id.
  • Path params: teamId (team id).
  • Request body: none.

Responses

400 Bad Request: the :teamId segment is missing:
200 OK: the rules text (empty when unset):
500 Internal Server Error: a DB failure:

Example


PUT /api/team-rules/:teamId

Replaces the team-rules text. content is required and must be a string; it is rejected (not truncated) when it exceeds 4000 characters. Returns the stored value.
  • Path params: teamId (team id).
  • Request body:

Responses

400 Bad Request: the :teamId segment is missing:
400 Bad Request: the body is missing or not an object:
400 Bad Request: content is absent or not a string:
400 Bad Request: content exceeds the 4000-character cap:
200 OK: the stored rules:
500 Internal Server Error: a DB failure:

Example


GET /api/team-chat

Cursor-reads the team’s mixed-runtime peer-chat room, the durable team_chat table where every member posts as a named peer. The model-facing write half is the TeamChat MCP server at /api/mcp/teamchat; this REST route is the UI-facing read. Pass teamId (the room resolves to team:<teamId>) or a roomId directly; one of the two is required. sinceSeq is the cursor (posts with seq > sinceSeq).
  • Path params: none.
  • Query params:
  • Request body: none.

Responses

400 Bad Request: neither teamId nor roomId was supplied:
200 OK: the room posts in ascending seq order plus the next cursor:
500 Internal Server Error: a DB failure:

Example


POST /api/team-chat/exchange

Kicks off ONE bounded peer-chat exchange for a team: it assembles the team’s active members into chat participants, drives a bounded round of turns through the real runtime adapters, and projects the speaker-selection / turn-bound lifecycle into the observability event log. This is a deliberate, invokable trigger, not an autonomous loop. The runtime adapters attach their MCP client to this server’s /api/mcp/* over a server-trusted loopback URL (never the client Host). The exchange is aborted between turns if the initiating request disconnects, and a per-room re-entrancy lock refuses an overlapping exchange.
  • Path/query params: none.
  • Request body:

Responses

400 Bad Request: missing teamId:
409 Conflict: an exchange is already running for this room (the per-room lock refuses an overlapping kickoff):
200 OK: the exchange ran to a bound and returned its result:
404 Not Found: the team does not exist (the exchange returned error: 'team not found'):
422 Unprocessable Entity: the exchange was refused for any other reason (e.g. team has no agents, unknown first speaker: <id>, budget_paused:<scope>):
500 Internal Server Error: an unexpected throw:
The board stays canonical: a peer-chat post never mutates the board. Decisions land as board mutations (see the board); the exchange only narrates them into the room as kind: 'system' lines.

Example


Error envelope

Every error response on these routes is the standard envelope { error: string }. The two exceptions are on /api/team-chat/exchange: the 409 re-entrancy refusal and the 404/422 exchange-refused branches use { ok: false, error: string } (the success shape is { ok: true, roomId, result }). The SSE route /api/teams/:id/chat/stream has no error body at all: it commits a 200 event stream up front and swallows transient tail-read errors to keep the connection alive.

See also

Last modified on August 21, 2026