> ## Documentation Index
> Fetch the complete documentation index at: https://docs.claw.boo/llms.txt
> Use this file to discover all available pages before exploring further.

# Teams API

> REST reference for teams and their sub-resources: CRUD, agent membership, the Know-Your-Team onboarding gate, team rules, and the peer-chat room.

REST surface for [teams](/appendices/glossary) 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](/reference/rest-api/tools-and-mcp) writes).

<Note>
  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](/reference/rest-api/agents).
</Note>

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

| Method | Path                              | Summary                                                      | Stream? |
| ------ | --------------------------------- | ------------------------------------------------------------ | ------- |
| GET    | `/api/teams`                      | List teams with `agentCount`, plus agent→team assignments    | No      |
| POST   | `/api/teams`                      | Create a team (optional client-provided UUID)                | No      |
| PATCH  | `/api/teams/:id`                  | Update a team (partial)                                      | No      |
| DELETE | `/api/teams/:id`                  | Delete a team, orphan its agents, clean team-scoped settings | No      |
| POST   | `/api/teams/:id/agents`           | Assign (upsert) an agent into the team                       | No      |
| DELETE | `/api/teams/:id/agents/:agentId`  | Remove an agent from a team                                  | No      |
| GET    | `/api/teams/:id/onboarding`       | Read the per-team onboarding flags + user-intro text         | No      |
| PATCH  | `/api/teams/:id/onboarding`       | Merge-update the onboarding state                            | No      |
| GET    | `/api/teams/:id/activity-summary` | Compact brief + board + memory + chat snapshot               | No      |
| POST   | `/api/teams/:id/chat`             | Ingest a user message into the server orchestrator (202)     | No      |
| POST   | `/api/teams/:id/chat/stop`        | User Stop: abort in-flight runs, release claimed tasks       | No      |
| GET    | `/api/teams/:id/chat/stream`      | SSE live-tail of the team transcript                         | SSE     |
| GET    | `/api/team-rules/:teamId`         | Read the durable team-rules text                             | No      |
| PUT    | `/api/team-rules/:teamId`         | Replace the team-rules text (4000-char cap)                  | No      |
| GET    | `/api/team-chat`                  | Cursor-read the team peer-chat room                          | No      |
| POST   | `/api/team-chat/exchange`         | Kick off ONE bounded peer-chat exchange                      | No      |

***

## `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**:

| Param             | Type   | Default | Notes                                               |
| ----------------- | ------ | ------- | --------------------------------------------------- |
| `includeArchived` | string | `false` | `"true"` includes archived teams (`isArchived = 1`) |

* **Request body**: none.

### Responses

**`200 OK`**: the team list plus assignments:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  teams: Array<{
    id: string
    name: string
    icon: string
    color: string
    colorCollectionId: string | null
    templateId: string | null
    leaderAgentId: string | null
    isArchived: number // 0 | 1
    createdAt: number
    updatedAt: number
    agentCount: number // COUNT(*) of agents with this team_id
  }>
  assignments: Array<{ agentId: string; teamId: string }> // agents with a non-null teamId
}
```

**`500 Internal Server Error`**: any DB failure:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl http://localhost:18790/api/teams
curl 'http://localhost:18790/api/teams?includeArchived=true'
```

***

## `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**:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  name: string               // required
  icon: string               // required (emoji)
  color: string              // required (hex)
  colorCollectionId?: string // null when omitted
  templateId?: string        // null when omitted
  leaderAgentId?: string     // null when omitted
  id?: string                // honored only if it matches the UUID regex; else server-minted
}
```

### Responses

**`400 Bad Request`**: body is missing or not an object:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "invalid JSON" }
```

**`400 Bad Request`**: a required field is missing:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "name, icon, and color are required" }
```

**`200 OK`**: the created team (note: **200**, not 201):

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  team: {
    id: string
    name: string
    icon: string
    color: string
    colorCollectionId: string | null
    templateId: string | null
    leaderAgentId: string | null
    isArchived: 0
    agentCount: 0
    createdAt: number
    updatedAt: number
  }
}
```

