P0: workspace scaffold, CI gates, tc-domain, tc-config, tc-db vs real Postgres

- Cargo workspace with 1250-line and no-placeholder CI gates wired first
- tc-domain: id newtypes, SessionKey codec (proptest round-trip), Role,
  GatedCategory (spec §15), AccessPolicy, core entities
- tc-config: figment TOML+env config, DeployTarget/provider/auth selection
  with semantic validation
- migrations/0001: full spec §14 schema incl. DB-enforced append-only audit_log
- tc-db: compile-time-checked sqlx repos (workspaces, users, agents+policies,
  credits, audit) with committed .sqlx offline metadata
- tc-testkit: per-test real-Postgres databases (testcontainers or
  TC_TEST_DATABASE_URL), embedded migrations

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-09 22:25:47 -05:00
co-authored by Claude Fable 5
commit 0afb359183
49 changed files with 7171 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: File size budget (1250 lines)
run: ./ci/check-loc.sh
- name: No placeholder markers
run: ./ci/check-no-placeholders.sh
rust:
runs-on: ubuntu-latest
needs: gates
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.96.0
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Format
run: cargo fmt --all --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test
run: cargo test --workspace
frontend:
runs-on: ubuntu-latest
needs: gates
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install
run: npm ci
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
- name: Lint
run: npm run lint
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
- name: Typecheck
run: npm run typecheck
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
- name: Unit and component tests
run: npm test
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
+11
View File
@@ -0,0 +1,11 @@
/target
**/node_modules
frontend/.next
frontend/out
frontend/coverage
frontend/playwright-report
frontend/test-results
.env
.env.local
*.log
.DS_Store
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO agents\n (id, workspace_id, name, job_title, system_prompt, avatar, accent,\n wallpaper, managed_by, status)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Text",
"Text",
"Text",
"Text",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "203aea258d31f9dfac64e7f12a148ffa2ba35ace2f85ef1408eab19a357590e5"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(SUM(remaining), 0)::BIGINT AS \"balance!\"\n FROM credit_lots WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "balance!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "77fb84f5567b8a7553b750d42275393f179553c74841083db508fabc02ac74bf"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, email, role, display_name, created_at\n FROM users WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "role",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "8624eb567bd3d5a5b9ef40e09add83cb8c82486416c221a0ea513ab519ab02ad"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO users (id, workspace_id, email, role, display_name)\n VALUES ($1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9321b7efd4c9a11b82d030ff17ca5697dc31c20ae6aa7b4f7fc38d4b57135acc"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT humans_mode, human_ids, agents_mode, agent_ids\n FROM access_policies WHERE agent_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "humans_mode",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "human_ids",
"type_info": "UuidArray"
},
{
"ordinal": 2,
"name": "agents_mode",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "agent_ids",
"type_info": "UuidArray"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "9f214997187dd16cb319b3ab58195e75efa03f2aba22eac2a9669d412ca50f94"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, email, role, display_name, created_at\n FROM users WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "role",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "9fb5fc0b2cb418076e40451994443d8d0d56e2b35762c0f0e4610cb34e672663"
}
@@ -0,0 +1,76 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, name, job_title, system_prompt, avatar,\n accent, wallpaper, managed_by, status\n FROM agents\n WHERE workspace_id = $1 AND deleted_at IS NULL\n ORDER BY created_at, id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "job_title",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "system_prompt",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "avatar",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "accent",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "wallpaper",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "managed_by",
"type_info": "Uuid"
},
{
"ordinal": 9,
"name": "status",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "aa5c479959536065ee2fb42e64bd8b0b9ac37aba2883d6bdcbb544676911c495"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE agents SET deleted_at = now(), status = 'offline'\n WHERE id = $1 AND deleted_at IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "aedf7bd6a046b1c06800db5d5890104119da58816d01bac3449ed9154ad3d84b"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspaces (id, name, plan) VALUES ($1, $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "b3c5ef4da7ac657b162e5c9464a6f629e5f8da163436fa046d890d2d0bf15fc7"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, email, role, display_name, created_at\n FROM users WHERE workspace_id = $1\n ORDER BY created_at, id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "role",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "d19f4165e8945345caad6bde9d32e49c0f77e70f3d0a7e417f436aa1dad9674c"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO credit_lots (id, workspace_id, amount, remaining, source)\n VALUES ($1, $2, $3, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Numeric",
"Text"
]
},
"nullable": []
},
"hash": "d1ee499d845959ce334323e6f314802d91aa105f6f8b6fca212a31928bc0c88b"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE agents SET status = $2 WHERE id = $1 AND deleted_at IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "e9086e580cd1811762e76ea786eb9fa4a2ab172b382336e7e9feea7a6a4923e4"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO access_policies\n (agent_id, humans_mode, human_ids, agents_mode, agent_ids)\n VALUES ($1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"UuidArray",
"Text",
"UuidArray"
]
},
"nullable": []
},
"hash": "efbcb1697b7f0f5f8aabc04fb059e1b9e302c9fadb5b5baf5cf1b4d1aad43104"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO audit_log\n (workspace_id, actor_kind, actor_id, event_type, subject_type,\n subject_id, detail)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Uuid",
"Text",
"Text",
"Text",
"Jsonb"
]
},
"nullable": [
false
]
},
"hash": "f899f45e5d7a01a2a5ac868e33dddab7dbb8650de76cc4087c3e38338492a4bd"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, plan FROM workspaces WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "plan",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "f93c1a9655bab412d7cc93dadff86de8a68ad6bd503475426f764ba7e1061f5d"
}
Generated
+3721
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
[workspace]
resolver = "2"
members = [
"crates/tc-domain",
"crates/tc-config",
"crates/tc-db",
"crates/tc-testkit",
]
[workspace.package]
edition = "2021"
rust-version = "1.96"
license = "UNLICENSED"
publish = false
[workspace.dependencies]
# Shared dependency versions; crates opt in via { workspace = true }.
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
uuid = { version = "1", features = ["v7", "serde"] }
proptest = "1"
time = { version = "0.3", features = ["serde", "serde-well-known"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"uuid",
"time",
"json",
"migrate",
"macros",
"bigdecimal",
] }
testcontainers-modules = { version = "0.13", features = ["postgres"] }
[workspace.lints.rust]
unsafe_code = "deny"
[workspace.lints.clippy]
todo = "deny"
unimplemented = "deny"
dbg_macro = "deny"
+457
View File
@@ -0,0 +1,457 @@
# AI Coworker Platform — Build Spec, PRD & Roadmap
_Internal working name: **TeamClaw** (replace with your brand). Original specification authored from product research. Art assets (avatars, wallpapers, logo) are generated separately and intentionally excluded._
---
## Table of Contents
1. Product Overview
2. Design System & Tokens
3. Motion / Animation Spec
4. Global App Shell & Layout
5. Agent Chat Workspace
6. Sessions Column
7. The "Computer" Slide-out Panel (full spec)
8. Global Pages (Skills, Apps, Team, Credits)
9. Agent Creation Wizard
10. Dialogs & Overlays
11. Component Inventory
12. Routing & URL State Model
13. API Contract
14. Data Model
15. Agent Runtime & Safety Layer
16. PRD Summary
17. Delivery Roadmap
---
## 1. Product Overview
An enterprise platform of customizable, collaborative AI agents ("claws"). Each agent has a persistent identity, a configurable system prompt, its own tool/app connections, files, scheduled routines, an inter-agent inbox, and a sandboxed runtime ("Computer") surfaced through a chat-first UI. A human-in-the-loop approval layer gates every sensitive or irreversible action.
**Personas:** Owner/Admin (provisioning, billing, access policy), Builder (creates/tunes agents), Operator (chats, approves gated actions), Agent (first-class actor with identity, inbox, routines, files, runtime).
---
## 2. Design System & Tokens
Dark-first. Coral brand accent.
| Token | Value |
|---|---|
| --background | #0a0a0a |
| --foreground | #fafafa |
| --card / --popover | #0a0a0a |
| --border / --input / --muted / --secondary | #262626 |
| --muted-foreground | #a3a3a3 |
| --subtle / --surface-warm | #141414 |
| --surface-warm-muted | #1f1f1f |
| --accent (coral) | #f96565 |
| --coral-light / --coral-dark | #fa7575 / #e85555 |
| --destructive | #7f1d1d |
| --primary | #fafafa |
| --radius (base) | 0.5rem |
| --radius-button (pill) | 9999px |
| app sidebar width | 176px |
**Type:** Geist (UI) + Geist Mono (code). Reserved: Satoshi, Familjen Grotesk, Ranade, Inter. Scale: xxs 10px → base 16px → xxxl 36px.
**Shadows:** bubble, button, card, dialog, popover, dock-tile, cta.
**Texture:** inline SVG feTurbulence fractal-noise grain layered over wallpaper PNGs.
---
## 3. Motion / Animation Spec
- Primary easing: `--ease-app: cubic-bezier(.32,.72,0,1)`
- Secondary easing: `--ease-out: cubic-bezier(.16,1,.3,1)`
- Durations: fast .15s / normal .25s / slow .4s
- **Slide-out panels animate WIDTH, not transform:** `transition: width .25s var(--ease-app), border-color .5s var(--ease-out)` collapsing 0 ↔ target (176 / 208 / 448px). This makes panels "push" layout rather than overlay.
- Keyframes to implement: slide-in-right, sheet-in, slide-up-in, scale-in, fade-in/out, fade-up, collapsible-expand/collapse, route-fade-in, shimmer, ripple, gradient-shift, caret-blink, shake, spin, ping, pulse, toolSlideIn, toolAccentPulse.
- Provisioning (new-agent spin-up): avatar-breathe, photo-rotate, photo-bob, tip-slide, tip-fade.
---
## 4. Global App Shell & Layout
Three persistent zones; up to three columns visible in chat at full width.
```
+--------+-----------------------------+--------------------------+
| LEFT | MAIN (chat / page) | COMPUTER PANEL (slide) |
| RAIL | | (right, ~448px) |
| 176px | header (h-14 / lg:h-20) | ?device=full|tablet| |
| | | phone |
| logo | ...content... | |
| [agent]| | |
| [agent]| | |
| [agent]| | |
| ( + ) | | |
| | | |
| Skills | | |
| Apps | | |
| Team | | |
| Credits| | |
| [user] | | |
+--------+-----------------------------+--------------------------+
```
Left rail: logo (top), agent roster (avatar + name + green online dot), add-agent (+), then nav: Skills / Apps / Team / Credits, current user pinned bottom. Active item uses coral. Rail width animates on collapse.
---
## 5. Agent Chat Workspace
Route: `/claws/{clawId}/chat/{sessionKey}`
### 5a. Empty / Welcome state
```
+---------------------------------------------+
| (avatar) |
| Hi, I'm {Name}. What can I |
| help with? |
| {Role} · Shared with your team |
| +-------------------------------------+ |
| | Message {Name}... [clip][skill]| |
| +-------------------------------------+ |
| [Create a daily briefing] [Write a report] |
| [Make a presentation] [What can it do?]|
+---------------------------------------------+
```
### 5b. Active transcript
```
HEADER: (avatar) {Name} / {Role} [slack][sessions][new]
------------------------------------------------------------
[ user bubble (right) ]
(avatar) agent message (left)
> N steps (collapsible tool/reasoning trace)
inline `code chip` coral action link
[Copy] [Helpful] [Not helpful]
------------------------------------------------------------
COMPOSER: [ Type your message... ] [clip] [skill]
```
- User bubbles right-aligned; agent messages left with avatar.
- "N steps" expands to show tool calls (toolSlideIn / toolAccentPulse).
- Gated actions render as coral "Review and approve" links → approvals queue.
---
## 6. Sessions Column
Toggle: `?sessions=1`. Slides in on the LEFT of the chat (separate from Computer panel), narrowing the chat column.
```
+------------------+
| [ Search ] |
+------------------+
| Session title |
| 4h ago (•)| <- active = coral left border
| Session title |
| 1d ago |
+------------------+
[ + New session ]
```
Each row: title + relative timestamp. Backed by session-history retrieval; sessions are resumable.
---
## 7. The "Computer" Slide-out Panel (FULL SPEC)
Right-hand slide-out, themed per-agent via wallpaper. Size toggles top-right: **Full** (expand to side nav) / **Tablet** (chat beside it) / **Phone**. Plus close (×). State via `?device=` and `?app=`.
### 7.0 Home screen (dock + app grid)
```
● {Name}'s Computer
+------------------------------------------+
| [Browser] [Slack] [Claw Chat] [+ Add] | <- app grid
| |
| (wallpaper area) |
| |
| +------------------------------------+ |
| | [Skills] [Files] [Routines][Settings] | <- dock (glassy)
| +------------------------------------+ |
+------------------------------------------+
```
Every app is a routable sub-view (`?app=...`) with header (title + close, optional back/tabs) and an empty state. Each sub-view title appears top-left; close (×) top-right.
### 7.1 Browser (live agent web browser)
```
[<] [>] ( address: newtab ) [⟳] [+] [×]
+------------------------------------------+
| web viewport |
+------------------------------------------+
```
Full browser chrome: back/forward, address bar, reload, new-tab, close. Renders the agent's actual browsing session (CDP-backed). Handle profile-load error state ("Some features may be unavailable" + OK).
### 7.2 Claw Chat (inter-agent inbox) — `?app=chat`
```
Claw Chat [×]
[ Search ]
+------------------------------------------+
| (av) {Agent} 2h |
| {Thread subject} |
| {last message preview…} |
+------------------------------------------+
```
Thread list → conversation detail. Threads may be flagged "sensitive." This is the surface governed by the "Other Claws" access toggle.
### 7.3 Slack (tabbed integration) — `?app=slack`
```
Slack [×]
[ Overview ] [ Channels ] [ Connection ]
+------------------------------------------+
| (Slack glyph) |
| Bring this claw into Slack |
| Connect Slack to respond on @mention |
| [ Connect Slack ] |
+------------------------------------------+
```
Pre-connect: all tabs show connect gate. Post-connect: Channels (pick channels), Connection (manage auth). Agent replies on @mention; outbound posts are gated.
### 7.4 Files — `?app=files`
```
Files [×]
+------------------------------------------+
| 📁 My Documents > |
| 📁 Received Files > |
| 📁 Shared ClawDrive (team) > |
+------------------------------------------+
```
Three drives; Shared drive is team-visible. Backed by openclaw/files + shared-drive/files.
### 7.5 Skills (per-agent installed) — `?app=skills`
```
Skills [×]
+------------------------------------------+
| (lightning) |
| {Name}'s Skills |
| Add workflows you want {Name} to do |
| |
| [ Add Skill ] |
+------------------------------------------+
```
Installed subset of the global Skill Library. Empty state + Add Skill.
### 7.6 Routines (scheduled tasks) — `?app=routines` (→ scheduled)
```
Routines [⟳][×]
+------------------------------------------+
| (bell) |
| No routines yet |
| Ask your claw to set up a routine — |
| daily digest, newsletter, calendar block|
| [ Schedule a task ] |
+------------------------------------------+
```
Routines are agent-created (interval/cron). List + refresh + create.
### 7.7 Settings — `?app=settings` (push/pop nav stack)
**Main:**
```
Settings [×]
(avatar)
{Name}
{Role subtitle}
+------------------------------------------+
| Slack handle ⚠ @handle (disconnected)|
| Managed by {Owner} |
| Edit profile > |
+------------------------------------------+
WHO ELSE HAS ACCESS
+------------------------------------------+
| (av) Other people [ON] |
| ◉ Entire team — anyone at {Org} |
| ○ Specific people — pick teammates |
+------------------------------------------+
| (av) Other Claws [ON] |
| ◉ Any Claw on the team |
| ○ Specific claws — pick claws |
+------------------------------------------+
[ Need help? support@… ]
```
**Edit profile (drill-in, ‹ back):**
```
‹ Edit profile
(avatar) [Change photo]
+------------------------------------------+
| Name {Name} |
| Job Title {Role} |
+------------------------------------------+
JOB DESCRIPTION
[ Describe how this claw should think... ]
"becomes part of its system prompt"
PERSONALIZATION
| Wallpaper {Theme} > |
+------------------------------------------+
[ 🗑 Delete claw ] (destructive)
```
Key: the Job Description textarea IS the system prompt. Wallpaper drives panel theming. Delete is destructive (gated/confirmed).
### 7.8 Add Apps (connect directory) — `?app=apps`
```
Add Apps [×]
[ Search apps ]
+------------------------------------------+
| (icon) Gmail [+] |
| (icon) Google Calendar [+] |
| (icon) Notion / Linear / GitHub … [+] |
+------------------------------------------+
[ + Add custom app ]
```
Per-row [+] starts OAuth. "Add custom app" → auth-method dialog (see §10).
---
## 8. Global Pages
### 8.1 Skill Library — `/skills`
```
Skill Library [ Create v ]
Browse skills published by your team and the catalog.
[ Search skills ] [ All categories v ]
+------------------+ +------------------+
| {Skill} [+] | | {Skill} [+] |
| by {author} | | by {author} |
| {description} | | {description} |
| N installs | | N installs |
+------------------+ +------------------+
```
Two-column cards: title, author, description, install count, install (+).
### 8.2 Apps — `/apps`
```
Apps [ + Add Custom App ]
[ Find an App ] [ Category v ]
Browse All Apps — connect via OAuth
+------------------+ +------------------+
|(logo){App} [+] | |(logo){App} [+] |
| {description} | | {description} |
+------------------+ +------------------+
```
Infinite scroll. Includes Gmail, Calendar, Drive, Notion, Linear, GitHub, Figma, Zoom, Stripe, HubSpot, Sheets, Telegram, HTTP/Webhook, Schedule, Supabase, MySQL, Postgres, AWS, SendGrid, SES, …
### 8.3 Team — `/team`
```
{Workspace} ✎ [ Invite ]
N members in your workspace
[ Members ] [ Claw org chart ] [ Leaderboard ]
+------------------------------------------+
| Name Email Joined |
| (NM) {User} [Owner] {email} {date} |
+------------------------------------------+
```
### 8.4 Credits — `/credits`
```
Credits — manage balance, subscriptions, usage
+------------------------------------------+
| AVAILABLE CREDITS {N} [Buy credits][Activity]|
| Plans from $/mo · save yearly |
| CREDIT LOTS · all credits never expire |
| USAGE · LAST 7 DAYS [▓▓▓▓▓▓░] ~2 days |
+------------------------------------------+
| Promo code [ PROMOCODE ] [ Redeem ] |
+------------------------------------------+
| Scaling beyond self-serve? [ Talk to sales ]|
+------------------------------------------+
```
---
## 9. Agent Creation Wizard — `/claws/new` (?step=identity|access|slack)
```
[▬▬▬▬] [ ] [ ] (3-step progress)
Create your Claw
Claws help get your work done.
(< avatar carousel >) Name *
● ● ● ● [+] (accent swatches) [ {name} ] (⟳)
Job title
[ {role} ]
[ Continue → ]
```
Step 1 identity (avatar carousel + accent/wallpaper swatches + name w/ randomizer + job title), Step 2 access (same scopes as Settings), Step 3 Slack (optional). Provisioning shows animated spin-up states. **Note:** completing creates a live agent — confirm before submit.
---
## 10. Dialogs & Overlays
### Add Custom App (centered modal)
```
(icon) Add Custom App [×]
Pick how this app authenticates
+------------------------------------------+
| 🔑 Keys — secret keys / API key / token |
| 🛡 Username & Password — HTTP basic auth |
| 🖥 MCP Server with OAuth — remote MCP |
+------------------------------------------+
[ Cancel ]
```
### Approval card (safety layer)
```
Review & approve
This claw wants to: {action summary}
PREVIEW: {exact payload / diff of what executes}
Source: {agent / thread} ⚠ {sensitive/unverified}
[ Reject ] [ Approve ]
```
---
## 11. Component Inventory
**Shell:** LeftRail, AgentRosterItem, AddAgentButton, GlobalNavItem, UserMenu.
**Chat:** ChatHeader, WelcomeState, SuggestedPromptChip, MessageList, UserBubble, AgentMessage, StepTrace, CodeChip, GatedActionLink, MessageActions, Composer.
**Sessions:** SessionsColumn, SessionSearch, SessionRow, NewSessionButton.
**Computer panel:** DevicePanel (resizable), DeviceSizeToggle, PanelHeader, AppGrid, AppTile, Dock, DockTile; modules: BrowserApp, ClawChatApp (ThreadList/ThreadRow/ThreadDetail), SlackApp (Tabs/ConnectGate), FilesApp (FolderRow), SkillsApp, RoutinesApp, SettingsApp (IdentityHeader/SettingRow/AccessToggleGroup/EditProfileForm/WallpaperPicker/DeleteAgent), AddAppsApp.
**Global:** SkillCard, AppCard, AddCustomAppDialog, MembersTable, CreditsCard, UsageMeter, PromoRedeem.
**Wizard:** StepProgress, AvatarCarousel, AccentSwatchRow, ProvisioningState.
**Safety:** ApprovalCard, ApprovalQueue, ActionPreview, AuditLogEntry, UntrustedContentBadge.
---
## 12. Routing & URL State Model
- Global: `/skills`, `/apps`, `/team`, `/credits`, `/claws/new`
- Agent: `/claws/{clawId}/chat/{sessionKey}` where sessionKey = `agent:{agentId}-claw-{shard}:session:{sessionId}:{messageId}`
- Overlays via query: `?app=browser|slack|chat|skills|files|routines(→scheduled)|settings|apps`, `?sessions=1`, `?device=full|tablet|phone`, `?step=identity|access|slack`
- All panel/overlay state is deep-linkable & shareable.
---
## 13. API Contract (REST, JSON, same-origin /api/, keyed by clawId)
**Observed:**
- Identity/team: `GET /user/me`, `GET /team/permissions`, `GET /team/credits`, `GET /team/claws`
- Per-agent: `GET /claws/{id}/health`, `GET /claws/settings/full?clawId=`, `GET /claws/{id}/custom-photos`, `GET /claws/{id}/slack-channels`, `GET /claws/{id}/apps/available`
- Sessions: `GET /sessions?clawId=`, `GET /sessions/active?clawId=`, `GET /sessions/history?sessionKey=&tools=true&clawId=`
- Apps/skills/slack: `GET /apps?clawId=`, `GET /skills?clawId=`, `GET /slack/me`, `GET /apps/slack/setup`
- Files: `GET /openclaw/files?clawId=`, `GET /shared-drive/files?clawId=`
- Runtime bridge: `POST /gateway?clawId=` (streaming)
**Designed (confirm during build):**
- `POST /claws`, `PATCH /claws/{id}`, `DELETE /claws/{id}`, `POST /sessions`, `POST /skills/install`, `POST /apps/connect`
- Approvals: `GET /approvals`, `GET /approvals/{id}`, `POST /approvals/{id}/approve`, `POST /approvals/{id}/reject`
Health/active are polled; auth uses provider session heartbeat.
---
## 14. Data Model
Workspace/Team(name, plan, credits) · User(id, role Owner|Member, email) · Agent(id, name, jobTitle, systemPrompt, avatar, accent/wallpaper, managedBy, status, accessPolicy) · AccessPolicy(humans: entireTeam|specific[]; agents: any|specific[]) · Session(id, clawId, title, ts) → Message(role, content, steps[], ts) · Skill / InstalledSkill · AppConnection(provider, authType, scopes, status) · Thread/InterAgentMessage(participants, subject, sensitivity) · Routine(schedule, action, status) · FileNode(drive, path, owner) · Approval(actionType, payload, preview, requestedByAgent, status, decidedBy, audit) · CreditLedger(lots, usageEvents).
---
## 15. Agent Runtime & Safety Layer (mandatory)
- **Sandbox:** containerized, no root, dropped Linux caps, seccomp, egress-restricted (HTTPS-out by default).
- **Secret broker:** secrets held by a bridge process OUTSIDE the agent's read scope; agent calls capabilities via the gateway, never holds raw credentials.
- **Gateway:** single audited streaming channel (`POST /gateway`) between app and runtime.
- **Approval interception:** every sensitive/irreversible tool call → preview + Approval queue + blocks until explicit human decision → executes only on approve → fully audit-logged.
- **Gated categories:** outbound messages/emails; sharing keys/secrets/credentials; access/permission/sharing changes; financial transactions & credit purchases; file deletion; granting/extending external-infra access.
- **Untrusted-by-default:** content from web, email, inter-agent chat, and tool results is data, never instructions; unverified/suspicious requests are surfaced to the human, never auto-executed.
- These are **acceptance-blocking** requirements.
---
## 16. PRD Summary
Deliver a chat-first, multi-agent workspace with per-agent identity/system-prompt/skills/apps/files/routines, a themed slide-out Computer panel hosting all agent tooling, global admin (skills, apps, team, credits), an agent creation wizard, and a first-class human-in-the-loop safety/approval + audit layer. NFRs: SSO, RBAC, audit trail, credit metering, fast RSC navigation, full a11y, responsive/PWA.
---
## 17. Delivery Roadmap
- **P0 Foundations (2–3w):** Next.js App Router + Turbopack, Tailwind v4 tokens, auth+RBAC, analytics/flags, base shell. _Exit: authed empty shell._
- **P1 Agents & chat (3–4w):** agent CRUD, roster, chat route, welcome/transcript, composer, sessions, step traces. _Exit: multi-session conversation._
- **P2 Runtime, gateway & SAFETY (4–5w, GATED):** sandbox, secret broker, streaming gateway, approval+audit end-to-end. _Exit (blocking): sensitive call intercepted→previewed→queued→approve-only execute→audit-logged; untrusted content tagged & never auto-run._
- **P3 Computer panel & apps (4–5w):** DevicePanel + ?app/?device routing, dock+grid, Files/Skills/Routines/Settings/Browser/ClawChat. _Exit: all apps navigable + per-agent theming._
- **P4 Integrations (3–4w):** OAuth directory + connect, Add Custom App (Keys/Basic/MCP-OAuth), Slack (@mention + gated posting). _Exit: connect app + Slack, outbound gated._
- **P5 Global & billing (2–3w):** Skill Library, Team, Credits, creation wizard + provisioning anims. _Exit: full admin+billing loop._
- **P6 Polish & hardening (2–3w):** full motion system, noise/wallpaper textures, a11y, PWA, perf, sandbox+approval security review. _Exit: a11y + security sign-off; identical look-and-feel._
Cross-cutting (continuous): audit/observability, credit metering, safety layer.
---
_End of document._
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Fails the build if any source file exceeds the 1,250-line budget.
# Warns for files over the 900-line soft threshold so splits happen
# before they hurt.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HARD_LIMIT=1250
SOFT_LIMIT=900
STATUS=0
# Source extensions under budget. Generated/vendored paths are excluded.
while IFS= read -r f; do
[ -z "$f" ] && continue
lines=$(wc -l <"$f" | tr -d ' ')
if [ "$lines" -gt "$HARD_LIMIT" ]; then
echo "FAIL: $f has $lines lines (limit $HARD_LIMIT)"
STATUS=1
elif [ "$lines" -gt "$SOFT_LIMIT" ]; then
echo "WARN: $f has $lines lines (soft limit $SOFT_LIMIT)"
fi
done < <(
find "$ROOT/crates" "$ROOT/frontend/src" "$ROOT/tools" "$ROOT/tests" \
-type f \( -name '*.rs' -o -name '*.ts' -o -name '*.tsx' -o -name '*.css' \) \
-not -path '*/node_modules/*' -not -path '*/target/*' 2>/dev/null
)
exit "$STATUS"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Fails the build if placeholder markers appear anywhere in source.
# The project ships no stubs, no TODOs, no deferred work.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PATTERN='TODO|FIXME|XXX:|todo!\(|unimplemented!\('
MATCHES=$(grep -RInE "$PATTERN" \
"$ROOT/crates" "$ROOT/frontend/src" "$ROOT/tools" "$ROOT/tests" \
--include='*.rs' --include='*.ts' --include='*.tsx' --include='*.css' \
--exclude-dir=node_modules --exclude-dir=target 2>/dev/null || true)
if [ -n "$MATCHES" ]; then
echo "Placeholder markers found:"
echo "$MATCHES"
exit 1
fi
exit 0
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "tc-config"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
figment = { version = "0.10", features = ["toml", "env"] }
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
figment = { version = "0.10", features = ["toml", "env", "test"] }
[lints]
workspace = true
+134
View File
@@ -0,0 +1,134 @@
//! Configuration loading for all TeamClaw binaries.
//!
//! One TOML file plus `TEAMCLAW_*` environment overrides (nested keys split
//! on `__`, e.g. `TEAMCLAW_DATABASE__URL`). The same configuration tree
//! drives both deployment targets; semantic validation rejects combinations
//! that would only fail at runtime (e.g. an OpenAI-compatible provider with
//! no endpoint to call).
use std::net::SocketAddr;
use std::path::Path;
use figment::providers::{Env, Format, Toml};
use figment::Figment;
use serde::Deserialize;
/// Which of the two first-class deployment targets this instance runs as.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeployTarget {
AirGapped,
Cloud,
}
/// Which `LlmProvider` implementation the runtime instantiates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmProviderKind {
Anthropic,
#[serde(rename = "openai_compat")]
OpenAiCompat,
Scripted,
}
/// Which `AuthProvider` implementation handles sign-in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthMode {
Local,
Oidc,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
}
fn default_max_connections() -> u32 {
10
}
#[derive(Debug, Clone, Deserialize)]
pub struct LlmConfig {
pub provider: LlmProviderKind,
/// Endpoint for `openai_compat`; unused by other providers.
pub base_url: Option<String>,
pub model: String,
/// Scenario file for the deterministic `scripted` provider.
pub scenario_path: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuthConfig {
pub mode: AuthMode,
pub issuer_url: Option<String>,
pub client_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
pub deploy_target: DeployTarget,
pub listen_addr: SocketAddr,
pub database: DatabaseConfig,
pub llm: LlmConfig,
pub auth: AuthConfig,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to load configuration: {0}")]
Load(String),
#[error("invalid configuration: {0}")]
Invalid(String),
}
impl AppConfig {
/// Loads configuration from `path`, overlaying `TEAMCLAW_*` environment
/// variables, and validates it.
pub fn load_from(path: &Path) -> Result<AppConfig, ConfigError> {
if !path.is_file() {
return Err(ConfigError::Load(format!(
"configuration file not found: {}",
path.display()
)));
}
let config: AppConfig = Figment::new()
.merge(Toml::file(path))
.merge(Env::prefixed("TEAMCLAW_").split("__"))
.extract()
.map_err(|e| ConfigError::Load(e.to_string()))?;
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), ConfigError> {
match self.llm.provider {
LlmProviderKind::OpenAiCompat if self.llm.base_url.is_none() => {
return Err(ConfigError::Invalid(
"llm.provider = \"openai_compat\" requires llm.base_url".into(),
));
}
LlmProviderKind::Scripted if self.llm.scenario_path.is_none() => {
return Err(ConfigError::Invalid(
"llm.provider = \"scripted\" requires llm.scenario_path".into(),
));
}
_ => {}
}
if self.auth.mode == AuthMode::Oidc {
if self.auth.issuer_url.is_none() {
return Err(ConfigError::Invalid(
"auth.mode = \"oidc\" requires auth.issuer_url".into(),
));
}
if self.auth.client_id.is_none() {
return Err(ConfigError::Invalid(
"auth.mode = \"oidc\" requires auth.client_id".into(),
));
}
}
Ok(())
}
}
+141
View File
@@ -0,0 +1,141 @@
// figment::Jail::expect_with dictates closures returning figment's own
// (large) error type; the lint has nothing actionable here.
#![allow(clippy::result_large_err)]
use tc_config::{AppConfig, AuthMode, ConfigError, DeployTarget, LlmProviderKind};
const AIR_GAPPED_TOML: &str = r#"
deploy_target = "air_gapped"
listen_addr = "0.0.0.0:8080"
[database]
url = "postgres://teamclaw:pw@db:5432/teamclaw"
[llm]
provider = "openai_compat"
base_url = "http://local-llm:8000/v1"
model = "qwen2.5-72b-instruct"
[auth]
mode = "local"
"#;
const CLOUD_TOML: &str = r#"
deploy_target = "cloud"
listen_addr = "0.0.0.0:8080"
[database]
url = "postgres://teamclaw:pw@db:5432/teamclaw"
max_connections = 32
[llm]
provider = "anthropic"
model = "claude-sonnet-4-5"
[auth]
mode = "oidc"
issuer_url = "https://idp.example.com"
client_id = "teamclaw"
"#;
#[test]
fn loads_air_gapped_config_from_toml() {
figment::Jail::expect_with(|jail| {
jail.create_file("teamclaw.toml", AIR_GAPPED_TOML)?;
let cfg = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap();
assert_eq!(cfg.deploy_target, DeployTarget::AirGapped);
assert_eq!(cfg.listen_addr.port(), 8080);
assert_eq!(cfg.database.url, "postgres://teamclaw:pw@db:5432/teamclaw");
assert_eq!(cfg.llm.provider, LlmProviderKind::OpenAiCompat);
assert_eq!(
cfg.llm.base_url.as_deref(),
Some("http://local-llm:8000/v1")
);
assert_eq!(cfg.auth.mode, AuthMode::Local);
Ok(())
});
}
#[test]
fn loads_cloud_config_with_oidc() {
figment::Jail::expect_with(|jail| {
jail.create_file("teamclaw.toml", CLOUD_TOML)?;
let cfg = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap();
assert_eq!(cfg.deploy_target, DeployTarget::Cloud);
assert_eq!(cfg.database.max_connections, 32);
assert_eq!(cfg.llm.provider, LlmProviderKind::Anthropic);
assert_eq!(cfg.auth.mode, AuthMode::Oidc);
assert_eq!(
cfg.auth.issuer_url.as_deref(),
Some("https://idp.example.com")
);
Ok(())
});
}
#[test]
fn env_overrides_toml_values() {
figment::Jail::expect_with(|jail| {
jail.create_file("teamclaw.toml", AIR_GAPPED_TOML)?;
jail.set_env("TEAMCLAW_DATABASE__URL", "postgres://other:pw@x:5432/y");
jail.set_env("TEAMCLAW_LLM__MODEL", "llama-3.1-8b-instruct");
let cfg = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap();
assert_eq!(cfg.database.url, "postgres://other:pw@x:5432/y");
assert_eq!(cfg.llm.model, "llama-3.1-8b-instruct");
Ok(())
});
}
#[test]
fn database_max_connections_defaults_to_ten() {
figment::Jail::expect_with(|jail| {
jail.create_file("teamclaw.toml", AIR_GAPPED_TOML)?;
let cfg = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap();
assert_eq!(cfg.database.max_connections, 10);
Ok(())
});
}
#[test]
fn openai_compat_without_base_url_is_rejected() {
figment::Jail::expect_with(|jail| {
let toml = AIR_GAPPED_TOML.replace("base_url = \"http://local-llm:8000/v1\"\n", "");
jail.create_file("teamclaw.toml", &toml)?;
let err = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap_err();
assert!(matches!(err, ConfigError::Invalid(msg) if msg.contains("base_url")));
Ok(())
});
}
#[test]
fn scripted_provider_requires_scenario_path() {
figment::Jail::expect_with(|jail| {
let toml = AIR_GAPPED_TOML
.replace("provider = \"openai_compat\"", "provider = \"scripted\"")
.replace("base_url = \"http://local-llm:8000/v1\"\n", "");
jail.create_file("teamclaw.toml", &toml)?;
let err = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap_err();
assert!(matches!(err, ConfigError::Invalid(msg) if msg.contains("scenario_path")));
Ok(())
});
}
#[test]
fn oidc_mode_requires_issuer_and_client_id() {
figment::Jail::expect_with(|jail| {
let toml = CLOUD_TOML.replace("issuer_url = \"https://idp.example.com\"\n", "");
jail.create_file("teamclaw.toml", &toml)?;
let err = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap_err();
assert!(matches!(err, ConfigError::Invalid(msg) if msg.contains("issuer_url")));
Ok(())
});
}
#[test]
fn missing_file_is_a_clear_error() {
figment::Jail::expect_with(|jail| {
let err = AppConfig::load_from(&jail.directory().join("absent.toml")).unwrap_err();
assert!(matches!(err, ConfigError::Load(_)));
Ok(())
});
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "tc-db"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde_json = { workspace = true }
sqlx = { workspace = true }
tc-domain = { path = "../tc-domain" }
thiserror = { workspace = true }
time = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
tc-testkit = { path = "../tc-testkit" }
tokio = { workspace = true }
[lints]
workspace = true
+43
View File
@@ -0,0 +1,43 @@
//! Postgres persistence for TeamClaw.
//!
//! Queries are compile-time checked (`sqlx::query!`) against the schema in
//! `/migrations`, and every repository is tested only against a real
//! Postgres via `tc-testkit` — no in-memory store exists.
pub mod repo;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
/// All schema migrations, embedded so binaries can self-migrate at boot.
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations");
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("not found")]
NotFound,
#[error("conflict: {0}")]
Conflict(String),
#[error(transparent)]
Other(sqlx::Error),
}
impl From<sqlx::Error> for DbError {
fn from(err: sqlx::Error) -> Self {
match &err {
sqlx::Error::RowNotFound => DbError::NotFound,
sqlx::Error::Database(db) if db.is_unique_violation() => {
DbError::Conflict(db.message().to_owned())
}
_ => DbError::Other(err),
}
}
}
/// Connects a pool sized from configuration. Callers run `MIGRATOR` at boot.
pub async fn connect(url: &str, max_connections: u32) -> Result<PgPool, DbError> {
Ok(PgPoolOptions::new()
.max_connections(max_connections)
.connect(url)
.await?)
}
+137
View File
@@ -0,0 +1,137 @@
use sqlx::PgPool;
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentScope, AgentStatus, HumanScope, UserId, WorkspaceId,
};
use uuid::Uuid;
use crate::DbError;
/// Inserts an agent together with its access policy in one transaction —
/// an agent without a policy must never be observable (§7.7).
pub async fn insert(pool: &PgPool, agent: &Agent, policy: &AccessPolicy) -> Result<(), DbError> {
let (humans_mode, human_ids): (&str, Vec<Uuid>) = match &policy.humans {
HumanScope::EntireTeam => ("entire_team", Vec::new()),
HumanScope::Specific(ids) => ("specific", ids.iter().map(UserId::as_uuid).collect()),
};
let (agents_mode, agent_ids): (&str, Vec<Uuid>) = match &policy.agents {
AgentScope::Any => ("any", Vec::new()),
AgentScope::Specific(ids) => ("specific", ids.iter().map(AgentId::as_uuid).collect()),
};
let mut tx = pool.begin().await.map_err(DbError::from)?;
sqlx::query!(
"INSERT INTO agents
(id, workspace_id, name, job_title, system_prompt, avatar, accent,
wallpaper, managed_by, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
agent.id.as_uuid(),
agent.workspace_id.as_uuid(),
agent.name,
agent.job_title,
agent.system_prompt,
agent.avatar,
agent.accent,
agent.wallpaper,
agent.managed_by.as_uuid(),
agent.status.as_str(),
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO access_policies
(agent_id, humans_mode, human_ids, agents_mode, agent_ids)
VALUES ($1, $2, $3, $4, $5)",
agent.id.as_uuid(),
humans_mode,
&human_ids,
agents_mode,
&agent_ids,
)
.execute(&mut *tx)
.await?;
tx.commit().await.map_err(DbError::from)?;
Ok(())
}
/// The left-rail roster (§4): live agents of a workspace, oldest first.
pub async fn roster(pool: &PgPool, workspace_id: WorkspaceId) -> Result<Vec<Agent>, DbError> {
let rows = sqlx::query!(
"SELECT id, workspace_id, name, job_title, system_prompt, avatar,
accent, wallpaper, managed_by, status
FROM agents
WHERE workspace_id = $1 AND deleted_at IS NULL
ORDER BY created_at, id",
workspace_id.as_uuid(),
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| Agent {
id: AgentId::from(row.id),
workspace_id: WorkspaceId::from(row.workspace_id),
name: row.name,
job_title: row.job_title,
system_prompt: row.system_prompt,
avatar: row.avatar,
accent: row.accent,
wallpaper: row.wallpaper,
managed_by: UserId::from(row.managed_by),
status: row.status.parse().expect("status CHECK constraint"),
})
.collect())
}
pub async fn access_policy(pool: &PgPool, agent_id: AgentId) -> Result<AccessPolicy, DbError> {
let row = sqlx::query!(
"SELECT humans_mode, human_ids, agents_mode, agent_ids
FROM access_policies WHERE agent_id = $1",
agent_id.as_uuid(),
)
.fetch_one(pool)
.await?;
let humans = if row.humans_mode == "entire_team" {
HumanScope::EntireTeam
} else {
HumanScope::Specific(row.human_ids.into_iter().map(UserId::from).collect())
};
let agents = if row.agents_mode == "any" {
AgentScope::Any
} else {
AgentScope::Specific(row.agent_ids.into_iter().map(AgentId::from).collect())
};
Ok(AccessPolicy { humans, agents })
}
pub async fn set_status(
pool: &PgPool,
agent_id: AgentId,
status: AgentStatus,
) -> Result<(), DbError> {
let result = sqlx::query!(
"UPDATE agents SET status = $2 WHERE id = $1 AND deleted_at IS NULL",
agent_id.as_uuid(),
status.as_str(),
)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound);
}
Ok(())
}
/// Deleting a claw is destructive and gated (§7.7); rows are kept for audit.
pub async fn soft_delete(pool: &PgPool, agent_id: AgentId) -> Result<(), DbError> {
let result = sqlx::query!(
"UPDATE agents SET deleted_at = now(), status = 'offline'
WHERE id = $1 AND deleted_at IS NULL",
agent_id.as_uuid(),
)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound);
}
Ok(())
}
+61
View File
@@ -0,0 +1,61 @@
use sqlx::PgPool;
use tc_domain::{AgentId, UserId, WorkspaceId};
use uuid::Uuid;
use crate::DbError;
/// Who performed an audited action (§15: every decision is attributable).
#[derive(Debug, Clone, Copy)]
pub enum Actor {
User(UserId),
Agent(AgentId),
System,
}
impl Actor {
fn kind(&self) -> &'static str {
match self {
Actor::User(_) => "user",
Actor::Agent(_) => "agent",
Actor::System => "system",
}
}
fn id(&self) -> Option<Uuid> {
match self {
Actor::User(id) => Some(id.as_uuid()),
Actor::Agent(id) => Some(id.as_uuid()),
Actor::System => None,
}
}
}
/// Appends an audit entry and returns its sequence id. The table rejects
/// UPDATE/DELETE at the database level (see migration 0001).
pub async fn append(
pool: &PgPool,
workspace_id: WorkspaceId,
actor: Actor,
event_type: &str,
subject_type: &str,
subject_id: &str,
detail: serde_json::Value,
) -> Result<i64, DbError> {
let row = sqlx::query!(
"INSERT INTO audit_log
(workspace_id, actor_kind, actor_id, event_type, subject_type,
subject_id, detail)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id",
workspace_id.as_uuid(),
actor.kind(),
actor.id(),
event_type,
subject_type,
subject_id,
detail,
)
.fetch_one(pool)
.await?;
Ok(row.id)
}
+37
View File
@@ -0,0 +1,37 @@
use sqlx::PgPool;
use tc_domain::WorkspaceId;
use uuid::Uuid;
use crate::DbError;
/// Available credits = sum of remaining balances across all lots; credits
/// never expire (§8.4).
pub async fn balance(pool: &PgPool, workspace_id: WorkspaceId) -> Result<i64, DbError> {
let row = sqlx::query!(
r#"SELECT COALESCE(SUM(remaining), 0)::BIGINT AS "balance!"
FROM credit_lots WHERE workspace_id = $1"#,
workspace_id.as_uuid(),
)
.fetch_one(pool)
.await?;
Ok(row.balance)
}
pub async fn add_lot(
pool: &PgPool,
workspace_id: WorkspaceId,
amount: i64,
source: &str,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO credit_lots (id, workspace_id, amount, remaining, source)
VALUES ($1, $2, $3, $3, $4)",
Uuid::now_v7(),
workspace_id.as_uuid(),
sqlx::types::BigDecimal::from(amount),
source,
)
.execute(pool)
.await?;
Ok(())
}
+5
View File
@@ -0,0 +1,5 @@
pub mod agents;
pub mod audit;
pub mod credits;
pub mod users;
pub mod workspaces;
+99
View File
@@ -0,0 +1,99 @@
use sqlx::PgPool;
use tc_domain::{Role, User, UserId, WorkspaceId};
use crate::DbError;
fn role_to_str(role: Role) -> &'static str {
match role {
Role::Owner => "owner",
Role::Member => "member",
}
}
fn role_from_str(s: &str) -> Role {
// The CHECK constraint guarantees only these two values exist.
if s == "owner" {
Role::Owner
} else {
Role::Member
}
}
/// Inserts a user. `created_at` is assigned by the database; the value on
/// the input struct is ignored.
pub async fn insert(pool: &PgPool, user: &User) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO users (id, workspace_id, email, role, display_name)
VALUES ($1, $2, $3, $4, $5)",
user.id.as_uuid(),
user.workspace_id.as_uuid(),
user.email,
role_to_str(user.role),
user.display_name,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn get(pool: &PgPool, id: UserId) -> Result<User, DbError> {
let row = sqlx::query!(
"SELECT id, workspace_id, email, role, display_name, created_at
FROM users WHERE id = $1",
id.as_uuid(),
)
.fetch_one(pool)
.await?;
Ok(User {
id: UserId::from(row.id),
workspace_id: WorkspaceId::from(row.workspace_id),
email: row.email,
role: role_from_str(&row.role),
display_name: row.display_name,
created_at: row.created_at,
})
}
pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<User, DbError> {
let row = sqlx::query!(
"SELECT id, workspace_id, email, role, display_name, created_at
FROM users WHERE email = $1",
email,
)
.fetch_one(pool)
.await?;
Ok(User {
id: UserId::from(row.id),
workspace_id: WorkspaceId::from(row.workspace_id),
email: row.email,
role: role_from_str(&row.role),
display_name: row.display_name,
created_at: row.created_at,
})
}
/// Members table for the Team page (§8.3), in join order.
pub async fn list_by_workspace(
pool: &PgPool,
workspace_id: WorkspaceId,
) -> Result<Vec<User>, DbError> {
let rows = sqlx::query!(
"SELECT id, workspace_id, email, role, display_name, created_at
FROM users WHERE workspace_id = $1
ORDER BY created_at, id",
workspace_id.as_uuid(),
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| User {
id: UserId::from(row.id),
workspace_id: WorkspaceId::from(row.workspace_id),
email: row.email,
role: role_from_str(&row.role),
display_name: row.display_name,
created_at: row.created_at,
})
.collect())
}
+30
View File
@@ -0,0 +1,30 @@
use sqlx::PgPool;
use tc_domain::{Workspace, WorkspaceId};
use crate::DbError;
pub async fn insert(pool: &PgPool, workspace: &Workspace) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO workspaces (id, name, plan) VALUES ($1, $2, $3)",
workspace.id.as_uuid(),
workspace.name,
workspace.plan,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn get(pool: &PgPool, id: WorkspaceId) -> Result<Workspace, DbError> {
let row = sqlx::query!(
"SELECT id, name, plan FROM workspaces WHERE id = $1",
id.as_uuid(),
)
.fetch_one(pool)
.await?;
Ok(Workspace {
id: WorkspaceId::from(row.id),
name: row.name,
plan: row.plan,
})
}
+231
View File
@@ -0,0 +1,231 @@
use std::str::FromStr;
use tc_db::repo::{agents, audit, credits, users, workspaces};
use tc_db::DbError;
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentScope, AgentStatus, HumanScope, Role, User, UserId,
Workspace, WorkspaceId,
};
fn workspace() -> Workspace {
Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
}
}
fn user_in(ws: &Workspace, role: Role) -> User {
User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role,
display_name: "Test User".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
}
}
fn agent_in(ws: &Workspace, owner: &User) -> Agent {
Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Scout".into(),
job_title: "Research Analyst".into(),
system_prompt: "You research things.".into(),
avatar: "scout-1".into(),
accent: "#f96565".into(),
wallpaper: "dunes".into(),
managed_by: owner.id,
status: AgentStatus::Provisioning,
}
}
#[tokio::test]
async fn workspace_round_trips() {
let pool = tc_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let found = workspaces::get(&pool, ws.id).await.unwrap();
assert_eq!(found, ws);
}
#[tokio::test]
async fn missing_workspace_is_not_found() {
let pool = tc_testkit::test_pool().await;
let err = workspaces::get(&pool, WorkspaceId::new())
.await
.unwrap_err();
assert!(matches!(err, DbError::NotFound));
}
#[tokio::test]
async fn user_round_trips_and_finds_by_email() {
let pool = tc_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let user = user_in(&ws, Role::Owner);
users::insert(&pool, &user).await.unwrap();
let by_id = users::get(&pool, user.id).await.unwrap();
assert_eq!(by_id.email, user.email);
assert_eq!(by_id.role, Role::Owner);
let by_email = users::find_by_email(&pool, &user.email).await.unwrap();
assert_eq!(by_email.id, user.id);
}
#[tokio::test]
async fn duplicate_email_is_a_conflict() {
let pool = tc_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let mut a = user_in(&ws, Role::Member);
let mut b = user_in(&ws, Role::Member);
b.email = a.email.clone();
users::insert(&pool, &a).await.unwrap();
let err = users::insert(&pool, &b).await.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)));
// Silence unused warnings for fields we only compare implicitly.
a.display_name.clear();
}
#[tokio::test]
async fn workspace_members_lists_in_join_order() {
let pool = tc_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let owner = user_in(&ws, Role::Owner);
let member = user_in(&ws, Role::Member);
users::insert(&pool, &owner).await.unwrap();
users::insert(&pool, &member).await.unwrap();
let members = users::list_by_workspace(&pool, ws.id).await.unwrap();
assert_eq!(members.len(), 2);
assert_eq!(members[0].id, owner.id);
assert_eq!(members[1].id, member.id);
}
#[tokio::test]
async fn agent_insert_creates_default_access_policy() {
let pool = tc_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let owner = user_in(&ws, Role::Owner);
users::insert(&pool, &owner).await.unwrap();
let agent = agent_in(&ws, &owner);
agents::insert(&pool, &agent, &AccessPolicy::default())
.await
.unwrap();
let policy = agents::access_policy(&pool, agent.id).await.unwrap();
assert_eq!(policy.humans, HumanScope::EntireTeam);
assert_eq!(policy.agents, AgentScope::Any);
}
#[tokio::test]
async fn agent_roster_excludes_deleted_and_round_trips_fields() {
let pool = tc_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let owner = user_in(&ws, Role::Owner);
users::insert(&pool, &owner).await.unwrap();
let keep = agent_in(&ws, &owner);
let remove = agent_in(&ws, &owner);
agents::insert(&pool, &keep, &AccessPolicy::default())
.await
.unwrap();
agents::insert(&pool, &remove, &AccessPolicy::default())
.await
.unwrap();
agents::soft_delete(&pool, remove.id).await.unwrap();
let roster = agents::roster(&pool, ws.id).await.unwrap();
assert_eq!(roster.len(), 1);
assert_eq!(roster[0], keep);
}
#[tokio::test]
async fn agent_status_updates() {
let pool = tc_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let owner = user_in(&ws, Role::Owner);
users::insert(&pool, &owner).await.unwrap();
let agent = agent_in(&ws, &owner);
agents::insert(&pool, &agent, &AccessPolicy::default())
.await
.unwrap();
agents::set_status(&pool, agent.id, AgentStatus::Online)
.await
.unwrap();
let roster = agents::roster(&pool, ws.id).await.unwrap();
assert_eq!(roster[0].status, AgentStatus::Online);
}
#[tokio::test]
async fn specific_access_policy_round_trips() {
let pool = tc_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let owner = user_in(&ws, Role::Owner);
users::insert(&pool, &owner).await.unwrap();
let agent = agent_in(&ws, &owner);
let policy = AccessPolicy {
humans: HumanScope::Specific(vec![owner.id]),
agents: AgentScope::Specific(vec![AgentId::from_str(&agent.id.to_string()).unwrap()]),
};
agents::insert(&pool, &agent, &policy).await.unwrap();
let stored = agents::access_policy(&pool, agent.id).await.unwrap();
assert_eq!(stored, policy);
}
#[tokio::test]
async fn credit_balance_sums_remaining_lots() {
let pool = tc_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 0);
credits::add_lot(&pool, ws.id, 1000, "purchase")
.await
.unwrap();
credits::add_lot(&pool, ws.id, 250, "promo").await.unwrap();
assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 1250);
}
#[tokio::test]
async fn audit_log_appends_and_rejects_mutation() {
let pool = tc_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let entry_id = audit::append(
&pool,
ws.id,
audit::Actor::System,
"workspace.created",
"workspace",
&ws.id.to_string(),
serde_json::json!({"plan": "team"}),
)
.await
.unwrap();
assert!(entry_id > 0);
// Append-only is enforced by the database itself, not convention.
let update = sqlx::query("UPDATE audit_log SET event_type = 'tampered' WHERE id = $1")
.bind(entry_id)
.execute(&pool)
.await;
assert!(update.is_err());
let delete = sqlx::query("DELETE FROM audit_log WHERE id = $1")
.bind(entry_id)
.execute(&pool)
.await;
assert!(delete.is_err());
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "tc-domain"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
time = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
serde_json = { workspace = true }
[lints]
workspace = true
+37
View File
@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
use crate::ids::{AgentId, UserId};
/// Which humans may use an agent (spec §7.7 "Other people" toggle).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", content = "ids", rename_all = "snake_case")]
pub enum HumanScope {
EntireTeam,
Specific(Vec<UserId>),
}
/// Which other agents may message an agent (spec §7.7 "Other Claws" toggle).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", content = "ids", rename_all = "snake_case")]
pub enum AgentScope {
Any,
Specific(Vec<AgentId>),
}
/// Per-agent access policy (spec §14 AccessPolicy).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessPolicy {
pub humans: HumanScope,
pub agents: AgentScope,
}
impl Default for AccessPolicy {
/// New agents are shared with the whole team and reachable by any claw,
/// matching the wizard's defaults (spec §9 step 2).
fn default() -> Self {
AccessPolicy {
humans: HumanScope::EntireTeam,
agents: AgentScope::Any,
}
}
}
+73
View File
@@ -0,0 +1,73 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::ids::{AgentId, UserId, WorkspaceId};
use crate::role::Role;
/// A tenant team (spec §14 Workspace/Team).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Workspace {
pub id: WorkspaceId,
pub name: String,
pub plan: String,
}
/// A human member of a workspace (spec §14 User).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct User {
pub id: UserId,
pub workspace_id: WorkspaceId,
pub email: String,
pub role: Role,
pub display_name: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
/// Lifecycle of an agent; `Online` renders the green roster dot (§4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentStatus {
Provisioning,
Online,
Offline,
}
impl AgentStatus {
pub fn as_str(&self) -> &'static str {
match self {
AgentStatus::Provisioning => "provisioning",
AgentStatus::Online => "online",
AgentStatus::Offline => "offline",
}
}
}
impl std::str::FromStr for AgentStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"provisioning" => Ok(AgentStatus::Provisioning),
"online" => Ok(AgentStatus::Online),
"offline" => Ok(AgentStatus::Offline),
other => Err(format!("unknown agent status: {other}")),
}
}
}
/// An AI coworker (spec §14 Agent). The `system_prompt` is the Settings
/// "Job Description" textarea verbatim (§7.7).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Agent {
pub id: AgentId,
pub workspace_id: WorkspaceId,
pub name: String,
pub job_title: String,
pub system_prompt: String,
pub avatar: String,
pub accent: String,
pub wallpaper: String,
pub managed_by: UserId,
pub status: AgentStatus,
}
+33
View File
@@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};
/// The six action categories that always require human approval before an
/// agent may execute them (spec §15, acceptance-blocking).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GatedCategory {
/// Outbound messages and emails leaving the workspace.
OutboundMessage,
/// Sharing keys, secrets, or credentials.
SecretSharing,
/// Access, permission, or sharing changes.
AccessChange,
/// Financial transactions and credit purchases.
FinancialTransaction,
/// File deletion.
FileDeletion,
/// Granting or extending external-infrastructure access.
InfraAccessGrant,
}
impl GatedCategory {
/// Every gated category, in spec order. The safety layer iterates this
/// to prove exhaustive coverage in tests.
pub const ALL: [GatedCategory; 6] = [
GatedCategory::OutboundMessage,
GatedCategory::SecretSharing,
GatedCategory::AccessChange,
GatedCategory::FinancialTransaction,
GatedCategory::FileDeletion,
GatedCategory::InfraAccessGrant,
];
}
+71
View File
@@ -0,0 +1,71 @@
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Defines a strongly-typed UUID wrapper so ids of different entities can
/// never be swapped for one another at compile time. New ids are UUIDv7 so
/// they sort by creation time in Postgres indexes.
macro_rules! define_id {
($(#[$doc:meta])* $name:ident) => {
$(#[$doc])*
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(transparent)]
pub struct $name(Uuid);
impl $name {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
Self(Uuid::now_v7())
}
pub fn as_uuid(&self) -> Uuid {
self.0
}
}
impl From<Uuid> for $name {
fn from(value: Uuid) -> Self {
Self(value)
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for $name {
type Err = uuid::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(Uuid::parse_str(s)?))
}
}
};
}
define_id!(
/// A tenant workspace (team).
WorkspaceId
);
define_id!(
/// A human member of a workspace.
UserId
);
define_id!(
/// An AI agent ("claw").
AgentId
);
define_id!(
/// A chat session between humans and one agent.
SessionId
);
define_id!(
/// A single message within a session.
MessageId
);
+19
View File
@@ -0,0 +1,19 @@
//! Core domain types for the TeamClaw platform (spec §14).
//!
//! This crate has no I/O: every other crate depends on it for identifiers,
//! the session-key codec, role/RBAC vocabulary, agent access policies, and
//! the fixed set of approval-gated action categories from spec §15.
mod access;
mod entities;
mod gated;
mod ids;
mod role;
mod session_key;
pub use access::{AccessPolicy, AgentScope, HumanScope};
pub use entities::{Agent, AgentStatus, User, Workspace};
pub use gated::GatedCategory;
pub use ids::{AgentId, MessageId, SessionId, UserId, WorkspaceId};
pub use role::Role;
pub use session_key::{SessionKey, SessionKeyError};
+16
View File
@@ -0,0 +1,16 @@
use serde::{Deserialize, Serialize};
/// Workspace-level RBAC role (spec §14: User.role Owner|Member).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Owner,
Member,
}
impl Role {
/// Owners administer provisioning, billing, and access policy (spec §1).
pub fn is_owner(&self) -> bool {
matches!(self, Role::Owner)
}
}
+89
View File
@@ -0,0 +1,89 @@
use std::fmt;
use std::str::FromStr;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::ids::{AgentId, MessageId, SessionId};
/// The deep-linkable chat session key from spec §12:
/// `agent:{agentId}-claw-{shard}:session:{sessionId}:{messageId}`.
///
/// This codec is the single source of truth for the format; the frontend
/// mirrors it in `frontend/src/lib/url/session-key.ts` and a contract test
/// keeps the two in lockstep.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SessionKey {
pub agent_id: AgentId,
/// Deterministic hash bucket of the agent id, reserved for future
/// partitioning. Carried verbatim through parse/format.
pub shard: u16,
pub session_id: SessionId,
pub message_id: MessageId,
}
#[derive(Debug, thiserror::Error)]
pub enum SessionKeyError {
#[error("malformed session key: {0}")]
Malformed(String),
#[error("invalid shard: {0}")]
InvalidShard(String),
#[error("invalid id in session key: {0}")]
InvalidId(#[from] uuid::Error),
}
impl fmt::Display for SessionKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"agent:{}-claw-{}:session:{}:{}",
self.agent_id, self.shard, self.session_id, self.message_id
)
}
}
impl FromStr for SessionKey {
type Err = SessionKeyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let malformed = || SessionKeyError::Malformed(s.to_owned());
let rest = s.strip_prefix("agent:").ok_or_else(malformed)?;
let (agent_part, rest) = rest.split_once(":session:").ok_or_else(malformed)?;
// `-claw-` cannot occur inside a UUID (hex digits and dashes only),
// so the rightmost occurrence cleanly separates id from shard.
let (agent_str, shard_str) = agent_part.rsplit_once("-claw-").ok_or_else(malformed)?;
let agent_id = AgentId::from_str(agent_str)?;
let shard: u16 = shard_str
.parse()
.map_err(|_| SessionKeyError::InvalidShard(shard_str.to_owned()))?;
let mut tail = rest.split(':');
let session_str = tail.next().ok_or_else(malformed)?;
let message_str = tail.next().ok_or_else(malformed)?;
if tail.next().is_some() {
return Err(malformed());
}
Ok(SessionKey {
agent_id,
shard,
session_id: SessionId::from_str(session_str)?,
message_id: MessageId::from_str(message_str)?,
})
}
}
impl Serialize for SessionKey {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for SessionKey {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
SessionKey::from_str(&s).map_err(D::Error::custom)
}
}
+88
View File
@@ -0,0 +1,88 @@
use std::str::FromStr;
use tc_domain::{AccessPolicy, AgentId, AgentScope, GatedCategory, HumanScope, Role, UserId};
#[test]
fn ids_display_as_uuid_and_parse_back() {
let id = AgentId::new();
let parsed = AgentId::from_str(&id.to_string()).unwrap();
assert_eq!(parsed, id);
}
#[test]
fn new_ids_are_unique_and_v7() {
let a = UserId::new();
let b = UserId::new();
assert_ne!(a, b);
assert_eq!(a.as_uuid().get_version_num(), 7);
}
#[test]
fn distinct_id_types_serialize_as_plain_uuid_strings() {
let id = AgentId::new();
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, format!("\"{id}\""));
}
#[test]
fn role_serde_uses_lowercase() {
assert_eq!(serde_json::to_string(&Role::Owner).unwrap(), "\"owner\"");
assert_eq!(serde_json::to_string(&Role::Member).unwrap(), "\"member\"");
let r: Role = serde_json::from_str("\"owner\"").unwrap();
assert_eq!(r, Role::Owner);
}
#[test]
fn gated_categories_cover_spec_section_15() {
// The six gated categories are fixed by spec §15; serde names are the
// wire contract with the frontend approval queue.
let all = GatedCategory::ALL;
assert_eq!(all.len(), 6);
let names: Vec<String> = all
.iter()
.map(|c| serde_json::to_string(c).unwrap())
.collect();
assert_eq!(
names,
vec![
"\"outbound_message\"",
"\"secret_sharing\"",
"\"access_change\"",
"\"financial_transaction\"",
"\"file_deletion\"",
"\"infra_access_grant\"",
]
);
}
#[test]
fn default_access_policy_is_entire_team_and_any_claw() {
let policy = AccessPolicy::default();
assert_eq!(policy.humans, HumanScope::EntireTeam);
assert_eq!(policy.agents, AgentScope::Any);
}
#[test]
fn specific_scopes_carry_member_lists() {
let user = UserId::new();
let agent = AgentId::new();
let policy = AccessPolicy {
humans: HumanScope::Specific(vec![user]),
agents: AgentScope::Specific(vec![agent]),
};
let json = serde_json::to_value(&policy).unwrap();
assert_eq!(json["humans"]["mode"], "specific");
assert_eq!(json["humans"]["ids"][0], user.to_string());
assert_eq!(json["agents"]["mode"], "specific");
assert_eq!(json["agents"]["ids"][0], agent.to_string());
let back: AccessPolicy = serde_json::from_value(json).unwrap();
assert_eq!(back, policy);
}
#[test]
fn entire_team_scope_serializes_with_mode_tag() {
let policy = AccessPolicy::default();
let json = serde_json::to_value(&policy).unwrap();
assert_eq!(json["humans"]["mode"], "entire_team");
assert_eq!(json["agents"]["mode"], "any");
}
+119
View File
@@ -0,0 +1,119 @@
use std::str::FromStr;
use tc_domain::{AgentId, MessageId, SessionId, SessionKey, SessionKeyError};
fn sample() -> SessionKey {
SessionKey {
agent_id: AgentId::from_str("018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e").unwrap(),
shard: 3,
session_id: SessionId::from_str("018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f").unwrap(),
message_id: MessageId::from_str("018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a").unwrap(),
}
}
#[test]
fn formats_to_spec_layout() {
let key = sample();
assert_eq!(
key.to_string(),
"agent:018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e-claw-3\
:session:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f\
:018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a"
);
}
#[test]
fn parses_its_own_output() {
let key = sample();
let parsed = SessionKey::from_str(&key.to_string()).unwrap();
assert_eq!(parsed, key);
}
#[test]
fn rejects_missing_agent_prefix() {
let err = SessionKey::from_str(
"claw:018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e-claw-3\
:session:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f\
:018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
)
.unwrap_err();
assert!(matches!(err, SessionKeyError::Malformed(_)));
}
#[test]
fn rejects_missing_session_segment() {
let err = SessionKey::from_str(
"agent:018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e-claw-3\
:chat:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f\
:018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
)
.unwrap_err();
assert!(matches!(err, SessionKeyError::Malformed(_)));
}
#[test]
fn rejects_non_numeric_shard() {
let err = SessionKey::from_str(
"agent:018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e-claw-x\
:session:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f\
:018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
)
.unwrap_err();
assert!(matches!(err, SessionKeyError::InvalidShard(_)));
}
#[test]
fn rejects_invalid_agent_uuid() {
let err = SessionKey::from_str(
"agent:not-a-uuid-claw-3\
:session:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f\
:018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
)
.unwrap_err();
assert!(matches!(err, SessionKeyError::InvalidId(_)));
}
#[test]
fn rejects_trailing_garbage() {
let mut s = sample().to_string();
s.push_str(":extra");
let err = SessionKey::from_str(&s).unwrap_err();
assert!(matches!(err, SessionKeyError::Malformed(_)));
}
#[test]
fn serde_round_trips_as_string() {
let key = sample();
let json = serde_json::to_string(&key).unwrap();
assert_eq!(json, format!("\"{key}\""));
let back: SessionKey = serde_json::from_str(&json).unwrap();
assert_eq!(back, key);
}
mod properties {
use super::*;
use proptest::prelude::*;
fn arb_uuid() -> impl Strategy<Value = uuid::Uuid> {
any::<u128>().prop_map(uuid::Uuid::from_u128)
}
proptest! {
#[test]
fn round_trips_for_any_ids(
agent in arb_uuid(),
session in arb_uuid(),
message in arb_uuid(),
shard in any::<u16>(),
) {
let key = SessionKey {
agent_id: AgentId::from(agent),
shard,
session_id: SessionId::from(session),
message_id: MessageId::from(message),
};
let parsed = SessionKey::from_str(&key.to_string()).unwrap();
prop_assert_eq!(parsed, key);
}
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "tc-testkit"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
sqlx = { workspace = true }
tc-db = { path = "../tc-db" }
testcontainers-modules = { workspace = true }
tokio = { workspace = true }
uuid = { workspace = true }
[lints]
workspace = true
+88
View File
@@ -0,0 +1,88 @@
//! Test harness for real-Postgres integration tests.
//!
//! Every test gets its own freshly-migrated database on a shared server:
//! either the one named by `TC_TEST_DATABASE_URL` (air-gapped CI runs a
//! preloaded Postgres) or a `postgres:16-alpine` testcontainer started once
//! per test process. There are no in-memory fakes; the suite proves behavior
//! against the same engine production runs.
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::testcontainers::runners::AsyncRunner;
use testcontainers_modules::testcontainers::ContainerAsync;
use tokio::sync::OnceCell;
use uuid::Uuid;
static SERVER: OnceCell<PgServer> = OnceCell::const_new();
struct PgServer {
/// Connection URL whose path component is a maintenance database we can
/// issue `CREATE DATABASE` from.
admin_url: String,
/// Keeps the container alive for the whole test process; `None` when an
/// external server is provided via `TC_TEST_DATABASE_URL`.
_container: Option<ContainerAsync<Postgres>>,
}
async fn server() -> &'static PgServer {
SERVER
.get_or_init(|| async {
if let Ok(url) = std::env::var("TC_TEST_DATABASE_URL") {
return PgServer {
admin_url: url,
_container: None,
};
}
let container = Postgres::default()
.start()
.await
.expect("start postgres testcontainer");
let port = container
.get_host_port_ipv4(5432)
.await
.expect("resolve postgres port");
PgServer {
admin_url: format!("postgres://postgres:[email protected]:{port}/postgres"),
_container: Some(container),
}
})
.await
}
/// Creates a unique database, runs all migrations, and returns a pool
/// connected to it.
pub async fn test_pool() -> PgPool {
let server = server().await;
let db_name = format!("test_{}", Uuid::now_v7().simple());
let admin = PgPoolOptions::new()
.max_connections(1)
.connect(&server.admin_url)
.await
.expect("connect admin database");
sqlx::query(&format!("CREATE DATABASE {db_name}"))
.execute(&admin)
.await
.expect("create test database");
admin.close().await;
let test_url = swap_database(&server.admin_url, &db_name);
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&test_url)
.await
.expect("connect test database");
tc_db::MIGRATOR.run(&pool).await.expect("run migrations");
pool
}
/// Replaces the database name (the path segment) of a Postgres URL.
fn swap_database(url: &str, db_name: &str) -> String {
let (head, tail) = url.rsplit_once('/').expect("postgres url has a path");
// Preserve any query string on the original URL.
match tail.split_once('?') {
Some((_, query)) => format!("{head}/{db_name}?{query}"),
None => format!("{head}/{db_name}"),
}
}
+457
View File
@@ -0,0 +1,457 @@
# AI Coworker Platform — Build Spec, PRD & Roadmap
_Internal working name: **TeamClaw** (replace with your brand). Original specification authored from product research. Art assets (avatars, wallpapers, logo) are generated separately and intentionally excluded._
---
## Table of Contents
1. Product Overview
2. Design System & Tokens
3. Motion / Animation Spec
4. Global App Shell & Layout
5. Agent Chat Workspace
6. Sessions Column
7. The "Computer" Slide-out Panel (full spec)
8. Global Pages (Skills, Apps, Team, Credits)
9. Agent Creation Wizard
10. Dialogs & Overlays
11. Component Inventory
12. Routing & URL State Model
13. API Contract
14. Data Model
15. Agent Runtime & Safety Layer
16. PRD Summary
17. Delivery Roadmap
---
## 1. Product Overview
An enterprise platform of customizable, collaborative AI agents ("claws"). Each agent has a persistent identity, a configurable system prompt, its own tool/app connections, files, scheduled routines, an inter-agent inbox, and a sandboxed runtime ("Computer") surfaced through a chat-first UI. A human-in-the-loop approval layer gates every sensitive or irreversible action.
**Personas:** Owner/Admin (provisioning, billing, access policy), Builder (creates/tunes agents), Operator (chats, approves gated actions), Agent (first-class actor with identity, inbox, routines, files, runtime).
---
## 2. Design System & Tokens
Dark-first. Coral brand accent.
| Token | Value |
|---|---|
| --background | #0a0a0a |
| --foreground | #fafafa |
| --card / --popover | #0a0a0a |
| --border / --input / --muted / --secondary | #262626 |
| --muted-foreground | #a3a3a3 |
| --subtle / --surface-warm | #141414 |
| --surface-warm-muted | #1f1f1f |
| --accent (coral) | #f96565 |
| --coral-light / --coral-dark | #fa7575 / #e85555 |
| --destructive | #7f1d1d |
| --primary | #fafafa |
| --radius (base) | 0.5rem |
| --radius-button (pill) | 9999px |
| app sidebar width | 176px |
**Type:** Geist (UI) + Geist Mono (code). Reserved: Satoshi, Familjen Grotesk, Ranade, Inter. Scale: xxs 10px → base 16px → xxxl 36px.
**Shadows:** bubble, button, card, dialog, popover, dock-tile, cta.
**Texture:** inline SVG feTurbulence fractal-noise grain layered over wallpaper PNGs.
---
## 3. Motion / Animation Spec
- Primary easing: `--ease-app: cubic-bezier(.32,.72,0,1)`
- Secondary easing: `--ease-out: cubic-bezier(.16,1,.3,1)`
- Durations: fast .15s / normal .25s / slow .4s
- **Slide-out panels animate WIDTH, not transform:** `transition: width .25s var(--ease-app), border-color .5s var(--ease-out)` collapsing 0 ↔ target (176 / 208 / 448px). This makes panels "push" layout rather than overlay.
- Keyframes to implement: slide-in-right, sheet-in, slide-up-in, scale-in, fade-in/out, fade-up, collapsible-expand/collapse, route-fade-in, shimmer, ripple, gradient-shift, caret-blink, shake, spin, ping, pulse, toolSlideIn, toolAccentPulse.
- Provisioning (new-agent spin-up): avatar-breathe, photo-rotate, photo-bob, tip-slide, tip-fade.
---
## 4. Global App Shell & Layout
Three persistent zones; up to three columns visible in chat at full width.
```
+--------+-----------------------------+--------------------------+
| LEFT | MAIN (chat / page) | COMPUTER PANEL (slide) |
| RAIL | | (right, ~448px) |
| 176px | header (h-14 / lg:h-20) | ?device=full|tablet| |
| | | phone |
| logo | ...content... | |
| [agent]| | |
| [agent]| | |
| [agent]| | |
| ( + ) | | |
| | | |
| Skills | | |
| Apps | | |
| Team | | |
| Credits| | |
| [user] | | |
+--------+-----------------------------+--------------------------+
```
Left rail: logo (top), agent roster (avatar + name + green online dot), add-agent (+), then nav: Skills / Apps / Team / Credits, current user pinned bottom. Active item uses coral. Rail width animates on collapse.
---
## 5. Agent Chat Workspace
Route: `/claws/{clawId}/chat/{sessionKey}`
### 5a. Empty / Welcome state
```
+---------------------------------------------+
| (avatar) |
| Hi, I'm {Name}. What can I |
| help with? |
| {Role} · Shared with your team |
| +-------------------------------------+ |
| | Message {Name}... [clip][skill]| |
| +-------------------------------------+ |
| [Create a daily briefing] [Write a report] |
| [Make a presentation] [What can it do?]|
+---------------------------------------------+
```
### 5b. Active transcript
```
HEADER: (avatar) {Name} / {Role} [slack][sessions][new]
------------------------------------------------------------
[ user bubble (right) ]
(avatar) agent message (left)
> N steps (collapsible tool/reasoning trace)
inline `code chip` coral action link
[Copy] [Helpful] [Not helpful]
------------------------------------------------------------
COMPOSER: [ Type your message... ] [clip] [skill]
```
- User bubbles right-aligned; agent messages left with avatar.
- "N steps" expands to show tool calls (toolSlideIn / toolAccentPulse).
- Gated actions render as coral "Review and approve" links → approvals queue.
---
## 6. Sessions Column
Toggle: `?sessions=1`. Slides in on the LEFT of the chat (separate from Computer panel), narrowing the chat column.
```
+------------------+
| [ Search ] |
+------------------+
| Session title |
| 4h ago (•)| <- active = coral left border
| Session title |
| 1d ago |
+------------------+
[ + New session ]
```
Each row: title + relative timestamp. Backed by session-history retrieval; sessions are resumable.
---
## 7. The "Computer" Slide-out Panel (FULL SPEC)
Right-hand slide-out, themed per-agent via wallpaper. Size toggles top-right: **Full** (expand to side nav) / **Tablet** (chat beside it) / **Phone**. Plus close (×). State via `?device=` and `?app=`.
### 7.0 Home screen (dock + app grid)
```
● {Name}'s Computer
+------------------------------------------+
| [Browser] [Slack] [Claw Chat] [+ Add] | <- app grid
| |
| (wallpaper area) |
| |
| +------------------------------------+ |
| | [Skills] [Files] [Routines][Settings] | <- dock (glassy)
| +------------------------------------+ |
+------------------------------------------+
```
Every app is a routable sub-view (`?app=...`) with header (title + close, optional back/tabs) and an empty state. Each sub-view title appears top-left; close (×) top-right.
### 7.1 Browser (live agent web browser)
```
[<] [>] ( address: newtab ) [⟳] [+] [×]
+------------------------------------------+
| web viewport |
+------------------------------------------+
```
Full browser chrome: back/forward, address bar, reload, new-tab, close. Renders the agent's actual browsing session (CDP-backed). Handle profile-load error state ("Some features may be unavailable" + OK).
### 7.2 Claw Chat (inter-agent inbox) — `?app=chat`
```
Claw Chat [×]
[ Search ]
+------------------------------------------+
| (av) {Agent} 2h |
| {Thread subject} |
| {last message preview…} |
+------------------------------------------+
```
Thread list → conversation detail. Threads may be flagged "sensitive." This is the surface governed by the "Other Claws" access toggle.
### 7.3 Slack (tabbed integration) — `?app=slack`
```
Slack [×]
[ Overview ] [ Channels ] [ Connection ]
+------------------------------------------+
| (Slack glyph) |
| Bring this claw into Slack |
| Connect Slack to respond on @mention |
| [ Connect Slack ] |
+------------------------------------------+
```
Pre-connect: all tabs show connect gate. Post-connect: Channels (pick channels), Connection (manage auth). Agent replies on @mention; outbound posts are gated.
### 7.4 Files — `?app=files`
```
Files [×]
+------------------------------------------+
| 📁 My Documents > |
| 📁 Received Files > |
| 📁 Shared ClawDrive (team) > |
+------------------------------------------+
```
Three drives; Shared drive is team-visible. Backed by openclaw/files + shared-drive/files.
### 7.5 Skills (per-agent installed) — `?app=skills`
```
Skills [×]
+------------------------------------------+
| (lightning) |
| {Name}'s Skills |
| Add workflows you want {Name} to do |
| |
| [ Add Skill ] |
+------------------------------------------+
```
Installed subset of the global Skill Library. Empty state + Add Skill.
### 7.6 Routines (scheduled tasks) — `?app=routines` (→ scheduled)
```
Routines [⟳][×]
+------------------------------------------+
| (bell) |
| No routines yet |
| Ask your claw to set up a routine — |
| daily digest, newsletter, calendar block|
| [ Schedule a task ] |
+------------------------------------------+
```
Routines are agent-created (interval/cron). List + refresh + create.
### 7.7 Settings — `?app=settings` (push/pop nav stack)
**Main:**
```
Settings [×]
(avatar)
{Name}
{Role subtitle}
+------------------------------------------+
| Slack handle ⚠ @handle (disconnected)|
| Managed by {Owner} |
| Edit profile > |
+------------------------------------------+
WHO ELSE HAS ACCESS
+------------------------------------------+
| (av) Other people [ON] |
| ◉ Entire team — anyone at {Org} |
| ○ Specific people — pick teammates |
+------------------------------------------+
| (av) Other Claws [ON] |
| ◉ Any Claw on the team |
| ○ Specific claws — pick claws |
+------------------------------------------+
[ Need help? support@… ]
```
**Edit profile (drill-in, ‹ back):**
```
‹ Edit profile
(avatar) [Change photo]
+------------------------------------------+
| Name {Name} |
| Job Title {Role} |
+------------------------------------------+
JOB DESCRIPTION
[ Describe how this claw should think... ]
"becomes part of its system prompt"
PERSONALIZATION
| Wallpaper {Theme} > |
+------------------------------------------+
[ 🗑 Delete claw ] (destructive)
```
Key: the Job Description textarea IS the system prompt. Wallpaper drives panel theming. Delete is destructive (gated/confirmed).
### 7.8 Add Apps (connect directory) — `?app=apps`
```
Add Apps [×]
[ Search apps ]
+------------------------------------------+
| (icon) Gmail [+] |
| (icon) Google Calendar [+] |
| (icon) Notion / Linear / GitHub … [+] |
+------------------------------------------+
[ + Add custom app ]
```
Per-row [+] starts OAuth. "Add custom app" → auth-method dialog (see §10).
---
## 8. Global Pages
### 8.1 Skill Library — `/skills`
```
Skill Library [ Create v ]
Browse skills published by your team and the catalog.
[ Search skills ] [ All categories v ]
+------------------+ +------------------+
| {Skill} [+] | | {Skill} [+] |
| by {author} | | by {author} |
| {description} | | {description} |
| N installs | | N installs |
+------------------+ +------------------+
```
Two-column cards: title, author, description, install count, install (+).
### 8.2 Apps — `/apps`
```
Apps [ + Add Custom App ]
[ Find an App ] [ Category v ]
Browse All Apps — connect via OAuth
+------------------+ +------------------+
|(logo){App} [+] | |(logo){App} [+] |
| {description} | | {description} |
+------------------+ +------------------+
```
Infinite scroll. Includes Gmail, Calendar, Drive, Notion, Linear, GitHub, Figma, Zoom, Stripe, HubSpot, Sheets, Telegram, HTTP/Webhook, Schedule, Supabase, MySQL, Postgres, AWS, SendGrid, SES, …
### 8.3 Team — `/team`
```
{Workspace} ✎ [ Invite ]
N members in your workspace
[ Members ] [ Claw org chart ] [ Leaderboard ]
+------------------------------------------+
| Name Email Joined |
| (NM) {User} [Owner] {email} {date} |
+------------------------------------------+
```
### 8.4 Credits — `/credits`
```
Credits — manage balance, subscriptions, usage
+------------------------------------------+
| AVAILABLE CREDITS {N} [Buy credits][Activity]|
| Plans from $/mo · save yearly |
| CREDIT LOTS · all credits never expire |
| USAGE · LAST 7 DAYS [▓▓▓▓▓▓░] ~2 days |
+------------------------------------------+
| Promo code [ PROMOCODE ] [ Redeem ] |
+------------------------------------------+
| Scaling beyond self-serve? [ Talk to sales ]|
+------------------------------------------+
```
---
## 9. Agent Creation Wizard — `/claws/new` (?step=identity|access|slack)
```
[▬▬▬▬] [ ] [ ] (3-step progress)
Create your Claw
Claws help get your work done.
(< avatar carousel >) Name *
● ● ● ● [+] (accent swatches) [ {name} ] (⟳)
Job title
[ {role} ]
[ Continue → ]
```
Step 1 identity (avatar carousel + accent/wallpaper swatches + name w/ randomizer + job title), Step 2 access (same scopes as Settings), Step 3 Slack (optional). Provisioning shows animated spin-up states. **Note:** completing creates a live agent — confirm before submit.
---
## 10. Dialogs & Overlays
### Add Custom App (centered modal)
```
(icon) Add Custom App [×]
Pick how this app authenticates
+------------------------------------------+
| 🔑 Keys — secret keys / API key / token |
| 🛡 Username & Password — HTTP basic auth |
| 🖥 MCP Server with OAuth — remote MCP |
+------------------------------------------+
[ Cancel ]
```
### Approval card (safety layer)
```
Review & approve
This claw wants to: {action summary}
PREVIEW: {exact payload / diff of what executes}
Source: {agent / thread} ⚠ {sensitive/unverified}
[ Reject ] [ Approve ]
```
---
## 11. Component Inventory
**Shell:** LeftRail, AgentRosterItem, AddAgentButton, GlobalNavItem, UserMenu.
**Chat:** ChatHeader, WelcomeState, SuggestedPromptChip, MessageList, UserBubble, AgentMessage, StepTrace, CodeChip, GatedActionLink, MessageActions, Composer.
**Sessions:** SessionsColumn, SessionSearch, SessionRow, NewSessionButton.
**Computer panel:** DevicePanel (resizable), DeviceSizeToggle, PanelHeader, AppGrid, AppTile, Dock, DockTile; modules: BrowserApp, ClawChatApp (ThreadList/ThreadRow/ThreadDetail), SlackApp (Tabs/ConnectGate), FilesApp (FolderRow), SkillsApp, RoutinesApp, SettingsApp (IdentityHeader/SettingRow/AccessToggleGroup/EditProfileForm/WallpaperPicker/DeleteAgent), AddAppsApp.
**Global:** SkillCard, AppCard, AddCustomAppDialog, MembersTable, CreditsCard, UsageMeter, PromoRedeem.
**Wizard:** StepProgress, AvatarCarousel, AccentSwatchRow, ProvisioningState.
**Safety:** ApprovalCard, ApprovalQueue, ActionPreview, AuditLogEntry, UntrustedContentBadge.
---
## 12. Routing & URL State Model
- Global: `/skills`, `/apps`, `/team`, `/credits`, `/claws/new`
- Agent: `/claws/{clawId}/chat/{sessionKey}` where sessionKey = `agent:{agentId}-claw-{shard}:session:{sessionId}:{messageId}`
- Overlays via query: `?app=browser|slack|chat|skills|files|routines(→scheduled)|settings|apps`, `?sessions=1`, `?device=full|tablet|phone`, `?step=identity|access|slack`
- All panel/overlay state is deep-linkable & shareable.
---
## 13. API Contract (REST, JSON, same-origin /api/, keyed by clawId)
**Observed:**
- Identity/team: `GET /user/me`, `GET /team/permissions`, `GET /team/credits`, `GET /team/claws`
- Per-agent: `GET /claws/{id}/health`, `GET /claws/settings/full?clawId=`, `GET /claws/{id}/custom-photos`, `GET /claws/{id}/slack-channels`, `GET /claws/{id}/apps/available`
- Sessions: `GET /sessions?clawId=`, `GET /sessions/active?clawId=`, `GET /sessions/history?sessionKey=&tools=true&clawId=`
- Apps/skills/slack: `GET /apps?clawId=`, `GET /skills?clawId=`, `GET /slack/me`, `GET /apps/slack/setup`
- Files: `GET /openclaw/files?clawId=`, `GET /shared-drive/files?clawId=`
- Runtime bridge: `POST /gateway?clawId=` (streaming)
**Designed (confirm during build):**
- `POST /claws`, `PATCH /claws/{id}`, `DELETE /claws/{id}`, `POST /sessions`, `POST /skills/install`, `POST /apps/connect`
- Approvals: `GET /approvals`, `GET /approvals/{id}`, `POST /approvals/{id}/approve`, `POST /approvals/{id}/reject`
Health/active are polled; auth uses provider session heartbeat.
---
## 14. Data Model
Workspace/Team(name, plan, credits) · User(id, role Owner|Member, email) · Agent(id, name, jobTitle, systemPrompt, avatar, accent/wallpaper, managedBy, status, accessPolicy) · AccessPolicy(humans: entireTeam|specific[]; agents: any|specific[]) · Session(id, clawId, title, ts) → Message(role, content, steps[], ts) · Skill / InstalledSkill · AppConnection(provider, authType, scopes, status) · Thread/InterAgentMessage(participants, subject, sensitivity) · Routine(schedule, action, status) · FileNode(drive, path, owner) · Approval(actionType, payload, preview, requestedByAgent, status, decidedBy, audit) · CreditLedger(lots, usageEvents).
---
## 15. Agent Runtime & Safety Layer (mandatory)
- **Sandbox:** containerized, no root, dropped Linux caps, seccomp, egress-restricted (HTTPS-out by default).
- **Secret broker:** secrets held by a bridge process OUTSIDE the agent's read scope; agent calls capabilities via the gateway, never holds raw credentials.
- **Gateway:** single audited streaming channel (`POST /gateway`) between app and runtime.
- **Approval interception:** every sensitive/irreversible tool call → preview + Approval queue + blocks until explicit human decision → executes only on approve → fully audit-logged.
- **Gated categories:** outbound messages/emails; sharing keys/secrets/credentials; access/permission/sharing changes; financial transactions & credit purchases; file deletion; granting/extending external-infra access.
- **Untrusted-by-default:** content from web, email, inter-agent chat, and tool results is data, never instructions; unverified/suspicious requests are surfaced to the human, never auto-executed.
- These are **acceptance-blocking** requirements.
---
## 16. PRD Summary
Deliver a chat-first, multi-agent workspace with per-agent identity/system-prompt/skills/apps/files/routines, a themed slide-out Computer panel hosting all agent tooling, global admin (skills, apps, team, credits), an agent creation wizard, and a first-class human-in-the-loop safety/approval + audit layer. NFRs: SSO, RBAC, audit trail, credit metering, fast RSC navigation, full a11y, responsive/PWA.
---
## 17. Delivery Roadmap
- **P0 Foundations (2–3w):** Next.js App Router + Turbopack, Tailwind v4 tokens, auth+RBAC, analytics/flags, base shell. _Exit: authed empty shell._
- **P1 Agents & chat (3–4w):** agent CRUD, roster, chat route, welcome/transcript, composer, sessions, step traces. _Exit: multi-session conversation._
- **P2 Runtime, gateway & SAFETY (4–5w, GATED):** sandbox, secret broker, streaming gateway, approval+audit end-to-end. _Exit (blocking): sensitive call intercepted→previewed→queued→approve-only execute→audit-logged; untrusted content tagged & never auto-run._
- **P3 Computer panel & apps (4–5w):** DevicePanel + ?app/?device routing, dock+grid, Files/Skills/Routines/Settings/Browser/ClawChat. _Exit: all apps navigable + per-agent theming._
- **P4 Integrations (3–4w):** OAuth directory + connect, Add Custom App (Keys/Basic/MCP-OAuth), Slack (@mention + gated posting). _Exit: connect app + Slack, outbound gated._
- **P5 Global & billing (2–3w):** Skill Library, Team, Credits, creation wizard + provisioning anims. _Exit: full admin+billing loop._
- **P6 Polish & hardening (2–3w):** full motion system, noise/wallpaper textures, a11y, PWA, perf, sandbox+approval security review. _Exit: a11y + security sign-off; identical look-and-feel._
Cross-cutting (continuous): audit/observability, credit metering, safety layer.
---
_End of document._
+269
View File
@@ -0,0 +1,269 @@
-- TeamClaw schema, spec §14. Single source of truth for the data model.
-- Ids are UUIDv7 generated by the application (tc-domain).
CREATE TABLE workspaces (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE users (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
display_name TEXT NOT NULL,
-- Subject claim for OIDC users; NULL for local-auth users.
auth_subject TEXT,
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX users_workspace_idx ON users (workspace_id);
CREATE TABLE agents (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
name TEXT NOT NULL,
job_title TEXT NOT NULL,
-- The Settings "Job Description" textarea IS the system prompt (§7.7).
system_prompt TEXT NOT NULL DEFAULT '',
avatar TEXT NOT NULL DEFAULT '',
accent TEXT NOT NULL DEFAULT '',
wallpaper TEXT NOT NULL DEFAULT '',
managed_by UUID NOT NULL REFERENCES users (id),
status TEXT NOT NULL CHECK (status IN ('provisioning', 'online', 'offline')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX agents_workspace_idx ON agents (workspace_id) WHERE deleted_at IS NULL;
CREATE TABLE access_policies (
agent_id UUID PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE,
humans_mode TEXT NOT NULL CHECK (humans_mode IN ('entire_team', 'specific')),
human_ids UUID[] NOT NULL DEFAULT '{}',
agents_mode TEXT NOT NULL CHECK (agents_mode IN ('any', 'specific')),
agent_ids UUID[] NOT NULL DEFAULT '{}'
);
CREATE TABLE sessions (
id UUID PRIMARY KEY,
agent_id UUID NOT NULL REFERENCES agents (id),
workspace_id UUID NOT NULL REFERENCES workspaces (id),
title TEXT NOT NULL DEFAULT '',
shard SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_active_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX sessions_agent_idx ON sessions (agent_id, last_active_at DESC);
CREATE TABLE messages (
id UUID PRIMARY KEY,
session_id UUID NOT NULL REFERENCES sessions (id),
seq BIGINT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('user', 'agent', 'system')),
content JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (session_id, seq)
);
CREATE TABLE steps (
id UUID PRIMARY KEY,
message_id UUID NOT NULL REFERENCES messages (id),
seq INT NOT NULL,
kind TEXT NOT NULL,
tool_name TEXT,
input JSONB,
output JSONB,
-- Taint sources: 'web', 'email', 'inter_agent', 'tool_result' (§15).
taint TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
UNIQUE (message_id, seq)
);
CREATE TABLE agent_runs (
id UUID PRIMARY KEY,
session_id UUID NOT NULL REFERENCES sessions (id),
state TEXT NOT NULL CHECK
(state IN ('running', 'awaiting_approval', 'completed', 'failed', 'cancelled')),
checkpoint JSONB,
last_event_id BIGINT NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE approvals (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
run_id UUID NOT NULL REFERENCES agent_runs (id),
session_key TEXT NOT NULL,
action_type TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN (
'outbound_message', 'secret_sharing', 'access_change',
'financial_transaction', 'file_deletion', 'infra_access_grant')),
payload JSONB NOT NULL,
-- The exact rendered preview shown to the human (§10 approval card).
preview JSONB NOT NULL,
requested_by_agent UUID NOT NULL REFERENCES agents (id),
taint_sources TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL CHECK
(status IN ('pending', 'approved', 'rejected', 'expired')),
decided_by UUID REFERENCES users (id),
decided_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX approvals_pending_idx ON approvals (workspace_id, created_at)
WHERE status = 'pending';
CREATE TABLE execution_grants (
id UUID PRIMARY KEY,
approval_id UUID NOT NULL UNIQUE REFERENCES approvals (id),
nonce TEXT NOT NULL,
consumed BOOLEAN NOT NULL DEFAULT false,
consumed_at TIMESTAMPTZ
);
CREATE TABLE audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user', 'agent', 'system')),
actor_id UUID,
event_type TEXT NOT NULL,
subject_type TEXT NOT NULL,
subject_id TEXT NOT NULL,
detail JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The audit log is append-only at the database level, not by convention.
CREATE FUNCTION audit_log_immutable() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_log is append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_log_no_update
BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_immutable();
CREATE TABLE skills (
id UUID PRIMARY KEY,
-- NULL workspace = catalog skill visible to every workspace (§8.1).
workspace_id UUID REFERENCES workspaces (id),
title TEXT NOT NULL,
author TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
installs INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE installed_skills (
agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
skill_id UUID NOT NULL REFERENCES skills (id),
installed_by UUID NOT NULL REFERENCES users (id),
installed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (agent_id, skill_id)
);
CREATE TABLE secrets (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
kind TEXT NOT NULL,
ciphertext BYTEA NOT NULL,
nonce BYTEA NOT NULL,
key_version INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE app_connections (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
agent_id UUID REFERENCES agents (id),
provider TEXT NOT NULL,
auth_type TEXT NOT NULL CHECK
(auth_type IN ('oauth', 'keys', 'basic', 'mcp_oauth')),
scopes TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL,
secret_ref UUID REFERENCES secrets (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE threads (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
subject TEXT NOT NULL,
sensitivity TEXT NOT NULL DEFAULT 'normal' CHECK
(sensitivity IN ('normal', 'sensitive')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE thread_participants (
thread_id UUID NOT NULL REFERENCES threads (id) ON DELETE CASCADE,
agent_id UUID NOT NULL REFERENCES agents (id),
PRIMARY KEY (thread_id, agent_id)
);
CREATE TABLE thread_messages (
id UUID PRIMARY KEY,
thread_id UUID NOT NULL REFERENCES threads (id),
from_agent UUID NOT NULL REFERENCES agents (id),
content JSONB NOT NULL,
taint TEXT[] NOT NULL DEFAULT '{inter_agent}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE routines (
id UUID PRIMARY KEY,
agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
name TEXT NOT NULL,
schedule_cron TEXT NOT NULL,
action JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK
(status IN ('active', 'paused')),
next_run_at TIMESTAMPTZ,
last_run_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE file_nodes (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
-- NULL for the team-wide shared drive; set for per-agent drives (§7.4).
agent_id UUID REFERENCES agents (id),
drive TEXT NOT NULL CHECK (drive IN ('documents', 'received', 'shared')),
path TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('file', 'folder')),
size BIGINT NOT NULL DEFAULT 0,
blob_ref TEXT,
owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user', 'agent')),
owner_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX file_nodes_path_idx
ON file_nodes (workspace_id, drive, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid), path);
CREATE TABLE credit_lots (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
amount NUMERIC NOT NULL CHECK (amount >= 0),
remaining NUMERIC NOT NULL CHECK (remaining >= 0),
source TEXT NOT NULL,
purchased_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE usage_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
agent_id UUID REFERENCES agents (id),
run_id UUID REFERENCES agent_runs (id),
kind TEXT NOT NULL,
tokens_in BIGINT NOT NULL DEFAULT 0,
tokens_out BIGINT NOT NULL DEFAULT 0,
credits NUMERIC NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.96.0"
components = ["rustfmt", "clippy"]