---
title: Team Chat (built-in channels and direct messages) (part 2)
---

# Team Chat (built-in channels and direct messages) (part 2)

## What it is

This is part 2 of the [Team Chat (built-in channels and direct messages)](team-chat.md) page. It covers everything added to Team Chat after the first version — the message-surface polish, the composer upgrades, saved items, custom status, scheduled send, attachments and custom emoji — and then the workspace story: the per-channel roles and invitations that let a channel be managed rather than just used, the self-serve chat workspaces you create and run yourself beside your company workspace, and the one-to-one connections that reach people in other organizations.

## Where to find it

The same surface as the parent page: once it is switched on, Team Chat appears as a built-in virtual project in the projects sidebar, with the channel and direct-message list in the sidebar pane and the open conversation filling the main panel. It ships dark until **Settings → Lab → Team Chat** is turned on. The [parent page](team-chat.md) covers finding and enabling it.

Everything on this page is found inside the chat surface itself: the second wave of features sits in the conversation, the composer, the sidebar header and the channel context menus, while self-serve workspaces are the switcher rail down the side of the chat sidebar and cross-organization connections are reached from the same New-message picker. The per-channel notification controls and the bell menu are described on [part 3](team-chat-part-3.md).

## How it behaves

### New in this wave (v2 — the "destroy Slack" overhaul)

The daily-driver polish + the four schema-foundation features are now **BUILT** (desktop; the phone PWA renders the text and ignores the richer bits until its own parity lane). Invariants + the locking tests for each: [team-chat-v2-features-contract.md](../../.claude/memory/contracts/team-chat-v2-features-contract.md).