**`500 Internal Server Error`**: the insert failed:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -X POST http://localhost:18790/api/teams \
  -H 'Content-Type: application/json' \
  -d '{"name":"Research Squad","icon":"🔬","color":"#34d399"}'
```

***

## `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):

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  name?: string
  icon?: string
  color?: string
  colorCollectionId?: string | null
  isArchived?: number          // truthy → 1, falsy → 0
  leaderAgentId?: string | null
}
```

### Responses

**`400 Bad Request`**: the `:id` segment is missing:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team id required" }
```

**`400 Bad Request`**: the body is missing or not an object:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "invalid JSON" }
```

**`404 Not Found`**: no team with that id:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team not found" }
```

**`200 OK`**: the updated team:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  team: {
    id: string
    name: string
    icon: string
    color: string
    colorCollectionId: string | null
    templateId: string | null
    leaderAgentId: string | null
    isArchived: number
    createdAt: number
    updatedAt: number
    agentCount: number
  }
}
```

**`500 Internal Server Error`**: the update failed:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -X PATCH http://localhost:18790/api/teams/<team-id> \
  -H 'Content-Type: application/json' \
  -d '{"name":"Renamed","leaderAgentId":"<agent-id>"}'
```

***

## `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:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team id required" }
```

**`200 OK`**: the team was deleted and its agents orphaned:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "ok": true }
```

**`500 Internal Server Error`**: a DB failure:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

<Note>
  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 }`.
</Note>

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -X DELETE http://localhost:18790/api/teams/<team-id>
```

***

## `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**:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  agentId: string     // required
  agentName?: string  // display name when creating a new row; defaults to agentId
}
```

### Responses

**`400 Bad Request`**: the `:id` segment is missing:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team id required" }
```

**`400 Bad Request`**: the body is missing/non-object or has no `agentId`:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "agentId is required" }
```

**`200 OK`**: the agent was assigned (created or updated):

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "ok": true }
```

**`500 Internal Server Error`**: the upsert failed:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -X POST http://localhost:18790/api/teams/<team-id>/agents \
  -H 'Content-Type: application/json' \
  -d '{"agentId":"<agent-id>"}'
```

***

## `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:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "agentId required" }
```

**`200 OK`**: the agent's `teamId` was cleared:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "ok": true }
```

**`500 Internal Server Error`**: the update failed:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -X DELETE http://localhost:18790/api/teams/<team-id>/agents/<agent-id>
```

***

## `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:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team id required" }
```

**`200 OK`**: the onboarding state (defaults when unset):

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  agentsIntroduced: boolean // default false
  userIntroduced: boolean // default false
  userIntroText: string // default ''
}
```

**`500 Internal Server Error`**: a DB failure:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl http://localhost:18790/api/teams/<team-id>/onboarding
```

***

## `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):

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  agentsIntroduced?: boolean
  userIntroduced?: boolean
  userIntroText?: string   // sliced to 4000 chars server-side
}
```

### Responses

**`400 Bad Request`**: the `:id` segment is missing:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team id required" }
```

**`400 Bad Request`**: the body is missing or not an object:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "invalid JSON" }
```

**`200 OK`**: the merged, updated state:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  agentsIntroduced: boolean
  userIntroduced: boolean
  userIntroText: string
}
```

**`500 Internal Server Error`**: a DB failure:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -X PATCH http://localhost:18790/api/teams/<team-id>/onboarding \
  -H 'Content-Type: application/json' \
  -d '{"agentsIntroduced":true,"userIntroduced":true,"userIntroText":"I run a small SaaS."}'
```

***

## `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:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team id required" }
```

**`200 OK`**: the summary block, or `null` when the team has nothing to report:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  content: string | null // null = no brief, no board tasks, no saved memory, no meaningful chat
}
```

**`500 Internal Server Error`**: a DB failure:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

<Note>
  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.
</Note>

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl http://localhost:18790/api/teams/<team-id>/activity-summary
```

***

## `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**:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  message: string          // required; trimmed, must be non-empty
  targetAgentId?: string   // honored only when it matches a roster member
  entryId?: string         // client-minted id for the persisted user entry (SSE dedup)
}
```

### Responses

**`400 Bad Request`**: the `:id` segment is missing:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team id required" }
```

**`400 Bad Request`**: `message` is absent, not a string, or empty after trimming:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "message required" }
```

**`404 Not Found`**: the team opted out of server orchestration:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team is not server-orchestrated" }
```

**`202 Accepted`**: the message was enqueued (the run has NOT finished):

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "ok": true }
```

**`500 Internal Server Error`**: an unexpected throw before the enqueue:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

<Note>
  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.
</Note>

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -X POST http://localhost:18790/api/teams/<team-id>/chat \
  -H 'Content-Type: application/json' \
  -d '{"message":"Plan the launch checklist."}'
```

***

## `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:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team id required" }
```

**`404 Not Found`**: the team opted out of server orchestration:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team is not server-orchestrated" }
```

**`200 OK`**: the stop was applied, or there was nothing to stop:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "ok": true }
```

**`500 Internal Server Error`**: an unexpected throw:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

<Note>
  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 }`.
</Note>

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -X POST http://localhost:18790/api/teams/<team-id>/chat/stop
```

***

## `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**:

| Param   | Type   | Notes                                                                                               |
| ------- | ------ | --------------------------------------------------------------------------------------------------- |
| `since` | number | Resume cursor over the `chat_messages` id. Negative/non-finite → `0`. `Last-Event-ID` wins over it. |

* **Request body**: none.

<Note>
  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.
</Note>

### Connection

On open, the handler writes `HTTP/1.1 200` with:

```http theme={"theme":{"light":"github-dark","dark":"github-dark"}}
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no
```

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

| Frame           | When                                                                                                                                  | Shape                                                                                        |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `: connected`   | Immediately on open                                                                                                                   | SSE comment (ignored by `EventSource`)                                                       |
| *(unnamed)*     | Per committed transcript row (poll every 750 ms)                                                                                      | `id: <row id>` line + `data: <TranscriptEntry json>` line                                    |
| `event: delta`  | Per live assistant-text delta                                                                                                         | `{ sessionKey, runId, text }`; `text` is the FULL running text so far (replace, not append)  |
| `event: board`  | Per board mutation on this team, from any write path (the orchestrator, the executor runner, a Tasks MCP tool, the stale sweep, REST) | `{ id, title?, status?, assigneeAgentId?, parentTaskId?, createdAt?, updatedAt?, summary? }` |
| `event: status` | Per run boundary, plus a snapshot on connect                                                                                          | `{ agentId, status: 'running' \| 'idle' \| 'error' }`                                        |
| `: keepalive`   | Every 20 s while open                                                                                                                 | SSE comment (keeps the connection warm)                                                      |

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:

```text theme={"theme":{"light":"github-dark","dark":"github-dark"}}
: connected

id: 8121
data: {"entryId":"...","role":"user","kind":"user","text":"Plan the launch checklist.","sessionKey":"agent:<id>:team:<team-id>","runId":null,"source":"local-send","timestampMs":1718450000000,"sequenceKey":1,"confirmed":true,"fingerprint":"..."}

event: status
data: {"agentId":"<agent-id>","status":"running"}

event: delta
data: {"sessionKey":"agent:<id>:team:<team-id>","runId":"<run-id>","text":"Here is the checklist so far"}

: keepalive
```

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -N http://localhost:18790/api/teams/<team-id>/chat/stream

# Resume from a known transcript row id
curl -N 'http://localhost:18790/api/teams/<team-id>/chat/stream?since=8121'
```

***

## `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.

<Note>
  This route uses the param name `:teamId` (not `:id` like the routes above). The value is still the team id.
</Note>

* **Path params**: `teamId` (team id).
* **Request body**: none.

### Responses

**`400 Bad Request`**: the `:teamId` segment is missing:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team id required" }
```

**`200 OK`**: the rules text (empty when unset):

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  content: string
}
```

**`500 Internal Server Error`**: a DB failure:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl http://localhost:18790/api/team-rules/<team-id>
```

***

## `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**:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ content?: string }   // required string; max 4000 chars
```

### Responses

**`400 Bad Request`**: the `:teamId` segment is missing:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "team id required" }
```

**`400 Bad Request`**: the body is missing or not an object:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "invalid JSON" }
```

**`400 Bad Request`**: `content` is absent or not a string:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "content (string) required" }
```