- **Message-surface polish** — Slack-style **day dividers** (a centered "Today / Yesterday / July 2" pill) and a bold **"N new messages"** line above the first unread, shown both in the full Team Chat view **and in the inbox conversation embed** (the embed fetches your read marker on open, so the new-messages line renders there too), a floating **jump-to-latest** pill (counting what arrived while you were scrolled up), a live **typing indicator** under the composer, and **online/away dots** on DM rows.
- **Composer power** — **drafts** that survive channel switches + restart (device-local, LRU-capped), **up-arrow** on an empty box to edit your last message, **`:emoji:` autocomplete** as you type, and **auto-capitalization** as you type — the first word, sentence starts (after `. ! ?`), and new paragraphs, while skipping URLs, paths, and abbreviations. It reuses the shared `@shared/smart-capitalize` engine (the same one the Tasks input + Quick Email body use), runs live on each word plus a finalize pass on send, is **default ON** with an opt-out toggle in **Settings → Sessions → Composer & input** (`autoCapitalizeTeamChat`), and is locked by composer contract **`auto-capitalize-as-you-type`**.
- **Search, wired in** — the sidebar **magnifier** opens a search overlay over the existing search index: type ≥2 chars → results (channel + author + snippet), click to jump. Degrades to "search isn't switched on yet" until the search Cloud Function is deployed.
- **Saved items** — a **bookmark** on any message + a sidebar **"Saved"** view (own-doc `saved/{uid}`, cap 200), newest-first. Each save captures a **snapshot** (author name, message text, channel name) so the Saved panel renders with zero Firestore reads and survives message deletion. Design modeled after an earlier internal prototype.
- **Custom status** — set an **emoji + text + auto-expiry** (30m / 1h / 4h / Today / This week / Don't clear); shown on the profile card, member directory, and beside DM names. **Global** (`workspaceId: null`): status shows in every workspace. Expired statuses are actively cleared from Firestore by `useStatusAutoClean` (a precise `setTimeout` at the `expiresAt` moment writes `null`; already-past expiries clear immediately on mount), and also hidden client-side via `statusIsActive()` as a fast visual gate. The field-scoped `customStatus` merge write can't clobber the presence heartbeat, and vice-versa.
- **Desktop notifications + unread badge** — an app-level watcher raises **native OS toasts** for new messages while you're elsewhere (honoring per-channel mutes + Focus Mode via the notification-service funnel) and folds an **unread count onto the Team Chat nav row**. Baselines on first load so there's no start-up storm; skips the channel you're actively looking at. **The same watcher also drops a row into your unified Inbox** — **one coalescing row per channel**, titled with the **sender's name** (resolved from the workspace roster, never a raw id), refreshed as new messages arrive — so a message you missed is **reliably** waiting there even when the OS toast was suppressed (the inbox is the durable record). It applies the same **audience** rule as the toast (DMs + @mentions + unmuted channels, never your own, and skips a channel you're actively reading) — but, unlike the ephemeral toast, the inbox row is **NOT** dropped just because a message is a few seconds old or arrived in a quick burst, so a delayed or bursty reply still lands there (this fixed the reported "replies don't reliably show in my inbox"). **And messages that arrived while Omniscio was fully closed are swept into your inbox the next time you open it** (silently, no late toast, and only for channels you'd genuinely left unread), so nothing is missed. Each row **stays until you dismiss it**. **Selecting the row embeds the live conversation + a composer, so you read the recent messages AND reply straight from the inbox** — a reply posts to the channel and clears the notice (a failed send keeps it, with Retry); viewing marks the channel read; **"Open channel"** stays as a secondary jump to the full Team Chat surface. After you dismiss, the next new message raises a fresh row. **Thread replies aimed at you surface too:** when someone replies in a thread to a message **you wrote** — or **@-mentions you** in a reply — you get a **separate, targeted Inbox row** ("Jordan replied to your message in #general"), distinct from the channel's new-message row, cleared when you read the channel, and **opening straight into that thread** when you click it (not just the channel). It never fires for your own reply, a muted channel, or a reply that isn't aimed at you, and there is an **on-by-default switch** (Settings → Team Chat notifications) to turn it off. (The phone push still treats a reply as an ordinary channel message — a "replied to you"-aware push is a follow-up.) **Reactions to your messages can surface too — opt-in, OFF by default:** with `teamChatNotifyReactions` ON, someone reacting (emoji) to a message **you wrote** raises a **separate, targeted Inbox row** ("Jordan reacted 👍 to your message"), **inbox-row ONLY** (no OS toast — reactions are higher-frequency and lower-signal), its own coalescing row that clears when you read the channel; never for your own reaction, a muted channel, or a reaction on someone else's message. Toggle in **Settings → Team Chat notifications**. Invariant I8 in [inbox-alert-contract.md](../../.claude/memory/contracts/inbox-alert-contract.md); thread-reply notice details in [team-chat-threads-contract.md](../../.claude/memory/contracts/team-chat-threads-contract.md); reaction-notice details in [team-chat-reactions-contract.md](../../.claude/memory/contracts/team-chat-reactions-contract.md).
- **Remind me later (snooze a note-to-self from the chat, not just the Inbox)** — on your **note-to-self** (a DM whose only participant is you), a **clock "Remind me later" button** in the conversation **header** and a **"Remind me later…"** item in the conversation's **right-click menu** in the sidebar both open the same snooze **time picker** you already use in the Inbox. Picking a time creates a **durable reminder** for that note and hides it until then, so it **pops back into your Inbox at the time you chose — even a note you'd already read** (unlike the ordinary "new message" notice, which reading clears). Clicking the reminder opens the note; you can re-snooze or dismiss it like any Inbox row, and setting one offers **undo**. It reuses the existing snooze storage (no new table, no database change) and is scoped to the note-to-self for now (the underlying action is channel-generic, so widening to any conversation later is a one-line change). **Desktop-only** — there is no CLI route for this action; it is driven entirely by the renderer UI. Mechanics + the `team-chat-reminder:` notice kind: [inbox-alert-contract.md](../../.claude/memory/contracts/inbox-alert-contract.md).
- **Attachments** — **paperclip / paste / drag-drop** a file (client pre-validated to 25 MB + a content-type allowlist mirroring the storage rules, ≤10/message, sanitized filename), uploaded to Storage with **progress + cancel** — while it is staged, its chip above the composer shows a **thumbnail of the image itself** (fitted whole, never cropped to a square) and a plain file icon for anything else; images render inline in the sent message (shared `ImageCanvas`), files as download cards. Send is blocked while uploads are in flight; an attachment-only send auto-fills the text with the filenames (the frozen text≥1 create rule) but the renderer hides that fallback body when every attachment name matches (the image/file cards already show the name). **Before you send**, a staged chip is removed by its `X` **or a middle-click on the chip**, and either is **undoable** (a toast **Undo**, or **Ctrl+Z / Ctrl+Y**) — a completed upload's bytes are not purged until the undo window has passed, so undo genuinely restores the file rather than a chip pointing at nothing. Once a message **is** sent, its attachments are not middle-click-removable — removing a sent message is the existing delete flow, which best-effort cleans up its files after the undo window (and undo restores them).
- **Scheduled send** — **"Send later"** (presets in your local time + a custom picker) writes an author-owned doc at `channels/{channelId}/scheduledMessages/{id}` (nested under the target channel); a sidebar **"Scheduled"** view lists + cancels. A once-a-minute **`onScheduledSendTick`** Cloud Function delivers each due message in a per-doc transaction (deterministic id `sched-<id>` = idempotent, re-checks the author can still post, drops if the channel's gone), then deletes the scheduled doc. **Security by construction (Uplink pattern):** the function derives the target channel from `schedDoc.ref.parent.parent` — never from a client field — so a scheduled doc can only post to the channel it was created under.
- **Custom emoji** — **all workspace members** upload custom images by default (party-parrot style, PNG/GIF/WebP ≤256 KB, no SVG) with a unique `:shortcode:` — via the **emoji picker's "+" button** (compact upload dialog), the sidebar **management dialog**, or the **bulk upload dialog** (up to 50 files at once with drag-and-drop, per-file validation, auto-deduplication of shortcodes, and sequential upload with per-file progress — opened from the management dialog's "Bulk Upload" button). A toggleable `adminOnly` flag on `canUploadCustomEmoji` lets a workspace restrict uploads to owners/admins without code changes. Members can delete their own uploads; admins can delete anyone's. Every member can use custom emoji in **the emoji picker's Custom tab**, in **reactions**, in **custom status**, and **inline in message text** (type `:shortcode:` and the rendered message shows the image). The current user's own uploads are also kept in a personal emoji layer, persisted by Firebase uid, so they remain available in DMs and after switching workspaces; the active workspace's emoji still win on shortcode collisions. The identifier format `custom:<shortcode>` slots into every surface a native emoji uses — existing reaction/status code needs no separate branch. Inline rendering uses the same remark-plugin pattern as @mentions (text node → internal-scheme link → component override, no innerHTML). Invariants: [team-chat-custom-emoji-contract.md](../../.claude/memory/contracts/team-chat-custom-emoji-contract.md).

**Delivery hops (need a live run / deploy to prove end to end):** the OS toast actually firing (main-process), Storage upload/download (**`storage.rules` now DEPLOYED + live-verified 2026-07-08** — org uploads flip 403→200; the client also gained self-serve `chatWorkspaces` upload support, shipping with the next app build), and the scheduled-send tick (needs `onScheduledSendTick` + a new `scheduledMessages` collection-group index on `deliverAt` deployed). The logic is unit/emulator-tested; the Storage hop is now proven live, the OS-toast + scheduled-send hops remain deploy steps. The scheduled-send path is now `{root}/channels/{channelId}/scheduledMessages/{id}` (nested under channels); the collection-group index on `deliverAt` is still needed for the tick function's cross-channel query.

### Channel invitations + per-channel roles

**Status:** Shipped (the `channel-invitations` flag is flipped to `shipped`). **Going live requires the Firestore rules to be deployed first** — the `channelMembers` subcollection rules plus the `postingPermission` admin-write clause — otherwise the live feature can't persist role or posting-permission changes. Follows the **Slack model**: instant add (no accept/decline flow), system messages for every membership change, workspace admin override.

- **Per-channel membership** — managers can **add workspace members** to specific channels (not just the whole workspace) and **remove** them. A member can **leave** any channel. Every change is instant and generates a centered **system message** (e.g. "Alice added Bob", "Charlie left").
- **Three-tier role model** — each channel member has a role: **manager** (full control — add/remove/promote/demote), **member** (read + write), or **readonly** (read + react, no posting). The channel creator is automatically a manager; invited members default to `member`.
- **Posting permissions** — a channel-wide `postingPermission` setting: `everyone` (default) or `managers-only`, set via the **"Who can post" control in the Edit Channel dialog** (workspace admins only). When set to managers-only, non-managers — and any `readonly` member in any channel — see a **ReadOnlyBar** banner in place of the message box (wired into the composer, matching what the server-side message-create rule enforces).
- **Bulk invite** — add up to **50 members** at once via the Add Members dialog, each with an assigned role. The dialog shows all workspace members with a search filter, checkboxes, and per-member role selectors.
- **Member count + panel** — the channel header shows a **member count button** (Users icon + number); clicking it opens a **ChannelMembersPanel** slide-out showing all members sorted by role (managers first), with role badges and management actions for managers.
- **Last-manager protection** — the last manager cannot leave, be removed, or be demoted. The system refuses the operation and surfaces an error.
- **Lazy backfill** — pre-feature members (in `memberUids` but with no subcollection doc) are treated as `role: 'member'`; `getChannelMemberRole()` creates the subcollection doc on first check.
- **DMs excluded** — all per-channel role operations skip DMs (DMs have no roles, no subcollection).
- **New Channel dialog** — when creating a **private** channel, a member picker appears to pre-select initial members with per-member role assignment.

For engineers: per-channel membership lives in a Firestore **subcollection** `channels/{channelId}/channelMembers/{uid}` (role + addedBy + addedAt). The existing denormalized `memberUids: string[]` array stays as the fast-query index (79+ files depend on it). Every membership mutation writes subcollection doc + memberUids + system message in **one `writeBatch`** (atomicity invariant). `joinChannelWithDoc` uses `setDoc(…, { merge: true })` for idempotent re-join. `messageType: 'membership-change'` renders as a centered divider. The `postingPermission` field is writable only by a workspace owner/admin (added to the channel admin-update rule alongside `visibility`); a plain member cannot set it. The `channelMembers` subcollection rules + this `postingPermission` clause must be **deployed** before the shipped feature works live (their emulator rules tests run in CI). Invariants + the deploy checklist: [team-chat-channel-invitations-contract.md](../../.claude/memory/contracts/team-chat-channel-invitations-contract.md).

### Self-serve workspaces (create + share your own chat spaces)

Alongside your **governed company workspace**, you can now **create and join your own chat workspaces** — Uplink-style, fully self-serve, with no admin to ask and no separate account to make. They live **beside** the company workspace in one **workspace switcher rail** down the side of the chat sidebar: the company workspace sits **first** (a building glyph), your self-serve workspaces follow (each a tile with its initial), and only the **active** one carries the accent. A **"+"** at the bottom opens a small chooser to **Create** a new workspace or **Join** one by code. Like the rest of Team Chat it ships **dark** — same **Settings → Lab** toggle / `AMC_SHOW_TEAM_CHAT=1` — and the backend is **authored but not yet deployed**.

- **Create a workspace** — name it and you become its **owner**; a **`#general`** channel is created and you're switched straight into it. (There's a small per-account cap on how many you can own.)
- **Share an invite code** — as an **owner/admin**, the sidebar's **"Invite people"** affordance opens a **share-code** dialog that **mints a 10-character code on demand** and copies it — hand it to whoever you want in (owners/admins can also revoke). There is **no list** of existing codes: clients can't read the code docs, so the freshly-minted code is the only time one is ever shown. For your **company** workspace the same affordance still opens the by-**email** invite instead.
- **Join by code** — paste a code into the **Join** dialog and you're added as a **member** and switched in. A **bad / revoked / expired / used-up** code is refused with a plain message, and the dialog **stays open** (never a false success).
- **Leave a workspace** — **right-click** (or **long-press** on mobile) a self-serve workspace tile and choose **"Leave workspace"**, behind a **confirm** that names what you lose (access to all its channels and messages; you can **rejoin later** with a code). Shown only to **non-owner members** — an owner can't leave (it would orphan the workspace), and the server refuses it too. Leaving the workspace you're currently viewing **switches you away first** so nothing flashes an error.
- **Delete a workspace (with a 30-day undo)** — as the **owner**, **right-click** (or **long-press** on mobile) your workspace tile and choose **"Delete workspace"** — the owner's counterpart to Leave (an owner can't leave, but can delete). It's a **soft delete** behind a **confirm**: the workspace and all its channels, messages, and members are **hidden immediately** but **kept for 30 days**, and a toast offers an **Undo** that restores it. After the 30-day window a scheduled reaper **permanently** deletes the whole tree. Deleting the workspace you're viewing **switches you away first**. This closes audit finding **F046** (a mis-click was previously an instant, unrecoverable delete).
- **Per-workspace identity** — your **display name** in a workspace defaults from your account and is **never blank**. You can set a **custom display name per workspace** (like Discord) via the **"Edit workspace name"** button in your profile popover — the custom name shows on all messages (past and future, resolved at render time), survives global profile edits (the mirror skips `displayName` when `customDisplayName` is set), and can be **reset to your account name** with one click. A name-change **system message** is optionally announced in **#general** (default on, togglable in the dialog). Works in **both** company org workspaces and self-serve workspaces. Per-workspace **avatar** editing is a follow-on.
- **Workspace icon color** — **right-click** (or **long-press** on mobile) a self-serve workspace tile to pick a **gradient color** from an 8-color palette (green, blue, red, purple, orange, pink, slate, teal), so you can tell your workspaces apart at a glance. The color is stored locally on each device; server persistence is a follow-on. Your custom color shows immediately when selected, including on the active tile (with an accent outline ring). See [workspace-icon-color.md](workspace-icon-color.md).
- **Workspace notification badge** — each workspace icon in the desktop **rail** and the mobile **switcher pill** shows a **notification count badge** (Discord-style) when that workspace has active inbox alerts (non-archived, non-snoozed, surfaceable channel). The count derives from the unified **Inbox** (parsed from each alert's `dedupKey` workspace encoding), auto-adjusts reactively when alerts are dismissed from the Inbox, and caps at **99+**. Connection requests (cross-workspace) are excluded. Implementation: `countPerWorkspaceTeamChatNotices()` in `team-chat-inbox-count.ts`; badge rendered on the tile wrapper (not inside the `overflow-hidden` Button).
- **What works in a self-serve workspace (v1):** the **core** chat surface — public/private **channels**, **DMs**, live **send / receive**, paged **history**, **@mentions**, real **names & avatars**, **edit/delete-your-own**, **presence/availability** (user-level, tied to your company org — your Online/Away/DND/OOO/Offline status persists across all workspaces), plus **reactions**, **pins**, **threads**, **saved items**, **scheduled send**, **unread badges**, and **desktop notifications** — all work identically in both roots (lifted 2026-07-15, CW9). The remaining **company-only** extras are **hidden** here (not shown as dead buttons): typing indicators, search, per-channel notification prefs, and push-when-away — see the deferred list below.

### Cross-org connections (1:1 DMs across organizations)

Connect with any Omniscio user by email — same workspace or a different organization — to start a 1:1 DM that forms only on the recipient's approval. Connections live in a separate top-level `connections/{connId}` collection (not under any org), with anti-enumeration privacy, server-only email lookups, and a per-user opt-out toggle. Full lifecycle, CLI routes, privacy controls, and renderer components: [cross-org-connections.md](cross-org-connections.md). Inbox approval flow: [connection-request-inbox.md](connection-request-inbox.md). Contract: [team-chat-connections-contract.md](../../.claude/memory/contracts/team-chat-connections-contract.md) (C1-C13).

### Link unfurling (rich link previews inside chat)

When a message contains a URL, Omniscio automatically fetches the page's Open Graph metadata and renders a **rich preview card** below the message — title, description, image, and site name — modeled after an earlier internal prototype's design patterns. In chat the card is a **compact horizontal** layout (a small thumbnail beside the text) so it stays dense in the conversation; the taller full-width hero image is reserved for roomier reading panes (inbox drip/alert bodies).

- **Automatic:** fires on render (eager, not hover-gated). Max **3 URLs** per message; image-only URLs (`.png`, `.jpg`, `.gif`, etc.) are skipped because they're already rendered inline as attachments.
- **SSRF-safe:** all fetches go through `redirectAwareFetch` in the main process — a three-layer guard (sync IP check → DNS resolution → connect-time pinned dispatcher) that blocks private/loopback/link-local/CGNAT/metadata IPs, including through redirects.
- **Metadata priority:** `og:title` → `twitter:title` → `<title>` tag; same fallback chain for description and image. Images must be `https://` (mixed-content rejected). Fields capped at 500 chars; text fields have HTML entities decoded to a bounded fixed point, so a double-encoded title (e.g. Vimeo's `&amp;#x27;`) renders correctly.
- **Caching:** main-process LRU (500 entries, 6h positive / 5min negative TTL) + renderer LRU (500 entries, 30min TTL) with deduped in-flight requests. No persistence (v1).
- **Rate-limited:** 30 IPC calls per 10s window per renderer.
- **Cost:** $0 — no AI spend; it's a plain HTTP fetch + HTML parse.

Sources of truth: `src/main/services/link-preview/link-unfurl.ts` (service), `src/shared/team-chat/extract-urls.ts` (URL extraction), `src/renderer/src/hooks/useChatLinkUnfurl.ts` (hook), `src/renderer/src/features/team-chat/TeamChatLinkPreviews.tsx` (component). Contract: [team-chat-link-unfurl-contract.md](../../.claude/memory/contracts/team-chat-link-unfurl-contract.md).

## For agents

### How self-serve works (for agents / engineers)

- **A separate tenant, on purpose (the frozen boundary).** Self-serve chat is a **new Firestore-native `chatWorkspaces/{wsId}` collection**, deliberately **NOT** the company org. `organizations/{orgId}` is **Postgres-bound business authority** (money · roles/tiers/entitlements · admin), so making it self-serve would violate the [cloud-authority-boundary](../../.claude/memory/cloud-authority-boundary.md). A `chatWorkspaces/*` workspace carries **chat-local roles only** — never a custom claim, never billing/entitlement coupling — which is exactly why it's legitimate realtime/edge data in Firestore (design **B**; Uplink is the named precedent). The company org stays **untouched** (identity `membership-truth-stays-on-the-user-doc` intact).
- **Two roots, one chat layer.** A `WorkspaceRef = { kind: 'org' | 'workspace', id }` drives `rootPath()` — `org`→`organizations/{id}`, `workspace`→`chatWorkspaces/{id}`. The core chat data layer builds every path from **tenant-root-agnostic `*For` builders** ([firestore-paths.ts](../../src/shared/team-chat/firestore-paths.ts)), so **one implementation serves both roots**; the original org-scalar builders stay **byte-identical**, so company chat + the live PWA don't move. Switching workspaces resets the org-scoped store slices (channels/messages/members/…) but keeps cross-tenant state (connections, composer drafts).
- **The trust boundary = ten Admin-SDK-only callables + a daily reaper** (`createChatWorkspace` / `joinChatWorkspace` / `leaveChatWorkspace` / `deleteChatWorkspace` / `restoreChatWorkspace` / `createChatInviteCode` / `revokeChatInviteCode` / `updateChatWorkspaceMemberRole` / `removeChatWorkspaceMember` / `updateChatWorkspace`, plus the scheduled `reapDeletedChatWorkspaces`, in [chat-workspaces.ts](../../firebase/functions/src/team-chat/chat-workspaces.ts)). The **workspace, members, invite-code, and user-index docs are all `write: if false`** — a user can **never** self-insert a membership by guessing a workspace id, nor self-elevate a role; the **only** way in is `joinChatWorkspace` with a code the **server** validates (revoked/expired/exhausted). This closes Uplink's known **self-join hole** by default. Clients write **only** channels + messages directly (member-gated); creation is **transactional + per-user rate-capped**, and join is **idempotent** (an existing member is a zero-write no-op). **`leaveChatWorkspace`** removes only the **caller's OWN** membership — the uid is the verified token, never the payload, so there's no way to evict another member — refuses the **sole owner** (leaving would orphan the workspace; mirrors the delete-account last-owner guard) and is an idempotent no-op for a non-member. **`deleteChatWorkspace`** is the owner's **soft delete** — it stamps `deletedAt`/`deletedBy` and destroys nothing; every listing filters `deletedAt` (so the workspace vanishes but its data is kept), and `joinChatWorkspace` now rejects a soft-deleted workspace so a live code can't resurrect it. **`restoreChatWorkspace`** clears the marker to undo, but is **refused once past the window** (so the undo window is strictly `[0, 30d)` and restore can never race the reaper). **`reapDeletedChatWorkspaces`** (daily `onSchedule`) does the permanent cascade — but only for a workspace whose `deletedAt` is >30 days old, re-verified against a **fresh read** with a pure fail-safe `isReapable` before it prunes indexes + deletes invite codes + `recursiveDelete`s the tree (kill-switch `CHATWORKSPACE_REAP_DISABLED=1`). Within a workspace, channel edits are **anti-griefing-gated** — a member may only toggle their own membership or denorm their own message's recency, never rename / re-topic / flip visibility / spoof an author / add or evict someone else (mirrors the org channel rule).
- **Not deployed.** The new rules blocks + the ten callables + the reaper are authored and (core/unit) tested but **not deployed** — they share Team Chat's undeployed-rules posture and are inert until a client writes the collections. Deploying the reaper additionally needs its Cloud Scheduler job created (over-retention is the safe failure if it's missing). Design, invariants (CW1–CW12), and the invariant→test map: [team-chat-workspaces-contract.md](../../.claude/memory/contracts/team-chat-workspaces-contract.md).
- **Explicitly deferred (v1 cut):** push-when-away · search query UI inside a self-serve workspace (indexing is workspace-aware, query side needs the shared-handler generalization) · per-channel notification prefs (needs subcollection + rules under `chatWorkspaces/*`; desktop notifications themselves fire — CW9); a full **cross-workspace unread** rollup (v1 unread = the active + company workspace only); a **per-workspace avatar** upload (the icon **color** picker and the **display-name** editor are now built — see above); server persistence of the icon color (the Firestore `iconColor` field exists; the `updateChatWorkspace` callable is now built — CW14); **invite expiry / max-uses** inputs (the fields exist server-side, no UI yet); and **PWA / phone** parity. (Workspace **delete + cascade** is now BUILT — as a soft delete with a 30-day undo window; see the "Delete a workspace" item above.)

## Related

This is one of four pages describing Team Chat. The [parent page](team-chat.md) describes the feature and what the first version can do. Its three companions are [part 2](team-chat-part-2.md) on the second wave of features and workspaces, [part 3](team-chat-part-3.md) on the phone client, notifications and the smaller surfaces, and [part 4](team-chat-part-4.md) on the limits and the engineering behind it.

Two pages outside this set are worth having open alongside it: [cross-org-connections.md](cross-org-connections.md) for connecting with someone in another organization, and [session-starter-bot.md](session-starter-bot.md) for the bot that starts a Claude session from a channel message.