**`400 Bad Request`**: `content` exceeds the 4000-character cap:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "content exceeds 4000 characters" }
```

**`200 OK`**: the stored rules:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  content: string
}
```

**`500 Internal Server Error`**: a DB failure:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -X PUT http://localhost:18790/api/team-rules/<team-id> \
  -H 'Content-Type: application/json' \
  -d '{"content":"Always delegate via <delegate>; never do the work yourself."}'
```

***

## `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](/reference/rest-api/tools-and-mcp) 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**:

| Param      | Type            | Default             | Notes                                                            |
| ---------- | --------------- | ------------------- | ---------------------------------------------------------------- |
| `teamId`   | string          | n/a                 | Resolves the room id to `team:<teamId>`                          |
| `roomId`   | string          | n/a                 | An explicit room id (overrides `teamId`)                         |
| `sinceSeq` | string (number) | `0`                 | Return posts with `seq > sinceSeq`; non-finite falls back to `0` |
| `limit`    | string (number) | `1000` (data layer) | Cap the batch; ignored if non-finite                             |

* **Request body**: none.

### Responses

**`400 Bad Request`**: neither `teamId` nor `roomId` was supplied:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "teamId or roomId is required" }
```

**`200 OK`**: the room posts in ascending `seq` order plus the next cursor:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  roomId: string
  posts: Array<{
    id: string
    roomId: string
    teamId: string
    authorAgentId: string
    body: string
    kind: 'peer' | 'system' | 'user' // 'peer' teammate post · 'system' board-mutation narration · 'user'
    createdAt: number
    seq: number // per-room monotonic
  }>
  nextSeq: number // the last post's seq, or the requested sinceSeq when empty
}
```

**`500 Internal Server Error`**: a DB failure:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl 'http://localhost:18790/api/team-chat?teamId=<team-id>&sinceSeq=0'
```

***

## `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**:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  teamId: string             // required
  stimulus?: string          // the initiating message for the first speaker's turn
  firstSpeakers?: string[]   // agent ids seeded to speak first; defaults to the leader
  maxExchangeTurns?: number  // clamped server-side to DEFAULT_MAX_EXCHANGE_TURNS (5) × participants
}
```

### Responses

**`400 Bad Request`**: missing `teamId`:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "teamId is required" }
```

**`409 Conflict`**: an exchange is already running for this room (the per-room lock refuses an overlapping kickoff):

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "ok": false, "error": "an exchange is already running for this room" }
```

**`200 OK`**: the exchange ran to a bound and returned its result:

```ts theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  ok: true
  roomId: string
  result: {
    turnsTaken: number
    endedReason: 'max_turns' | 'no_pending_obligation' | 'budget_paused' | 'aborted'
    speakers: string[]   // ordered agent ids who spoke this exchange
  }
}
```

**`404 Not Found`**: the team does not exist (the exchange returned `error: 'team not found'`):

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "ok": false, "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>`):

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "ok": false, "error": "<reason>" }
```

**`500 Internal Server Error`**: an unexpected throw:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{ "error": "<message>" }
```

<Info>
  The board stays canonical: a peer-chat post never mutates the board. Decisions land as board mutations (see [the board](/concepts/the-board)); the exchange only narrates them into the room as `kind: 'system'` lines.
</Info>

### Example

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -X POST http://localhost:18790/api/team-chat/exchange \
  -H 'Content-Type: application/json' \
  -d '{"teamId":"<team-id>","stimulus":"Plan the launch checklist."}'
```

***

## 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

* [Using teams](/using/teams): create, manage leaders, rules, and color collections in the UI
* [Group chat + the Know-Your-Team gate](/using/group-chat)
* [Mixed-runtime peer chat](/concepts/peer-chat): rooms, speaker selection, `isUser=false`
* [Agents API](/reference/rest-api/agents): the agent registry these team-membership routes reference
* [Tools & MCP API](/reference/rest-api/tools-and-mcp): the TeamChat MCP server (`/api/mcp/teamchat`) write half
* [Database schema](/reference/database-schema): the `teams`, `agents`, `settings`, and `team_chat` tables
* [REST API overview](/reference/rest-api/index)
