From c6120352f2bf14f8897239c076dd71bd28c5d7ee Mon Sep 17 00:00:00 2001
From: Guy Ben-Aharon
Date: Sun, 15 Feb 2026 21:37:51 +0200
Subject: [PATCH 1/8] fix: refactor codebase
---
.env.example | 18 +-
.gitignore | 10 +-
.prettierignore | 2 +-
{.agency => .squadhub}/.gitkeep | 0
CLAUDE.md | 6 +-
README.md | 24 +-
apps/watcher/.env.example | 4 +-
apps/watcher/Dockerfile | 2 +-
apps/watcher/README.md | 10 +-
apps/watcher/src/config.spec.ts | 24 +-
apps/watcher/src/config.ts | 6 +-
apps/watcher/src/index.ts | 344 ++++--------------
apps/web/CLAUDE.md | 8 +-
.../src/app/(dashboard)/@header/default.tsx | 4 +-
apps/web/src/app/(dashboard)/agents/page.tsx | 2 +-
.../_components/agents-panel/agents-panel.tsx | 2 +-
.../_components/business-settings-form.tsx | 2 +-
.../general/_components/timezone-settings.tsx | 4 +-
.../_components/telegram-integration-card.tsx | 10 +-
.../_components/telegram-remove-dialog.tsx | 4 +-
.../_components/telegram-setup-dialog.tsx | 16 +-
apps/web/src/app/api/agency/health/route.ts | 7 -
.../web/src/app/api/business/context/route.ts | 20 +-
apps/web/src/app/api/chat/abort/route.spec.ts | 6 +-
apps/web/src/app/api/chat/abort/route.ts | 5 +-
.../src/app/api/chat/history/route.spec.ts | 6 +-
apps/web/src/app/api/chat/history/route.ts | 7 +-
apps/web/src/app/api/chat/route.ts | 18 +-
apps/web/src/app/api/squadhub/health/route.ts | 8 +
.../api/{agency => squadhub}/pairing/route.ts | 13 +-
.../web/src/app/api/tenant/provision/route.ts | 37 ++
apps/web/src/app/page.tsx | 2 +-
apps/web/src/app/setup/business/page.tsx | 2 +-
apps/web/src/app/setup/complete/page.tsx | 2 +-
apps/web/src/app/setup/layout.tsx | 6 +-
apps/web/src/app/setup/telegram/page.tsx | 18 +-
apps/web/src/app/setup/welcome/page.tsx | 12 +-
apps/web/src/components/chat/chat-message.tsx | 2 +-
...{agency-status.tsx => squadhub-status.tsx} | 12 +-
apps/web/src/hooks/use-onboarding-guard.ts | 4 +-
...gency-status.ts => use-squadhub-status.ts} | 14 +-
.../lib/{agency => squadhub}/actions.spec.ts | 17 +-
.../src/lib/{agency => squadhub}/actions.ts | 19 +-
apps/web/src/lib/squadhub/connection.ts | 8 +
apps/web/src/lib/squadhub/provision.ts | 215 +++++++++++
apps/web/vitest.config.ts | 4 +-
docker-compose.override.yml | 6 +-
docker-compose.yml | 26 +-
docker/{agency => squadhub}/Dockerfile | 6 +-
docker/{agency => squadhub}/entrypoint.sh | 4 +-
.../scripts/init-agents.sh | 0
.../scripts/pair-device.js | 0
.../templates/config.template.json | 0
.../templates/shared/CLAWE-CLI.md | 0
.../templates/shared/WORKFLOW.md | 0
.../templates/shared/WORKING.md | 0
.../templates/workspaces/clawe/AGENTS.md | 0
.../templates/workspaces/clawe/BOOTSTRAP.md | 0
.../templates/workspaces/clawe/HEARTBEAT.md | 0
.../templates/workspaces/clawe/MEMORY.md | 0
.../templates/workspaces/clawe/SOUL.md | 0
.../templates/workspaces/clawe/TOOLS.md | 0
.../templates/workspaces/clawe/USER.md | 0
.../templates/workspaces/inky/AGENTS.md | 0
.../templates/workspaces/inky/HEARTBEAT.md | 0
.../templates/workspaces/inky/MEMORY.md | 0
.../templates/workspaces/inky/SOUL.md | 0
.../templates/workspaces/inky/TOOLS.md | 0
.../templates/workspaces/inky/USER.md | 0
.../templates/workspaces/pixel/AGENTS.md | 0
.../templates/workspaces/pixel/HEARTBEAT.md | 0
.../templates/workspaces/pixel/MEMORY.md | 0
.../templates/workspaces/pixel/SOUL.md | 0
.../templates/workspaces/pixel/TOOLS.md | 0
.../templates/workspaces/pixel/USER.md | 0
.../templates/workspaces/scout/AGENTS.md | 0
.../templates/workspaces/scout/HEARTBEAT.md | 0
.../templates/workspaces/scout/MEMORY.md | 0
.../templates/workspaces/scout/SOUL.md | 0
.../templates/workspaces/scout/TOOLS.md | 0
.../templates/workspaces/scout/USER.md | 0
package.json | 4 +-
packages/backend/convex/accounts.ts | 64 ++++
packages/backend/convex/activities.ts | 90 +++--
packages/backend/convex/agents.ts | 123 ++++---
packages/backend/convex/businessContext.ts | 65 ++--
packages/backend/convex/channels.ts | 55 ++-
packages/backend/convex/documents.ts | 87 +++--
packages/backend/convex/lib/auth.ts | 230 ++++++++++++
packages/backend/convex/messages.ts | 69 ++--
packages/backend/convex/notifications.ts | 82 +++--
packages/backend/convex/routines.ts | 67 +++-
packages/backend/convex/schema.ts | 87 ++++-
packages/backend/convex/settings.ts | 109 ------
packages/backend/convex/tasks.ts | 96 ++++-
packages/backend/convex/tenants.ts | 68 ++++
packages/backend/convex/tsconfig.json | 1 +
packages/backend/convex/users.ts | 85 +++++
packages/backend/package.json | 1 +
packages/cli/src/client.spec.ts | 27 +-
packages/cli/src/client.ts | 49 ++-
.../cli/src/commands/agent-register.spec.ts | 18 +-
packages/cli/src/commands/agent-register.ts | 4 +-
packages/cli/src/commands/business-get.ts | 4 +-
packages/cli/src/commands/business-set.ts | 10 +-
packages/cli/src/commands/check.spec.ts | 26 +-
packages/cli/src/commands/check.ts | 8 +-
packages/cli/src/commands/deliver.spec.ts | 16 +-
packages/cli/src/commands/deliver.ts | 6 +-
packages/cli/src/commands/feed.spec.ts | 20 +-
packages/cli/src/commands/feed.ts | 4 +-
packages/cli/src/commands/notify.spec.ts | 16 +-
packages/cli/src/commands/notify.ts | 4 +-
packages/cli/src/commands/squad.spec.ts | 14 +-
packages/cli/src/commands/squad.ts | 4 +-
packages/cli/src/commands/subtask-add.spec.ts | 22 +-
packages/cli/src/commands/subtask-add.ts | 4 +-
.../cli/src/commands/subtask-check.spec.ts | 26 +-
packages/cli/src/commands/subtask-check.ts | 10 +-
packages/cli/src/commands/task-assign.spec.ts | 14 +-
packages/cli/src/commands/task-assign.ts | 4 +-
.../cli/src/commands/task-comment.spec.ts | 14 +-
packages/cli/src/commands/task-comment.ts | 4 +-
packages/cli/src/commands/task-create.spec.ts | 26 +-
packages/cli/src/commands/task-create.ts | 4 +-
packages/cli/src/commands/task-plan.spec.ts | 32 +-
packages/cli/src/commands/task-plan.ts | 4 +-
packages/cli/src/commands/task-status.spec.ts | 14 +-
packages/cli/src/commands/task-status.ts | 4 +-
packages/cli/src/commands/task-view.spec.ts | 18 +-
packages/cli/src/commands/task-view.ts | 4 +-
packages/cli/src/commands/tasks.spec.ts | 12 +-
packages/cli/src/commands/tasks.ts | 4 +-
packages/shared/package.json | 6 +-
.../src/{agency => squadhub}/client.spec.ts | 22 +-
.../shared/src/{agency => squadhub}/client.ts | 82 +++--
.../gateway-client.spec.ts | 36 +-
.../{agency => squadhub}/gateway-client.ts | 16 +-
.../src/{agency => squadhub}/gateway-types.ts | 0
.../shared/src/{agency => squadhub}/index.ts | 1 +
.../src/{agency => squadhub}/pairing.ts | 14 +-
.../src/{agency => squadhub}/shared-client.ts | 6 +-
.../shared/src/{agency => squadhub}/types.ts | 6 +-
pnpm-lock.yaml | 15 +
scripts/start.sh | 6 +-
turbo.json | 6 +-
146 files changed, 1923 insertions(+), 1134 deletions(-)
rename {.agency => .squadhub}/.gitkeep (100%)
delete mode 100644 apps/web/src/app/api/agency/health/route.ts
create mode 100644 apps/web/src/app/api/squadhub/health/route.ts
rename apps/web/src/app/api/{agency => squadhub}/pairing/route.ts (77%)
create mode 100644 apps/web/src/app/api/tenant/provision/route.ts
rename apps/web/src/components/{agency-status.tsx => squadhub-status.tsx} (85%)
rename apps/web/src/hooks/{use-agency-status.ts => use-squadhub-status.ts} (63%)
rename apps/web/src/lib/{agency => squadhub}/actions.spec.ts (84%)
rename apps/web/src/lib/{agency => squadhub}/actions.ts (62%)
create mode 100644 apps/web/src/lib/squadhub/connection.ts
create mode 100644 apps/web/src/lib/squadhub/provision.ts
rename docker/{agency => squadhub}/Dockerfile (84%)
rename docker/{agency => squadhub}/entrypoint.sh (95%)
rename docker/{agency => squadhub}/scripts/init-agents.sh (100%)
rename docker/{agency => squadhub}/scripts/pair-device.js (100%)
rename docker/{agency => squadhub}/templates/config.template.json (100%)
rename docker/{agency => squadhub}/templates/shared/CLAWE-CLI.md (100%)
rename docker/{agency => squadhub}/templates/shared/WORKFLOW.md (100%)
rename docker/{agency => squadhub}/templates/shared/WORKING.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/clawe/AGENTS.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/clawe/BOOTSTRAP.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/clawe/HEARTBEAT.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/clawe/MEMORY.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/clawe/SOUL.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/clawe/TOOLS.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/clawe/USER.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/inky/AGENTS.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/inky/HEARTBEAT.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/inky/MEMORY.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/inky/SOUL.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/inky/TOOLS.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/inky/USER.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/pixel/AGENTS.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/pixel/HEARTBEAT.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/pixel/MEMORY.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/pixel/SOUL.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/pixel/TOOLS.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/pixel/USER.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/scout/AGENTS.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/scout/HEARTBEAT.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/scout/MEMORY.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/scout/SOUL.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/scout/TOOLS.md (100%)
rename docker/{agency => squadhub}/templates/workspaces/scout/USER.md (100%)
create mode 100644 packages/backend/convex/accounts.ts
create mode 100644 packages/backend/convex/lib/auth.ts
delete mode 100644 packages/backend/convex/settings.ts
create mode 100644 packages/backend/convex/tenants.ts
create mode 100644 packages/backend/convex/users.ts
rename packages/shared/src/{agency => squadhub}/client.spec.ts (89%)
rename packages/shared/src/{agency => squadhub}/client.ts (72%)
rename packages/shared/src/{agency => squadhub}/gateway-client.spec.ts (67%)
rename packages/shared/src/{agency => squadhub}/gateway-client.ts (94%)
rename packages/shared/src/{agency => squadhub}/gateway-types.ts (100%)
rename packages/shared/src/{agency => squadhub}/index.ts (98%)
rename packages/shared/src/{agency => squadhub}/pairing.ts (91%)
rename packages/shared/src/{agency => squadhub}/shared-client.ts (86%)
rename packages/shared/src/{agency => squadhub}/types.ts (87%)
diff --git a/.env.example b/.env.example
index 135c037..c3204c3 100644
--- a/.env.example
+++ b/.env.example
@@ -13,9 +13,9 @@ ANTHROPIC_API_KEY=sk-ant-...
# Apps reference this as NEXT_PUBLIC_CONVEX_URL
CONVEX_URL=https://your-deployment.convex.cloud
-# Agency gateway authentication token
+# SquadHub gateway authentication token
# Auto-generated by scripts/start.sh, or set your own secure random string
-AGENCY_TOKEN=your-secure-token-here
+SQUADHUB_TOKEN=your-secure-token-here
# =============================================================================
# OPTIONAL
@@ -27,16 +27,16 @@ AGENCY_TOKEN=your-secure-token-here
# Environment: dev or prod
ENVIRONMENT=dev
-# Agency gateway URL
+# SquadHub gateway URL
# Development: http://localhost:18790 (Docker exposed on host)
-# Production: http://agency:18789 (Docker internal network)
-AGENCY_URL=http://localhost:18790
+# Production: http://squadhub:18789 (Docker internal network)
+SQUADHUB_URL=http://localhost:18790
# =============================================================================
# ADVANCED (usually don't need to change)
# =============================================================================
-# Agency state directory (path to config dir, mounted from Docker)
-# Development: ./.agency/config (relative to project root)
-# Production: /agency-data/config (shared Docker volume)
-AGENCY_STATE_DIR=./.agency/config
+# SquadHub state directory (path to config dir, mounted from Docker)
+# Development: ./.squadhub/config (relative to project root)
+# Production: /squadhub-data/config (shared Docker volume)
+SQUADHUB_STATE_DIR=./.squadhub/config
diff --git a/.gitignore b/.gitignore
index 9885419..89b056f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,8 +49,8 @@ convex/_generated
# Local data directory (Clawe config)
.data/
-# Agency state directory (shared with Docker in dev)
-.agency/*
-!.agency/.gitkeep
-.agency/logs/*
-!.agency/logs/.gitkeep
\ No newline at end of file
+# SquadHub state directory (shared with Docker in dev)
+.squadhub/*
+!.squadhub/.gitkeep
+.squadhub/logs/*
+!.squadhub/logs/.gitkeep
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
index f76797d..87ca534 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,2 +1,2 @@
# Template files with shell variable substitution
-docker/agency/templates/*.json
+docker/squadhub/templates/*.json
diff --git a/.agency/.gitkeep b/.squadhub/.gitkeep
similarity index 100%
rename from .agency/.gitkeep
rename to .squadhub/.gitkeep
diff --git a/CLAUDE.md b/CLAUDE.md
index 772beab..1640422 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,9 +54,9 @@ Core models: `agents`, `tasks`, `messages` (see `packages/backend/convex/schema.
## Environment Variables
- `NEXT_PUBLIC_CONVEX_URL`: Convex deployment URL (required)
-- `ANTHROPIC_API_KEY`: Anthropic API key for AI operations (required, passed to agency)
-- `AGENCY_TOKEN`: Authentication token for agency gateway (required)
-- `AGENCY_URL`: Agency gateway URL (set in `.env.development` / `.env.production`)
+- `ANTHROPIC_API_KEY`: Anthropic API key for AI operations (required, passed to squadhub)
+- `SQUADHUB_TOKEN`: Authentication token for squadhub gateway (required)
+- `SQUADHUB_URL`: SquadHub gateway URL (set in `.env.development` / `.env.production`)
- `NODE_ENV`: local (`development`) vs deployed (`production`) — controls dev tooling
- `ENVIRONMENT`: deployment target (`dev` / `prod`) — controls feature flags
diff --git a/README.md b/README.md
index 728dc60..dfa3185 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@ Edit `.env`:
```bash
# Required
ANTHROPIC_API_KEY=sk-ant-...
-AGENCY_TOKEN=your-secure-token
+SQUADHUB_TOKEN=your-secure-token
CONVEX_URL=https://your-deployment.convex.cloud
# Optional
@@ -71,7 +71,7 @@ npx convex deploy
This script will:
- Create `.env` from `.env.example` if missing
-- Auto-generate a secure `AGENCY_TOKEN`
+- Auto-generate a secure `SQUADHUB_TOKEN`
- Validate all required environment variables
- Build necessary packages
- Start the Docker containers
@@ -79,7 +79,7 @@ This script will:
**Development:**
```bash
-# Start agency gateway only (use local web dev server)
+# Start squadhub gateway only (use local web dev server)
pnpm dev:docker
# In another terminal, start web + Convex
@@ -88,7 +88,7 @@ pnpm dev
The production stack starts:
-- **agency**: Gateway running all agents
+- **squadhub**: Gateway running all agents
- **watcher**: Notification delivery + cron setup
- **clawe**: Web dashboard at http://localhost:3000
@@ -120,7 +120,7 @@ Schedule recurring tasks that automatically create inbox items:
┌─────────────────────────────────────────────────────────────┐
│ DOCKER COMPOSE │
├─────────────────┬─────────────────────┬─────────────────────┤
-│ agency │ watcher │ clawe │
+│ squadhub │ watcher │ clawe │
│ │ │ │
│ Agent Gateway │ • Register agents │ Web Dashboard │
│ with 4 agents │ • Setup crons │ • Squad status │
@@ -151,10 +151,10 @@ clawe/
├── packages/
│ ├── backend/ # Convex schema & functions
│ ├── cli/ # `clawe` CLI for agents
-│ ├── shared/ # Shared agency client
+│ ├── shared/ # Shared squadhub client
│ └── ui/ # UI components
└── docker/
- └── agency/
+ └── squadhub/
├── Dockerfile
├── entrypoint.sh
├── scripts/ # init-agents.sh
@@ -221,8 +221,8 @@ Each agent has an isolated workspace with:
### Adding New Agents
-1. Create workspace template in `docker/agency/templates/workspaces/{name}/`
-2. Add agent to `docker/agency/templates/config.template.json`
+1. Create workspace template in `docker/squadhub/templates/workspaces/{name}/`
+2. Add agent to `docker/squadhub/templates/config.template.json`
3. Add agent to watcher's `AGENTS` array in `apps/watcher/src/index.ts`
4. Rebuild: `docker compose build && docker compose up -d`
@@ -252,13 +252,13 @@ pnpm install
# Terminal 1: Start Convex dev server
pnpm convex:dev
-# Terminal 2: Start agency gateway in Docker
+# Terminal 2: Start squadhub gateway in Docker
pnpm dev:docker
# Terminal 3: Start web dashboard
pnpm dev:web
-# Or run everything together (Convex + web, but not agency)
+# Or run everything together (Convex + web, but not squadhub)
pnpm dev
```
@@ -284,6 +284,6 @@ pnpm convex:deploy
| Variable | Required | Description |
| ------------------- | -------- | --------------------------------- |
| `ANTHROPIC_API_KEY` | Yes | Anthropic API key for Claude |
-| `AGENCY_TOKEN` | Yes | Auth token for agency gateway |
+| `SQUADHUB_TOKEN` | Yes | Auth token for squadhub gateway |
| `CONVEX_URL` | Yes | Convex deployment URL |
| `OPENAI_API_KEY` | No | OpenAI key (for image generation) |
diff --git a/apps/watcher/.env.example b/apps/watcher/.env.example
index 34d504a..e2b07ef 100644
--- a/apps/watcher/.env.example
+++ b/apps/watcher/.env.example
@@ -4,5 +4,5 @@
# Required variables (from root .env):
# - CONVEX_URL
-# - AGENCY_URL
-# - AGENCY_TOKEN
+# - SQUADHUB_URL
+# - SQUADHUB_TOKEN
diff --git a/apps/watcher/Dockerfile b/apps/watcher/Dockerfile
index 32158e8..b7e2571 100644
--- a/apps/watcher/Dockerfile
+++ b/apps/watcher/Dockerfile
@@ -8,7 +8,7 @@ WORKDIR /app
# Watcher
COPY apps/watcher/dist/ ./dist/
-# Shared package (agency client)
+# Shared package (squadhub client)
COPY packages/shared/package.json ./node_modules/@clawe/shared/package.json
COPY packages/shared/dist/ ./node_modules/@clawe/shared/dist/
diff --git a/apps/watcher/README.md b/apps/watcher/README.md
index fba4570..c1ade99 100644
--- a/apps/watcher/README.md
+++ b/apps/watcher/README.md
@@ -18,8 +18,8 @@ This enables:
| Variable | Required | Description |
| -------------- | -------- | --------------------------- |
| `CONVEX_URL` | Yes | Convex deployment URL |
-| `AGENCY_URL` | Yes | Agency gateway URL |
-| `AGENCY_TOKEN` | Yes | Agency authentication token |
+| `SQUADHUB_URL` | Yes | Squadhub gateway URL |
+| `SQUADHUB_TOKEN` | Yes | Squadhub authentication token |
## Running
@@ -53,7 +53,7 @@ Schedules are staggered to avoid rate limits.
│ │
│ ┌─────────────┐ │
│ │ On Startup │──> Check/create heartbeat crons │
-│ └─────────────┘ via agency cron API │
+│ └─────────────┘ via squadhub cron API │
│ │
│ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Poll Loop │───────>│ convex.query( │ │
@@ -62,13 +62,13 @@ Schedules are staggered to avoid rate limits.
│ │ └─────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────┐ │
-│ └──────────────>│ agency.sessionsSend() │ │
+│ └──────────────>│ squadhub.sessionsSend() │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌───────────┐ ┌───────────────┐
- │ CONVEX │ │ AGENCY │
+ │ CONVEX │ │ SQUADHUB │
│ (data) │ │ (delivery) │
└───────────┘ └───────────────┘
```
diff --git a/apps/watcher/src/config.spec.ts b/apps/watcher/src/config.spec.ts
index 89e9d1b..f31c5eb 100644
--- a/apps/watcher/src/config.spec.ts
+++ b/apps/watcher/src/config.spec.ts
@@ -15,8 +15,8 @@ describe("config", () => {
describe("validateEnv", () => {
it("exits when CONVEX_URL is missing", async () => {
delete process.env.CONVEX_URL;
- process.env.AGENCY_URL = "http://localhost:18789";
- process.env.AGENCY_TOKEN = "test-token";
+ process.env.SQUADHUB_URL = "http://localhost:18789";
+ process.env.SQUADHUB_TOKEN = "test-token";
const mockExit = vi
.spyOn(process, "exit")
@@ -35,10 +35,10 @@ describe("config", () => {
mockError.mockRestore();
});
- it("exits when AGENCY_URL is missing", async () => {
+ it("exits when SQUADHUB_URL is missing", async () => {
process.env.CONVEX_URL = "https://test.convex.cloud";
- delete process.env.AGENCY_URL;
- process.env.AGENCY_TOKEN = "test-token";
+ delete process.env.SQUADHUB_URL;
+ process.env.SQUADHUB_TOKEN = "test-token";
const mockExit = vi
.spyOn(process, "exit")
@@ -49,7 +49,7 @@ describe("config", () => {
validateEnv();
expect(mockError).toHaveBeenCalledWith(
- expect.stringContaining("AGENCY_URL"),
+ expect.stringContaining("SQUADHUB_URL"),
);
expect(mockExit).toHaveBeenCalledWith(1);
@@ -59,8 +59,8 @@ describe("config", () => {
it("does not exit when all required vars are set", async () => {
process.env.CONVEX_URL = "https://test.convex.cloud";
- process.env.AGENCY_URL = "http://localhost:18789";
- process.env.AGENCY_TOKEN = "test-token";
+ process.env.SQUADHUB_URL = "http://localhost:18789";
+ process.env.SQUADHUB_TOKEN = "test-token";
const mockExit = vi
.spyOn(process, "exit")
@@ -78,14 +78,14 @@ describe("config", () => {
describe("config object", () => {
it("has correct default values", async () => {
process.env.CONVEX_URL = "https://test.convex.cloud";
- process.env.AGENCY_URL = "http://custom:8080";
- process.env.AGENCY_TOKEN = "my-token";
+ process.env.SQUADHUB_URL = "http://custom:8080";
+ process.env.SQUADHUB_TOKEN = "my-token";
const { config } = await import("./config.js");
expect(config.convexUrl).toBe("https://test.convex.cloud");
- expect(config.agencyUrl).toBe("http://custom:8080");
- expect(config.agencyToken).toBe("my-token");
+ expect(config.squadhubUrl).toBe("http://custom:8080");
+ expect(config.squadhubToken).toBe("my-token");
});
});
diff --git a/apps/watcher/src/config.ts b/apps/watcher/src/config.ts
index 871275c..89079c7 100644
--- a/apps/watcher/src/config.ts
+++ b/apps/watcher/src/config.ts
@@ -4,7 +4,7 @@ export const POLL_INTERVAL_MS = 2000; // Check every 2 seconds
// Environment validation
export function validateEnv(): void {
- const required = ["CONVEX_URL", "AGENCY_URL", "AGENCY_TOKEN"];
+ const required = ["CONVEX_URL", "SQUADHUB_URL", "SQUADHUB_TOKEN"];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
@@ -17,7 +17,7 @@ export function validateEnv(): void {
export const config = {
convexUrl: process.env.CONVEX_URL || "",
- agencyUrl: process.env.AGENCY_URL || "http://localhost:18789",
- agencyToken: process.env.AGENCY_TOKEN || "",
+ squadhubUrl: process.env.SQUADHUB_URL || "http://localhost:18789",
+ squadhubToken: process.env.SQUADHUB_TOKEN || "",
pollIntervalMs: POLL_INTERVAL_MS,
};
diff --git a/apps/watcher/src/index.ts b/apps/watcher/src/index.ts
index 2323f9e..7eb5035 100644
--- a/apps/watcher/src/index.ts
+++ b/apps/watcher/src/index.ts
@@ -1,25 +1,27 @@
/**
* Clawe Notification Watcher
*
- * 1. On startup: ensures heartbeat crons are configured for all agents
- * 2. Continuously: polls Convex for undelivered notifications and delivers them
+ * Continuously polls Convex for undelivered notifications and delivers them.
+ * Also checks for due routines and triggers them.
+ *
+ * Setup logic (agent registration, cron setup, routine seeding) has been
+ * moved to the provisioning API route (POST /api/tenant/provision).
+ *
+ * Multi-tenant ready: iterates over active tenants each loop iteration.
+ * Currently falls back to single-tenant mode using SQUADHUB_URL/SQUADHUB_TOKEN env vars.
*
* Environment variables:
* CONVEX_URL - Convex deployment URL
- * AGENCY_URL - Agency gateway URL
- * AGENCY_TOKEN - Agency authentication token
+ * SQUADHUB_URL - Squadhub gateway URL (single-tenant fallback)
+ * SQUADHUB_TOKEN - Squadhub authentication token (single-tenant fallback)
*/
import { ConvexHttpClient } from "convex/browser";
import { api } from "@clawe/backend";
-import type { Doc } from "@clawe/backend/dataModel";
import {
sessionsSend,
- cronList,
- cronAdd,
- type CronAddJob,
- type CronJob,
-} from "@clawe/shared/agency";
+ type SquadhubConnection,
+} from "@clawe/shared/squadhub";
import { getTimeInZone, DEFAULT_TIMEZONE } from "@clawe/shared/timezone";
import { validateEnv, config, POLL_INTERVAL_MS } from "./config.js";
@@ -28,262 +30,41 @@ validateEnv();
const convex = new ConvexHttpClient(config.convexUrl);
-// Agent configuration
-const AGENTS = [
- {
- id: "main",
- name: "Clawe",
- emoji: "🦞",
- role: "Squad Lead",
- cron: "0,15,30,45 * * * *",
- },
- {
- id: "inky",
- name: "Inky",
- emoji: "✍️",
- role: "Writer",
- cron: "3,18,33,48 * * * *",
- },
- {
- id: "pixel",
- name: "Pixel",
- emoji: "🎨",
- role: "Designer",
- cron: "7,22,37,52 * * * *",
- },
- {
- id: "scout",
- name: "Scout",
- emoji: "🔍",
- role: "SEO",
- cron: "11,26,41,56 * * * *",
- },
-];
-
-const HEARTBEAT_MESSAGE =
- "Read HEARTBEAT.md and follow it strictly. Check for notifications with 'clawe check'. If nothing needs attention, reply HEARTBEAT_OK.";
-
-// Input type for creating a routine (fields required by routines.create mutation)
-type RoutineInput = Pick<
- Doc<"routines">,
- "title" | "description" | "priority" | "schedule" | "color"
->;
-
-// Routine seed data (hardcoded for initial setup)
-const SEED_ROUTINES: RoutineInput[] = [
- {
- title: "Weekly Performance Review",
- description:
- "Review last week's content performance, engagement metrics, and campaign results. Identify top-performing pieces and areas for improvement.",
- priority: "normal",
- schedule: {
- type: "weekly",
- daysOfWeek: [1],
- hour: 9,
- minute: 0,
- },
- color: "emerald",
- },
- {
- title: "Morning Brief",
- description: "Prepare daily morning brief for the team",
- priority: "high",
- schedule: {
- type: "weekly",
- daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
- hour: 8,
- minute: 0,
- },
- color: "amber",
- },
- {
- title: "Competitor Scan",
- description: "Scan competitor activities and updates",
- priority: "normal",
- schedule: {
- type: "weekly",
- daysOfWeek: [1, 4],
- hour: 10,
- minute: 0,
- },
- color: "rose",
- },
-];
-
-const RETRY_BASE_DELAY_MS = 3000;
-const RETRY_MAX_DELAY_MS = 30000;
-const ROUTINE_CHECK_INTERVAL_MS = 10_000; // Check routines every 10 seconds
-
-/**
- * Sleep helper
- */
-function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-/**
- * Retry a function indefinitely with exponential backoff (capped)
- */
-async function withRetry(
- fn: () => Promise,
- label: string,
- baseDelayMs = RETRY_BASE_DELAY_MS,
-): Promise {
- let attempt = 0;
-
- while (true) {
- attempt++;
- try {
- return await fn();
- } catch (err) {
- const error = err instanceof Error ? err : new Error(String(err));
- const delayMs = Math.min(baseDelayMs * attempt, RETRY_MAX_DELAY_MS);
-
- console.log(
- `[watcher] ${label} failed (attempt ${attempt}), retrying in ${delayMs / 1000}s... (${error.message})`,
- );
- await sleep(delayMs);
- }
- }
-}
-
/**
- * Register all agents in Convex (upsert - creates or updates)
+ * Represents an active tenant for the watcher to service.
*/
-async function registerAgents(): Promise {
- console.log("[watcher] Registering agents in Convex...");
- console.log("[watcher] CONVEX_URL:", config.convexUrl);
-
- // Try to register first agent with retry (waits for Convex to be ready)
- const firstAgent = AGENTS[0];
- if (firstAgent) {
- await withRetry(async () => {
- const sessionKey = `agent:${firstAgent.id}:main`;
- await convex.mutation(api.agents.upsert, {
- name: firstAgent.name,
- role: firstAgent.role,
- sessionKey,
- emoji: firstAgent.emoji,
- });
- console.log(
- `[watcher] ✓ ${firstAgent.name} ${firstAgent.emoji} registered (${sessionKey})`,
- );
- }, "Convex connection");
- }
-
- // Register remaining agents (Convex is now ready)
- for (const agent of AGENTS.slice(1)) {
- const sessionKey = `agent:${agent.id}:main`;
-
- try {
- await convex.mutation(api.agents.upsert, {
- name: agent.name,
- role: agent.role,
- sessionKey,
- emoji: agent.emoji,
- });
- console.log(
- `[watcher] ✓ ${agent.name} ${agent.emoji} registered (${sessionKey})`,
- );
- } catch (err) {
- console.error(
- `[watcher] Failed to register ${agent.name}:`,
- err instanceof Error ? err.message : err,
- );
- }
- }
-
- console.log("[watcher] Agent registration complete.\n");
-}
+type TenantInfo = {
+ id: string;
+ connection: SquadhubConnection;
+};
/**
- * Setup heartbeat crons for all agents (if not already configured)
+ * Get the list of active tenants to service.
+ *
+ * TODO (Phase 2): Query Convex `tenants.listActive` with WATCHER_TOKEN
+ * to get all active tenants with their squadhubUrl + squadhubToken.
+ *
+ * For now, falls back to single-tenant mode using env vars.
*/
-async function setupCrons(): Promise {
- console.log("[watcher] Checking heartbeat crons...");
-
- // Retry getting cron list (waits for agency to be ready)
- const result = await withRetry(async () => {
- const res = await cronList();
- if (!res.ok) {
- throw new Error(res.error?.message ?? "Failed to list crons");
- }
- return res;
- }, "Agency connection");
-
- const existingNames = new Set(
- result.result.details.jobs.map((j: CronJob) => j.name),
- );
-
- for (const agent of AGENTS) {
- const cronName = `${agent.id}-heartbeat`;
-
- if (existingNames.has(cronName)) {
- console.log(`[watcher] ✓ ${agent.name} ${agent.emoji} heartbeat exists`);
- continue;
- }
-
- console.log(`[watcher] Adding ${agent.name} ${agent.emoji} heartbeat...`);
-
- const job: CronAddJob = {
- name: cronName,
- agentId: agent.id,
- enabled: true,
- schedule: { kind: "cron", expr: agent.cron },
- sessionTarget: "isolated",
- payload: {
- kind: "agentTurn",
- message: HEARTBEAT_MESSAGE,
- model: "anthropic/claude-sonnet-4-20250514",
- timeoutSeconds: 600,
+async function getActiveTenants(): Promise {
+ return [
+ {
+ id: "default",
+ connection: {
+ squadhubUrl: config.squadhubUrl,
+ squadhubToken: config.squadhubToken,
},
- delivery: { mode: "none" },
- };
-
- const addResult = await cronAdd(job);
- if (addResult.ok) {
- console.log(
- `[watcher] ✓ ${agent.name} ${agent.emoji} heartbeat: ${agent.cron}`,
- );
- } else {
- console.error(
- `[watcher] Failed to add ${cronName}:`,
- addResult.error?.message,
- );
- }
- }
-
- console.log("[watcher] Cron setup complete.\n");
+ },
+ ];
}
+const ROUTINE_CHECK_INTERVAL_MS = 10_000; // Check routines every 10 seconds
+
/**
- * Seed initial routines if none exist
+ * Sleep helper
*/
-async function seedRoutines(): Promise {
- console.log("[watcher] Checking routines...");
-
- const existing = await convex.query(api.routines.list, {});
-
- if (existing.length > 0) {
- console.log(`[watcher] ✓ ${existing.length} routine(s) already exist`);
- return;
- }
-
- console.log("[watcher] Seeding initial routines...");
-
- for (const routine of SEED_ROUTINES) {
- try {
- await convex.mutation(api.routines.create, routine);
- console.log(`[watcher] ✓ Created routine: ${routine.title}`);
- } catch (err) {
- console.error(
- `[watcher] Failed to create routine "${routine.title}":`,
- err instanceof Error ? err.message : err,
- );
- }
- }
-
- console.log("[watcher] Routine seeding complete.\n");
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
@@ -291,12 +72,15 @@ async function seedRoutines(): Promise {
*
* Uses a 1-hour window for crash tolerance: if a routine is scheduled
* for 6:00 AM, it can trigger anytime between 6:00 AM and 6:59 AM.
+ *
*/
async function checkRoutines(): Promise {
try {
- // Get user's timezone from settings
+ // Get tenant's timezone from tenant settings
const timezone =
- (await convex.query(api.settings.getTimezone)) ?? DEFAULT_TIMEZONE;
+ (await convex.query(api.tenants.getTimezone, {
+ machineToken: config.squadhubToken,
+ })) ?? DEFAULT_TIMEZONE;
// Get current timestamp and time in user's timezone
const now = new Date();
@@ -305,6 +89,7 @@ async function checkRoutines(): Promise {
// Query for due routines (with 1-hour window tolerance)
const dueRoutines = await convex.query(api.routines.getDueRoutines, {
+ machineToken: config.squadhubToken,
currentTimestamp,
dayOfWeek,
hour,
@@ -315,6 +100,7 @@ async function checkRoutines(): Promise {
for (const routine of dueRoutines) {
try {
const taskId = await convex.mutation(api.routines.trigger, {
+ machineToken: config.squadhubToken,
routineId: routine._id,
});
console.log(
@@ -363,12 +149,16 @@ function formatNotification(notification: {
}
/**
- * Deliver notifications to a single agent
+ * Deliver notifications to a single agent via the tenant's squadhub
*/
-async function deliverToAgent(sessionKey: string): Promise {
+async function deliverToAgent(
+ connection: SquadhubConnection,
+ sessionKey: string,
+): Promise {
try {
// Get undelivered notifications for this agent
const notifications = await convex.query(api.notifications.getUndelivered, {
+ machineToken: config.squadhubToken,
sessionKey,
});
@@ -385,12 +175,13 @@ async function deliverToAgent(sessionKey: string): Promise {
// Format the notification message
const message = formatNotification(notification);
- // Try to deliver to agent session
- const result = await sessionsSend(sessionKey, message, 10);
+ // Try to deliver to agent session via tenant's squadhub
+ const result = await sessionsSend(connection, sessionKey, message, 10);
if (result.ok) {
// Mark as delivered in Convex
await convex.mutation(api.notifications.markDelivered, {
+ machineToken: config.squadhubToken,
notificationIds: [notification._id],
});
@@ -419,15 +210,21 @@ async function deliverToAgent(sessionKey: string): Promise {
}
/**
- * Main delivery loop
+ * Main delivery loop — iterates over all active tenants
*/
async function deliveryLoop(): Promise {
- // Get all registered agents from Convex
- const agents = await convex.query(api.agents.list, {});
+ const tenants = await getActiveTenants();
+
+ for (const tenant of tenants) {
+ // Get all registered agents for this tenant from Convex
+ const agents = await convex.query(api.agents.list, {
+ machineToken: tenant.connection.squadhubToken,
+ });
- for (const agent of agents) {
- if (agent.sessionKey) {
- await deliverToAgent(agent.sessionKey);
+ for (const agent of agents) {
+ if (agent.sessionKey) {
+ await deliverToAgent(tenant.connection, agent.sessionKey);
+ }
}
}
}
@@ -476,21 +273,12 @@ async function startDeliveryLoop(): Promise {
async function main(): Promise {
console.log("[watcher] 🦞 Clawe Watcher starting...");
console.log(`[watcher] Convex: ${config.convexUrl}`);
- console.log(`[watcher] Agency: ${config.agencyUrl}`);
+ console.log(`[watcher] Squadhub: ${config.squadhubUrl}`);
console.log(`[watcher] Notification poll interval: ${POLL_INTERVAL_MS}ms`);
console.log(
`[watcher] Routine check interval: ${ROUTINE_CHECK_INTERVAL_MS}ms\n`,
);
- // Register agents in Convex
- await registerAgents();
-
- // Setup crons on startup
- await setupCrons();
-
- // Seed routines if needed
- await seedRoutines();
-
console.log("[watcher] Starting loops...\n");
// Start routine check loop (every 10 seconds)
diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md
index ae7a8d8..7890e51 100644
--- a/apps/web/CLAUDE.md
+++ b/apps/web/CLAUDE.md
@@ -76,9 +76,9 @@ Or infer from query results (preferred when using the data directly).
**Environment variables:**
- `NEXT_PUBLIC_CONVEX_URL` → Convex deployment URL (required)
-- `ANTHROPIC_API_KEY` → Anthropic API key (passed to agency container)
-- `AGENCY_URL` → Agency gateway URL
-- `AGENCY_TOKEN` → Agency authentication token (from root `.env`)
+- `ANTHROPIC_API_KEY` → Anthropic API key (passed to squadhub container)
+- `SQUADHUB_URL` → SquadHub gateway URL
+- `SQUADHUB_TOKEN` → SquadHub authentication token (from root `.env`)
## Adding Routes
@@ -173,7 +173,7 @@ Dark: text-pink-400, hover bg-pink-400/5
```
src/
├── lib/
-│ └── agency/
+│ └── squadhub/
│ ├── client.ts
│ ├── client.spec.ts # Unit tests for client
│ ├── actions.ts
diff --git a/apps/web/src/app/(dashboard)/@header/default.tsx b/apps/web/src/app/(dashboard)/@header/default.tsx
index 8bbd672..742cc61 100644
--- a/apps/web/src/app/(dashboard)/@header/default.tsx
+++ b/apps/web/src/app/(dashboard)/@header/default.tsx
@@ -6,7 +6,7 @@ import { Separator } from "@clawe/ui/components/separator";
import { SidebarToggle } from "@dashboard/sidebar-toggle";
import { ChatPanelToggle } from "@dashboard/chat-panel-toggle";
import { isLockedSidebarRoute } from "@dashboard/sidebar-config";
-import { AgencyStatus } from "@/components/agency-status";
+import { SquadhubStatus } from "@/components/squadhub-status";
const DefaultHeaderContent = () => {
const pathname = usePathname();
@@ -37,7 +37,7 @@ const DefaultHeaderContent = () => {
-
+
{
};
const AgentsPage = () => {
- const agents = useQuery(api.agents.squad);
+ const agents = useQuery(api.agents.squad, {});
return (
<>
diff --git a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel.tsx b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel.tsx
index 43d6910..0e7aa41 100644
--- a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel.tsx
+++ b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel.tsx
@@ -20,7 +20,7 @@ export const AgentsPanel = ({
selectedAgentIds = [],
onSelectionChange,
}: AgentsPanelProps) => {
- const agents = useQuery(api.agents.squad);
+ const agents = useQuery(api.agents.squad, {});
const total = agents?.length ?? 0;
diff --git a/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx b/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx
index c71e513..3df5e21 100644
--- a/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx
+++ b/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx
@@ -11,7 +11,7 @@ import { Spinner } from "@clawe/ui/components/spinner";
import { Globe, Building2, Users, Palette } from "lucide-react";
export const BusinessSettingsForm = () => {
- const businessContext = useQuery(api.businessContext.get);
+ const businessContext = useQuery(api.businessContext.get, {});
const saveBusinessContext = useMutation(api.businessContext.save);
const [url, setUrl] = useState("");
diff --git a/apps/web/src/app/(dashboard)/settings/general/_components/timezone-settings.tsx b/apps/web/src/app/(dashboard)/settings/general/_components/timezone-settings.tsx
index 61b353a..412856f 100644
--- a/apps/web/src/app/(dashboard)/settings/general/_components/timezone-settings.tsx
+++ b/apps/web/src/app/(dashboard)/settings/general/_components/timezone-settings.tsx
@@ -18,8 +18,8 @@ import {
import { Skeleton } from "@clawe/ui/components/skeleton";
export const TimezoneSettings = () => {
- const timezone = useQuery(api.settings.getTimezone);
- const setTimezone = useMutation(api.settings.setTimezone);
+ const timezone = useQuery(api.tenants.getTimezone, {});
+ const setTimezone = useMutation(api.tenants.setTimezone);
const [search, setSearch] = useState("");
// Filter and group timezones
diff --git a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx
index f8f50e2..d3abe9f 100644
--- a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx
+++ b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx
@@ -7,15 +7,15 @@ import { api } from "@clawe/backend";
import { Button } from "@clawe/ui/components/button";
import { Badge } from "@clawe/ui/components/badge";
import { Skeleton } from "@clawe/ui/components/skeleton";
-import { useAgencyStatus } from "@/hooks/use-agency-status";
+import { useSquadhubStatus } from "@/hooks/use-squadhub-status";
import { TelegramSetupDialog } from "./telegram-setup-dialog";
import { TelegramDisconnectDialog } from "./telegram-disconnect-dialog";
import { TelegramRemoveDialog } from "./telegram-remove-dialog";
export const TelegramIntegrationCard = () => {
const channel = useQuery(api.channels.getByType, { type: "telegram" });
- const { status: agencyStatus, isLoading: isAgencyLoading } =
- useAgencyStatus();
+ const { status: squadhubStatus, isLoading: isSquadhubLoading } =
+ useSquadhubStatus();
const [setupOpen, setSetupOpen] = useState(false);
const [disconnectOpen, setDisconnectOpen] = useState(false);
@@ -23,7 +23,7 @@ export const TelegramIntegrationCard = () => {
const isLoading = channel === undefined;
const isConnected = channel?.status === "connected";
- const isOffline = !isAgencyLoading && agencyStatus === "down";
+ const isOffline = !isSquadhubLoading && squadhubStatus === "down";
if (isLoading) {
return ;
@@ -94,7 +94,7 @@ export const TelegramIntegrationCard = () => {
)}
{isOffline && (
- Agency is offline
+ Squadhub is offline
)}
diff --git a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-remove-dialog.tsx b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-remove-dialog.tsx
index bd8d498..92ee5ca 100644
--- a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-remove-dialog.tsx
+++ b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-remove-dialog.tsx
@@ -14,7 +14,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@clawe/ui/components/alert-dialog";
-import { removeTelegramBot } from "@/lib/agency/actions";
+import { removeTelegramBot } from "@/lib/squadhub/actions";
export interface TelegramRemoveDialogProps {
open: boolean;
@@ -33,7 +33,7 @@ export const TelegramRemoveDialog = ({
const handleRemove = async () => {
setIsRemoving(true);
try {
- // Remove token from agency config
+ // Remove token from squadhub config
const result = await removeTelegramBot();
if (!result.ok) {
throw new Error("Failed to remove bot token");
diff --git a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx
index 1dd61cd..fe799f3 100644
--- a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx
+++ b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx
@@ -19,12 +19,12 @@ import {
DialogHeader,
DialogTitle,
} from "@clawe/ui/components/dialog";
-import { useAgencyStatus } from "@/hooks/use-agency-status";
+import { useSquadhubStatus } from "@/hooks/use-squadhub-status";
import {
validateTelegramToken,
saveTelegramBotToken,
approvePairingCode,
-} from "@/lib/agency/actions";
+} from "@/lib/squadhub/actions";
type Step = "token" | "pairing" | "success";
@@ -37,8 +37,8 @@ export const TelegramSetupDialog = ({
open,
onOpenChange,
}: TelegramSetupDialogProps) => {
- const { status, isLoading: isAgencyLoading } = useAgencyStatus();
- const isOffline = !isAgencyLoading && status === "down";
+ const { status, isLoading: isSquadhubLoading } = useSquadhubStatus();
+ const isOffline = !isSquadhubLoading && status === "down";
const [step, setStep] = useState("token");
const [botToken, setBotToken] = useState("");
@@ -214,10 +214,10 @@ export const TelegramSetupDialog = ({
- Agency is offline
+ Squadhub is offline
- The agency service needs to be running to verify pairing.
+ The squadhub service needs to be running to verify pairing.
@@ -354,10 +354,10 @@ export const TelegramSetupDialog = ({
- Agency is offline
+ Squadhub is offline
- The agency service needs to be running to connect Telegram.
+ The squadhub service needs to be running to connect Telegram.
diff --git a/apps/web/src/app/api/agency/health/route.ts b/apps/web/src/app/api/agency/health/route.ts
deleted file mode 100644
index 4872258..0000000
--- a/apps/web/src/app/api/agency/health/route.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { NextResponse } from "next/server";
-import { checkHealth } from "@clawe/shared/agency";
-
-export async function POST() {
- const result = await checkHealth();
- return NextResponse.json(result);
-}
diff --git a/apps/web/src/app/api/business/context/route.ts b/apps/web/src/app/api/business/context/route.ts
index 217200c..7a45abf 100644
--- a/apps/web/src/app/api/business/context/route.ts
+++ b/apps/web/src/app/api/business/context/route.ts
@@ -5,8 +5,12 @@ import { api } from "@clawe/backend";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
-const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
-const agencyToken = process.env.AGENCY_TOKEN;
+function getEnvConfig() {
+ return {
+ convexUrl: process.env.NEXT_PUBLIC_CONVEX_URL,
+ squadhubToken: process.env.SQUADHUB_TOKEN,
+ };
+}
/**
* GET /api/business/context
@@ -14,14 +18,16 @@ const agencyToken = process.env.AGENCY_TOKEN;
* Returns the current business context.
* Used by agents to understand what business they're working for.
*
- * Requires: Authorization header with AGENCY_TOKEN
+ * Requires: Authorization header with SQUADHUB_TOKEN
*/
export const GET = async (request: Request) => {
+ const { convexUrl, squadhubToken } = getEnvConfig();
+
// Validate token
const authHeader = request.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");
- if (!token || token !== agencyToken) {
+ if (!token || token !== squadhubToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -63,14 +69,16 @@ export const GET = async (request: Request) => {
* Saves or updates the business context.
* Used by Clawe CLI during onboarding.
*
- * Requires: Authorization header with AGENCY_TOKEN
+ * Requires: Authorization header with SQUADHUB_TOKEN
*/
export const POST = async (request: Request) => {
+ const { convexUrl, squadhubToken } = getEnvConfig();
+
// Validate token
const authHeader = request.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");
- if (!token || token !== agencyToken) {
+ if (!token || token !== squadhubToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
diff --git a/apps/web/src/app/api/chat/abort/route.spec.ts b/apps/web/src/app/api/chat/abort/route.spec.ts
index e2399cf..b7b1b2c 100644
--- a/apps/web/src/app/api/chat/abort/route.spec.ts
+++ b/apps/web/src/app/api/chat/abort/route.spec.ts
@@ -5,8 +5,8 @@ import { POST } from "./route";
// Mock the shared client
const mockRequest = vi.fn();
-vi.mock("@clawe/shared/agency", () => ({
- getSharedClient: vi.fn(async () => ({
+vi.mock("@clawe/shared/squadhub", () => ({
+ getSharedClient: vi.fn(async (_connection: unknown) => ({
request: mockRequest,
isConnected: vi.fn().mockReturnValue(true),
})),
@@ -95,7 +95,7 @@ describe("POST /api/chat/abort", () => {
});
it("returns 500 when getSharedClient fails", async () => {
- const { getSharedClient } = await import("@clawe/shared/agency");
+ const { getSharedClient } = await import("@clawe/shared/squadhub");
vi.mocked(getSharedClient).mockRejectedValueOnce(
new Error("Connection failed"),
);
diff --git a/apps/web/src/app/api/chat/abort/route.ts b/apps/web/src/app/api/chat/abort/route.ts
index fc75509..2e8c30b 100644
--- a/apps/web/src/app/api/chat/abort/route.ts
+++ b/apps/web/src/app/api/chat/abort/route.ts
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
-import { getSharedClient } from "@clawe/shared/agency";
+import { getSharedClient } from "@clawe/shared/squadhub";
+import { getConnection } from "@/lib/squadhub/connection";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -32,7 +33,7 @@ export async function POST(request: NextRequest) {
}
try {
- const client = await getSharedClient();
+ const client = await getSharedClient(getConnection());
await client.request("chat.abort", {
sessionKey,
diff --git a/apps/web/src/app/api/chat/history/route.spec.ts b/apps/web/src/app/api/chat/history/route.spec.ts
index 7b80f56..f5b62e0 100644
--- a/apps/web/src/app/api/chat/history/route.spec.ts
+++ b/apps/web/src/app/api/chat/history/route.spec.ts
@@ -5,8 +5,8 @@ import { GET } from "./route";
// Mock the shared client
const mockRequest = vi.fn();
-vi.mock("@clawe/shared/agency", () => ({
- getSharedClient: vi.fn(async () => ({
+vi.mock("@clawe/shared/squadhub", () => ({
+ getSharedClient: vi.fn(async (_connection: unknown) => ({
request: mockRequest,
isConnected: vi.fn().mockReturnValue(true),
})),
@@ -87,7 +87,7 @@ describe("GET /api/chat/history", () => {
});
it("returns 500 when getSharedClient fails", async () => {
- const { getSharedClient } = await import("@clawe/shared/agency");
+ const { getSharedClient } = await import("@clawe/shared/squadhub");
vi.mocked(getSharedClient).mockRejectedValueOnce(
new Error("Connection failed"),
);
diff --git a/apps/web/src/app/api/chat/history/route.ts b/apps/web/src/app/api/chat/history/route.ts
index f8e1bc4..a6600a0 100644
--- a/apps/web/src/app/api/chat/history/route.ts
+++ b/apps/web/src/app/api/chat/history/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
-import { getSharedClient } from "@clawe/shared/agency";
-import type { ChatHistoryResponse } from "@clawe/shared/agency";
+import { getSharedClient } from "@clawe/shared/squadhub";
+import type { ChatHistoryResponse } from "@clawe/shared/squadhub";
+import { getConnection } from "@/lib/squadhub/connection";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -29,7 +30,7 @@ export async function GET(request: NextRequest) {
}
try {
- const client = await getSharedClient();
+ const client = await getSharedClient(getConnection());
const response = await client.request("chat.history", {
sessionKey,
diff --git a/apps/web/src/app/api/chat/route.ts b/apps/web/src/app/api/chat/route.ts
index 93ccbbb..2c15835 100644
--- a/apps/web/src/app/api/chat/route.ts
+++ b/apps/web/src/app/api/chat/route.ts
@@ -1,15 +1,13 @@
import { createOpenAI } from "@ai-sdk/openai";
import { streamText } from "ai";
+import { getConnection } from "@/lib/squadhub/connection";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
-const agencyUrl = process.env.AGENCY_URL || "http://localhost:18789";
-const agencyToken = process.env.AGENCY_TOKEN || "";
-
/**
* POST /api/chat
- * Proxy chat requests to the agency's OpenAI-compatible endpoint.
+ * Proxy chat requests to the squadhub's OpenAI-compatible endpoint.
*/
export async function POST(request: Request) {
try {
@@ -30,16 +28,18 @@ export async function POST(request: Request) {
});
}
- // Create OpenAI-compatible client pointing to agency gateway
- const agency = createOpenAI({
- baseURL: `${agencyUrl}/v1`,
- apiKey: agencyToken,
+ const { squadhubUrl, squadhubToken } = getConnection();
+
+ // Create OpenAI-compatible client pointing to squadhub gateway
+ const squadhub = createOpenAI({
+ baseURL: `${squadhubUrl}/v1`,
+ apiKey: squadhubToken,
});
// Stream response using Vercel AI SDK
// Use .chat() to force Chat Completions API instead of Responses API
const result = streamText({
- model: agency.chat("openclaw"),
+ model: squadhub.chat("openclaw"),
messages,
headers: {
"X-OpenClaw-Session-Key": sessionKey,
diff --git a/apps/web/src/app/api/squadhub/health/route.ts b/apps/web/src/app/api/squadhub/health/route.ts
new file mode 100644
index 0000000..2c81c12
--- /dev/null
+++ b/apps/web/src/app/api/squadhub/health/route.ts
@@ -0,0 +1,8 @@
+import { NextResponse } from "next/server";
+import { checkHealth } from "@clawe/shared/squadhub";
+import { getConnection } from "@/lib/squadhub/connection";
+
+export async function POST() {
+ const result = await checkHealth(getConnection());
+ return NextResponse.json(result);
+}
diff --git a/apps/web/src/app/api/agency/pairing/route.ts b/apps/web/src/app/api/squadhub/pairing/route.ts
similarity index 77%
rename from apps/web/src/app/api/agency/pairing/route.ts
rename to apps/web/src/app/api/squadhub/pairing/route.ts
index 76c098f..f6dc601 100644
--- a/apps/web/src/app/api/agency/pairing/route.ts
+++ b/apps/web/src/app/api/squadhub/pairing/route.ts
@@ -2,9 +2,10 @@ import { NextResponse } from "next/server";
import {
listChannelPairingRequests,
approveChannelPairingCode,
-} from "@clawe/shared/agency";
+} from "@clawe/shared/squadhub";
+import { getConnection } from "@/lib/squadhub/connection";
-// GET /api/agency/pairing?channel=telegram - List pending pairing requests
+// GET /api/squadhub/pairing?channel=telegram - List pending pairing requests
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const channel = searchParams.get("channel") || "telegram";
@@ -18,7 +19,7 @@ export async function GET(request: Request) {
return NextResponse.json(result.result);
}
-// POST /api/agency/pairing - Approve a pairing code
+// POST /api/squadhub/pairing - Approve a pairing code
export async function POST(request: Request) {
try {
const body = await request.json();
@@ -31,7 +32,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Code is required" }, { status: 400 });
}
- const result = await approveChannelPairingCode(channel, code);
+ const result = await approveChannelPairingCode(
+ getConnection(),
+ channel,
+ code,
+ );
if (!result.ok) {
const status = result.error.type === "not_found" ? 404 : 500;
diff --git a/apps/web/src/app/api/tenant/provision/route.ts b/apps/web/src/app/api/tenant/provision/route.ts
new file mode 100644
index 0000000..5619b90
--- /dev/null
+++ b/apps/web/src/app/api/tenant/provision/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from "next/server";
+import { provisionTenant } from "@/lib/squadhub/provision";
+import { getConnection } from "@/lib/squadhub/connection";
+
+/**
+ * POST /api/tenant/provision
+ *
+ * Runs tenant setup: registers default agents, configures heartbeat crons,
+ * and seeds initial routines. Idempotent — safe to call multiple times.
+ *
+ * In Phase 7 this will also handle AWS infrastructure (ECS, EFS, CloudMap).
+ * For now it only runs the application-level setup.
+ */
+export async function POST() {
+ const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
+ if (!convexUrl) {
+ return NextResponse.json(
+ { error: "NEXT_PUBLIC_CONVEX_URL not configured" },
+ { status: 500 },
+ );
+ }
+
+ try {
+ const result = await provisionTenant(getConnection(), convexUrl);
+
+ return NextResponse.json({
+ ok: result.errors.length === 0,
+ agents: result.agents,
+ crons: result.crons,
+ routines: result.routines,
+ errors: result.errors.length > 0 ? result.errors : undefined,
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index a6f7295..da57bdd 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -7,7 +7,7 @@ import { api } from "@clawe/backend";
export default function Home() {
const router = useRouter();
- const isOnboardingComplete = useQuery(api.settings.isOnboardingComplete);
+ const isOnboardingComplete = useQuery(api.tenants.isOnboardingComplete, {});
useEffect(() => {
// Wait for query to load
diff --git a/apps/web/src/app/setup/business/page.tsx b/apps/web/src/app/setup/business/page.tsx
index 1d88cca..9f129ba 100644
--- a/apps/web/src/app/setup/business/page.tsx
+++ b/apps/web/src/app/setup/business/page.tsx
@@ -17,7 +17,7 @@ export default function BusinessPage() {
const router = useRouter();
// Real-time subscription - auto-updates when CLI saves
- const businessContext = useQuery(api.businessContext.get);
+ const businessContext = useQuery(api.businessContext.get, {});
const canContinue = businessContext?.approved === true;
return (
diff --git a/apps/web/src/app/setup/complete/page.tsx b/apps/web/src/app/setup/complete/page.tsx
index 2959b53..fd2a068 100644
--- a/apps/web/src/app/setup/complete/page.tsx
+++ b/apps/web/src/app/setup/complete/page.tsx
@@ -14,7 +14,7 @@ const CURRENT_STEP = 4;
export default function CompletePage() {
const router = useRouter();
- const completeOnboarding = useMutation(api.settings.completeOnboarding);
+ const completeOnboarding = useMutation(api.tenants.completeOnboarding);
const [isCompleting, setIsCompleting] = useState(false);
const handleFinish = async () => {
diff --git a/apps/web/src/app/setup/layout.tsx b/apps/web/src/app/setup/layout.tsx
index 5154cd7..279492c 100644
--- a/apps/web/src/app/setup/layout.tsx
+++ b/apps/web/src/app/setup/layout.tsx
@@ -3,7 +3,7 @@
import Image from "next/image";
import type { ReactNode } from "react";
-import { AgencyStatus } from "@/components/agency-status";
+import { SquadhubStatus } from "@/components/squadhub-status";
import { useRedirectIfOnboarded } from "@/hooks/use-onboarding-guard";
import { SetupUserMenu } from "./_components/setup-user-menu";
import {
@@ -47,7 +47,7 @@ export default function SetupLayout({ children }: { children: ReactNode }) {
{/* User menu and status - top right (on illustration side) */}
@@ -58,7 +58,7 @@ export default function SetupLayout({ children }: { children: ReactNode }) {
Clawe
{/* User menu and status on mobile */}
diff --git a/apps/web/src/app/setup/telegram/page.tsx b/apps/web/src/app/setup/telegram/page.tsx
index f12228c..bc9763e 100644
--- a/apps/web/src/app/setup/telegram/page.tsx
+++ b/apps/web/src/app/setup/telegram/page.tsx
@@ -22,13 +22,13 @@ import {
TooltipContent,
TooltipTrigger,
} from "@clawe/ui/components/tooltip";
-import { useAgencyStatus } from "@/hooks/use-agency-status";
+import { useSquadhubStatus } from "@/hooks/use-squadhub-status";
import { api } from "@clawe/backend";
import {
validateTelegramToken,
saveTelegramBotToken,
approvePairingCode,
-} from "@/lib/agency/actions";
+} from "@/lib/squadhub/actions";
import { SetupRightPanelContent } from "../_components/setup-right-panel";
import { DemoVideo } from "./_components/demo-video";
@@ -57,7 +57,7 @@ const DemoVideoPanel = () => {
export default function TelegramPage() {
const router = useRouter();
- const { status, isLoading } = useAgencyStatus();
+ const { status, isLoading } = useSquadhubStatus();
const isOffline = !isLoading && status === "down";
const [step, setStep] = useState("token");
const [botToken, setBotToken] = useState("");
@@ -252,10 +252,10 @@ export default function TelegramPage() {
- Agency is offline
+ Squadhub is offline
- The agency service needs to be running to verify pairing.
+ The squadhub service needs to be running to verify pairing.
@@ -322,7 +322,7 @@ export default function TelegramPage() {
{isOffline && (
- Start agency to continue
+ Start squadhub to continue
)}
@@ -424,10 +424,10 @@ export default function TelegramPage() {
- Agency is offline
+ Squadhub is offline
- The agency service needs to be running to connect Telegram.
+ The squadhub service needs to be running to connect Telegram.
@@ -488,7 +488,7 @@ export default function TelegramPage() {
{isOffline && (
- Start agency to continue
+ Start squadhub to continue
)}
diff --git a/apps/web/src/app/setup/welcome/page.tsx b/apps/web/src/app/setup/welcome/page.tsx
index 50aa048..005f078 100644
--- a/apps/web/src/app/setup/welcome/page.tsx
+++ b/apps/web/src/app/setup/welcome/page.tsx
@@ -9,14 +9,14 @@ import {
TooltipContent,
TooltipTrigger,
} from "@clawe/ui/components/tooltip";
-import { useAgencyStatus } from "@/hooks/use-agency-status";
+import { useSquadhubStatus } from "@/hooks/use-squadhub-status";
const TOTAL_STEPS = 4;
const CURRENT_STEP = 1;
export default function WelcomePage() {
const router = useRouter();
- const { status, isLoading } = useAgencyStatus();
+ const { status, isLoading } = useSquadhubStatus();
const isOffline = !isLoading && status === "down";
@@ -71,14 +71,14 @@ export default function WelcomePage() {
- Agency service is offline
+ Squadhub service is offline
- The agency service needs to be running before you can
+ The squadhub service needs to be running before you can
continue. Start it with:
- sudo docker compose up -d agency
+ sudo docker compose up -d squadhub
This status will update automatically once the service is
@@ -107,7 +107,7 @@ export default function WelcomePage() {
{isOffline && (
- Start agency to continue
+ Start squadhub to continue
)}
diff --git a/apps/web/src/components/chat/chat-message.tsx b/apps/web/src/components/chat/chat-message.tsx
index cc81163..d566fd3 100644
--- a/apps/web/src/components/chat/chat-message.tsx
+++ b/apps/web/src/components/chat/chat-message.tsx
@@ -8,7 +8,7 @@ import remarkGfm from "remark-gfm";
import type { Message } from "@/hooks/use-chat";
/**
- * Context message patterns - messages injected by agency for context.
+ * Context message patterns - messages injected by squadhub for context.
*/
const CONTEXT_MESSAGE_PATTERNS = [
/^\[Chat messages since your last reply/i,
diff --git a/apps/web/src/components/agency-status.tsx b/apps/web/src/components/squadhub-status.tsx
similarity index 85%
rename from apps/web/src/components/agency-status.tsx
rename to apps/web/src/components/squadhub-status.tsx
index 4baadfd..ac910ff 100644
--- a/apps/web/src/components/agency-status.tsx
+++ b/apps/web/src/components/squadhub-status.tsx
@@ -6,9 +6,9 @@ import {
TooltipContent,
TooltipTrigger,
} from "@clawe/ui/components/tooltip";
-import { useAgencyStatus } from "@/hooks/use-agency-status";
+import { useSquadhubStatus } from "@/hooks/use-squadhub-status";
-type AgencyStatusProps = {
+type SquadhubStatusProps = {
className?: string;
};
@@ -30,8 +30,8 @@ const statusConfig = {
},
};
-export const AgencyStatus = ({ className }: AgencyStatusProps) => {
- const { status, isLoading } = useAgencyStatus();
+export const SquadhubStatus = ({ className }: SquadhubStatusProps) => {
+ const { status, isLoading } = useSquadhubStatus();
const config = isLoading
? { label: "Connecting", dot: "bg-yellow-500", ping: "bg-yellow-400" }
@@ -40,8 +40,8 @@ export const AgencyStatus = ({ className }: AgencyStatusProps) => {
const tooltipText = isLoading
? "Checking connection..."
: status === "active"
- ? "Agency service is online and ready"
- : "Unable to connect to agency service";
+ ? "Squadhub service is online and ready"
+ : "Unable to connect to squadhub service";
const shouldAnimate = isLoading || status === "active";
diff --git a/apps/web/src/hooks/use-onboarding-guard.ts b/apps/web/src/hooks/use-onboarding-guard.ts
index 6c98b7c..0da57df 100644
--- a/apps/web/src/hooks/use-onboarding-guard.ts
+++ b/apps/web/src/hooks/use-onboarding-guard.ts
@@ -11,7 +11,7 @@ import { api } from "@clawe/backend";
*/
export const useRequireOnboarding = () => {
const router = useRouter();
- const isComplete = useQuery(api.settings.isOnboardingComplete);
+ const isComplete = useQuery(api.tenants.isOnboardingComplete, {});
useEffect(() => {
if (isComplete === false) {
@@ -28,7 +28,7 @@ export const useRequireOnboarding = () => {
*/
export const useRedirectIfOnboarded = () => {
const router = useRouter();
- const isComplete = useQuery(api.settings.isOnboardingComplete);
+ const isComplete = useQuery(api.tenants.isOnboardingComplete, {});
useEffect(() => {
if (isComplete === true) {
diff --git a/apps/web/src/hooks/use-agency-status.ts b/apps/web/src/hooks/use-squadhub-status.ts
similarity index 63%
rename from apps/web/src/hooks/use-agency-status.ts
rename to apps/web/src/hooks/use-squadhub-status.ts
index 7dab5ea..5303ee4 100644
--- a/apps/web/src/hooks/use-agency-status.ts
+++ b/apps/web/src/hooks/use-squadhub-status.ts
@@ -3,12 +3,12 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
-type AgencyStatus = "active" | "down" | "idle";
+type SquadhubStatus = "active" | "down" | "idle";
-const checkAgencyHealth = async (): Promise => {
+const checkSquadhubHealth = async (): Promise => {
try {
const { data } = await axios.post(
- "/api/agency/health",
+ "/api/squadhub/health",
{},
{ timeout: 5000 },
);
@@ -18,16 +18,16 @@ const checkAgencyHealth = async (): Promise => {
}
};
-export const useAgencyStatus = () => {
+export const useSquadhubStatus = () => {
const { data: isHealthy, isLoading } = useQuery({
- queryKey: ["agency-health"],
- queryFn: checkAgencyHealth,
+ queryKey: ["squadhub-health"],
+ queryFn: checkSquadhubHealth,
refetchInterval: 30000, // Check every 30 seconds
staleTime: 10000,
retry: false,
});
- const status: AgencyStatus = isLoading
+ const status: SquadhubStatus = isLoading
? "idle"
: isHealthy
? "active"
diff --git a/apps/web/src/lib/agency/actions.spec.ts b/apps/web/src/lib/squadhub/actions.spec.ts
similarity index 84%
rename from apps/web/src/lib/agency/actions.spec.ts
rename to apps/web/src/lib/squadhub/actions.spec.ts
index 55f332c..930b39d 100644
--- a/apps/web/src/lib/agency/actions.spec.ts
+++ b/apps/web/src/lib/squadhub/actions.spec.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the shared package
-vi.mock("@clawe/shared/agency", () => ({
+vi.mock("@clawe/shared/squadhub", () => ({
checkHealth: vi.fn(),
getConfig: vi.fn(),
saveTelegramBotToken: vi.fn(),
@@ -12,15 +12,15 @@ vi.mock("@clawe/shared/agency", () => ({
import {
saveTelegramBotToken,
validateTelegramToken,
- checkAgencyHealth,
+ checkSquadhubHealth,
} from "./actions";
import {
checkHealth,
saveTelegramBotToken as saveTelegramBotTokenClient,
probeTelegramToken,
-} from "@clawe/shared/agency";
+} from "@clawe/shared/squadhub";
-describe("Agency Actions", () => {
+describe("Squadhub Actions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -55,7 +55,10 @@ describe("Agency Actions", () => {
const result = await saveTelegramBotToken("123456:ABC-DEF");
expect(probeTelegramToken).toHaveBeenCalledWith("123456:ABC-DEF");
- expect(saveTelegramBotTokenClient).toHaveBeenCalledWith("123456:ABC-DEF");
+ expect(saveTelegramBotTokenClient).toHaveBeenCalledWith(
+ expect.objectContaining({ squadhubUrl: expect.any(String), squadhubToken: expect.any(String) }),
+ "123456:ABC-DEF",
+ );
expect(result.ok).toBe(true);
});
@@ -75,7 +78,7 @@ describe("Agency Actions", () => {
});
});
- describe("checkAgencyHealth", () => {
+ describe("checkSquadhubHealth", () => {
it("returns health status", async () => {
vi.mocked(checkHealth).mockResolvedValueOnce({
ok: true,
@@ -85,7 +88,7 @@ describe("Agency Actions", () => {
},
});
- const result = await checkAgencyHealth();
+ const result = await checkSquadhubHealth();
expect(result.ok).toBe(true);
});
});
diff --git a/apps/web/src/lib/agency/actions.ts b/apps/web/src/lib/squadhub/actions.ts
similarity index 62%
rename from apps/web/src/lib/agency/actions.ts
rename to apps/web/src/lib/squadhub/actions.ts
index b3ffbfe..ffe25e0 100644
--- a/apps/web/src/lib/agency/actions.ts
+++ b/apps/web/src/lib/squadhub/actions.ts
@@ -6,15 +6,16 @@ import {
saveTelegramBotToken as saveTelegramBotTokenClient,
removeTelegramBotToken as removeTelegramBotTokenClient,
probeTelegramToken,
-} from "@clawe/shared/agency";
-import { approveChannelPairingCode } from "@clawe/shared/agency";
+ approveChannelPairingCode,
+} from "@clawe/shared/squadhub";
+import { getConnection } from "./connection";
-export async function checkAgencyHealth() {
- return checkHealth();
+export async function checkSquadhubHealth() {
+ return checkHealth(getConnection());
}
-export async function getAgencyConfig() {
- return getConfig();
+export async function getSquadhubConfig() {
+ return getConfig(getConnection());
}
export async function validateTelegramToken(botToken: string) {
@@ -32,16 +33,16 @@ export async function saveTelegramBotToken(botToken: string) {
},
};
}
- return saveTelegramBotTokenClient(botToken);
+ return saveTelegramBotTokenClient(getConnection(), botToken);
}
export async function approvePairingCode(
code: string,
channel: string = "telegram",
) {
- return approveChannelPairingCode(channel, code);
+ return approveChannelPairingCode(getConnection(), channel, code);
}
export async function removeTelegramBot() {
- return removeTelegramBotTokenClient();
+ return removeTelegramBotTokenClient(getConnection());
}
diff --git a/apps/web/src/lib/squadhub/connection.ts b/apps/web/src/lib/squadhub/connection.ts
new file mode 100644
index 0000000..4b7f642
--- /dev/null
+++ b/apps/web/src/lib/squadhub/connection.ts
@@ -0,0 +1,8 @@
+import type { SquadhubConnection } from "@clawe/shared/squadhub";
+
+export function getConnection(): SquadhubConnection {
+ return {
+ squadhubUrl: process.env.SQUADHUB_URL || "http://localhost:18790",
+ squadhubToken: process.env.SQUADHUB_TOKEN || "",
+ };
+}
diff --git a/apps/web/src/lib/squadhub/provision.ts b/apps/web/src/lib/squadhub/provision.ts
new file mode 100644
index 0000000..6ce32d7
--- /dev/null
+++ b/apps/web/src/lib/squadhub/provision.ts
@@ -0,0 +1,215 @@
+import { ConvexHttpClient } from "convex/browser";
+import { api } from "@clawe/backend";
+import {
+ cronList,
+ cronAdd,
+ checkHealth,
+ type SquadhubConnection,
+ type CronAddJob,
+ type CronJob,
+} from "@clawe/shared/squadhub";
+
+/**
+ * Default agent definitions for new tenants.
+ */
+const DEFAULT_AGENTS = [
+ { id: "main", name: "Clawe", emoji: "\u{1F99E}", role: "Squad Lead", cron: "0,15,30,45 * * * *" },
+ { id: "inky", name: "Inky", emoji: "\u270D\uFE0F", role: "Writer", cron: "3,18,33,48 * * * *" },
+ { id: "pixel", name: "Pixel", emoji: "\u{1F3A8}", role: "Designer", cron: "7,22,37,52 * * * *" },
+ { id: "scout", name: "Scout", emoji: "\u{1F50D}", role: "SEO", cron: "11,26,41,56 * * * *" },
+];
+
+const HEARTBEAT_MESSAGE =
+ "Read HEARTBEAT.md and follow it strictly. Check for notifications with 'clawe check'. If nothing needs attention, reply HEARTBEAT_OK.";
+
+/**
+ * Default routines seeded for new tenants.
+ */
+const SEED_ROUTINES = [
+ {
+ title: "Weekly Performance Review",
+ description:
+ "Review last week's content performance, engagement metrics, and campaign results. Identify top-performing pieces and areas for improvement.",
+ priority: "normal" as const,
+ schedule: { type: "weekly" as const, daysOfWeek: [1], hour: 9, minute: 0 },
+ color: "emerald",
+ },
+ {
+ title: "Morning Brief",
+ description: "Prepare daily morning brief for the team",
+ priority: "high" as const,
+ schedule: { type: "weekly" as const, daysOfWeek: [0, 1, 2, 3, 4, 5, 6], hour: 8, minute: 0 },
+ color: "amber",
+ },
+ {
+ title: "Competitor Scan",
+ description: "Scan competitor activities and updates",
+ priority: "normal" as const,
+ schedule: { type: "weekly" as const, daysOfWeek: [1, 4], hour: 10, minute: 0 },
+ color: "rose",
+ },
+];
+
+type ProvisionResult = {
+ agents: number;
+ crons: number;
+ routines: number;
+ errors: string[];
+};
+
+/**
+ * Register default agents in Convex.
+ */
+async function registerAgents(convex: ConvexHttpClient): Promise<{
+ count: number;
+ errors: string[];
+}> {
+ const errors: string[] = [];
+ let count = 0;
+
+ for (const agent of DEFAULT_AGENTS) {
+ const sessionKey = `agent:${agent.id}:main`;
+ try {
+ await convex.mutation(api.agents.upsert, {
+ name: agent.name,
+ role: agent.role,
+ sessionKey,
+ emoji: agent.emoji,
+ });
+ count++;
+ } catch (err) {
+ errors.push(
+ `Failed to register ${agent.name}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+
+ return { count, errors };
+}
+
+/**
+ * Setup heartbeat cron jobs on the squadhub gateway.
+ */
+async function setupCrons(connection: SquadhubConnection): Promise<{
+ count: number;
+ errors: string[];
+}> {
+ const errors: string[] = [];
+ let count = 0;
+
+ const result = await cronList(connection);
+ if (!result.ok) {
+ return { count: 0, errors: [`Failed to list crons: ${result.error?.message}`] };
+ }
+
+ const existingNames = new Set(
+ result.result.details.jobs.map((j: CronJob) => j.name),
+ );
+
+ for (const agent of DEFAULT_AGENTS) {
+ const cronName = `${agent.id}-heartbeat`;
+
+ if (existingNames.has(cronName)) {
+ count++;
+ continue;
+ }
+
+ const job: CronAddJob = {
+ name: cronName,
+ agentId: agent.id,
+ enabled: true,
+ schedule: { kind: "cron", expr: agent.cron },
+ sessionTarget: "isolated",
+ payload: {
+ kind: "agentTurn",
+ message: HEARTBEAT_MESSAGE,
+ model: "anthropic/claude-sonnet-4-20250514",
+ timeoutSeconds: 600,
+ },
+ delivery: { mode: "none" },
+ };
+
+ const addResult = await cronAdd(connection, job);
+ if (addResult.ok) {
+ count++;
+ } else {
+ errors.push(`Failed to add ${cronName}: ${addResult.error?.message}`);
+ }
+ }
+
+ return { count, errors };
+}
+
+/**
+ * Seed default routines if none exist.
+ */
+async function seedRoutines(convex: ConvexHttpClient): Promise<{
+ count: number;
+ errors: string[];
+}> {
+ const errors: string[] = [];
+ let count = 0;
+
+ const existing = await convex.query(api.routines.list, {});
+ if (existing.length > 0) {
+ return { count: existing.length, errors: [] };
+ }
+
+ for (const routine of SEED_ROUTINES) {
+ try {
+ await convex.mutation(api.routines.create, routine);
+ count++;
+ } catch (err) {
+ errors.push(
+ `Failed to create routine "${routine.title}": ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+
+ return { count, errors };
+}
+
+/**
+ * Run full tenant provisioning setup:
+ * 1. Wait for squadhub to be healthy
+ * 2. Register default agents in Convex
+ * 3. Setup heartbeat cron jobs on squadhub
+ * 4. Seed default routines in Convex
+ */
+export async function provisionTenant(
+ connection: SquadhubConnection,
+ convexUrl: string,
+): Promise {
+ const convex = new ConvexHttpClient(convexUrl);
+ const allErrors: string[] = [];
+
+ // Check squadhub is reachable
+ const health = await checkHealth(connection);
+ if (!health.ok) {
+ return {
+ agents: 0,
+ crons: 0,
+ routines: 0,
+ errors: [`Squadhub not reachable: ${health.error?.message}`],
+ };
+ }
+
+ // Register agents
+ const agentResult = await registerAgents(convex);
+ allErrors.push(...agentResult.errors);
+
+ // Setup crons
+ const cronResult = await setupCrons(connection);
+ allErrors.push(...cronResult.errors);
+
+ // Seed routines
+ const routineResult = await seedRoutines(convex);
+ allErrors.push(...routineResult.errors);
+
+ return {
+ agents: agentResult.count,
+ crons: cronResult.count,
+ routines: routineResult.count,
+ errors: allErrors,
+ };
+}
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
index 59226b4..5c235b6 100644
--- a/apps/web/vitest.config.ts
+++ b/apps/web/vitest.config.ts
@@ -17,9 +17,9 @@ export default defineConfig({
__dirname,
"../../packages/backend/convex/_generated/api",
),
- "@clawe/shared/agency": path.resolve(
+ "@clawe/shared/squadhub": path.resolve(
__dirname,
- "../../packages/shared/src/agency/index.ts",
+ "../../packages/shared/src/squadhub/index.ts",
),
},
},
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
index f8368ce..3d8d80e 100644
--- a/docker-compose.override.yml
+++ b/docker-compose.override.yml
@@ -1,10 +1,10 @@
services:
- agency:
+ squadhub:
ports:
- "18790:18789"
volumes:
- - ./.agency:/data
- - ./.agency/logs:/tmp/openclaw
+ - ./.squadhub:/data
+ - ./.squadhub/logs:/tmp/openclaw
- .:/home/ubuntu/clawe
extra_hosts:
- "host.docker.internal:host-gateway"
diff --git a/docker-compose.yml b/docker-compose.yml
index dd87565..87e6400 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,28 +8,28 @@ services:
environment:
- NODE_ENV=production
- NEXT_PUBLIC_CONVEX_URL=${CONVEX_URL}
- - AGENCY_URL=http://agency:18789
- - AGENCY_TOKEN=${AGENCY_TOKEN}
- - AGENCY_STATE_DIR=/agency-data/config
+ - SQUADHUB_URL=http://squadhub:18789
+ - SQUADHUB_TOKEN=${SQUADHUB_TOKEN}
+ - SQUADHUB_STATE_DIR=/squadhub-data/config
volumes:
- - agency-data:/agency-data:ro
+ - squadhub-data:/squadhub-data:ro
depends_on:
- agency:
+ squadhub:
condition: service_healthy
restart: unless-stopped
- agency:
+ squadhub:
build:
context: .
- dockerfile: docker/agency/Dockerfile
+ dockerfile: docker/squadhub/Dockerfile
user: "${UID:-1000}:${GID:-1000}"
environment:
- - AGENCY_TOKEN=${AGENCY_TOKEN}
+ - SQUADHUB_TOKEN=${SQUADHUB_TOKEN}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- CONVEX_URL=${CONVEX_URL}
volumes:
- - agency-data:/data
+ - squadhub-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:18789/health"]
interval: 5s
@@ -43,13 +43,13 @@ services:
dockerfile: apps/watcher/Dockerfile
environment:
- CONVEX_URL=${CONVEX_URL}
- - AGENCY_URL=http://agency:18789
- - AGENCY_TOKEN=${AGENCY_TOKEN}
+ - SQUADHUB_URL=http://squadhub:18789
+ - SQUADHUB_TOKEN=${SQUADHUB_TOKEN}
depends_on:
- agency:
+ squadhub:
condition: service_healthy
restart: unless-stopped
volumes:
- agency-data:
+ squadhub-data:
driver: local
diff --git a/docker/agency/Dockerfile b/docker/squadhub/Dockerfile
similarity index 84%
rename from docker/agency/Dockerfile
rename to docker/squadhub/Dockerfile
index 7596759..3dbc35f 100644
--- a/docker/agency/Dockerfile
+++ b/docker/squadhub/Dockerfile
@@ -16,8 +16,8 @@ RUN npm install -g openclaw@latest
RUN mkdir -p /data/config /data/workspace
# Copy templates and scripts
-COPY docker/agency/templates/ /opt/clawe/templates/
-COPY docker/agency/scripts/ /opt/clawe/scripts/
+COPY docker/squadhub/templates/ /opt/clawe/templates/
+COPY docker/squadhub/scripts/ /opt/clawe/scripts/
RUN chmod +x /opt/clawe/scripts/*.sh
# Copy bundled CLI (single file with all deps included)
@@ -28,7 +28,7 @@ ENV OPENCLAW_STATE_DIR=/data/config
ENV OPENCLAW_PORT=18789
ENV OPENCLAW_SKIP_GMAIL_WATCHER=1
-COPY docker/agency/entrypoint.sh /entrypoint.sh
+COPY docker/squadhub/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
HEALTHCHECK --interval=5s --timeout=3s --retries=10 \
diff --git a/docker/agency/entrypoint.sh b/docker/squadhub/entrypoint.sh
similarity index 95%
rename from docker/agency/entrypoint.sh
rename to docker/squadhub/entrypoint.sh
index 10032ed..13ab518 100644
--- a/docker/agency/entrypoint.sh
+++ b/docker/squadhub/entrypoint.sh
@@ -3,14 +3,14 @@ set -e
CONFIG_FILE="${OPENCLAW_STATE_DIR}/openclaw.json"
PORT="${OPENCLAW_PORT:-18789}"
-TOKEN="${AGENCY_TOKEN:-}"
+TOKEN="${SQUADHUB_TOKEN:-}"
TEMPLATES_DIR="/opt/clawe/templates"
# Map to OPENCLAW_TOKEN for the openclaw CLI
export OPENCLAW_TOKEN="$TOKEN"
if [ -z "$TOKEN" ]; then
- echo "ERROR: AGENCY_TOKEN environment variable is required"
+ echo "ERROR: SQUADHUB_TOKEN environment variable is required"
exit 1
fi
diff --git a/docker/agency/scripts/init-agents.sh b/docker/squadhub/scripts/init-agents.sh
similarity index 100%
rename from docker/agency/scripts/init-agents.sh
rename to docker/squadhub/scripts/init-agents.sh
diff --git a/docker/agency/scripts/pair-device.js b/docker/squadhub/scripts/pair-device.js
similarity index 100%
rename from docker/agency/scripts/pair-device.js
rename to docker/squadhub/scripts/pair-device.js
diff --git a/docker/agency/templates/config.template.json b/docker/squadhub/templates/config.template.json
similarity index 100%
rename from docker/agency/templates/config.template.json
rename to docker/squadhub/templates/config.template.json
diff --git a/docker/agency/templates/shared/CLAWE-CLI.md b/docker/squadhub/templates/shared/CLAWE-CLI.md
similarity index 100%
rename from docker/agency/templates/shared/CLAWE-CLI.md
rename to docker/squadhub/templates/shared/CLAWE-CLI.md
diff --git a/docker/agency/templates/shared/WORKFLOW.md b/docker/squadhub/templates/shared/WORKFLOW.md
similarity index 100%
rename from docker/agency/templates/shared/WORKFLOW.md
rename to docker/squadhub/templates/shared/WORKFLOW.md
diff --git a/docker/agency/templates/shared/WORKING.md b/docker/squadhub/templates/shared/WORKING.md
similarity index 100%
rename from docker/agency/templates/shared/WORKING.md
rename to docker/squadhub/templates/shared/WORKING.md
diff --git a/docker/agency/templates/workspaces/clawe/AGENTS.md b/docker/squadhub/templates/workspaces/clawe/AGENTS.md
similarity index 100%
rename from docker/agency/templates/workspaces/clawe/AGENTS.md
rename to docker/squadhub/templates/workspaces/clawe/AGENTS.md
diff --git a/docker/agency/templates/workspaces/clawe/BOOTSTRAP.md b/docker/squadhub/templates/workspaces/clawe/BOOTSTRAP.md
similarity index 100%
rename from docker/agency/templates/workspaces/clawe/BOOTSTRAP.md
rename to docker/squadhub/templates/workspaces/clawe/BOOTSTRAP.md
diff --git a/docker/agency/templates/workspaces/clawe/HEARTBEAT.md b/docker/squadhub/templates/workspaces/clawe/HEARTBEAT.md
similarity index 100%
rename from docker/agency/templates/workspaces/clawe/HEARTBEAT.md
rename to docker/squadhub/templates/workspaces/clawe/HEARTBEAT.md
diff --git a/docker/agency/templates/workspaces/clawe/MEMORY.md b/docker/squadhub/templates/workspaces/clawe/MEMORY.md
similarity index 100%
rename from docker/agency/templates/workspaces/clawe/MEMORY.md
rename to docker/squadhub/templates/workspaces/clawe/MEMORY.md
diff --git a/docker/agency/templates/workspaces/clawe/SOUL.md b/docker/squadhub/templates/workspaces/clawe/SOUL.md
similarity index 100%
rename from docker/agency/templates/workspaces/clawe/SOUL.md
rename to docker/squadhub/templates/workspaces/clawe/SOUL.md
diff --git a/docker/agency/templates/workspaces/clawe/TOOLS.md b/docker/squadhub/templates/workspaces/clawe/TOOLS.md
similarity index 100%
rename from docker/agency/templates/workspaces/clawe/TOOLS.md
rename to docker/squadhub/templates/workspaces/clawe/TOOLS.md
diff --git a/docker/agency/templates/workspaces/clawe/USER.md b/docker/squadhub/templates/workspaces/clawe/USER.md
similarity index 100%
rename from docker/agency/templates/workspaces/clawe/USER.md
rename to docker/squadhub/templates/workspaces/clawe/USER.md
diff --git a/docker/agency/templates/workspaces/inky/AGENTS.md b/docker/squadhub/templates/workspaces/inky/AGENTS.md
similarity index 100%
rename from docker/agency/templates/workspaces/inky/AGENTS.md
rename to docker/squadhub/templates/workspaces/inky/AGENTS.md
diff --git a/docker/agency/templates/workspaces/inky/HEARTBEAT.md b/docker/squadhub/templates/workspaces/inky/HEARTBEAT.md
similarity index 100%
rename from docker/agency/templates/workspaces/inky/HEARTBEAT.md
rename to docker/squadhub/templates/workspaces/inky/HEARTBEAT.md
diff --git a/docker/agency/templates/workspaces/inky/MEMORY.md b/docker/squadhub/templates/workspaces/inky/MEMORY.md
similarity index 100%
rename from docker/agency/templates/workspaces/inky/MEMORY.md
rename to docker/squadhub/templates/workspaces/inky/MEMORY.md
diff --git a/docker/agency/templates/workspaces/inky/SOUL.md b/docker/squadhub/templates/workspaces/inky/SOUL.md
similarity index 100%
rename from docker/agency/templates/workspaces/inky/SOUL.md
rename to docker/squadhub/templates/workspaces/inky/SOUL.md
diff --git a/docker/agency/templates/workspaces/inky/TOOLS.md b/docker/squadhub/templates/workspaces/inky/TOOLS.md
similarity index 100%
rename from docker/agency/templates/workspaces/inky/TOOLS.md
rename to docker/squadhub/templates/workspaces/inky/TOOLS.md
diff --git a/docker/agency/templates/workspaces/inky/USER.md b/docker/squadhub/templates/workspaces/inky/USER.md
similarity index 100%
rename from docker/agency/templates/workspaces/inky/USER.md
rename to docker/squadhub/templates/workspaces/inky/USER.md
diff --git a/docker/agency/templates/workspaces/pixel/AGENTS.md b/docker/squadhub/templates/workspaces/pixel/AGENTS.md
similarity index 100%
rename from docker/agency/templates/workspaces/pixel/AGENTS.md
rename to docker/squadhub/templates/workspaces/pixel/AGENTS.md
diff --git a/docker/agency/templates/workspaces/pixel/HEARTBEAT.md b/docker/squadhub/templates/workspaces/pixel/HEARTBEAT.md
similarity index 100%
rename from docker/agency/templates/workspaces/pixel/HEARTBEAT.md
rename to docker/squadhub/templates/workspaces/pixel/HEARTBEAT.md
diff --git a/docker/agency/templates/workspaces/pixel/MEMORY.md b/docker/squadhub/templates/workspaces/pixel/MEMORY.md
similarity index 100%
rename from docker/agency/templates/workspaces/pixel/MEMORY.md
rename to docker/squadhub/templates/workspaces/pixel/MEMORY.md
diff --git a/docker/agency/templates/workspaces/pixel/SOUL.md b/docker/squadhub/templates/workspaces/pixel/SOUL.md
similarity index 100%
rename from docker/agency/templates/workspaces/pixel/SOUL.md
rename to docker/squadhub/templates/workspaces/pixel/SOUL.md
diff --git a/docker/agency/templates/workspaces/pixel/TOOLS.md b/docker/squadhub/templates/workspaces/pixel/TOOLS.md
similarity index 100%
rename from docker/agency/templates/workspaces/pixel/TOOLS.md
rename to docker/squadhub/templates/workspaces/pixel/TOOLS.md
diff --git a/docker/agency/templates/workspaces/pixel/USER.md b/docker/squadhub/templates/workspaces/pixel/USER.md
similarity index 100%
rename from docker/agency/templates/workspaces/pixel/USER.md
rename to docker/squadhub/templates/workspaces/pixel/USER.md
diff --git a/docker/agency/templates/workspaces/scout/AGENTS.md b/docker/squadhub/templates/workspaces/scout/AGENTS.md
similarity index 100%
rename from docker/agency/templates/workspaces/scout/AGENTS.md
rename to docker/squadhub/templates/workspaces/scout/AGENTS.md
diff --git a/docker/agency/templates/workspaces/scout/HEARTBEAT.md b/docker/squadhub/templates/workspaces/scout/HEARTBEAT.md
similarity index 100%
rename from docker/agency/templates/workspaces/scout/HEARTBEAT.md
rename to docker/squadhub/templates/workspaces/scout/HEARTBEAT.md
diff --git a/docker/agency/templates/workspaces/scout/MEMORY.md b/docker/squadhub/templates/workspaces/scout/MEMORY.md
similarity index 100%
rename from docker/agency/templates/workspaces/scout/MEMORY.md
rename to docker/squadhub/templates/workspaces/scout/MEMORY.md
diff --git a/docker/agency/templates/workspaces/scout/SOUL.md b/docker/squadhub/templates/workspaces/scout/SOUL.md
similarity index 100%
rename from docker/agency/templates/workspaces/scout/SOUL.md
rename to docker/squadhub/templates/workspaces/scout/SOUL.md
diff --git a/docker/agency/templates/workspaces/scout/TOOLS.md b/docker/squadhub/templates/workspaces/scout/TOOLS.md
similarity index 100%
rename from docker/agency/templates/workspaces/scout/TOOLS.md
rename to docker/squadhub/templates/workspaces/scout/TOOLS.md
diff --git a/docker/agency/templates/workspaces/scout/USER.md b/docker/squadhub/templates/workspaces/scout/USER.md
similarity index 100%
rename from docker/agency/templates/workspaces/scout/USER.md
rename to docker/squadhub/templates/workspaces/scout/USER.md
diff --git a/package.json b/package.json
index 257ab7f..aafb354 100644
--- a/package.json
+++ b/package.json
@@ -4,8 +4,8 @@
"scripts": {
"build": "dotenv -e .env -- turbo run build",
"dev": "dotenv -e .env -- turbo run dev",
- "dev:docker": "pnpm --filter @clawe/cli build && docker compose up --build agency",
- "build:docker": "pnpm --filter @clawe/cli build && docker compose build --no-cache agency",
+ "dev:docker": "pnpm --filter @clawe/cli build && docker compose up --build squadhub",
+ "build:docker": "pnpm --filter @clawe/cli build && docker compose build --no-cache squadhub",
"debug": "dotenv -e .env -- turbo run debug",
"dev:web": "dotenv -e .env -- turbo run dev --filter=web",
"convex:dev": "dotenv -e .env -- turbo run dev --filter=@clawe/backend",
diff --git a/packages/backend/convex/accounts.ts b/packages/backend/convex/accounts.ts
new file mode 100644
index 0000000..d5335f9
--- /dev/null
+++ b/packages/backend/convex/accounts.ts
@@ -0,0 +1,64 @@
+import { query, mutation } from "./_generated/server";
+import { getUser } from "./lib/auth";
+
+/**
+ * Get or create an account for the current authenticated user.
+ * Called during provisioning or first login.
+ * If the user already has an account membership, returns that account.
+ * Otherwise, creates a new account and membership.
+ */
+export const getOrCreateForUser = mutation({
+ args: {},
+ handler: async (ctx) => {
+ const user = await getUser(ctx);
+
+ // Check for existing account membership
+ const membership = await ctx.db
+ .query("accountMembers")
+ .withIndex("by_user", (q) => q.eq("userId", user._id))
+ .first();
+
+ if (membership) {
+ return (await ctx.db.get(membership.accountId))!;
+ }
+
+ // Create new account + membership
+ const now = Date.now();
+ const accountId = await ctx.db.insert("accounts", {
+ name: user.name ? `${user.name}'s Account` : undefined,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ await ctx.db.insert("accountMembers", {
+ userId: user._id,
+ accountId,
+ role: "owner",
+ createdAt: now,
+ });
+
+ return (await ctx.db.get(accountId))!;
+ },
+});
+
+/**
+ * Get the account for the current authenticated user.
+ * Returns null if the user has no account membership.
+ */
+export const getForCurrentUser = query({
+ args: {},
+ handler: async (ctx) => {
+ const user = await getUser(ctx);
+
+ const membership = await ctx.db
+ .query("accountMembers")
+ .withIndex("by_user", (q) => q.eq("userId", user._id))
+ .first();
+
+ if (!membership) {
+ return null;
+ }
+
+ return await ctx.db.get(membership.accountId);
+ },
+});
diff --git a/packages/backend/convex/activities.ts b/packages/backend/convex/activities.ts
index cd2a9b9..000113f 100644
--- a/packages/backend/convex/activities.ts
+++ b/packages/backend/convex/activities.ts
@@ -1,5 +1,6 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
+import { resolveTenantId, resolveTenantIdMut } from "./lib/auth";
// Get activity feed (most recent first)
export const feed = query({
@@ -7,33 +8,32 @@ export const feed = query({
limit: v.optional(v.number()),
agentId: v.optional(v.id("agents")),
taskId: v.optional(v.id("tasks")),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
- const limit = args.limit ?? 50;
+ const tenantId = await resolveTenantId(ctx, args);
+ const { machineToken: _, ...filters } = args;
+ const limit = filters.limit ?? 50;
+
+ // Always query by tenant first, then filter in JS
+ const allActivities = await ctx.db
+ .query("activities")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .order("desc")
+ .collect();
let activities;
- if (args.taskId) {
- // Filter by task
- activities = await ctx.db
- .query("activities")
- .withIndex("by_task", (q) => q.eq("taskId", args.taskId))
- .order("desc")
- .take(limit);
- } else if (args.agentId) {
- // Filter by agent
- activities = await ctx.db
- .query("activities")
- .withIndex("by_agent", (q) => q.eq("agentId", args.agentId))
- .order("desc")
- .take(limit);
+ if (filters.taskId) {
+ activities = allActivities
+ .filter((a) => a.taskId === filters.taskId)
+ .slice(0, limit);
+ } else if (filters.agentId) {
+ activities = allActivities
+ .filter((a) => a.agentId === filters.agentId)
+ .slice(0, limit);
} else {
- // All activities
- activities = await ctx.db
- .query("activities")
- .withIndex("by_createdAt")
- .order("desc")
- .take(limit);
+ activities = allActivities.slice(0, limit);
}
// Enrich with agent and task info
@@ -77,15 +77,23 @@ export const byType = query({
v.literal("notification_sent"),
),
limit: v.optional(v.number()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
- const limit = args.limit ?? 50;
+ const tenantId = await resolveTenantId(ctx, args);
+ const { machineToken: _, ...filters } = args;
+ const limit = filters.limit ?? 50;
- return await ctx.db
+ // Query by tenant first, then filter by type in JS
+ const allActivities = await ctx.db
.query("activities")
- .withIndex("by_type", (q) => q.eq("type", args.type))
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.order("desc")
- .take(limit);
+ .collect();
+
+ return allActivities
+ .filter((a) => a.type === filters.type)
+ .slice(0, limit);
},
});
@@ -106,14 +114,19 @@ export const log = mutation({
taskId: v.optional(v.id("tasks")),
message: v.string(),
metadata: v.optional(v.any()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const { machineToken: _, ...fields } = args;
+
return await ctx.db.insert("activities", {
- type: args.type,
- agentId: args.agentId,
- taskId: args.taskId,
- message: args.message,
- metadata: args.metadata,
+ tenantId,
+ type: fields.type,
+ agentId: fields.agentId,
+ taskId: fields.taskId,
+ message: fields.message,
+ metadata: fields.metadata,
createdAt: Date.now(),
});
},
@@ -136,12 +149,16 @@ export const logBySession = mutation({
taskId: v.optional(v.id("tasks")),
message: v.string(),
metadata: v.optional(v.any()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const { machineToken: _, ...fields } = args;
+
let agentId = undefined;
- if (args.sessionKey) {
- const sessionKey = args.sessionKey;
+ if (fields.sessionKey) {
+ const sessionKey = fields.sessionKey;
const agent = await ctx.db
.query("agents")
.withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey))
@@ -152,11 +169,12 @@ export const logBySession = mutation({
}
return await ctx.db.insert("activities", {
- type: args.type,
+ tenantId,
+ type: fields.type,
agentId,
- taskId: args.taskId,
- message: args.message,
- metadata: args.metadata,
+ taskId: fields.taskId,
+ message: fields.message,
+ metadata: fields.metadata,
createdAt: Date.now(),
});
},
diff --git a/packages/backend/convex/agents.ts b/packages/backend/convex/agents.ts
index 8500a97..fa62b97 100644
--- a/packages/backend/convex/agents.ts
+++ b/packages/backend/convex/agents.ts
@@ -1,51 +1,65 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
+import { resolveTenantId, resolveTenantIdMut } from "./lib/auth";
const agentStatusValidator = v.union(v.literal("online"), v.literal("offline"));
// List all agents
export const list = query({
- args: {},
- handler: async (ctx) => {
- return await ctx.db.query("agents").collect();
+ args: { machineToken: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
+ return await ctx.db
+ .query("agents")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
},
});
// Get agent by ID
export const get = query({
- args: { id: v.id("agents") },
+ args: { id: v.id("agents"), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
+ await resolveTenantId(ctx, args);
return await ctx.db.get(args.id);
},
});
// Get agent by session key
export const getBySessionKey = query({
- args: { sessionKey: v.string() },
+ args: { sessionKey: v.string(), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
- return await ctx.db
+ const tenantId = await resolveTenantId(ctx, args);
+ const agents = await ctx.db
.query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey))
- .first();
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+ return agents.find((a) => a.sessionKey === args.sessionKey) ?? null;
},
});
// List agents by status
export const listByStatus = query({
- args: { status: agentStatusValidator },
+ args: { status: agentStatusValidator, machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
- return await ctx.db
+ const tenantId = await resolveTenantId(ctx, args);
+ const agents = await ctx.db
.query("agents")
- .withIndex("by_status", (q) => q.eq("status", args.status))
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
+ return agents.filter((a) => a.status === args.status);
},
});
// Squad status - get all agents with their current state
export const squad = query({
- args: {},
- handler: async (ctx) => {
- const agents = await ctx.db.query("agents").collect();
+ args: { machineToken: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
+ const agents = await ctx.db
+ .query("agents")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
return Promise.all(
agents.map(async (agent) => {
@@ -76,32 +90,37 @@ export const upsert = mutation({
sessionKey: v.string(),
emoji: v.optional(v.string()),
config: v.optional(v.any()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const { machineToken: _, ...rest } = args;
const now = Date.now();
- const existing = await ctx.db
+ const agents = await ctx.db
.query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey))
- .first();
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+ const existing = agents.find((a) => a.sessionKey === rest.sessionKey);
if (existing) {
await ctx.db.patch(existing._id, {
- name: args.name,
- role: args.role,
- emoji: args.emoji,
- config: args.config,
+ name: rest.name,
+ role: rest.role,
+ emoji: rest.emoji,
+ config: rest.config,
updatedAt: now,
});
return existing._id;
} else {
return await ctx.db.insert("agents", {
- name: args.name,
- role: args.role,
- sessionKey: args.sessionKey,
- emoji: args.emoji,
- config: args.config,
+ name: rest.name,
+ role: rest.role,
+ sessionKey: rest.sessionKey,
+ emoji: rest.emoji,
+ config: rest.config,
status: "offline",
+ tenantId,
createdAt: now,
updatedAt: now,
});
@@ -117,16 +136,20 @@ export const create = mutation({
sessionKey: v.string(),
emoji: v.optional(v.string()),
config: v.optional(v.any()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const { machineToken: _, ...rest } = args;
const now = Date.now();
return await ctx.db.insert("agents", {
- name: args.name,
- role: args.role,
- sessionKey: args.sessionKey,
- emoji: args.emoji,
- config: args.config,
+ name: rest.name,
+ role: rest.role,
+ sessionKey: rest.sessionKey,
+ emoji: rest.emoji,
+ config: rest.config,
status: "offline",
+ tenantId,
createdAt: now,
updatedAt: now,
});
@@ -138,8 +161,10 @@ export const updateStatus = mutation({
args: {
id: v.id("agents"),
status: agentStatusValidator,
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ await resolveTenantIdMut(ctx, args);
await ctx.db.patch(args.id, {
status: args.status,
updatedAt: Date.now(),
@@ -149,14 +174,16 @@ export const updateStatus = mutation({
// Record agent heartbeat
export const heartbeat = mutation({
- args: { sessionKey: v.string() },
+ args: { sessionKey: v.string(), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
- const agent = await ctx.db
+ const agents = await ctx.db
.query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey))
- .first();
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+ const agent = agents.find((a) => a.sessionKey === args.sessionKey);
if (!agent) {
throw new Error(`Agent not found: ${args.sessionKey}`);
@@ -178,6 +205,7 @@ export const heartbeat = mutation({
type: "agent_heartbeat",
agentId: agent._id,
message: `${agent.name} is online`,
+ tenantId: agent.tenantId,
createdAt: now,
});
}
@@ -191,12 +219,15 @@ export const setCurrentTask = mutation({
args: {
sessionKey: v.string(),
taskId: v.optional(v.id("tasks")),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
- const agent = await ctx.db
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const agents = await ctx.db
.query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey))
- .first();
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+ const agent = agents.find((a) => a.sessionKey === args.sessionKey);
if (!agent) {
throw new Error(`Agent not found: ${args.sessionKey}`);
@@ -214,12 +245,15 @@ export const setActivity = mutation({
args: {
sessionKey: v.string(),
activity: v.optional(v.string()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
- const agent = await ctx.db
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const agents = await ctx.db
.query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey))
- .first();
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+ const agent = agents.find((a) => a.sessionKey === args.sessionKey);
if (!agent) {
throw new Error(`Agent not found: ${args.sessionKey}`);
@@ -241,9 +275,11 @@ export const update = mutation({
role: v.optional(v.string()),
emoji: v.optional(v.string()),
config: v.optional(v.any()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
- const { id, ...updates } = args;
+ await resolveTenantIdMut(ctx, args);
+ const { id, machineToken: _, ...updates } = args;
const filteredUpdates = Object.fromEntries(
Object.entries(updates).filter(([, value]) => value !== undefined),
);
@@ -256,8 +292,9 @@ export const update = mutation({
// Remove agent
export const remove = mutation({
- args: { id: v.id("agents") },
+ args: { id: v.id("agents"), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
+ await resolveTenantIdMut(ctx, args);
await ctx.db.delete(args.id);
},
});
diff --git a/packages/backend/convex/businessContext.ts b/packages/backend/convex/businessContext.ts
index db23d7a..9189873 100644
--- a/packages/backend/convex/businessContext.ts
+++ b/packages/backend/convex/businessContext.ts
@@ -1,15 +1,19 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
+import { resolveTenantId, resolveTenantIdMut } from "./lib/auth";
/**
* Get the current business context.
* Returns null if not configured.
*/
export const get = query({
- args: {},
- handler: async (ctx) => {
- // Only one business context should exist - get the first one
- return await ctx.db.query("businessContext").first();
+ args: { machineToken: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
+ return await ctx.db
+ .query("businessContext")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .first();
},
});
@@ -17,9 +21,13 @@ export const get = query({
* Check if business context is configured and approved.
*/
export const isConfigured = query({
- args: {},
- handler: async (ctx) => {
- const context = await ctx.db.query("businessContext").first();
+ args: { machineToken: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
+ const context = await ctx.db
+ .query("businessContext")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .first();
return context?.approved === true;
},
});
@@ -30,6 +38,7 @@ export const isConfigured = query({
*/
export const save = mutation({
args: {
+ machineToken: v.optional(v.string()),
url: v.string(),
name: v.optional(v.string()),
description: v.optional(v.string()),
@@ -47,17 +56,22 @@ export const save = mutation({
approved: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const { machineToken: _, ...rest } = args;
const now = Date.now();
- const existing = await ctx.db.query("businessContext").first();
+ const existing = await ctx.db
+ .query("businessContext")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .first();
const data = {
- url: args.url,
- name: args.name,
- description: args.description,
- favicon: args.favicon,
- metadata: args.metadata,
- approved: args.approved ?? false,
- approvedAt: args.approved ? now : undefined,
+ url: rest.url,
+ name: rest.name,
+ description: rest.description,
+ favicon: rest.favicon,
+ metadata: rest.metadata,
+ approved: rest.approved ?? false,
+ approvedAt: rest.approved ? now : undefined,
updatedAt: now,
};
@@ -68,6 +82,7 @@ export const save = mutation({
return await ctx.db.insert("businessContext", {
...data,
+ tenantId,
createdAt: now,
});
},
@@ -77,9 +92,13 @@ export const save = mutation({
* Mark the current business context as approved.
*/
export const approve = mutation({
- args: {},
- handler: async (ctx) => {
- const existing = await ctx.db.query("businessContext").first();
+ args: { machineToken: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const existing = await ctx.db
+ .query("businessContext")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .first();
if (!existing) {
throw new Error("No business context to approve");
@@ -100,9 +119,13 @@ export const approve = mutation({
* Used for resetting onboarding.
*/
export const clear = mutation({
- args: {},
- handler: async (ctx) => {
- const existing = await ctx.db.query("businessContext").first();
+ args: { machineToken: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const existing = await ctx.db
+ .query("businessContext")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .first();
if (existing) {
await ctx.db.delete(existing._id);
diff --git a/packages/backend/convex/channels.ts b/packages/backend/convex/channels.ts
index 575dca4..8416008 100644
--- a/packages/backend/convex/channels.ts
+++ b/packages/backend/convex/channels.ts
@@ -1,42 +1,59 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
+import { resolveTenantId, resolveTenantIdMut } from "./lib/auth";
export const list = query({
- args: {},
- handler: async (ctx) => {
- return await ctx.db.query("channels").collect();
+ args: { machineToken: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
+ return await ctx.db
+ .query("channels")
+ .withIndex("by_tenant_type", (q) => q.eq("tenantId", tenantId))
+ .collect();
},
});
export const getByType = query({
- args: { type: v.string() },
+ args: {
+ machineToken: v.optional(v.string()),
+ type: v.string(),
+ },
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
return await ctx.db
.query("channels")
- .withIndex("by_type", (q) => q.eq("type", args.type))
+ .withIndex("by_tenant_type", (q) =>
+ q.eq("tenantId", tenantId).eq("type", args.type),
+ )
.first();
},
});
export const upsert = mutation({
args: {
+ machineToken: v.optional(v.string()),
type: v.string(),
status: v.union(v.literal("connected"), v.literal("disconnected")),
accountId: v.optional(v.string()),
metadata: v.optional(v.any()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const { machineToken: _, ...rest } = args;
+
const existing = await ctx.db
.query("channels")
- .withIndex("by_type", (q) => q.eq("type", args.type))
+ .withIndex("by_tenant_type", (q) =>
+ q.eq("tenantId", tenantId).eq("type", rest.type),
+ )
.first();
const data = {
- type: args.type,
- status: args.status,
- accountId: args.accountId,
- metadata: args.metadata,
- connectedAt: args.status === "connected" ? Date.now() : undefined,
+ type: rest.type,
+ status: rest.status,
+ accountId: rest.accountId,
+ metadata: rest.metadata,
+ connectedAt: rest.status === "connected" ? Date.now() : undefined,
};
if (existing) {
@@ -44,16 +61,26 @@ export const upsert = mutation({
return existing._id;
}
- return await ctx.db.insert("channels", data);
+ return await ctx.db.insert("channels", {
+ ...data,
+ tenantId,
+ });
},
});
export const disconnect = mutation({
- args: { type: v.string() },
+ args: {
+ machineToken: v.optional(v.string()),
+ type: v.string(),
+ },
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+
const existing = await ctx.db
.query("channels")
- .withIndex("by_type", (q) => q.eq("type", args.type))
+ .withIndex("by_tenant_type", (q) =>
+ q.eq("tenantId", tenantId).eq("type", args.type),
+ )
.first();
if (existing) {
diff --git a/packages/backend/convex/documents.ts b/packages/backend/convex/documents.ts
index e166dd5..d74a434 100644
--- a/packages/backend/convex/documents.ts
+++ b/packages/backend/convex/documents.ts
@@ -1,9 +1,10 @@
import { v } from "convex/values";
import { action, mutation, query } from "./_generated/server";
+import { resolveTenantId, resolveTenantIdMut } from "./lib/auth";
// Generate upload URL for file storage
export const generateUploadUrl = action({
- args: {},
+ args: { machineToken: v.optional(v.string()) },
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
@@ -21,32 +22,38 @@ export const list = query({
),
),
limit: v.optional(v.number()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
const limit = args.limit ?? 100;
- if (args.type) {
- const type = args.type;
- return await ctx.db
- .query("documents")
- .withIndex("by_type", (q) => q.eq("type", type))
- .order("desc")
- .take(limit);
- }
+ const allDocs = await ctx.db
+ .query("documents")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .order("desc")
+ .collect();
- return await ctx.db.query("documents").order("desc").take(limit);
+ const filtered = args.type
+ ? allDocs.filter((doc) => doc.type === args.type)
+ : allDocs;
+
+ return filtered.slice(0, limit);
},
});
// Get documents for a task (deliverables)
export const getForTask = query({
- args: { taskId: v.id("tasks") },
+ args: { taskId: v.id("tasks"), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
- const documents = await ctx.db
+ const tenantId = await resolveTenantId(ctx, args);
+ const allDocs = await ctx.db
.query("documents")
- .withIndex("by_task", (q) => q.eq("taskId", args.taskId))
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
+ const documents = allDocs.filter((doc) => doc.taskId === args.taskId);
+
// Enrich with creator info and file URL
return Promise.all(
documents.map(async (doc) => {
@@ -69,8 +76,9 @@ export const getForTask = query({
// Get document by ID
export const get = query({
- args: { id: v.id("documents") },
+ args: { id: v.id("documents"), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
+ await resolveTenantId(ctx, args);
return await ctx.db.get(args.id);
},
});
@@ -89,29 +97,33 @@ export const create = mutation({
),
taskId: v.optional(v.id("tasks")),
createdBySessionKey: v.string(),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const { machineToken: _, ...rest } = args;
const now = Date.now();
// Find the creator agent
const agent = await ctx.db
.query("agents")
.withIndex("by_sessionKey", (q) =>
- q.eq("sessionKey", args.createdBySessionKey),
+ q.eq("sessionKey", rest.createdBySessionKey),
)
.first();
if (!agent) {
- throw new Error(`Agent not found: ${args.createdBySessionKey}`);
+ throw new Error(`Agent not found: ${rest.createdBySessionKey}`);
}
const documentId = await ctx.db.insert("documents", {
- title: args.title,
- content: args.content,
- path: args.path,
- type: args.type,
- taskId: args.taskId,
+ title: rest.title,
+ content: rest.content,
+ path: rest.path,
+ type: rest.type,
+ taskId: rest.taskId,
createdBy: agent._id,
+ tenantId,
createdAt: now,
updatedAt: now,
});
@@ -120,8 +132,9 @@ export const create = mutation({
await ctx.db.insert("activities", {
type: "document_created",
agentId: agent._id,
- taskId: args.taskId,
- message: `${agent.name} created ${args.type}: ${args.title}`,
+ taskId: rest.taskId,
+ message: `${agent.name} created ${rest.type}: ${rest.title}`,
+ tenantId,
createdAt: now,
});
@@ -137,28 +150,32 @@ export const registerDeliverable = mutation({
fileId: v.optional(v.id("_storage")),
taskId: v.id("tasks"),
createdBySessionKey: v.string(),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const { machineToken: _, ...rest } = args;
const now = Date.now();
const agent = await ctx.db
.query("agents")
.withIndex("by_sessionKey", (q) =>
- q.eq("sessionKey", args.createdBySessionKey),
+ q.eq("sessionKey", rest.createdBySessionKey),
)
.first();
if (!agent) {
- throw new Error(`Agent not found: ${args.createdBySessionKey}`);
+ throw new Error(`Agent not found: ${rest.createdBySessionKey}`);
}
const documentId = await ctx.db.insert("documents", {
- title: args.title,
- path: args.path,
- fileId: args.fileId,
+ title: rest.title,
+ path: rest.path,
+ fileId: rest.fileId,
type: "deliverable",
- taskId: args.taskId,
+ taskId: rest.taskId,
createdBy: agent._id,
+ tenantId,
createdAt: now,
updatedAt: now,
});
@@ -167,8 +184,9 @@ export const registerDeliverable = mutation({
await ctx.db.insert("activities", {
type: "document_created",
agentId: agent._id,
- taskId: args.taskId,
- message: `${agent.name} registered deliverable: ${args.title}`,
+ taskId: rest.taskId,
+ message: `${agent.name} registered deliverable: ${rest.title}`,
+ tenantId,
createdAt: now,
});
@@ -183,9 +201,11 @@ export const update = mutation({
title: v.optional(v.string()),
content: v.optional(v.string()),
path: v.optional(v.string()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
- const { id, ...updates } = args;
+ await resolveTenantIdMut(ctx, args);
+ const { id, machineToken: _, ...updates } = args;
const filteredUpdates = Object.fromEntries(
Object.entries(updates).filter(([, value]) => value !== undefined),
);
@@ -199,8 +219,9 @@ export const update = mutation({
// Delete a document
export const remove = mutation({
- args: { id: v.id("documents") },
+ args: { id: v.id("documents"), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
+ await resolveTenantIdMut(ctx, args);
await ctx.db.delete(args.id);
},
});
diff --git a/packages/backend/convex/lib/auth.ts b/packages/backend/convex/lib/auth.ts
new file mode 100644
index 0000000..414f1d1
--- /dev/null
+++ b/packages/backend/convex/lib/auth.ts
@@ -0,0 +1,230 @@
+import type { Id } from "../_generated/dataModel";
+import type { QueryCtx, MutationCtx } from "../_generated/server";
+
+type ReadCtx = { db: QueryCtx["db"]; auth: QueryCtx["auth"] };
+
+const DEV_TENANT_EMAIL = "dev@clawe.local";
+
+/**
+ * Browser path: get the current user from JWT identity.
+ * Looks up the `users` table by the email from the auth identity.
+ */
+export async function getUser(ctx: ReadCtx) {
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity) {
+ throw new Error("Not authenticated");
+ }
+
+ const email = identity.email;
+ if (!email) {
+ throw new Error("No email in auth identity");
+ }
+
+ const user = await ctx.db
+ .query("users")
+ .withIndex("by_email", (q) => q.eq("email", email))
+ .first();
+
+ if (!user) {
+ throw new Error(`User not found for email: ${email}`);
+ }
+
+ return user;
+}
+
+/**
+ * Resolve tenantId from user via accountMembers → accounts → tenants.
+ * Used by both JWT and dev-mode paths.
+ */
+async function getTenantIdFromUser(
+ ctx: ReadCtx,
+ userId: Id<"users">,
+): Promise | null> {
+ const membership = await ctx.db
+ .query("accountMembers")
+ .withIndex("by_user", (q) => q.eq("userId", userId))
+ .first();
+
+ if (!membership) {
+ return null;
+ }
+
+ const tenant = await ctx.db
+ .query("tenants")
+ .withIndex("by_account", (q) => q.eq("accountId", membership.accountId))
+ .first();
+
+ if (!tenant) {
+ return null;
+ }
+
+ return tenant._id;
+}
+
+/**
+ * Browser path: resolve tenantId from the JWT identity.
+ * Gets the user, then looks up: accountMembers → account → tenants.
+ */
+export async function getTenantIdFromJwt(
+ ctx: ReadCtx,
+): Promise> {
+ const user = await getUser(ctx);
+
+ const tenantId = await getTenantIdFromUser(ctx, user._id);
+
+ if (!tenantId) {
+ throw new Error("No tenant found for user");
+ }
+
+ return tenantId;
+}
+
+/**
+ * Machine path (CLI): resolve tenantId from a per-tenant squadhub token.
+ * Queries the `tenants` table by the `by_squadhubToken` index.
+ */
+export async function getTenantIdFromToken(
+ ctx: ReadCtx,
+ machineToken: string,
+): Promise> {
+ const tenant = await ctx.db
+ .query("tenants")
+ .withIndex("by_squadhubToken", (q) => q.eq("squadhubToken", machineToken))
+ .first();
+
+ if (!tenant) {
+ throw new Error("Invalid machine token");
+ }
+
+ return tenant._id;
+}
+
+/**
+ * Machine path (watcher): validate system-level watcher token.
+ * Compares against `WATCHER_TOKEN` Convex env var.
+ */
+export function validateWatcherToken(
+ _ctx: ReadCtx,
+ watcherToken: string,
+): void {
+ const expected = process.env.WATCHER_TOKEN;
+ if (!expected || watcherToken !== expected) {
+ throw new Error("Invalid watcher token");
+ }
+}
+
+/**
+ * Dev-only: get or create a default dev tenant (used when AUTH_ENABLED=false).
+ */
+async function getOrCreateDevTenant(
+ ctx: MutationCtx,
+): Promise> {
+ // Look for existing dev user
+ let user = await ctx.db
+ .query("users")
+ .withIndex("by_email", (q) => q.eq("email", DEV_TENANT_EMAIL))
+ .first();
+
+ if (!user) {
+ const now = Date.now();
+ const userId = await ctx.db.insert("users", {
+ email: DEV_TENANT_EMAIL,
+ name: "Dev User",
+ createdAt: now,
+ updatedAt: now,
+ });
+ user = (await ctx.db.get(userId))!;
+ }
+
+ // Look for existing tenant via accountMembers → account → tenants
+ const tenantId = await getTenantIdFromUser(ctx, user._id);
+ if (tenantId) {
+ return tenantId;
+ }
+
+ // Create account + accountMembers + tenant
+ const now = Date.now();
+ const accountId = await ctx.db.insert("accounts", {
+ name: "Dev Account",
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ await ctx.db.insert("accountMembers", {
+ userId: user._id,
+ accountId,
+ role: "owner",
+ createdAt: now,
+ });
+
+ const newTenantId = await ctx.db.insert("tenants", {
+ accountId,
+ status: "active",
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ return newTenantId;
+}
+
+/**
+ * Unified tenant resolver for all tenant-scoped functions.
+ *
+ * Resolution order:
+ * 1. If `machineToken` provided → look up tenant by squadhub token
+ * 2. If `AUTH_ENABLED=true` → resolve from JWT identity
+ * 3. If `AUTH_ENABLED=false` → get or create default dev tenant
+ *
+ * Use this in queries (read-only ctx) when AUTH_ENABLED is true or machineToken is provided.
+ * Use `resolveTenantIdMut` in mutations when dev tenant auto-creation may be needed.
+ */
+export async function resolveTenantId(
+ ctx: ReadCtx,
+ args: { machineToken?: string },
+): Promise> {
+ if (args.machineToken) {
+ return getTenantIdFromToken(ctx, args.machineToken);
+ }
+
+ const authEnabled = process.env.AUTH_ENABLED !== "false";
+
+ if (authEnabled) {
+ return getTenantIdFromJwt(ctx);
+ }
+
+ // AUTH_ENABLED=false in a read-only context — try to find existing dev tenant
+ const user = await ctx.db
+ .query("users")
+ .withIndex("by_email", (q) => q.eq("email", DEV_TENANT_EMAIL))
+ .first();
+
+ if (user) {
+ const tenantId = await getTenantIdFromUser(ctx, user._id);
+ if (tenantId) {
+ return tenantId;
+ }
+ }
+
+ throw new Error("No dev tenant found. Run a mutation first to auto-create it.");
+}
+
+/**
+ * Mutation variant of resolveTenantId that can auto-create a dev tenant.
+ * Use this in mutations instead of `resolveTenantId` when AUTH_ENABLED=false.
+ */
+export async function resolveTenantIdMut(
+ ctx: MutationCtx,
+ args: { machineToken?: string },
+): Promise> {
+ if (args.machineToken) {
+ return getTenantIdFromToken(ctx, args.machineToken);
+ }
+
+ const authEnabled = process.env.AUTH_ENABLED !== "false";
+
+ if (authEnabled) {
+ return getTenantIdFromJwt(ctx);
+ }
+
+ return getOrCreateDevTenant(ctx);
+}
diff --git a/packages/backend/convex/messages.ts b/packages/backend/convex/messages.ts
index dbf8954..383e39d 100644
--- a/packages/backend/convex/messages.ts
+++ b/packages/backend/convex/messages.ts
@@ -1,15 +1,19 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
+import { resolveTenantId, resolveTenantIdMut } from "./lib/auth";
// List messages for a task
export const listForTask = query({
- args: { taskId: v.id("tasks") },
+ args: { taskId: v.id("tasks"), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
- const messages = await ctx.db
+ const tenantId = await resolveTenantId(ctx, args);
+ const allMessages = await ctx.db
.query("messages")
- .withIndex("by_task", (q) => q.eq("taskId", args.taskId))
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
+ const messages = allMessages.filter((m) => m.taskId === args.taskId);
+
// Enrich with author info
return Promise.all(
messages.map(async (m) => {
@@ -38,37 +42,49 @@ export const listByAgent = query({
args: {
sessionKey: v.string(),
limit: v.optional(v.number()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
- const agent = await ctx.db
+ const tenantId = await resolveTenantId(ctx, args);
+
+ const agents = await ctx.db
.query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey))
- .first();
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+ const agent = agents.find((a) => a.sessionKey === args.sessionKey);
if (!agent) {
return [];
}
- let query = ctx.db
+ const allMessages = await ctx.db
.query("messages")
- .withIndex("by_agent", (q) => q.eq("fromAgentId", agent._id))
- .order("desc");
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
- return args.limit ? await query.take(args.limit) : await query.collect();
+ const agentMessages = allMessages
+ .filter((m) => m.fromAgentId === agent._id)
+ .sort((a, b) => b.createdAt - a.createdAt);
+
+ return args.limit ? agentMessages.slice(0, args.limit) : agentMessages;
},
});
// Get recent messages
export const recent = query({
- args: { limit: v.optional(v.number()) },
+ args: { limit: v.optional(v.number()), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
const limit = args.limit ?? 50;
- const messages = await ctx.db
+ const allMessages = await ctx.db
.query("messages")
- .withIndex("by_created")
- .order("desc")
- .take(limit);
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+
+ const messages = allMessages
+ .sort((a, b) => b.createdAt - a.createdAt)
+ .slice(0, limit);
// Enrich with author and task info
return Promise.all(
@@ -113,13 +129,16 @@ export const create = mutation({
),
fromSessionKey: v.optional(v.string()),
humanAuthor: v.optional(v.string()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const { machineToken: _, ...rest } = args;
const now = Date.now();
let fromAgentId = undefined;
- if (args.fromSessionKey) {
- const sessionKey = args.fromSessionKey;
+ if (rest.fromSessionKey) {
+ const sessionKey = rest.fromSessionKey;
const agent = await ctx.db
.query("agents")
.withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey))
@@ -130,17 +149,18 @@ export const create = mutation({
}
const messageId = await ctx.db.insert("messages", {
- taskId: args.taskId,
+ tenantId,
+ taskId: rest.taskId,
fromAgentId,
- humanAuthor: args.humanAuthor,
- type: args.type ?? "comment",
- content: args.content,
+ humanAuthor: rest.humanAuthor,
+ type: rest.type ?? "comment",
+ content: rest.content,
createdAt: now,
});
// Update task timestamp if linked to a task
- if (args.taskId) {
- await ctx.db.patch(args.taskId, { updatedAt: now });
+ if (rest.taskId) {
+ await ctx.db.patch(rest.taskId, { updatedAt: now });
}
return messageId;
@@ -149,8 +169,9 @@ export const create = mutation({
// Delete a message
export const remove = mutation({
- args: { id: v.id("messages") },
+ args: { id: v.id("messages"), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
+ await resolveTenantIdMut(ctx, args);
await ctx.db.delete(args.id);
},
});
diff --git a/packages/backend/convex/notifications.ts b/packages/backend/convex/notifications.ts
index 5b22f71..bc009ef 100644
--- a/packages/backend/convex/notifications.ts
+++ b/packages/backend/convex/notifications.ts
@@ -1,15 +1,19 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
+import { resolveTenantId, resolveTenantIdMut } from "./lib/auth";
// Get undelivered notifications for an agent (by session key)
export const getUndelivered = query({
- args: { sessionKey: v.string() },
+ args: { sessionKey: v.string(), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
- // Find the agent
- const agent = await ctx.db
+ const tenantId = await resolveTenantId(ctx, args);
+
+ // Find the agent within this tenant
+ const agents = await ctx.db
.query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey))
- .first();
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+ const agent = agents.find((a) => a.sessionKey === args.sessionKey);
if (!agent) {
return [];
@@ -59,12 +63,16 @@ export const getForAgent = query({
args: {
sessionKey: v.string(),
limit: v.optional(v.number()),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
- const agent = await ctx.db
+ const tenantId = await resolveTenantId(ctx, args);
+
+ const agents = await ctx.db
.query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey))
- .first();
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+ const agent = agents.find((a) => a.sessionKey === args.sessionKey);
if (!agent) {
return [];
@@ -87,8 +95,10 @@ export const getForAgent = query({
export const markDelivered = mutation({
args: {
notificationIds: v.array(v.id("notifications")),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ await resolveTenantIdMut(ctx, args);
const now = Date.now();
for (const id of args.notificationIds) {
@@ -116,16 +126,19 @@ export const send = mutation({
),
taskId: v.optional(v.id("tasks")),
content: v.string(),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
const targetKey = args.targetSessionKey;
- // Find target agent
- const targetAgent = await ctx.db
+ // Find target agent within this tenant
+ const agents = await ctx.db
.query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", targetKey))
- .first();
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+ const targetAgent = agents.find((a) => a.sessionKey === targetKey);
if (!targetAgent) {
throw new Error(`Target agent not found: ${args.targetSessionKey}`);
@@ -134,11 +147,9 @@ export const send = mutation({
// Find source agent if provided
let sourceAgentId = undefined;
if (args.sourceSessionKey) {
- const sourceKey = args.sourceSessionKey;
- const sourceAgent = await ctx.db
- .query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sourceKey))
- .first();
+ const sourceAgent = agents.find(
+ (a) => a.sessionKey === args.sourceSessionKey,
+ );
if (sourceAgent) {
sourceAgentId = sourceAgent._id;
}
@@ -146,6 +157,7 @@ export const send = mutation({
// Create notification
const notificationId = await ctx.db.insert("notifications", {
+ tenantId,
targetAgentId: targetAgent._id,
sourceAgentId,
type: args.type,
@@ -157,6 +169,7 @@ export const send = mutation({
// Log activity
await ctx.db.insert("activities", {
+ tenantId,
type: "notification_sent",
agentId: sourceAgentId,
taskId: args.taskId,
@@ -184,32 +197,38 @@ export const sendToMany = mutation({
),
taskId: v.optional(v.id("tasks")),
content: v.string(),
+ machineToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
const notificationIds: string[] = [];
+ // Load all agents for this tenant once
+ const agents = await ctx.db
+ .query("agents")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+
// Find source agent if provided
let sourceAgentId = undefined;
if (args.sourceSessionKey) {
- const sourceKey = args.sourceSessionKey;
- const sourceAgent = await ctx.db
- .query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sourceKey))
- .first();
+ const sourceAgent = agents.find(
+ (a) => a.sessionKey === args.sourceSessionKey,
+ );
if (sourceAgent) {
sourceAgentId = sourceAgent._id;
}
}
for (const targetSessionKey of args.targetSessionKeys) {
- const targetAgent = await ctx.db
- .query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", targetSessionKey))
- .first();
+ const targetAgent = agents.find(
+ (a) => a.sessionKey === targetSessionKey,
+ );
if (targetAgent) {
const id = await ctx.db.insert("notifications", {
+ tenantId,
targetAgentId: targetAgent._id,
sourceAgentId,
type: args.type,
@@ -228,12 +247,15 @@ export const sendToMany = mutation({
// Clear all notifications for an agent (mark all as delivered)
export const clearAll = mutation({
- args: { sessionKey: v.string() },
+ args: { sessionKey: v.string(), machineToken: v.optional(v.string()) },
handler: async (ctx, args) => {
- const agent = await ctx.db
+ const tenantId = await resolveTenantIdMut(ctx, args);
+
+ const agents = await ctx.db
.query("agents")
- .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey))
- .first();
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+ const agent = agents.find((a) => a.sessionKey === args.sessionKey);
if (!agent) {
return 0;
diff --git a/packages/backend/convex/routines.ts b/packages/backend/convex/routines.ts
index 8351810..68f98b6 100644
--- a/packages/backend/convex/routines.ts
+++ b/packages/backend/convex/routines.ts
@@ -1,5 +1,6 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
+import { resolveTenantId, resolveTenantIdMut } from "./lib/auth";
// Schedule validator (reusable)
const scheduleValidator = v.object({
@@ -21,22 +22,32 @@ const priorityValidator = v.optional(
// List all routines (or only enabled ones)
export const list = query({
- args: { enabledOnly: v.optional(v.boolean()) },
+ args: {
+ machineToken: v.optional(v.string()),
+ enabledOnly: v.optional(v.boolean()),
+ },
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
+ const routines = await ctx.db
+ .query("routines")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect();
+
if (args.enabledOnly) {
- return await ctx.db
- .query("routines")
- .withIndex("by_enabled", (q) => q.eq("enabled", true))
- .collect();
+ return routines.filter((r) => r.enabled);
}
- return await ctx.db.query("routines").collect();
+ return routines;
},
});
// Get a single routine by ID
export const get = query({
- args: { routineId: v.id("routines") },
+ args: {
+ machineToken: v.optional(v.string()),
+ routineId: v.id("routines"),
+ },
handler: async (ctx, args) => {
+ await resolveTenantId(ctx, args);
return await ctx.db.get(args.routineId);
},
});
@@ -44,6 +55,7 @@ export const get = query({
// Create a new routine
export const create = mutation({
args: {
+ machineToken: v.optional(v.string()),
title: v.string(),
description: v.optional(v.string()),
priority: priorityValidator,
@@ -51,13 +63,16 @@ export const create = mutation({
color: v.string(),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const { machineToken: _, ...rest } = args;
const now = Date.now();
return await ctx.db.insert("routines", {
- title: args.title,
- description: args.description,
- priority: args.priority,
- schedule: args.schedule,
- color: args.color,
+ tenantId,
+ title: rest.title,
+ description: rest.description,
+ priority: rest.priority,
+ schedule: rest.schedule,
+ color: rest.color,
enabled: true,
createdAt: now,
updatedAt: now,
@@ -68,6 +83,7 @@ export const create = mutation({
// Update routine details
export const update = mutation({
args: {
+ machineToken: v.optional(v.string()),
routineId: v.id("routines"),
title: v.optional(v.string()),
description: v.optional(v.string()),
@@ -77,7 +93,8 @@ export const update = mutation({
enabled: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
- const { routineId, ...updates } = args;
+ await resolveTenantIdMut(ctx, args);
+ const { routineId, machineToken: _, ...updates } = args;
// Filter out undefined values
const filteredUpdates = Object.fromEntries(
@@ -93,16 +110,24 @@ export const update = mutation({
// Delete a routine
export const remove = mutation({
- args: { routineId: v.id("routines") },
+ args: {
+ machineToken: v.optional(v.string()),
+ routineId: v.id("routines"),
+ },
handler: async (ctx, args) => {
+ await resolveTenantIdMut(ctx, args);
await ctx.db.delete(args.routineId);
},
});
// Trigger a routine - create a task from the routine template
export const trigger = mutation({
- args: { routineId: v.id("routines") },
+ args: {
+ machineToken: v.optional(v.string()),
+ routineId: v.id("routines"),
+ },
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const routine = await ctx.db.get(args.routineId);
if (!routine) {
throw new Error("Routine not found");
@@ -136,6 +161,7 @@ export const trigger = mutation({
// Create task from routine template
const taskId = await ctx.db.insert("tasks", {
+ tenantId,
title: routine.title,
description: routine.description,
priority: routine.priority ?? "normal",
@@ -153,6 +179,7 @@ export const trigger = mutation({
// Log activity
await ctx.db.insert("activities", {
+ tenantId,
type: "task_created",
agentId: clawe?._id,
taskId,
@@ -177,20 +204,24 @@ export const trigger = mutation({
*/
export const getDueRoutines = query({
args: {
+ machineToken: v.optional(v.string()),
currentTimestamp: v.number(), // Current UTC timestamp from watcher
dayOfWeek: v.number(), // Current day in user's timezone (0-6)
hour: v.number(), // Current hour in user's timezone (0-23)
minute: v.number(), // Current minute in user's timezone (0-59)
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
const { currentTimestamp, dayOfWeek, hour, minute } = args;
- // Get all enabled routines
+ // Get all enabled routines for this tenant
const routines = await ctx.db
.query("routines")
- .withIndex("by_enabled", (q) => q.eq("enabled", true))
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
+ const enabledRoutines = routines.filter((r) => r.enabled);
+
// Current time as minutes since midnight (in user's timezone)
const currentMinuteOfDay = hour * 60 + minute;
@@ -200,7 +231,7 @@ export const getDueRoutines = query({
cycleStart: number;
}> = [];
- for (const routine of routines) {
+ for (const routine of enabledRoutines) {
// Check if today is a scheduled day
if (!routine.schedule.daysOfWeek.includes(dayOfWeek)) {
continue;
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index 34da711..4691edf 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -2,12 +2,66 @@ import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
+ // Users - Human users (created from Cognito JWT on first login)
+ users: defineTable({
+ email: v.string(),
+ name: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ }).index("by_email", ["email"]),
+
+ // Accounts - Billing/organizational unit (one auto-created per user on signup)
+ accounts: defineTable({
+ name: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ }),
+
+ // Account Members - join table linking users to accounts
+ accountMembers: defineTable({
+ userId: v.id("users"),
+ accountId: v.id("accounts"),
+ role: v.union(v.literal("owner"), v.literal("member")),
+ createdAt: v.number(),
+ })
+ .index("by_user", ["userId"])
+ .index("by_account", ["accountId"])
+ .index("by_user_account", ["userId", "accountId"]),
+
+ // Tenants - One per account, owns a squadhub instance
+ tenants: defineTable({
+ accountId: v.id("accounts"),
+ status: v.union(
+ v.literal("provisioning"),
+ v.literal("active"),
+ v.literal("stopped"),
+ v.literal("error"),
+ ),
+ squadhubUrl: v.optional(v.string()),
+ squadhubToken: v.optional(v.string()),
+ squadhubServiceArn: v.optional(v.string()),
+ efsAccessPointId: v.optional(v.string()),
+ anthropicApiKey: v.optional(v.string()),
+ settings: v.optional(
+ v.object({
+ timezone: v.optional(v.string()),
+ onboardingComplete: v.optional(v.boolean()),
+ }),
+ ),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_account", ["accountId"])
+ .index("by_status", ["status"])
+ .index("by_squadhubToken", ["squadhubToken"]),
+
// Agents - AI agent profiles with coordination support
agents: defineTable({
+ tenantId: v.id("tenants"),
name: v.string(),
role: v.string(), // "Squad Lead", "Content Writer", etc.
emoji: v.optional(v.string()), // "🦞", "✍️", etc.
- sessionKey: v.string(), // "agent:main:main" - agency session key
+ sessionKey: v.string(), // "agent:main:main" - squadhub session key
status: v.union(v.literal("online"), v.literal("offline")),
currentTaskId: v.optional(v.id("tasks")),
config: v.optional(v.any()), // Agent-specific configuration
@@ -17,12 +71,14 @@ export default defineSchema({
createdAt: v.number(),
updatedAt: v.number(),
})
+ .index("by_tenant", ["tenantId"])
.index("by_sessionKey", ["sessionKey"])
.index("by_status", ["status"])
.index("by_lastSeen", ["lastSeen"]),
// Tasks - Mission queue with full workflow support
tasks: defineTable({
+ tenantId: v.id("tenants"),
title: v.string(),
description: v.optional(v.string()),
status: v.union(
@@ -70,11 +126,13 @@ export default defineSchema({
updatedAt: v.number(),
completedAt: v.optional(v.number()),
})
+ .index("by_tenant", ["tenantId"])
.index("by_status", ["status"])
.index("by_createdAt", ["createdAt"]),
// Messages - Task comments and agent communication
messages: defineTable({
+ tenantId: v.id("tenants"),
taskId: v.optional(v.id("tasks")),
fromAgentId: v.optional(v.id("agents")), // Optional for human users
humanAuthor: v.optional(v.string()), // For human commenters
@@ -88,12 +146,14 @@ export default defineSchema({
metadata: v.optional(v.any()),
createdAt: v.number(),
})
+ .index("by_tenant", ["tenantId"])
.index("by_task", ["taskId"])
.index("by_agent", ["fromAgentId"])
.index("by_created", ["createdAt"]),
// Notifications - Agent-to-agent coordination
notifications: defineTable({
+ tenantId: v.id("tenants"),
targetAgentId: v.id("agents"), // Who receives this
sourceAgentId: v.optional(v.id("agents")), // Who triggered it (optional for system)
type: v.union(
@@ -111,12 +171,14 @@ export default defineSchema({
deliveredAt: v.optional(v.number()),
createdAt: v.number(),
})
+ .index("by_tenant", ["tenantId"])
.index("by_target_undelivered", ["targetAgentId", "delivered"])
.index("by_target", ["targetAgentId"])
.index("by_createdAt", ["createdAt"]),
// Activities - Audit log / activity feed
activities: defineTable({
+ tenantId: v.id("tenants"),
type: v.union(
v.literal("task_created"),
v.literal("task_assigned"),
@@ -133,6 +195,7 @@ export default defineSchema({
metadata: v.optional(v.any()),
createdAt: v.number(),
})
+ .index("by_tenant", ["tenantId"])
.index("by_type", ["type"])
.index("by_agent", ["agentId"])
.index("by_task", ["taskId"])
@@ -140,6 +203,7 @@ export default defineSchema({
// Documents - Deliverables and file references
documents: defineTable({
+ tenantId: v.id("tenants"),
title: v.string(),
content: v.optional(v.string()), // Markdown content (for text docs)
path: v.optional(v.string()), // File path (for file deliverables)
@@ -155,19 +219,14 @@ export default defineSchema({
createdAt: v.number(),
updatedAt: v.number(),
})
+ .index("by_tenant", ["tenantId"])
.index("by_task", ["taskId"])
.index("by_type", ["type"])
.index("by_agent", ["createdBy"]),
- // Settings - Key-value store for app configuration
- settings: defineTable({
- key: v.string(),
- value: v.any(),
- updatedAt: v.number(),
- }).index("by_key", ["key"]),
-
// Business Context - Website/business info for agent context
businessContext: defineTable({
+ tenantId: v.id("tenants"),
url: v.string(),
name: v.optional(v.string()),
description: v.optional(v.string()),
@@ -186,19 +245,23 @@ export default defineSchema({
approvedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
- }),
+ }).index("by_tenant", ["tenantId"]),
// Channels - Connected messaging channels (Telegram, etc.)
channels: defineTable({
+ tenantId: v.id("tenants"),
type: v.string(),
status: v.union(v.literal("connected"), v.literal("disconnected")),
accountId: v.optional(v.string()),
connectedAt: v.optional(v.number()),
metadata: v.optional(v.any()),
- }).index("by_type", ["type"]),
+ })
+ .index("by_tenant_type", ["tenantId", "type"])
+ .index("by_type", ["type"]),
// Routines - Recurring task templates with schedules
routines: defineTable({
+ tenantId: v.id("tenants"),
// Template info (used to create tasks)
title: v.string(),
description: v.optional(v.string()),
@@ -230,5 +293,7 @@ export default defineSchema({
// Metadata
createdAt: v.number(),
updatedAt: v.number(),
- }).index("by_enabled", ["enabled"]),
+ })
+ .index("by_tenant", ["tenantId"])
+ .index("by_enabled", ["enabled"]),
});
diff --git a/packages/backend/convex/settings.ts b/packages/backend/convex/settings.ts
deleted file mode 100644
index 8bc9878..0000000
--- a/packages/backend/convex/settings.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { query, mutation } from "./_generated/server";
-import { v } from "convex/values";
-
-export const get = query({
- args: { key: v.string() },
- handler: async (ctx, args) => {
- return await ctx.db
- .query("settings")
- .withIndex("by_key", (q) => q.eq("key", args.key))
- .first();
- },
-});
-
-export const set = mutation({
- args: { key: v.string(), value: v.any() },
- handler: async (ctx, args) => {
- const existing = await ctx.db
- .query("settings")
- .withIndex("by_key", (q) => q.eq("key", args.key))
- .first();
-
- if (existing) {
- await ctx.db.patch(existing._id, {
- value: args.value,
- updatedAt: Date.now(),
- });
- return existing._id;
- }
-
- return await ctx.db.insert("settings", {
- key: args.key,
- value: args.value,
- updatedAt: Date.now(),
- });
- },
-});
-
-export const isOnboardingComplete = query({
- args: {},
- handler: async (ctx) => {
- const setting = await ctx.db
- .query("settings")
- .withIndex("by_key", (q) => q.eq("key", "onboarding_complete"))
- .first();
- return setting?.value === true;
- },
-});
-
-export const completeOnboarding = mutation({
- args: {},
- handler: async (ctx) => {
- const existing = await ctx.db
- .query("settings")
- .withIndex("by_key", (q) => q.eq("key", "onboarding_complete"))
- .first();
-
- if (existing) {
- await ctx.db.patch(existing._id, {
- value: true,
- updatedAt: Date.now(),
- });
- return existing._id;
- }
-
- return await ctx.db.insert("settings", {
- key: "onboarding_complete",
- value: true,
- updatedAt: Date.now(),
- });
- },
-});
-
-// Timezone settings
-const DEFAULT_TIMEZONE = "America/New_York";
-
-export const getTimezone = query({
- args: {},
- handler: async (ctx) => {
- const setting = await ctx.db
- .query("settings")
- .withIndex("by_key", (q) => q.eq("key", "timezone"))
- .first();
- return (setting?.value as string) ?? DEFAULT_TIMEZONE;
- },
-});
-
-export const setTimezone = mutation({
- args: { timezone: v.string() },
- handler: async (ctx, args) => {
- const existing = await ctx.db
- .query("settings")
- .withIndex("by_key", (q) => q.eq("key", "timezone"))
- .first();
-
- if (existing) {
- await ctx.db.patch(existing._id, {
- value: args.timezone,
- updatedAt: Date.now(),
- });
- return existing._id;
- }
-
- return await ctx.db.insert("settings", {
- key: "timezone",
- value: args.timezone,
- updatedAt: Date.now(),
- });
- },
-});
diff --git a/packages/backend/convex/tasks.ts b/packages/backend/convex/tasks.ts
index 8abdffe..ee1f0a8 100644
--- a/packages/backend/convex/tasks.ts
+++ b/packages/backend/convex/tasks.ts
@@ -1,10 +1,12 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
+import { resolveTenantId, resolveTenantIdMut } from "./lib/auth";
// List all tasks with optional filters
export const list = query({
args: {
+ machineToken: v.optional(v.string()),
status: v.optional(
v.union(
v.literal("inbox"),
@@ -17,21 +19,16 @@ export const list = query({
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
- let tasks;
+ const tenantId = await resolveTenantId(ctx, args);
+
+ let tasks = await ctx.db
+ .query("tasks")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .order("desc")
+ .collect();
if (args.status) {
- const status = args.status;
- tasks = await ctx.db
- .query("tasks")
- .withIndex("by_status", (q) => q.eq("status", status))
- .order("desc")
- .collect();
- } else {
- tasks = await ctx.db
- .query("tasks")
- .withIndex("by_createdAt")
- .order("desc")
- .collect();
+ tasks = tasks.filter((t) => t.status === args.status);
}
if (args.limit) {
@@ -74,8 +71,13 @@ export const list = query({
// Get tasks for a specific agent
export const getForAgent = query({
- args: { sessionKey: v.string() },
+ args: {
+ machineToken: v.optional(v.string()),
+ sessionKey: v.string(),
+ },
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
+
const agent = await ctx.db
.query("agents")
.withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey))
@@ -85,10 +87,10 @@ export const getForAgent = query({
return [];
}
- // Get all non-done tasks and filter by assignee
+ // Get all tasks for this tenant and filter by assignee
const allTasks = await ctx.db
.query("tasks")
- .withIndex("by_createdAt")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.order("desc")
.collect();
@@ -100,8 +102,13 @@ export const getForAgent = query({
// Get task by ID with full details
export const get = query({
- args: { taskId: v.id("tasks") },
+ args: {
+ machineToken: v.optional(v.string()),
+ taskId: v.id("tasks"),
+ },
handler: async (ctx, args) => {
+ await resolveTenantId(ctx, args);
+
const task = await ctx.db.get(args.taskId);
if (!task) return null;
@@ -188,6 +195,7 @@ export const get = query({
// Create a new task
export const create = mutation({
args: {
+ machineToken: v.optional(v.string()),
title: v.string(),
description: v.optional(v.string()),
priority: v.optional(
@@ -202,6 +210,7 @@ export const create = mutation({
createdBySessionKey: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
// Find assignee if provided
@@ -232,6 +241,7 @@ export const create = mutation({
}
const taskId = await ctx.db.insert("tasks", {
+ tenantId,
title: args.title,
description: args.description,
status: assigneeIds.length > 0 ? "assigned" : "inbox",
@@ -244,6 +254,7 @@ export const create = mutation({
// Log activity
await ctx.db.insert("activities", {
+ tenantId,
type: "task_created",
agentId: createdBy,
taskId,
@@ -257,6 +268,7 @@ export const create = mutation({
const assignee = await ctx.db.get(firstAssigneeId);
if (assignee) {
await ctx.db.insert("notifications", {
+ tenantId,
targetAgentId: assignee._id,
sourceAgentId: createdBy,
type: "task_assigned",
@@ -275,6 +287,7 @@ export const create = mutation({
// Update task status
export const updateStatus = mutation({
args: {
+ machineToken: v.optional(v.string()),
taskId: v.id("tasks"),
status: v.union(
v.literal("inbox"),
@@ -286,6 +299,7 @@ export const updateStatus = mutation({
bySessionKey: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
const task = await ctx.db.get(args.taskId);
if (!task) throw new Error("Task not found");
@@ -337,6 +351,7 @@ export const updateStatus = mutation({
// Log activity
await ctx.db.insert("activities", {
+ tenantId,
type: "task_status_changed",
agentId,
taskId: args.taskId,
@@ -348,6 +363,7 @@ export const updateStatus = mutation({
// Send notifications for review
if (args.status === "review" && task.createdBy) {
await ctx.db.insert("notifications", {
+ tenantId,
targetAgentId: task.createdBy,
sourceAgentId: agentId,
type: "review_requested",
@@ -363,10 +379,12 @@ export const updateStatus = mutation({
// Approve a task in review → done
export const approve = mutation({
args: {
+ machineToken: v.optional(v.string()),
taskId: v.id("tasks"),
humanAuthor: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
const task = await ctx.db.get(args.taskId);
if (!task) throw new Error("Task not found");
@@ -383,6 +401,7 @@ export const approve = mutation({
// Add comment
await ctx.db.insert("messages", {
+ tenantId,
taskId: args.taskId,
humanAuthor: authorName,
type: "comment",
@@ -392,6 +411,7 @@ export const approve = mutation({
// Log activity
await ctx.db.insert("activities", {
+ tenantId,
type: "task_status_changed",
taskId: args.taskId,
message: `${authorName} approved "${task.title}"`,
@@ -403,6 +423,7 @@ export const approve = mutation({
if (task.assigneeIds) {
for (const assigneeId of task.assigneeIds) {
await ctx.db.insert("notifications", {
+ tenantId,
targetAgentId: assigneeId,
type: "task_completed",
taskId: args.taskId,
@@ -418,11 +439,13 @@ export const approve = mutation({
// Request changes on a task in review → back to in_progress
export const requestChanges = mutation({
args: {
+ machineToken: v.optional(v.string()),
taskId: v.id("tasks"),
feedback: v.string(),
humanAuthor: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
const task = await ctx.db.get(args.taskId);
if (!task) throw new Error("Task not found");
@@ -438,6 +461,7 @@ export const requestChanges = mutation({
// Add feedback as comment
await ctx.db.insert("messages", {
+ tenantId,
taskId: args.taskId,
humanAuthor: authorName,
type: "comment",
@@ -447,6 +471,7 @@ export const requestChanges = mutation({
// Log activity
await ctx.db.insert("activities", {
+ tenantId,
type: "task_status_changed",
taskId: args.taskId,
message: `${authorName} requested changes on "${task.title}"`,
@@ -458,6 +483,7 @@ export const requestChanges = mutation({
if (task.assigneeIds) {
for (const assigneeId of task.assigneeIds) {
await ctx.db.insert("notifications", {
+ tenantId,
targetAgentId: assigneeId,
type: "task_assigned",
taskId: args.taskId,
@@ -473,11 +499,13 @@ export const requestChanges = mutation({
// Assign task to agent(s)
export const assign = mutation({
args: {
+ machineToken: v.optional(v.string()),
taskId: v.id("tasks"),
assigneeSessionKeys: v.array(v.string()),
bySessionKey: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
const task = await ctx.db.get(args.taskId);
if (!task) throw new Error("Task not found");
@@ -517,6 +545,7 @@ export const assign = mutation({
// Send notifications to assignees
for (const assigneeId of assigneeIds) {
await ctx.db.insert("notifications", {
+ tenantId,
targetAgentId: assigneeId,
sourceAgentId: assignerId,
type: "task_assigned",
@@ -529,6 +558,7 @@ export const assign = mutation({
// Log activity
await ctx.db.insert("activities", {
+ tenantId,
type: "task_assigned",
agentId: assignerId,
taskId: args.taskId,
@@ -541,12 +571,14 @@ export const assign = mutation({
// Add a comment to a task
export const addComment = mutation({
args: {
+ machineToken: v.optional(v.string()),
taskId: v.id("tasks"),
content: v.string(),
bySessionKey: v.optional(v.string()),
humanAuthor: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
let fromAgentId = undefined;
@@ -565,6 +597,7 @@ export const addComment = mutation({
}
const messageId = await ctx.db.insert("messages", {
+ tenantId,
taskId: args.taskId,
fromAgentId,
humanAuthor: args.humanAuthor,
@@ -578,6 +611,7 @@ export const addComment = mutation({
// Log activity
await ctx.db.insert("activities", {
+ tenantId,
type: "message_sent",
agentId: fromAgentId,
taskId: args.taskId,
@@ -592,12 +626,15 @@ export const addComment = mutation({
// Add a subtask
export const addSubtask = mutation({
args: {
+ machineToken: v.optional(v.string()),
taskId: v.id("tasks"),
title: v.string(),
description: v.optional(v.string()),
assigneeSessionKey: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ await resolveTenantIdMut(ctx, args);
+
const task = await ctx.db.get(args.taskId);
if (!task) throw new Error("Task not found");
@@ -636,6 +673,7 @@ export const addSubtask = mutation({
// Update subtask status
export const updateSubtask = mutation({
args: {
+ machineToken: v.optional(v.string()),
taskId: v.id("tasks"),
subtaskIndex: v.number(),
done: v.optional(v.boolean()),
@@ -651,6 +689,7 @@ export const updateSubtask = mutation({
bySessionKey: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
const task = await ctx.db.get(args.taskId);
if (!task) throw new Error("Task not found");
@@ -705,6 +744,7 @@ export const updateSubtask = mutation({
// Log activity
if (isDone) {
await ctx.db.insert("activities", {
+ tenantId,
type: "subtask_completed",
agentId,
taskId: args.taskId,
@@ -713,6 +753,7 @@ export const updateSubtask = mutation({
});
} else if (newStatus === "blocked") {
await ctx.db.insert("activities", {
+ tenantId,
type: "subtask_blocked" as any,
agentId,
taskId: args.taskId,
@@ -723,6 +764,7 @@ export const updateSubtask = mutation({
// Notify task creator about the blocked subtask
if (task.createdBy) {
await ctx.db.insert("notifications", {
+ tenantId,
targetAgentId: task.createdBy,
sourceAgentId: agentId,
type: "review_requested",
@@ -739,6 +781,7 @@ export const updateSubtask = mutation({
// Update task details
export const update = mutation({
args: {
+ machineToken: v.optional(v.string()),
taskId: v.id("tasks"),
title: v.optional(v.string()),
description: v.optional(v.string()),
@@ -752,7 +795,8 @@ export const update = mutation({
),
},
handler: async (ctx, args) => {
- const { taskId, ...updates } = args;
+ await resolveTenantIdMut(ctx, args);
+ const { machineToken, taskId, ...updates } = args;
const filteredUpdates = Object.fromEntries(
Object.entries(updates).filter(([, value]) => value !== undefined),
);
@@ -767,6 +811,7 @@ export const update = mutation({
// Create a task from the dashboard (attributed to Clawe)
export const createFromDashboard = mutation({
args: {
+ machineToken: v.optional(v.string()),
title: v.string(),
description: v.optional(v.string()),
priority: v.optional(
@@ -779,6 +824,7 @@ export const createFromDashboard = mutation({
),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
// Find Clawe (main leader) to attribute the task creation
@@ -788,6 +834,7 @@ export const createFromDashboard = mutation({
.first();
const taskId = await ctx.db.insert("tasks", {
+ tenantId,
title: args.title,
description: args.description,
status: "inbox",
@@ -799,6 +846,7 @@ export const createFromDashboard = mutation({
// Log activity
await ctx.db.insert("activities", {
+ tenantId,
type: "task_created",
agentId: clawe?._id,
taskId,
@@ -813,6 +861,7 @@ export const createFromDashboard = mutation({
// Create a full task with description, subtasks, and assignments in one atomic operation
export const createWithPlan = mutation({
args: {
+ machineToken: v.optional(v.string()),
title: v.string(),
description: v.string(),
priority: v.optional(
@@ -834,6 +883,7 @@ export const createWithPlan = mutation({
),
},
handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
const now = Date.now();
// Resolve creator
@@ -893,6 +943,7 @@ export const createWithPlan = mutation({
// Create the task
const taskId = await ctx.db.insert("tasks", {
+ tenantId,
title: args.title,
description: args.description,
status: assigneeIds.length > 0 ? "assigned" : "inbox",
@@ -906,6 +957,7 @@ export const createWithPlan = mutation({
// Log activity
await ctx.db.insert("activities", {
+ tenantId,
type: "task_created",
agentId: createdBy,
taskId,
@@ -928,6 +980,7 @@ export const createWithPlan = mutation({
: `📋 New task assigned: "${args.title}"`;
await ctx.db.insert("notifications", {
+ tenantId,
targetAgentId: assigneeId,
sourceAgentId: createdBy,
type: "task_assigned",
@@ -945,8 +998,13 @@ export const createWithPlan = mutation({
// Delete a task
export const remove = mutation({
- args: { taskId: v.id("tasks") },
+ args: {
+ machineToken: v.optional(v.string()),
+ taskId: v.id("tasks"),
+ },
handler: async (ctx, args) => {
+ await resolveTenantIdMut(ctx, args);
+
// Also delete related messages
const messages = await ctx.db
.query("messages")
diff --git a/packages/backend/convex/tenants.ts b/packages/backend/convex/tenants.ts
new file mode 100644
index 0000000..10f7642
--- /dev/null
+++ b/packages/backend/convex/tenants.ts
@@ -0,0 +1,68 @@
+import { v } from "convex/values";
+import { query, mutation } from "./_generated/server";
+import { resolveTenantId, resolveTenantIdMut } from "./lib/auth";
+
+const DEFAULT_TIMEZONE = "America/New_York";
+
+// Get timezone for the current tenant
+export const getTimezone = query({
+ args: { machineToken: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
+ const tenant = await ctx.db.get(tenantId);
+ return tenant?.settings?.timezone ?? DEFAULT_TIMEZONE;
+ },
+});
+
+// Set timezone for the current tenant
+export const setTimezone = mutation({
+ args: {
+ machineToken: v.optional(v.string()),
+ timezone: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const tenant = await ctx.db.get(tenantId);
+ if (!tenant) {
+ throw new Error("Tenant not found");
+ }
+
+ await ctx.db.patch(tenantId, {
+ settings: {
+ ...tenant.settings,
+ timezone: args.timezone,
+ },
+ updatedAt: Date.now(),
+ });
+ },
+});
+
+// Check if onboarding is complete for the current tenant
+export const isOnboardingComplete = query({
+ args: { machineToken: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantId(ctx, args);
+ const tenant = await ctx.db.get(tenantId);
+ return tenant?.settings?.onboardingComplete === true;
+ },
+});
+
+// Mark onboarding as complete for the current tenant
+export const completeOnboarding = mutation({
+ args: { machineToken: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const tenant = await ctx.db.get(tenantId);
+ if (!tenant) {
+ throw new Error("Tenant not found");
+ }
+
+ await ctx.db.patch(tenantId, {
+ settings: {
+ ...tenant.settings,
+ onboardingComplete: true,
+ },
+ updatedAt: Date.now(),
+ });
+ },
+});
diff --git a/packages/backend/convex/tsconfig.json b/packages/backend/convex/tsconfig.json
index 7374127..62eda33 100644
--- a/packages/backend/convex/tsconfig.json
+++ b/packages/backend/convex/tsconfig.json
@@ -11,6 +11,7 @@
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
+ "types": ["node"],
/* These compiler options are required by Convex */
"target": "ESNext",
diff --git a/packages/backend/convex/users.ts b/packages/backend/convex/users.ts
new file mode 100644
index 0000000..f72c5e9
--- /dev/null
+++ b/packages/backend/convex/users.ts
@@ -0,0 +1,85 @@
+import { query, mutation } from "./_generated/server";
+import { v } from "convex/values";
+
+export const getOrCreateFromAuth = mutation({
+ args: {},
+ handler: async (ctx) => {
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity) {
+ throw new Error("Not authenticated");
+ }
+
+ const email = identity.email;
+ if (!email) {
+ throw new Error("No email in auth identity");
+ }
+
+ const existing = await ctx.db
+ .query("users")
+ .withIndex("by_email", (q) => q.eq("email", email))
+ .first();
+
+ if (existing) {
+ return existing;
+ }
+
+ const now = Date.now();
+ const userId = await ctx.db.insert("users", {
+ email,
+ name: identity.name ?? undefined,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ return (await ctx.db.get(userId))!;
+ },
+});
+
+export const get = query({
+ args: {},
+ handler: async (ctx) => {
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity) {
+ return null;
+ }
+
+ const email = identity.email;
+ if (!email) {
+ return null;
+ }
+
+ return await ctx.db
+ .query("users")
+ .withIndex("by_email", (q) => q.eq("email", email))
+ .first();
+ },
+});
+
+export const update = mutation({
+ args: { name: v.string() },
+ handler: async (ctx, args) => {
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity) {
+ throw new Error("Not authenticated");
+ }
+
+ const email = identity.email;
+ if (!email) {
+ throw new Error("No email in auth identity");
+ }
+
+ const user = await ctx.db
+ .query("users")
+ .withIndex("by_email", (q) => q.eq("email", email))
+ .first();
+
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ await ctx.db.patch(user._id, {
+ name: args.name,
+ updatedAt: Date.now(),
+ });
+ },
+});
diff --git a/packages/backend/package.json b/packages/backend/package.json
index bb92fe8..2811805 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -32,6 +32,7 @@
},
"devDependencies": {
"@clawe/typescript-config": "workspace:*",
+ "@types/node": "^25.2.3",
"typescript": "5.9.2"
}
}
diff --git a/packages/cli/src/client.spec.ts b/packages/cli/src/client.spec.ts
index 7269de2..9008811 100644
--- a/packages/cli/src/client.spec.ts
+++ b/packages/cli/src/client.spec.ts
@@ -12,12 +12,33 @@ describe("client", () => {
process.env = originalEnv;
});
- it("exports a ConvexHttpClient when CONVEX_URL is set", async () => {
+ it("exports query, mutation, action wrappers when CONVEX_URL is set", async () => {
process.env.CONVEX_URL = "https://test.convex.cloud";
- const { client } = await import("./client.js");
+ const mod = await import("./client.js");
- expect(client).toBeDefined();
+ expect(mod.query).toBeTypeOf("function");
+ expect(mod.mutation).toBeTypeOf("function");
+ expect(mod.action).toBeTypeOf("function");
+ expect(mod.uploadFile).toBeTypeOf("function");
+ });
+
+ it("exports machineToken from SQUADHUB_TOKEN env var", async () => {
+ process.env.CONVEX_URL = "https://test.convex.cloud";
+ process.env.SQUADHUB_TOKEN = "test-machine-token";
+
+ const { machineToken } = await import("./client.js");
+
+ expect(machineToken).toBe("test-machine-token");
+ });
+
+ it("defaults machineToken to empty string when SQUADHUB_TOKEN is not set", async () => {
+ process.env.CONVEX_URL = "https://test.convex.cloud";
+ delete process.env.SQUADHUB_TOKEN;
+
+ const { machineToken } = await import("./client.js");
+
+ expect(machineToken).toBe("");
});
it("exits with error when CONVEX_URL is not set", async () => {
diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts
index 87be529..07df7ad 100644
--- a/packages/cli/src/client.ts
+++ b/packages/cli/src/client.ts
@@ -1,10 +1,22 @@
import { ConvexHttpClient } from "convex/browser";
+import type {
+ FunctionReference,
+ FunctionReturnType,
+ OptionalRestArgs,
+} from "convex/server";
import { api } from "@clawe/backend";
import * as fs from "fs";
import * as path from "path";
const CONVEX_URL = process.env.CONVEX_URL;
+/**
+ * Machine token read from SQUADHUB_TOKEN env var.
+ * Used to identify which tenant this CLI session belongs to.
+ * TODO (Phase 2.6): Inject into all Convex calls for tenant scoping.
+ */
+export const machineToken = process.env.SQUADHUB_TOKEN || "";
+
// Common MIME types by extension
const MIME_TYPES: Record = {
".md": "text/markdown",
@@ -33,7 +45,40 @@ if (!CONVEX_URL) {
process.exit(1);
}
-export const client = new ConvexHttpClient(CONVEX_URL);
+const client = new ConvexHttpClient(CONVEX_URL);
+
+/**
+ * Wrapper around ConvexHttpClient.query.
+ * TODO (Phase 2.6): Inject machineToken into args for tenant scoping.
+ */
+export function query>(
+ fn: F,
+ ...args: OptionalRestArgs
+): Promise> {
+ return client.query(fn, ...args);
+}
+
+/**
+ * Wrapper around ConvexHttpClient.mutation.
+ * TODO (Phase 2.6): Inject machineToken into args for tenant scoping.
+ */
+export function mutation>(
+ fn: F,
+ ...args: OptionalRestArgs
+): Promise> {
+ return client.mutation(fn, ...args);
+}
+
+/**
+ * Wrapper around ConvexHttpClient.action.
+ * TODO (Phase 2.6): Inject machineToken into args for tenant scoping.
+ */
+export function action>(
+ fn: F,
+ ...args: OptionalRestArgs
+): Promise> {
+ return client.action(fn, ...args);
+}
/**
* Upload a file to Convex storage
@@ -44,7 +89,7 @@ export async function uploadFile(filePath: string): Promise {
const fileBuffer = fs.readFileSync(filePath);
// Get upload URL from Convex
- let uploadUrl = await client.action(api.documents.generateUploadUrl, {});
+ let uploadUrl = await action(api.documents.generateUploadUrl, {});
// When running in Docker, rewrite localhost/127.0.0.1 to host.docker.internal
// so the container can reach the host's Convex dev server
diff --git a/packages/cli/src/commands/agent-register.spec.ts b/packages/cli/src/commands/agent-register.spec.ts
index 8ac91bb..45e6075 100644
--- a/packages/cli/src/commands/agent-register.spec.ts
+++ b/packages/cli/src/commands/agent-register.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { agentRegister } from "./agent-register.js";
vi.mock("../client.js", () => ({
- client: {
- mutation: vi.fn(),
- },
+ mutation: vi.fn(),
}));
-import { client } from "../client.js";
+import { mutation } from "../client.js";
describe("agentRegister", () => {
beforeEach(() => {
@@ -16,11 +14,11 @@ describe("agentRegister", () => {
});
it("registers an agent with basic info", async () => {
- vi.mocked(client.mutation).mockResolvedValue("agent-123");
+ vi.mocked(mutation).mockResolvedValue("agent-123");
await agentRegister("Scout", "SEO Analyst", "agent:scout:main", {});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
name: "Scout",
role: "SEO Analyst",
sessionKey: "agent:scout:main",
@@ -32,13 +30,13 @@ describe("agentRegister", () => {
});
it("registers an agent with emoji", async () => {
- vi.mocked(client.mutation).mockResolvedValue("agent-456");
+ vi.mocked(mutation).mockResolvedValue("agent-456");
await agentRegister("Pixel", "Designer", "agent:pixel:main", {
emoji: "🎨",
});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
name: "Pixel",
role: "Designer",
sessionKey: "agent:pixel:main",
@@ -50,13 +48,13 @@ describe("agentRegister", () => {
});
it("handles upsert (update existing agent)", async () => {
- vi.mocked(client.mutation).mockResolvedValue("existing-agent-id");
+ vi.mocked(mutation).mockResolvedValue("existing-agent-id");
await agentRegister("Clawe", "Squad Lead", "agent:main:main", {
emoji: "🦞",
});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
name: "Clawe",
role: "Squad Lead",
sessionKey: "agent:main:main",
diff --git a/packages/cli/src/commands/agent-register.ts b/packages/cli/src/commands/agent-register.ts
index 154056b..7e261fb 100644
--- a/packages/cli/src/commands/agent-register.ts
+++ b/packages/cli/src/commands/agent-register.ts
@@ -1,4 +1,4 @@
-import { client } from "../client.js";
+import { mutation } from "../client.js";
import { api } from "@clawe/backend";
interface AgentRegisterOptions {
@@ -11,7 +11,7 @@ export async function agentRegister(
sessionKey: string,
options: AgentRegisterOptions,
): Promise {
- const agentId = await client.mutation(api.agents.upsert, {
+ const agentId = await mutation(api.agents.upsert, {
name,
role,
sessionKey,
diff --git a/packages/cli/src/commands/business-get.ts b/packages/cli/src/commands/business-get.ts
index cda333b..b93e575 100644
--- a/packages/cli/src/commands/business-get.ts
+++ b/packages/cli/src/commands/business-get.ts
@@ -1,4 +1,4 @@
-import { client } from "../client.js";
+import { query } from "../client.js";
import { api } from "@clawe/backend";
/**
@@ -6,7 +6,7 @@ import { api } from "@clawe/backend";
* Any agent can use this to understand the business they're working for.
*/
export async function businessGet(): Promise {
- const context = await client.query(api.businessContext.get, {});
+ const context = await query(api.businessContext.get, {});
if (!context) {
console.log("Business context not configured.");
diff --git a/packages/cli/src/commands/business-set.ts b/packages/cli/src/commands/business-set.ts
index 86a850c..9680f01 100644
--- a/packages/cli/src/commands/business-set.ts
+++ b/packages/cli/src/commands/business-set.ts
@@ -1,7 +1,7 @@
import { promises as fs } from "fs";
import path from "path";
import os from "os";
-import { client } from "../client.js";
+import { mutation } from "../client.js";
import { api } from "@clawe/backend";
export type BusinessSetOptions = {
@@ -33,7 +33,7 @@ export async function businessSet(
}
// Save to Convex
- const id = await client.mutation(api.businessContext.save, {
+ const id = await mutation(api.businessContext.save, {
url,
name: options.name,
description: options.description,
@@ -59,10 +59,10 @@ export async function businessSet(
// Remove BOOTSTRAP.md if requested
if (options.removeBootstrap) {
- const agencyStateDir =
- process.env.AGENCY_STATE_DIR || path.join(os.homedir(), ".agency");
+ const squadhubStateDir =
+ process.env.SQUADHUB_STATE_DIR || path.join(os.homedir(), ".squadhub");
const bootstrapPath = path.join(
- agencyStateDir,
+ squadhubStateDir,
"workspaces",
"clawe",
"BOOTSTRAP.md",
diff --git a/packages/cli/src/commands/check.spec.ts b/packages/cli/src/commands/check.spec.ts
index f543092..2d80852 100644
--- a/packages/cli/src/commands/check.spec.ts
+++ b/packages/cli/src/commands/check.spec.ts
@@ -2,13 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { check } from "./check.js";
vi.mock("../client.js", () => ({
- client: {
- mutation: vi.fn(),
- query: vi.fn(),
- },
+ mutation: vi.fn(),
+ query: vi.fn(),
}));
-import { client } from "../client.js";
+import { mutation, query } from "../client.js";
describe("check", () => {
beforeEach(() => {
@@ -17,19 +15,19 @@ describe("check", () => {
});
it("outputs HEARTBEAT_OK when no notifications", async () => {
- vi.mocked(client.mutation).mockResolvedValue("agent-id");
- vi.mocked(client.query).mockResolvedValue([]);
+ vi.mocked(mutation).mockResolvedValue("agent-id");
+ vi.mocked(query).mockResolvedValue([]);
await check("agent:main:main");
- expect(client.mutation).toHaveBeenCalled();
- expect(client.query).toHaveBeenCalled();
+ expect(mutation).toHaveBeenCalled();
+ expect(query).toHaveBeenCalled();
expect(console.log).toHaveBeenCalledWith("HEARTBEAT_OK");
});
it("displays notifications when present", async () => {
- vi.mocked(client.mutation).mockResolvedValue("agent-id");
- vi.mocked(client.query).mockResolvedValue([
+ vi.mocked(mutation).mockResolvedValue("agent-id");
+ vi.mocked(query).mockResolvedValue([
{
_id: "notif-1",
type: "task_assigned",
@@ -48,8 +46,8 @@ describe("check", () => {
});
it("marks notifications as delivered", async () => {
- vi.mocked(client.mutation).mockResolvedValue("agent-id");
- vi.mocked(client.query).mockResolvedValue([
+ vi.mocked(mutation).mockResolvedValue("agent-id");
+ vi.mocked(query).mockResolvedValue([
{
_id: "notif-1",
type: "custom",
@@ -61,6 +59,6 @@ describe("check", () => {
await check("agent:main:main");
- expect(client.mutation).toHaveBeenCalledTimes(2);
+ expect(mutation).toHaveBeenCalledTimes(2);
});
});
diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts
index b867efd..dde509d 100644
--- a/packages/cli/src/commands/check.ts
+++ b/packages/cli/src/commands/check.ts
@@ -1,12 +1,12 @@
-import { client } from "../client.js";
+import { query, mutation } from "../client.js";
import { api } from "@clawe/backend";
export async function check(sessionKey: string): Promise {
// Record heartbeat
- await client.mutation(api.agents.heartbeat, { sessionKey });
+ await mutation(api.agents.heartbeat, { sessionKey });
// Get undelivered notifications
- const notifications = await client.query(api.notifications.getUndelivered, {
+ const notifications = await query(api.notifications.getUndelivered, {
sessionKey,
});
@@ -16,7 +16,7 @@ export async function check(sessionKey: string): Promise {
}
// Mark as delivered
- await client.mutation(api.notifications.markDelivered, {
+ await mutation(api.notifications.markDelivered, {
notificationIds: notifications.map((n) => n._id),
});
diff --git a/packages/cli/src/commands/deliver.spec.ts b/packages/cli/src/commands/deliver.spec.ts
index a6670e6..527b24a 100644
--- a/packages/cli/src/commands/deliver.spec.ts
+++ b/packages/cli/src/commands/deliver.spec.ts
@@ -3,10 +3,8 @@ import { deliver, deliverables } from "./deliver.js";
import * as fs from "fs";
vi.mock("../client.js", () => ({
- client: {
- mutation: vi.fn(),
- query: vi.fn(),
- },
+ mutation: vi.fn(),
+ query: vi.fn(),
uploadFile: vi.fn(),
}));
@@ -14,7 +12,7 @@ vi.mock("fs", () => ({
existsSync: vi.fn(),
}));
-import { client, uploadFile } from "../client.js";
+import { mutation, query, uploadFile } from "../client.js";
describe("deliver", () => {
beforeEach(() => {
@@ -25,7 +23,7 @@ describe("deliver", () => {
it("registers a deliverable", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(uploadFile).mockResolvedValue("file-id");
- vi.mocked(client.mutation).mockResolvedValue("doc-id");
+ vi.mocked(mutation).mockResolvedValue("doc-id");
await deliver("task-123", "/output/report.pdf", "Final Report", {
by: "agent:inky:main",
@@ -33,7 +31,7 @@ describe("deliver", () => {
expect(fs.existsSync).toHaveBeenCalledWith("/output/report.pdf");
expect(uploadFile).toHaveBeenCalledWith("/output/report.pdf");
- expect(client.mutation).toHaveBeenCalledWith(
+ expect(mutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
taskId: "task-123",
@@ -56,7 +54,7 @@ describe("deliverables", () => {
});
it("shows message when no deliverables", async () => {
- vi.mocked(client.query).mockResolvedValue([]);
+ vi.mocked(query).mockResolvedValue([]);
await deliverables("task-123");
@@ -65,7 +63,7 @@ describe("deliverables", () => {
it("lists deliverables with details", async () => {
const now = Date.now();
- vi.mocked(client.query).mockResolvedValue([
+ vi.mocked(query).mockResolvedValue([
{
_id: "doc-1",
title: "Logo Design",
diff --git a/packages/cli/src/commands/deliver.ts b/packages/cli/src/commands/deliver.ts
index 1d512e6..51a320a 100644
--- a/packages/cli/src/commands/deliver.ts
+++ b/packages/cli/src/commands/deliver.ts
@@ -1,4 +1,4 @@
-import { client, uploadFile } from "../client.js";
+import { query, mutation, uploadFile } from "../client.js";
import { api } from "@clawe/backend";
import type { Id } from "@clawe/backend/dataModel";
import * as fs from "fs";
@@ -26,7 +26,7 @@ export async function deliver(
console.log(`✅ File uploaded`);
// Register deliverable with fileId
- await client.mutation(api.documents.registerDeliverable, {
+ await mutation(api.documents.registerDeliverable, {
taskId: taskId as Id<"tasks">,
path,
fileId: fileId as Id<"_storage">,
@@ -38,7 +38,7 @@ export async function deliver(
}
export async function deliverables(taskId: string): Promise {
- const docs = await client.query(api.documents.getForTask, {
+ const docs = await query(api.documents.getForTask, {
taskId: taskId as Id<"tasks">,
});
diff --git a/packages/cli/src/commands/feed.spec.ts b/packages/cli/src/commands/feed.spec.ts
index 8aae920..b042028 100644
--- a/packages/cli/src/commands/feed.spec.ts
+++ b/packages/cli/src/commands/feed.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { feed } from "./feed.js";
vi.mock("../client.js", () => ({
- client: {
- query: vi.fn(),
- },
+ query: vi.fn(),
}));
-import { client } from "../client.js";
+import { query } from "../client.js";
describe("feed", () => {
beforeEach(() => {
@@ -16,7 +14,7 @@ describe("feed", () => {
});
it("displays message when no activity", async () => {
- vi.mocked(client.query).mockResolvedValue([]);
+ vi.mocked(query).mockResolvedValue([]);
await feed({});
@@ -25,7 +23,7 @@ describe("feed", () => {
it("displays activity feed with agent names", async () => {
const now = Date.now();
- vi.mocked(client.query).mockResolvedValue([
+ vi.mocked(query).mockResolvedValue([
{
_id: "activity-1",
type: "task_created",
@@ -44,7 +42,7 @@ describe("feed", () => {
});
it("shows System for activities without agent", async () => {
- vi.mocked(client.query).mockResolvedValue([
+ vi.mocked(query).mockResolvedValue([
{
_id: "activity-2",
type: "system",
@@ -62,18 +60,18 @@ describe("feed", () => {
});
it("uses default limit of 20", async () => {
- vi.mocked(client.query).mockResolvedValue([]);
+ vi.mocked(query).mockResolvedValue([]);
await feed({});
- expect(client.query).toHaveBeenCalledWith(expect.anything(), { limit: 20 });
+ expect(query).toHaveBeenCalledWith(expect.anything(), { limit: 20 });
});
it("uses custom limit when provided", async () => {
- vi.mocked(client.query).mockResolvedValue([]);
+ vi.mocked(query).mockResolvedValue([]);
await feed({ limit: 50 });
- expect(client.query).toHaveBeenCalledWith(expect.anything(), { limit: 50 });
+ expect(query).toHaveBeenCalledWith(expect.anything(), { limit: 50 });
});
});
diff --git a/packages/cli/src/commands/feed.ts b/packages/cli/src/commands/feed.ts
index f6f233d..dfc602d 100644
--- a/packages/cli/src/commands/feed.ts
+++ b/packages/cli/src/commands/feed.ts
@@ -1,4 +1,4 @@
-import { client } from "../client.js";
+import { query } from "../client.js";
import { api } from "@clawe/backend";
interface FeedOptions {
@@ -6,7 +6,7 @@ interface FeedOptions {
}
export async function feed(options: FeedOptions): Promise {
- const activities = await client.query(api.activities.feed, {
+ const activities = await query(api.activities.feed, {
limit: options.limit ?? 20,
});
diff --git a/packages/cli/src/commands/notify.spec.ts b/packages/cli/src/commands/notify.spec.ts
index c4a430f..1bca0dc 100644
--- a/packages/cli/src/commands/notify.spec.ts
+++ b/packages/cli/src/commands/notify.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { notify } from "./notify.js";
vi.mock("../client.js", () => ({
- client: {
- mutation: vi.fn(),
- },
+ mutation: vi.fn(),
}));
-import { client } from "../client.js";
+import { mutation } from "../client.js";
describe("notify", () => {
beforeEach(() => {
@@ -16,11 +14,11 @@ describe("notify", () => {
});
it("sends notification to target agent", async () => {
- vi.mocked(client.mutation).mockResolvedValue("notif-id");
+ vi.mocked(mutation).mockResolvedValue("notif-id");
await notify("agent:inky:main", "Please review the draft", {});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
targetSessionKey: "agent:inky:main",
sourceSessionKey: undefined,
type: "custom",
@@ -29,13 +27,13 @@ describe("notify", () => {
});
it("includes source agent when from option provided", async () => {
- vi.mocked(client.mutation).mockResolvedValue("notif-id");
+ vi.mocked(mutation).mockResolvedValue("notif-id");
await notify("agent:inky:main", "Task completed", {
from: "agent:main:main",
});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
targetSessionKey: "agent:inky:main",
sourceSessionKey: "agent:main:main",
type: "custom",
@@ -44,7 +42,7 @@ describe("notify", () => {
});
it("logs success message", async () => {
- vi.mocked(client.mutation).mockResolvedValue("notif-id");
+ vi.mocked(mutation).mockResolvedValue("notif-id");
await notify("agent:inky:main", "Hello", {});
diff --git a/packages/cli/src/commands/notify.ts b/packages/cli/src/commands/notify.ts
index 2f7cd29..85544b1 100644
--- a/packages/cli/src/commands/notify.ts
+++ b/packages/cli/src/commands/notify.ts
@@ -1,4 +1,4 @@
-import { client } from "../client.js";
+import { mutation } from "../client.js";
import { api } from "@clawe/backend";
interface NotifyOptions {
@@ -10,7 +10,7 @@ export async function notify(
message: string,
options: NotifyOptions,
): Promise {
- await client.mutation(api.notifications.send, {
+ await mutation(api.notifications.send, {
targetSessionKey,
sourceSessionKey: options.from,
type: "custom",
diff --git a/packages/cli/src/commands/squad.spec.ts b/packages/cli/src/commands/squad.spec.ts
index 1c54941..62348a9 100644
--- a/packages/cli/src/commands/squad.spec.ts
+++ b/packages/cli/src/commands/squad.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { squad } from "./squad.js";
vi.mock("../client.js", () => ({
- client: {
- query: vi.fn(),
- },
+ query: vi.fn(),
}));
-import { client } from "../client.js";
+import { query } from "../client.js";
describe("squad", () => {
beforeEach(() => {
@@ -16,7 +14,7 @@ describe("squad", () => {
});
it("displays squad status header", async () => {
- vi.mocked(client.query).mockResolvedValue([]);
+ vi.mocked(query).mockResolvedValue([]);
await squad();
@@ -24,7 +22,7 @@ describe("squad", () => {
});
it("displays agent details with online status", async () => {
- vi.mocked(client.query).mockResolvedValue([
+ vi.mocked(query).mockResolvedValue([
{
_id: "agent-1",
name: "Clawe",
@@ -46,7 +44,7 @@ describe("squad", () => {
});
it("displays offline status when no heartbeat", async () => {
- vi.mocked(client.query).mockResolvedValue([
+ vi.mocked(query).mockResolvedValue([
{
_id: "agent-2",
name: "Inky",
@@ -62,7 +60,7 @@ describe("squad", () => {
});
it("displays offline status when heartbeat is stale", async () => {
- vi.mocked(client.query).mockResolvedValue([
+ vi.mocked(query).mockResolvedValue([
{
_id: "agent-3",
name: "Pixel",
diff --git a/packages/cli/src/commands/squad.ts b/packages/cli/src/commands/squad.ts
index 1d08c4b..d4e9c96 100644
--- a/packages/cli/src/commands/squad.ts
+++ b/packages/cli/src/commands/squad.ts
@@ -1,9 +1,9 @@
-import { client } from "../client.js";
+import { query } from "../client.js";
import { api } from "@clawe/backend";
import { deriveStatus } from "@clawe/shared/agents";
export async function squad(): Promise {
- const agents = await client.query(api.agents.squad, {});
+ const agents = await query(api.agents.squad, {});
console.log("🤖 Squad Status:\n");
for (const agent of agents) {
diff --git a/packages/cli/src/commands/subtask-add.spec.ts b/packages/cli/src/commands/subtask-add.spec.ts
index 15bcac3..8802ae8 100644
--- a/packages/cli/src/commands/subtask-add.spec.ts
+++ b/packages/cli/src/commands/subtask-add.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { subtaskAdd } from "./subtask-add.js";
vi.mock("../client.js", () => ({
- client: {
- mutation: vi.fn(),
- },
+ mutation: vi.fn(),
}));
-import { client } from "../client.js";
+import { mutation } from "../client.js";
describe("subtaskAdd", () => {
beforeEach(() => {
@@ -16,11 +14,11 @@ describe("subtaskAdd", () => {
});
it("adds a subtask with title only", async () => {
- vi.mocked(client.mutation).mockResolvedValue(0);
+ vi.mocked(mutation).mockResolvedValue(0);
await subtaskAdd("task-123", "Write unit tests", {});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-123",
title: "Write unit tests",
description: undefined,
@@ -32,13 +30,13 @@ describe("subtaskAdd", () => {
});
it("adds a subtask with description", async () => {
- vi.mocked(client.mutation).mockResolvedValue(1);
+ vi.mocked(mutation).mockResolvedValue(1);
await subtaskAdd("task-456", "Review code", {
description: "Check for edge cases",
});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-456",
title: "Review code",
description: "Check for edge cases",
@@ -47,13 +45,13 @@ describe("subtaskAdd", () => {
});
it("adds a subtask with assignee", async () => {
- vi.mocked(client.mutation).mockResolvedValue(2);
+ vi.mocked(mutation).mockResolvedValue(2);
await subtaskAdd("task-789", "Design mockup", {
assign: "agent:pixel:main",
});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-789",
title: "Design mockup",
description: undefined,
@@ -62,14 +60,14 @@ describe("subtaskAdd", () => {
});
it("adds a subtask with all options", async () => {
- vi.mocked(client.mutation).mockResolvedValue(3);
+ vi.mocked(mutation).mockResolvedValue(3);
await subtaskAdd("task-full", "Complete task", {
description: "Full details here",
assign: "agent:inky:main",
});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-full",
title: "Complete task",
description: "Full details here",
diff --git a/packages/cli/src/commands/subtask-add.ts b/packages/cli/src/commands/subtask-add.ts
index 949a946..a0ce15f 100644
--- a/packages/cli/src/commands/subtask-add.ts
+++ b/packages/cli/src/commands/subtask-add.ts
@@ -1,4 +1,4 @@
-import { client } from "../client.js";
+import { mutation } from "../client.js";
import { api } from "@clawe/backend";
import type { Id } from "@clawe/backend/dataModel";
@@ -12,7 +12,7 @@ export async function subtaskAdd(
title: string,
options: SubtaskAddOptions,
): Promise {
- const index = await client.mutation(api.tasks.addSubtask, {
+ const index = await mutation(api.tasks.addSubtask, {
taskId: taskId as Id<"tasks">,
title,
description: options.description,
diff --git a/packages/cli/src/commands/subtask-check.spec.ts b/packages/cli/src/commands/subtask-check.spec.ts
index e1bd0a3..8048084 100644
--- a/packages/cli/src/commands/subtask-check.spec.ts
+++ b/packages/cli/src/commands/subtask-check.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { subtaskCheck, subtaskUncheck } from "./subtask-check.js";
vi.mock("../client.js", () => ({
- client: {
- mutation: vi.fn(),
- },
+ mutation: vi.fn(),
}));
-import { client } from "../client.js";
+import { mutation } from "../client.js";
describe("subtaskCheck", () => {
beforeEach(() => {
@@ -16,11 +14,11 @@ describe("subtaskCheck", () => {
});
it("marks subtask as done", async () => {
- vi.mocked(client.mutation).mockResolvedValue(undefined);
+ vi.mocked(mutation).mockResolvedValue(undefined);
await subtaskCheck("task-123", "0", {});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-123",
subtaskIndex: 0,
done: true,
@@ -31,11 +29,11 @@ describe("subtaskCheck", () => {
});
it("marks subtask as done with agent attribution", async () => {
- vi.mocked(client.mutation).mockResolvedValue(undefined);
+ vi.mocked(mutation).mockResolvedValue(undefined);
await subtaskCheck("task-456", "2", { by: "agent:inky:main" });
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-456",
subtaskIndex: 2,
done: true,
@@ -45,11 +43,11 @@ describe("subtaskCheck", () => {
});
it("parses string index correctly", async () => {
- vi.mocked(client.mutation).mockResolvedValue(undefined);
+ vi.mocked(mutation).mockResolvedValue(undefined);
await subtaskCheck("task-789", "5", {});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-789",
subtaskIndex: 5,
done: true,
@@ -66,11 +64,11 @@ describe("subtaskUncheck", () => {
});
it("marks subtask as not done", async () => {
- vi.mocked(client.mutation).mockResolvedValue(undefined);
+ vi.mocked(mutation).mockResolvedValue(undefined);
await subtaskUncheck("task-123", "1", {});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-123",
subtaskIndex: 1,
done: false,
@@ -81,11 +79,11 @@ describe("subtaskUncheck", () => {
});
it("marks subtask as not done with agent attribution", async () => {
- vi.mocked(client.mutation).mockResolvedValue(undefined);
+ vi.mocked(mutation).mockResolvedValue(undefined);
await subtaskUncheck("task-456", "0", { by: "agent:main:main" });
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-456",
subtaskIndex: 0,
done: false,
diff --git a/packages/cli/src/commands/subtask-check.ts b/packages/cli/src/commands/subtask-check.ts
index ac6869e..73f109b 100644
--- a/packages/cli/src/commands/subtask-check.ts
+++ b/packages/cli/src/commands/subtask-check.ts
@@ -1,4 +1,4 @@
-import { client } from "../client.js";
+import { mutation } from "../client.js";
import { api } from "@clawe/backend";
import type { Id } from "@clawe/backend/dataModel";
@@ -11,7 +11,7 @@ export async function subtaskCheck(
index: string,
options: SubtaskCheckOptions,
): Promise {
- await client.mutation(api.tasks.updateSubtask, {
+ await mutation(api.tasks.updateSubtask, {
taskId: taskId as Id<"tasks">,
subtaskIndex: parseInt(index, 10),
done: true,
@@ -27,7 +27,7 @@ export async function subtaskUncheck(
index: string,
options: SubtaskCheckOptions,
): Promise {
- await client.mutation(api.tasks.updateSubtask, {
+ await mutation(api.tasks.updateSubtask, {
taskId: taskId as Id<"tasks">,
subtaskIndex: parseInt(index, 10),
done: false,
@@ -48,7 +48,7 @@ export async function subtaskBlock(
index: string,
options: SubtaskBlockOptions,
): Promise {
- await client.mutation(api.tasks.updateSubtask, {
+ await mutation(api.tasks.updateSubtask, {
taskId: taskId as Id<"tasks">,
subtaskIndex: parseInt(index, 10),
status: "blocked",
@@ -70,7 +70,7 @@ export async function subtaskProgress(
index: string,
options: SubtaskProgressOptions,
): Promise {
- await client.mutation(api.tasks.updateSubtask, {
+ await mutation(api.tasks.updateSubtask, {
taskId: taskId as Id<"tasks">,
subtaskIndex: parseInt(index, 10),
status: "in_progress",
diff --git a/packages/cli/src/commands/task-assign.spec.ts b/packages/cli/src/commands/task-assign.spec.ts
index 13f129a..f24fbb6 100644
--- a/packages/cli/src/commands/task-assign.spec.ts
+++ b/packages/cli/src/commands/task-assign.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { taskAssign } from "./task-assign.js";
vi.mock("../client.js", () => ({
- client: {
- mutation: vi.fn(),
- },
+ mutation: vi.fn(),
}));
-import { client } from "../client.js";
+import { mutation } from "../client.js";
describe("taskAssign", () => {
beforeEach(() => {
@@ -16,11 +14,11 @@ describe("taskAssign", () => {
});
it("assigns task to an agent", async () => {
- vi.mocked(client.mutation).mockResolvedValue(undefined);
+ vi.mocked(mutation).mockResolvedValue(undefined);
await taskAssign("task-123", "agent:inky:main", {});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-123",
assigneeSessionKeys: ["agent:inky:main"],
bySessionKey: undefined,
@@ -31,11 +29,11 @@ describe("taskAssign", () => {
});
it("assigns task with assigner attribution", async () => {
- vi.mocked(client.mutation).mockResolvedValue(undefined);
+ vi.mocked(mutation).mockResolvedValue(undefined);
await taskAssign("task-456", "agent:pixel:main", { by: "agent:main:main" });
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-456",
assigneeSessionKeys: ["agent:pixel:main"],
bySessionKey: "agent:main:main",
diff --git a/packages/cli/src/commands/task-assign.ts b/packages/cli/src/commands/task-assign.ts
index 831a601..552d44b 100644
--- a/packages/cli/src/commands/task-assign.ts
+++ b/packages/cli/src/commands/task-assign.ts
@@ -1,4 +1,4 @@
-import { client } from "../client.js";
+import { mutation } from "../client.js";
import { api } from "@clawe/backend";
import type { Id } from "@clawe/backend/dataModel";
@@ -11,7 +11,7 @@ export async function taskAssign(
assigneeSessionKey: string,
options: TaskAssignOptions,
): Promise {
- await client.mutation(api.tasks.assign, {
+ await mutation(api.tasks.assign, {
taskId: taskId as Id<"tasks">,
assigneeSessionKeys: [assigneeSessionKey],
bySessionKey: options.by,
diff --git a/packages/cli/src/commands/task-comment.spec.ts b/packages/cli/src/commands/task-comment.spec.ts
index 1049a97..5635422 100644
--- a/packages/cli/src/commands/task-comment.spec.ts
+++ b/packages/cli/src/commands/task-comment.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { taskComment } from "./task-comment.js";
vi.mock("../client.js", () => ({
- client: {
- mutation: vi.fn(),
- },
+ mutation: vi.fn(),
}));
-import { client } from "../client.js";
+import { mutation } from "../client.js";
describe("taskComment", () => {
beforeEach(() => {
@@ -16,11 +14,11 @@ describe("taskComment", () => {
});
it("adds a comment to a task", async () => {
- vi.mocked(client.mutation).mockResolvedValue("message-id");
+ vi.mocked(mutation).mockResolvedValue("message-id");
await taskComment("task-123", "Looking good!", {});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-123",
content: "Looking good!",
bySessionKey: undefined,
@@ -29,13 +27,13 @@ describe("taskComment", () => {
});
it("adds a comment with agent attribution", async () => {
- vi.mocked(client.mutation).mockResolvedValue("message-id");
+ vi.mocked(mutation).mockResolvedValue("message-id");
await taskComment("task-456", "I'll review this", {
by: "agent:main:main",
});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-456",
content: "I'll review this",
bySessionKey: "agent:main:main",
diff --git a/packages/cli/src/commands/task-comment.ts b/packages/cli/src/commands/task-comment.ts
index 48aa2c0..f55c006 100644
--- a/packages/cli/src/commands/task-comment.ts
+++ b/packages/cli/src/commands/task-comment.ts
@@ -1,4 +1,4 @@
-import { client } from "../client.js";
+import { mutation } from "../client.js";
import { api } from "@clawe/backend";
import type { Id } from "@clawe/backend/dataModel";
@@ -11,7 +11,7 @@ export async function taskComment(
message: string,
options: TaskCommentOptions,
): Promise {
- await client.mutation(api.tasks.addComment, {
+ await mutation(api.tasks.addComment, {
taskId: taskId as Id<"tasks">,
content: message,
bySessionKey: options.by,
diff --git a/packages/cli/src/commands/task-create.spec.ts b/packages/cli/src/commands/task-create.spec.ts
index 6b3d1c7..404ea18 100644
--- a/packages/cli/src/commands/task-create.spec.ts
+++ b/packages/cli/src/commands/task-create.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { taskCreate } from "./task-create.js";
vi.mock("../client.js", () => ({
- client: {
- mutation: vi.fn(),
- },
+ mutation: vi.fn(),
}));
-import { client } from "../client.js";
+import { mutation } from "../client.js";
describe("taskCreate", () => {
beforeEach(() => {
@@ -16,11 +14,11 @@ describe("taskCreate", () => {
});
it("creates a task with title only", async () => {
- vi.mocked(client.mutation).mockResolvedValue("task-123");
+ vi.mocked(mutation).mockResolvedValue("task-123");
await taskCreate("Write documentation", {});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
title: "Write documentation",
assigneeSessionKey: undefined,
createdBySessionKey: undefined,
@@ -30,11 +28,11 @@ describe("taskCreate", () => {
});
it("creates a task with assignee", async () => {
- vi.mocked(client.mutation).mockResolvedValue("task-456");
+ vi.mocked(mutation).mockResolvedValue("task-456");
await taskCreate("Design logo", { assign: "agent:pixel:main" });
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
title: "Design logo",
assigneeSessionKey: "agent:pixel:main",
createdBySessionKey: undefined,
@@ -43,14 +41,14 @@ describe("taskCreate", () => {
});
it("creates a task with priority and creator", async () => {
- vi.mocked(client.mutation).mockResolvedValue("task-789");
+ vi.mocked(mutation).mockResolvedValue("task-789");
await taskCreate("Fix critical bug", {
priority: "urgent",
by: "agent:main:main",
});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
title: "Fix critical bug",
assigneeSessionKey: undefined,
createdBySessionKey: "agent:main:main",
@@ -59,13 +57,13 @@ describe("taskCreate", () => {
});
it("creates a task with description", async () => {
- vi.mocked(client.mutation).mockResolvedValue("task-desc");
+ vi.mocked(mutation).mockResolvedValue("task-desc");
await taskCreate("Write blog post", {
description: "2000 words, practical focus",
});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
title: "Write blog post",
description: "2000 words, practical focus",
assigneeSessionKey: undefined,
@@ -75,7 +73,7 @@ describe("taskCreate", () => {
});
it("creates a task with all options", async () => {
- vi.mocked(client.mutation).mockResolvedValue("task-full");
+ vi.mocked(mutation).mockResolvedValue("task-full");
await taskCreate("Full featured task", {
assign: "agent:inky:main",
@@ -84,7 +82,7 @@ describe("taskCreate", () => {
description: "Detailed task description",
});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
title: "Full featured task",
description: "Detailed task description",
assigneeSessionKey: "agent:inky:main",
diff --git a/packages/cli/src/commands/task-create.ts b/packages/cli/src/commands/task-create.ts
index 730f32a..461bef8 100644
--- a/packages/cli/src/commands/task-create.ts
+++ b/packages/cli/src/commands/task-create.ts
@@ -1,4 +1,4 @@
-import { client } from "../client.js";
+import { mutation } from "../client.js";
import { api } from "@clawe/backend";
interface TaskCreateOptions {
@@ -12,7 +12,7 @@ export async function taskCreate(
title: string,
options: TaskCreateOptions,
): Promise {
- const taskId = await client.mutation(api.tasks.create, {
+ const taskId = await mutation(api.tasks.create, {
title,
description: options.description,
assigneeSessionKey: options.assign,
diff --git a/packages/cli/src/commands/task-plan.spec.ts b/packages/cli/src/commands/task-plan.spec.ts
index 4080c16..5309279 100644
--- a/packages/cli/src/commands/task-plan.spec.ts
+++ b/packages/cli/src/commands/task-plan.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { taskPlan } from "./task-plan.js";
vi.mock("../client.js", () => ({
- client: {
- mutation: vi.fn(),
- },
+ mutation: vi.fn(),
}));
-import { client } from "../client.js";
+import { mutation } from "../client.js";
describe("taskPlan", () => {
beforeEach(() => {
@@ -20,7 +18,7 @@ describe("taskPlan", () => {
});
it("creates a task plan with all fields", async () => {
- vi.mocked(client.mutation).mockResolvedValue("task-plan-1");
+ vi.mocked(mutation).mockResolvedValue("task-plan-1");
const plan = JSON.stringify({
title: "Blog Post: AI Teams",
@@ -37,7 +35,7 @@ describe("taskPlan", () => {
await taskPlan(plan);
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
title: "Blog Post: AI Teams",
description: "Write a 2000-word post",
priority: "high",
@@ -65,7 +63,7 @@ describe("taskPlan", () => {
});
it("creates a minimal task plan", async () => {
- vi.mocked(client.mutation).mockResolvedValue("task-plan-2");
+ vi.mocked(mutation).mockResolvedValue("task-plan-2");
const plan = JSON.stringify({
title: "Quick task",
@@ -75,7 +73,7 @@ describe("taskPlan", () => {
await taskPlan(plan);
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
title: "Quick task",
description: "Do something simple",
priority: undefined,
@@ -92,7 +90,7 @@ describe("taskPlan", () => {
});
it("maps subtask descriptions correctly", async () => {
- vi.mocked(client.mutation).mockResolvedValue("task-plan-3");
+ vi.mocked(mutation).mockResolvedValue("task-plan-3");
const plan = JSON.stringify({
title: "Detailed task",
@@ -105,7 +103,7 @@ describe("taskPlan", () => {
await taskPlan(plan);
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
title: "Detailed task",
description: "Task with subtask descriptions",
priority: undefined,
@@ -132,7 +130,7 @@ describe("taskPlan", () => {
expect(console.error).toHaveBeenCalledWith(
"Error: Invalid JSON. Expected a task plan object.",
);
- expect(client.mutation).not.toHaveBeenCalled();
+ expect(mutation).not.toHaveBeenCalled();
});
it("exits when title is missing", async () => {
@@ -146,7 +144,7 @@ describe("taskPlan", () => {
expect(console.error).toHaveBeenCalledWith(
"Error: Plan must include 'title'",
);
- expect(client.mutation).not.toHaveBeenCalled();
+ expect(mutation).not.toHaveBeenCalled();
});
it("exits when description is missing", async () => {
@@ -160,7 +158,7 @@ describe("taskPlan", () => {
expect(console.error).toHaveBeenCalledWith(
"Error: Plan must include 'description'",
);
- expect(client.mutation).not.toHaveBeenCalled();
+ expect(mutation).not.toHaveBeenCalled();
});
it("exits when subtasks are missing", async () => {
@@ -174,7 +172,7 @@ describe("taskPlan", () => {
expect(console.error).toHaveBeenCalledWith(
"Error: Plan must include at least one subtask",
);
- expect(client.mutation).not.toHaveBeenCalled();
+ expect(mutation).not.toHaveBeenCalled();
});
it("exits when subtasks array is empty", async () => {
@@ -189,11 +187,11 @@ describe("taskPlan", () => {
expect(console.error).toHaveBeenCalledWith(
"Error: Plan must include at least one subtask",
);
- expect(client.mutation).not.toHaveBeenCalled();
+ expect(mutation).not.toHaveBeenCalled();
});
it("handles mutation error", async () => {
- vi.mocked(client.mutation).mockRejectedValue(new Error("Convex error"));
+ vi.mocked(mutation).mockRejectedValue(new Error("Convex error"));
const plan = JSON.stringify({
title: "Failing task",
@@ -207,7 +205,7 @@ describe("taskPlan", () => {
});
it("prints subtask listing on success", async () => {
- vi.mocked(client.mutation).mockResolvedValue("task-plan-list");
+ vi.mocked(mutation).mockResolvedValue("task-plan-list");
const plan = JSON.stringify({
title: "Task with listing",
diff --git a/packages/cli/src/commands/task-plan.ts b/packages/cli/src/commands/task-plan.ts
index 8db0a91..e43472c 100644
--- a/packages/cli/src/commands/task-plan.ts
+++ b/packages/cli/src/commands/task-plan.ts
@@ -1,4 +1,4 @@
-import { client } from "../client.js";
+import { mutation } from "../client.js";
import { api } from "@clawe/backend";
interface TaskPlan {
@@ -64,7 +64,7 @@ export async function taskPlan(planJson: string): Promise {
console.log("");
try {
- const taskId = await client.mutation(api.tasks.createWithPlan, {
+ const taskId = await mutation(api.tasks.createWithPlan, {
title: plan.title,
description: plan.description,
priority: plan.priority,
diff --git a/packages/cli/src/commands/task-status.spec.ts b/packages/cli/src/commands/task-status.spec.ts
index b597852..1bd4875 100644
--- a/packages/cli/src/commands/task-status.spec.ts
+++ b/packages/cli/src/commands/task-status.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { taskStatus } from "./task-status.js";
vi.mock("../client.js", () => ({
- client: {
- mutation: vi.fn(),
- },
+ mutation: vi.fn(),
}));
-import { client } from "../client.js";
+import { mutation } from "../client.js";
describe("taskStatus", () => {
beforeEach(() => {
@@ -16,11 +14,11 @@ describe("taskStatus", () => {
});
it("updates task status", async () => {
- vi.mocked(client.mutation).mockResolvedValue(undefined);
+ vi.mocked(mutation).mockResolvedValue(undefined);
await taskStatus("task-123", "in_progress", {});
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-123",
status: "in_progress",
bySessionKey: undefined,
@@ -31,11 +29,11 @@ describe("taskStatus", () => {
});
it("updates task status with agent attribution", async () => {
- vi.mocked(client.mutation).mockResolvedValue(undefined);
+ vi.mocked(mutation).mockResolvedValue(undefined);
await taskStatus("task-456", "done", { by: "agent:main:main" });
- expect(client.mutation).toHaveBeenCalledWith(expect.anything(), {
+ expect(mutation).toHaveBeenCalledWith(expect.anything(), {
taskId: "task-456",
status: "done",
bySessionKey: "agent:main:main",
diff --git a/packages/cli/src/commands/task-status.ts b/packages/cli/src/commands/task-status.ts
index f56aecd..ad77d9b 100644
--- a/packages/cli/src/commands/task-status.ts
+++ b/packages/cli/src/commands/task-status.ts
@@ -1,4 +1,4 @@
-import { client } from "../client.js";
+import { mutation } from "../client.js";
import { api } from "@clawe/backend";
import type { Id } from "@clawe/backend/dataModel";
@@ -27,7 +27,7 @@ export async function taskStatus(
process.exit(1);
}
- await client.mutation(api.tasks.updateStatus, {
+ await mutation(api.tasks.updateStatus, {
taskId: taskId as Id<"tasks">,
status: status as TaskStatus,
bySessionKey: options.by,
diff --git a/packages/cli/src/commands/task-view.spec.ts b/packages/cli/src/commands/task-view.spec.ts
index db8192d..fd746d2 100644
--- a/packages/cli/src/commands/task-view.spec.ts
+++ b/packages/cli/src/commands/task-view.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { taskView } from "./task-view.js";
vi.mock("../client.js", () => ({
- client: {
- query: vi.fn(),
- },
+ query: vi.fn(),
}));
-import { client } from "../client.js";
+import { query } from "../client.js";
describe("taskView", () => {
beforeEach(() => {
@@ -17,7 +15,7 @@ describe("taskView", () => {
});
it("displays task not found error", async () => {
- vi.mocked(client.query).mockResolvedValue(null);
+ vi.mocked(query).mockResolvedValue(null);
const mockExit = vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
@@ -33,7 +31,7 @@ describe("taskView", () => {
});
it("displays basic task info", async () => {
- vi.mocked(client.query).mockResolvedValue({
+ vi.mocked(query).mockResolvedValue({
_id: "task-123",
title: "Write documentation",
status: "in_progress",
@@ -49,7 +47,7 @@ describe("taskView", () => {
});
it("displays description when present", async () => {
- vi.mocked(client.query).mockResolvedValue({
+ vi.mocked(query).mockResolvedValue({
_id: "task-456",
title: "Task with desc",
status: "assigned",
@@ -63,7 +61,7 @@ describe("taskView", () => {
});
it("displays subtasks with progress", async () => {
- vi.mocked(client.query).mockResolvedValue({
+ vi.mocked(query).mockResolvedValue({
_id: "task-789",
title: "Task with subtasks",
status: "in_progress",
@@ -85,7 +83,7 @@ describe("taskView", () => {
});
it("displays deliverables", async () => {
- vi.mocked(client.query).mockResolvedValue({
+ vi.mocked(query).mockResolvedValue({
_id: "task-del",
title: "Task with deliverables",
status: "done",
@@ -102,7 +100,7 @@ describe("taskView", () => {
it("displays comments", async () => {
const now = Date.now();
- vi.mocked(client.query).mockResolvedValue({
+ vi.mocked(query).mockResolvedValue({
_id: "task-comments",
title: "Task with comments",
status: "review",
diff --git a/packages/cli/src/commands/task-view.ts b/packages/cli/src/commands/task-view.ts
index 2e18074..c37edca 100644
--- a/packages/cli/src/commands/task-view.ts
+++ b/packages/cli/src/commands/task-view.ts
@@ -1,9 +1,9 @@
-import { client } from "../client.js";
+import { query } from "../client.js";
import { api } from "@clawe/backend";
import type { Id } from "@clawe/backend/dataModel";
export async function taskView(taskId: string): Promise {
- const task = await client.query(api.tasks.get, {
+ const task = await query(api.tasks.get, {
taskId: taskId as Id<"tasks">,
});
diff --git a/packages/cli/src/commands/tasks.spec.ts b/packages/cli/src/commands/tasks.spec.ts
index 159a991..d13f10f 100644
--- a/packages/cli/src/commands/tasks.spec.ts
+++ b/packages/cli/src/commands/tasks.spec.ts
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { tasks } from "./tasks.js";
vi.mock("../client.js", () => ({
- client: {
- query: vi.fn(),
- },
+ query: vi.fn(),
}));
-import { client } from "../client.js";
+import { query } from "../client.js";
describe("tasks", () => {
beforeEach(() => {
@@ -16,7 +14,7 @@ describe("tasks", () => {
});
it("displays message when no tasks", async () => {
- vi.mocked(client.query).mockResolvedValue([]);
+ vi.mocked(query).mockResolvedValue([]);
await tasks("agent:main:main");
@@ -24,7 +22,7 @@ describe("tasks", () => {
});
it("lists active tasks with details", async () => {
- vi.mocked(client.query).mockResolvedValue([
+ vi.mocked(query).mockResolvedValue([
{
_id: "task-1",
title: "Write tests",
@@ -47,7 +45,7 @@ describe("tasks", () => {
});
it("handles tasks without priority", async () => {
- vi.mocked(client.query).mockResolvedValue([
+ vi.mocked(query).mockResolvedValue([
{
_id: "task-2",
title: "Simple task",
diff --git a/packages/cli/src/commands/tasks.ts b/packages/cli/src/commands/tasks.ts
index 436c80d..57c2824 100644
--- a/packages/cli/src/commands/tasks.ts
+++ b/packages/cli/src/commands/tasks.ts
@@ -1,8 +1,8 @@
-import { client } from "../client.js";
+import { query } from "../client.js";
import { api } from "@clawe/backend";
export async function tasks(sessionKey: string): Promise {
- const taskList = await client.query(api.tasks.getForAgent, { sessionKey });
+ const taskList = await query(api.tasks.getForAgent, { sessionKey });
if (taskList.length === 0) {
console.log("No active tasks.");
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 97f8415..d2af38f 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -4,9 +4,9 @@
"private": true,
"type": "module",
"exports": {
- "./agency": {
- "types": "./dist/agency/index.d.ts",
- "default": "./dist/agency/index.js"
+ "./squadhub": {
+ "types": "./dist/squadhub/index.d.ts",
+ "default": "./dist/squadhub/index.js"
},
"./agents": {
"types": "./dist/agents/index.d.ts",
diff --git a/packages/shared/src/agency/client.spec.ts b/packages/shared/src/squadhub/client.spec.ts
similarity index 89%
rename from packages/shared/src/agency/client.spec.ts
rename to packages/shared/src/squadhub/client.spec.ts
index 3a70df8..b750415 100644
--- a/packages/shared/src/agency/client.spec.ts
+++ b/packages/shared/src/squadhub/client.spec.ts
@@ -32,8 +32,14 @@ import {
probeTelegramToken,
patchConfig,
} from "./client";
+import type { SquadhubConnection } from "./client";
-describe("Agency Client", () => {
+const connection: SquadhubConnection = {
+ squadhubUrl: "http://localhost:18789",
+ squadhubToken: "test-token",
+};
+
+describe("Squadhub Client", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -47,7 +53,7 @@ describe("Agency Client", () => {
},
});
- const result = await checkHealth();
+ const result = await checkHealth(connection);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.result.hash).toBe("abc123");
@@ -57,7 +63,7 @@ describe("Agency Client", () => {
it("returns error when gateway is unreachable", async () => {
mockPost.mockRejectedValueOnce(new Error("Network error"));
- const result = await checkHealth();
+ const result = await checkHealth(connection);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("unreachable");
@@ -72,7 +78,7 @@ describe("Agency Client", () => {
},
});
- const result = await checkHealth();
+ const result = await checkHealth(connection);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("unhealthy");
@@ -133,7 +139,7 @@ describe("Agency Client", () => {
},
});
- const result = await saveTelegramBotToken("123456:ABC-DEF");
+ const result = await saveTelegramBotToken(connection, "123456:ABC-DEF");
expect(result.ok).toBe(true);
expect(mockPost).toHaveBeenCalledWith("/tools/invoke", {
@@ -156,7 +162,7 @@ describe("Agency Client", () => {
},
});
- const result = await patchConfig({
+ const result = await patchConfig(connection, {
models: { providers: { anthropic: { apiKey: "sk-test" } } },
});
@@ -182,7 +188,7 @@ describe("Agency Client", () => {
mockPost.mockRejectedValueOnce(axiosError);
- const result = await patchConfig({ test: true });
+ const result = await patchConfig(connection, { test: true });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("http_error");
@@ -193,7 +199,7 @@ describe("Agency Client", () => {
it("returns error on network error", async () => {
mockPost.mockRejectedValueOnce(new Error("Network error"));
- const result = await patchConfig({ test: true });
+ const result = await patchConfig(connection, { test: true });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("network_error");
diff --git a/packages/shared/src/agency/client.ts b/packages/shared/src/squadhub/client.ts
similarity index 72%
rename from packages/shared/src/agency/client.ts
rename to packages/shared/src/squadhub/client.ts
index ae6a186..0d5b156 100644
--- a/packages/shared/src/agency/client.ts
+++ b/packages/shared/src/squadhub/client.ts
@@ -8,30 +8,30 @@ import type {
TelegramProbeResult,
} from "./types";
-let _agencyClient: ReturnType | null = null;
+export type SquadhubConnection = {
+ squadhubUrl: string;
+ squadhubToken: string;
+};
-function getAgencyClient() {
- if (!_agencyClient) {
- const url = process.env.AGENCY_URL || "http://localhost:18789";
- const token = process.env.AGENCY_TOKEN || "";
- _agencyClient = axios.create({
- baseURL: url,
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${token}`,
- },
- });
- }
- return _agencyClient;
+function createClient(connection: SquadhubConnection) {
+ return axios.create({
+ baseURL: connection.squadhubUrl,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${connection.squadhubToken}`,
+ },
+ });
}
async function invokeTool(
+ connection: SquadhubConnection,
tool: string,
action?: string,
args?: Record,
): Promise> {
try {
- const { data } = await getAgencyClient().post("/tools/invoke", {
+ const client = createClient(connection);
+ const { data } = await client.post("/tools/invoke", {
tool,
action,
args,
@@ -57,10 +57,12 @@ async function invokeTool(
}
}
-// Health check - uses sessions_list to verify gateway connectivity
-export async function checkHealth(): Promise> {
+export async function checkHealth(
+ connection: SquadhubConnection,
+): Promise> {
try {
- const { data } = await getAgencyClient().post("/tools/invoke", {
+ const client = createClient(connection);
+ const { data } = await client.post("/tools/invoke", {
tool: "sessions_list",
action: "json",
});
@@ -83,9 +85,10 @@ export async function checkHealth(): Promise> {
// Telegram Configuration
export async function saveTelegramBotToken(
+ connection: SquadhubConnection,
botToken: string,
): Promise> {
- return patchConfig({
+ return patchConfig(connection, {
channels: {
telegram: {
enabled: true,
@@ -96,10 +99,10 @@ export async function saveTelegramBotToken(
});
}
-export async function removeTelegramBotToken(): Promise<
- ToolResult
-> {
- return patchConfig({
+export async function removeTelegramBotToken(
+ connection: SquadhubConnection,
+): Promise> {
+ return patchConfig(connection, {
channels: {
telegram: {
enabled: false,
@@ -143,15 +146,18 @@ export async function probeTelegramToken(
}
// Configuration
-export async function getConfig(): Promise> {
- return invokeTool("gateway", "config.get");
+export async function getConfig(
+ connection: SquadhubConnection,
+): Promise> {
+ return invokeTool(connection, "gateway", "config.get");
}
export async function patchConfig(
+ connection: SquadhubConnection,
config: Record,
baseHash?: string,
): Promise> {
- return invokeTool("gateway", "config.patch", {
+ return invokeTool(connection, "gateway", "config.patch", {
raw: JSON.stringify(config),
baseHash,
});
@@ -159,34 +165,41 @@ export async function patchConfig(
// Sessions
export async function listSessions(
+ connection: SquadhubConnection,
activeMinutes?: number,
): Promise> {
- return invokeTool("sessions_list", "json", { activeMinutes });
+ return invokeTool(connection, "sessions_list", "json", { activeMinutes });
}
// Messages
export async function sendMessage(
+ connection: SquadhubConnection,
channel: string,
target: string,
message: string,
): Promise> {
- return invokeTool("message", undefined, { channel, target, message });
+ return invokeTool(connection, "message", undefined, {
+ channel,
+ target,
+ message,
+ });
}
// Sessions - Send message to an agent session
export async function sessionsSend(
+ connection: SquadhubConnection,
sessionKey: string,
message: string,
timeoutSeconds?: number,
): Promise> {
- return invokeTool("sessions_send", undefined, {
+ return invokeTool(connection, "sessions_send", undefined, {
sessionKey,
message,
timeoutSeconds: timeoutSeconds ?? 10,
});
}
-// Cron types (matching agency src/cron/types.ts)
+// Cron types (matching squadhub src/cron/types.ts)
export type CronSchedule =
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
@@ -263,13 +276,16 @@ export interface CronAddJob {
}
// Cron - List jobs
-export async function cronList(): Promise> {
- return invokeTool("cron", undefined, { action: "list" });
+export async function cronList(
+ connection: SquadhubConnection,
+): Promise> {
+ return invokeTool(connection, "cron", undefined, { action: "list" });
}
// Cron - Add job
export async function cronAdd(
+ connection: SquadhubConnection,
job: CronAddJob,
): Promise> {
- return invokeTool("cron", undefined, { action: "add", job });
+ return invokeTool(connection, "cron", undefined, { action: "add", job });
}
diff --git a/packages/shared/src/agency/gateway-client.spec.ts b/packages/shared/src/squadhub/gateway-client.spec.ts
similarity index 67%
rename from packages/shared/src/agency/gateway-client.spec.ts
rename to packages/shared/src/squadhub/gateway-client.spec.ts
index 014f40d..b871298 100644
--- a/packages/shared/src/agency/gateway-client.spec.ts
+++ b/packages/shared/src/squadhub/gateway-client.spec.ts
@@ -13,6 +13,11 @@ vi.mock("ws", () => {
};
});
+const connection = {
+ squadhubUrl: "http://localhost:18789",
+ squadhubToken: "test-token",
+};
+
describe("GatewayClient", () => {
let client: GatewayClient;
@@ -56,38 +61,15 @@ describe("GatewayClient", () => {
});
describe("createGatewayClient", () => {
- const originalEnv = process.env;
-
- beforeEach(() => {
- vi.resetModules();
- process.env = { ...originalEnv };
- });
-
- afterEach(() => {
- process.env = originalEnv;
- });
-
- it("creates client with default URL when env not set", () => {
- delete process.env.AGENCY_URL;
- delete process.env.AGENCY_TOKEN;
-
- const client = createGatewayClient();
- expect(client).toBeInstanceOf(GatewayClient);
- client.close();
- });
-
- it("creates client with env URL and token", () => {
- process.env.AGENCY_URL = "http://custom:8080";
- process.env.AGENCY_TOKEN = "custom-token";
-
- const client = createGatewayClient();
+ it("creates client with connection params", () => {
+ const client = createGatewayClient(connection);
expect(client).toBeInstanceOf(GatewayClient);
client.close();
});
- it("merges custom options with env config", () => {
+ it("merges custom options with connection", () => {
const onEvent = vi.fn();
- const client = createGatewayClient({ onEvent });
+ const client = createGatewayClient(connection, { onEvent });
expect(client).toBeInstanceOf(GatewayClient);
client.close();
});
diff --git a/packages/shared/src/agency/gateway-client.ts b/packages/shared/src/squadhub/gateway-client.ts
similarity index 94%
rename from packages/shared/src/agency/gateway-client.ts
rename to packages/shared/src/squadhub/gateway-client.ts
index 7a148cb..f99db98 100644
--- a/packages/shared/src/agency/gateway-client.ts
+++ b/packages/shared/src/squadhub/gateway-client.ts
@@ -24,8 +24,8 @@ export type GatewayClientOptions = {
const PROTOCOL_VERSION = 3;
/**
- * Server-side WebSocket client for agency gateway.
- * Used by API routes to communicate with the agency.
+ * Server-side WebSocket client for squadhub gateway.
+ * Used by API routes to communicate with squadhub.
*/
export class GatewayClient {
private ws: WebSocket | null = null;
@@ -242,17 +242,15 @@ export class GatewayClient {
}
/**
- * Create a gateway client with environment config.
+ * Create a gateway client with explicit connection params.
*/
export function createGatewayClient(
- options?: Partial,
+ connection: { squadhubUrl: string; squadhubToken: string },
+ options?: Partial>,
): GatewayClient {
- const url = process.env.AGENCY_URL || "http://localhost:18789";
- const token = process.env.AGENCY_TOKEN || "";
-
return new GatewayClient({
- url,
- token,
+ url: connection.squadhubUrl,
+ token: connection.squadhubToken,
...options,
});
}
diff --git a/packages/shared/src/agency/gateway-types.ts b/packages/shared/src/squadhub/gateway-types.ts
similarity index 100%
rename from packages/shared/src/agency/gateway-types.ts
rename to packages/shared/src/squadhub/gateway-types.ts
diff --git a/packages/shared/src/agency/index.ts b/packages/shared/src/squadhub/index.ts
similarity index 98%
rename from packages/shared/src/agency/index.ts
rename to packages/shared/src/squadhub/index.ts
index c114810..10d4321 100644
--- a/packages/shared/src/agency/index.ts
+++ b/packages/shared/src/squadhub/index.ts
@@ -13,6 +13,7 @@ export {
cronAdd,
} from "./client";
export type {
+ SquadhubConnection,
CronJob,
CronListResult,
CronAddJob,
diff --git a/packages/shared/src/agency/pairing.ts b/packages/shared/src/squadhub/pairing.ts
similarity index 91%
rename from packages/shared/src/agency/pairing.ts
rename to packages/shared/src/squadhub/pairing.ts
index e5a7cbf..7591345 100644
--- a/packages/shared/src/agency/pairing.ts
+++ b/packages/shared/src/squadhub/pairing.ts
@@ -7,11 +7,11 @@ import type {
PairingApproveResult,
DirectResult,
} from "./types";
-import { getConfig, patchConfig } from "./client";
+import { getConfig, patchConfig, type SquadhubConnection } from "./client";
-const AGENCY_STATE_DIR =
- process.env.AGENCY_STATE_DIR || path.join(os.homedir(), ".agency");
-const CREDENTIALS_DIR = path.join(AGENCY_STATE_DIR, "credentials");
+const SQUADHUB_STATE_DIR =
+ process.env.SQUADHUB_STATE_DIR || path.join(os.homedir(), ".squadhub");
+const CREDENTIALS_DIR = path.join(SQUADHUB_STATE_DIR, "credentials");
type PairingStore = {
version: 1;
@@ -84,6 +84,7 @@ export async function listChannelPairingRequests(
}
export async function approveChannelPairingCode(
+ connection: SquadhubConnection,
channel: string,
code: string,
): Promise> {
@@ -98,7 +99,7 @@ export async function approveChannelPairingCode(
const normalizedCode = code.trim().toUpperCase();
const pairingPath = resolvePairingPath(channel);
- // Read current pairing requests (file-based - agency writes these)
+ // Read current pairing requests (file-based - squadhub writes these)
const store = await readJsonFile(pairingPath, {
version: 1,
requests: [],
@@ -121,7 +122,7 @@ export async function approveChannelPairingCode(
}
// Get current config to read existing allowFrom list
- const configResult = await getConfig();
+ const configResult = await getConfig(connection);
if (!configResult.ok) {
return {
ok: false,
@@ -145,6 +146,7 @@ export async function approveChannelPairingCode(
// Add user ID to allowFrom if not already present
if (!existingAllowFrom.includes(entry.id)) {
const patchResult = await patchConfig(
+ connection,
{
channels: {
[channel]: {
diff --git a/packages/shared/src/agency/shared-client.ts b/packages/shared/src/squadhub/shared-client.ts
similarity index 86%
rename from packages/shared/src/agency/shared-client.ts
rename to packages/shared/src/squadhub/shared-client.ts
index 8df9142..4bb0895 100644
--- a/packages/shared/src/agency/shared-client.ts
+++ b/packages/shared/src/squadhub/shared-client.ts
@@ -1,5 +1,6 @@
import { GatewayClient, createGatewayClient } from "./gateway-client";
import type { GatewayClientOptions } from "./gateway-client";
+import type { SquadhubConnection } from "./client";
let sharedClient: GatewayClient | null = null;
let connectingPromise: Promise | null = null;
@@ -9,7 +10,8 @@ let connectingPromise: Promise | null = null;
* Reconnects automatically if the connection drops.
*/
export async function getSharedClient(
- options?: Partial,
+ connection: SquadhubConnection,
+ options?: Partial>,
): Promise {
if (sharedClient?.isConnected()) {
return sharedClient;
@@ -26,7 +28,7 @@ export async function getSharedClient(
// Create new client
connectingPromise = (async () => {
sharedClient?.close();
- sharedClient = createGatewayClient({
+ sharedClient = createGatewayClient(connection, {
...options,
onClose: (_code, _reason) => {
// Mark as disconnected; next call will reconnect
diff --git a/packages/shared/src/agency/types.ts b/packages/shared/src/squadhub/types.ts
similarity index 87%
rename from packages/shared/src/agency/types.ts
rename to packages/shared/src/squadhub/types.ts
index 7091921..3ed726d 100644
--- a/packages/shared/src/agency/types.ts
+++ b/packages/shared/src/squadhub/types.ts
@@ -1,4 +1,4 @@
-// AgentToolResult matches agency's tool execution result structure
+// AgentToolResult matches squadhub's tool execution result structure
export type AgentToolResult = {
content: Array<{
type: string;
@@ -9,12 +9,12 @@ export type AgentToolResult = {
details: T;
};
-// ToolResult for agency tool invocations (result contains content + details)
+// ToolResult for squadhub tool invocations (result contains content + details)
export type ToolResult =
| { ok: true; result: AgentToolResult }
| { ok: false; error: { type: string; message: string } };
-// DirectResult for operations that don't go through agency tools
+// DirectResult for operations that don't go through squadhub tools
export type DirectResult =
| { ok: true; result: T }
| { ok: false; error: { type: string; message: string } };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 322a3f0..3ba1fdf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -206,6 +206,9 @@ importers:
'@clawe/typescript-config':
specifier: workspace:*
version: link:../typescript-config
+ '@types/node':
+ specifier: ^25.2.3
+ version: 25.2.3
typescript:
specifier: 5.9.2
version: 5.9.2
@@ -2322,6 +2325,9 @@ packages:
'@types/node@22.15.3':
resolution: {integrity: sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==}
+ '@types/node@25.2.3':
+ resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==}
+
'@types/react-dom@19.2.2':
resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==}
peerDependencies:
@@ -4553,6 +4559,9 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
undici@7.21.0:
resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==}
engines: {node: '>=20.18.1'}
@@ -6615,6 +6624,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/node@25.2.3':
+ dependencies:
+ undici-types: 7.16.0
+
'@types/react-dom@19.2.2(@types/react@19.2.2)':
dependencies:
'@types/react': 19.2.2
@@ -9421,6 +9434,8 @@ snapshots:
undici-types@6.21.0: {}
+ undici-types@7.16.0: {}
+
undici@7.21.0: {}
unified@11.0.5:
diff --git a/scripts/start.sh b/scripts/start.sh
index a19ba7c..e7bc598 100755
--- a/scripts/start.sh
+++ b/scripts/start.sh
@@ -31,7 +31,7 @@ if [ ! -f .env ]; then
sed -i "s/your-secure-token-here/$TOKEN/" .env
fi
- echo_info "Generated AGENCY_TOKEN: ${TOKEN:0:8}..."
+ echo_info "Generated SQUADHUB_TOKEN: ${TOKEN:0:8}..."
echo_warn "Please edit .env and set your ANTHROPIC_API_KEY and CONVEX_URL"
fi
@@ -51,8 +51,8 @@ if [ -z "$CONVEX_URL" ] || [ "$CONVEX_URL" = "https://your-deployment.convex.clo
MISSING_VARS+=("CONVEX_URL")
fi
-if [ -z "$AGENCY_TOKEN" ] || [ "$AGENCY_TOKEN" = "your-secure-token-here" ]; then
- MISSING_VARS+=("AGENCY_TOKEN")
+if [ -z "$SQUADHUB_TOKEN" ] || [ "$SQUADHUB_TOKEN" = "your-secure-token-here" ]; then
+ MISSING_VARS+=("SQUADHUB_TOKEN")
fi
if [ ${#MISSING_VARS[@]} -gt 0 ]; then
diff --git a/turbo.json b/turbo.json
index 45c727c..66b008e 100644
--- a/turbo.json
+++ b/turbo.json
@@ -6,10 +6,10 @@
"CONVEX_URL",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
- "AGENCY_URL",
- "AGENCY_TOKEN",
+ "SQUADHUB_URL",
+ "SQUADHUB_TOKEN",
"CLAWE_DATA_DIR",
- "AGENCY_STATE_DIR"
+ "SQUADHUB_STATE_DIR"
],
"tasks": {
"build": {
From 73ba72f82fc9a001e9252f551a69d21c36f7952d Mon Sep 17 00:00:00 2001
From: Guy Ben-Aharon
Date: Sun, 15 Feb 2026 21:44:52 +0200
Subject: [PATCH 2/8] fix: refactor codebase
---
apps/watcher/README.md | 6 +--
apps/watcher/src/index.ts | 5 +-
.../_components/telegram-setup-dialog.tsx | 6 ++-
apps/web/src/app/api/chat/abort/route.spec.ts | 2 +-
.../src/app/api/chat/history/route.spec.ts | 2 +-
apps/web/src/lib/squadhub/actions.spec.ts | 5 +-
apps/web/src/lib/squadhub/provision.ts | 51 ++++++++++++++++---
packages/backend/convex/activities.ts | 4 +-
packages/backend/convex/lib/auth.ts | 12 ++---
packages/backend/convex/notifications.ts | 4 +-
10 files changed, 65 insertions(+), 32 deletions(-)
diff --git a/apps/watcher/README.md b/apps/watcher/README.md
index c1ade99..b522179 100644
--- a/apps/watcher/README.md
+++ b/apps/watcher/README.md
@@ -15,9 +15,9 @@ This enables:
## Environment Variables
-| Variable | Required | Description |
-| -------------- | -------- | --------------------------- |
-| `CONVEX_URL` | Yes | Convex deployment URL |
+| Variable | Required | Description |
+| ---------------- | -------- | ----------------------------- |
+| `CONVEX_URL` | Yes | Convex deployment URL |
| `SQUADHUB_URL` | Yes | Squadhub gateway URL |
| `SQUADHUB_TOKEN` | Yes | Squadhub authentication token |
diff --git a/apps/watcher/src/index.ts b/apps/watcher/src/index.ts
index 7eb5035..c4a58d4 100644
--- a/apps/watcher/src/index.ts
+++ b/apps/watcher/src/index.ts
@@ -18,10 +18,7 @@
import { ConvexHttpClient } from "convex/browser";
import { api } from "@clawe/backend";
-import {
- sessionsSend,
- type SquadhubConnection,
-} from "@clawe/shared/squadhub";
+import { sessionsSend, type SquadhubConnection } from "@clawe/shared/squadhub";
import { getTimeInZone, DEFAULT_TIMEZONE } from "@clawe/shared/timezone";
import { validateEnv, config, POLL_INTERVAL_MS } from "./config.js";
diff --git a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx
index fe799f3..7967625 100644
--- a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx
+++ b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx
@@ -217,7 +217,8 @@ export const TelegramSetupDialog = ({
Squadhub is offline
- The squadhub service needs to be running to verify pairing.
+ The squadhub service needs to be running to verify
+ pairing.
@@ -357,7 +358,8 @@ export const TelegramSetupDialog = ({
Squadhub is offline
- The squadhub service needs to be running to connect Telegram.
+ The squadhub service needs to be running to connect
+ Telegram.
diff --git a/apps/web/src/app/api/chat/abort/route.spec.ts b/apps/web/src/app/api/chat/abort/route.spec.ts
index b7b1b2c..f10ba65 100644
--- a/apps/web/src/app/api/chat/abort/route.spec.ts
+++ b/apps/web/src/app/api/chat/abort/route.spec.ts
@@ -6,7 +6,7 @@ import { POST } from "./route";
const mockRequest = vi.fn();
vi.mock("@clawe/shared/squadhub", () => ({
- getSharedClient: vi.fn(async (_connection: unknown) => ({
+ getSharedClient: vi.fn(async () => ({
request: mockRequest,
isConnected: vi.fn().mockReturnValue(true),
})),
diff --git a/apps/web/src/app/api/chat/history/route.spec.ts b/apps/web/src/app/api/chat/history/route.spec.ts
index f5b62e0..6c76a70 100644
--- a/apps/web/src/app/api/chat/history/route.spec.ts
+++ b/apps/web/src/app/api/chat/history/route.spec.ts
@@ -6,7 +6,7 @@ import { GET } from "./route";
const mockRequest = vi.fn();
vi.mock("@clawe/shared/squadhub", () => ({
- getSharedClient: vi.fn(async (_connection: unknown) => ({
+ getSharedClient: vi.fn(async () => ({
request: mockRequest,
isConnected: vi.fn().mockReturnValue(true),
})),
diff --git a/apps/web/src/lib/squadhub/actions.spec.ts b/apps/web/src/lib/squadhub/actions.spec.ts
index 930b39d..8f1b6d6 100644
--- a/apps/web/src/lib/squadhub/actions.spec.ts
+++ b/apps/web/src/lib/squadhub/actions.spec.ts
@@ -56,7 +56,10 @@ describe("Squadhub Actions", () => {
expect(probeTelegramToken).toHaveBeenCalledWith("123456:ABC-DEF");
expect(saveTelegramBotTokenClient).toHaveBeenCalledWith(
- expect.objectContaining({ squadhubUrl: expect.any(String), squadhubToken: expect.any(String) }),
+ expect.objectContaining({
+ squadhubUrl: expect.any(String),
+ squadhubToken: expect.any(String),
+ }),
"123456:ABC-DEF",
);
expect(result.ok).toBe(true);
diff --git a/apps/web/src/lib/squadhub/provision.ts b/apps/web/src/lib/squadhub/provision.ts
index 6ce32d7..2b19c3c 100644
--- a/apps/web/src/lib/squadhub/provision.ts
+++ b/apps/web/src/lib/squadhub/provision.ts
@@ -13,10 +13,34 @@ import {
* Default agent definitions for new tenants.
*/
const DEFAULT_AGENTS = [
- { id: "main", name: "Clawe", emoji: "\u{1F99E}", role: "Squad Lead", cron: "0,15,30,45 * * * *" },
- { id: "inky", name: "Inky", emoji: "\u270D\uFE0F", role: "Writer", cron: "3,18,33,48 * * * *" },
- { id: "pixel", name: "Pixel", emoji: "\u{1F3A8}", role: "Designer", cron: "7,22,37,52 * * * *" },
- { id: "scout", name: "Scout", emoji: "\u{1F50D}", role: "SEO", cron: "11,26,41,56 * * * *" },
+ {
+ id: "main",
+ name: "Clawe",
+ emoji: "\u{1F99E}",
+ role: "Squad Lead",
+ cron: "0,15,30,45 * * * *",
+ },
+ {
+ id: "inky",
+ name: "Inky",
+ emoji: "\u270D\uFE0F",
+ role: "Writer",
+ cron: "3,18,33,48 * * * *",
+ },
+ {
+ id: "pixel",
+ name: "Pixel",
+ emoji: "\u{1F3A8}",
+ role: "Designer",
+ cron: "7,22,37,52 * * * *",
+ },
+ {
+ id: "scout",
+ name: "Scout",
+ emoji: "\u{1F50D}",
+ role: "SEO",
+ cron: "11,26,41,56 * * * *",
+ },
];
const HEARTBEAT_MESSAGE =
@@ -38,14 +62,24 @@ const SEED_ROUTINES = [
title: "Morning Brief",
description: "Prepare daily morning brief for the team",
priority: "high" as const,
- schedule: { type: "weekly" as const, daysOfWeek: [0, 1, 2, 3, 4, 5, 6], hour: 8, minute: 0 },
+ schedule: {
+ type: "weekly" as const,
+ daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
+ hour: 8,
+ minute: 0,
+ },
color: "amber",
},
{
title: "Competitor Scan",
description: "Scan competitor activities and updates",
priority: "normal" as const,
- schedule: { type: "weekly" as const, daysOfWeek: [1, 4], hour: 10, minute: 0 },
+ schedule: {
+ type: "weekly" as const,
+ daysOfWeek: [1, 4],
+ hour: 10,
+ minute: 0,
+ },
color: "rose",
},
];
@@ -99,7 +133,10 @@ async function setupCrons(connection: SquadhubConnection): Promise<{
const result = await cronList(connection);
if (!result.ok) {
- return { count: 0, errors: [`Failed to list crons: ${result.error?.message}`] };
+ return {
+ count: 0,
+ errors: [`Failed to list crons: ${result.error?.message}`],
+ };
}
const existingNames = new Set(
diff --git a/packages/backend/convex/activities.ts b/packages/backend/convex/activities.ts
index 000113f..c00fb8e 100644
--- a/packages/backend/convex/activities.ts
+++ b/packages/backend/convex/activities.ts
@@ -91,9 +91,7 @@ export const byType = query({
.order("desc")
.collect();
- return allActivities
- .filter((a) => a.type === filters.type)
- .slice(0, limit);
+ return allActivities.filter((a) => a.type === filters.type).slice(0, limit);
},
});
diff --git a/packages/backend/convex/lib/auth.ts b/packages/backend/convex/lib/auth.ts
index 414f1d1..770d26b 100644
--- a/packages/backend/convex/lib/auth.ts
+++ b/packages/backend/convex/lib/auth.ts
@@ -65,9 +65,7 @@ async function getTenantIdFromUser(
* Browser path: resolve tenantId from the JWT identity.
* Gets the user, then looks up: accountMembers → account → tenants.
*/
-export async function getTenantIdFromJwt(
- ctx: ReadCtx,
-): Promise> {
+export async function getTenantIdFromJwt(ctx: ReadCtx): Promise> {
const user = await getUser(ctx);
const tenantId = await getTenantIdFromUser(ctx, user._id);
@@ -116,9 +114,7 @@ export function validateWatcherToken(
/**
* Dev-only: get or create a default dev tenant (used when AUTH_ENABLED=false).
*/
-async function getOrCreateDevTenant(
- ctx: MutationCtx,
-): Promise> {
+async function getOrCreateDevTenant(ctx: MutationCtx): Promise> {
// Look for existing dev user
let user = await ctx.db
.query("users")
@@ -205,7 +201,9 @@ export async function resolveTenantId(
}
}
- throw new Error("No dev tenant found. Run a mutation first to auto-create it.");
+ throw new Error(
+ "No dev tenant found. Run a mutation first to auto-create it.",
+ );
}
/**
diff --git a/packages/backend/convex/notifications.ts b/packages/backend/convex/notifications.ts
index bc009ef..3130490 100644
--- a/packages/backend/convex/notifications.ts
+++ b/packages/backend/convex/notifications.ts
@@ -222,9 +222,7 @@ export const sendToMany = mutation({
}
for (const targetSessionKey of args.targetSessionKeys) {
- const targetAgent = agents.find(
- (a) => a.sessionKey === targetSessionKey,
- );
+ const targetAgent = agents.find((a) => a.sessionKey === targetSessionKey);
if (targetAgent) {
const id = await ctx.db.insert("notifications", {
From 1acaba761b736ced23ed14cfdf55d46639802282 Mon Sep 17 00:00:00 2001
From: Guy Ben-Aharon
Date: Sun, 15 Feb 2026 21:56:42 +0200
Subject: [PATCH 3/8] fix: refactor codebase
---
apps/watcher/src/config.ts | 1 +
apps/watcher/src/index.ts | 33 ++++++--
packages/backend/convex/tenants.ts | 131 ++++++++++++++++++++++++++++-
3 files changed, 159 insertions(+), 6 deletions(-)
diff --git a/apps/watcher/src/config.ts b/apps/watcher/src/config.ts
index 89079c7..9107b94 100644
--- a/apps/watcher/src/config.ts
+++ b/apps/watcher/src/config.ts
@@ -19,5 +19,6 @@ export const config = {
convexUrl: process.env.CONVEX_URL || "",
squadhubUrl: process.env.SQUADHUB_URL || "http://localhost:18789",
squadhubToken: process.env.SQUADHUB_TOKEN || "",
+ watcherToken: process.env.WATCHER_TOKEN || "",
pollIntervalMs: POLL_INTERVAL_MS,
};
diff --git a/apps/watcher/src/index.ts b/apps/watcher/src/index.ts
index c4a58d4..a1c6b98 100644
--- a/apps/watcher/src/index.ts
+++ b/apps/watcher/src/index.ts
@@ -8,10 +8,12 @@
* moved to the provisioning API route (POST /api/tenant/provision).
*
* Multi-tenant ready: iterates over active tenants each loop iteration.
- * Currently falls back to single-tenant mode using SQUADHUB_URL/SQUADHUB_TOKEN env vars.
+ * When WATCHER_TOKEN is set, queries Convex for all active tenants.
+ * Falls back to single-tenant mode using SQUADHUB_URL/SQUADHUB_TOKEN env vars.
*
* Environment variables:
* CONVEX_URL - Convex deployment URL
+ * WATCHER_TOKEN - System-level auth token for querying all tenants (optional)
* SQUADHUB_URL - Squadhub gateway URL (single-tenant fallback)
* SQUADHUB_TOKEN - Squadhub authentication token (single-tenant fallback)
*/
@@ -38,12 +40,28 @@ type TenantInfo = {
/**
* Get the list of active tenants to service.
*
- * TODO (Phase 2): Query Convex `tenants.listActive` with WATCHER_TOKEN
- * to get all active tenants with their squadhubUrl + squadhubToken.
+ * When WATCHER_TOKEN is set, queries Convex `tenants.listActive` for all
+ * active tenants with their squadhub connection info.
*
- * For now, falls back to single-tenant mode using env vars.
+ * Falls back to single-tenant mode using SQUADHUB_URL/SQUADHUB_TOKEN env vars.
*/
async function getActiveTenants(): Promise {
+ if (config.watcherToken) {
+ const tenants = await convex.query(api.tenants.listActive, {
+ watcherToken: config.watcherToken,
+ });
+ return tenants.map(
+ (t: { id: string; squadhubUrl: string; squadhubToken: string }) => ({
+ id: t.id,
+ connection: {
+ squadhubUrl: t.squadhubUrl,
+ squadhubToken: t.squadhubToken,
+ },
+ }),
+ );
+ }
+
+ // Fallback: single-tenant mode from env vars
return [
{
id: "default",
@@ -270,7 +288,12 @@ async function startDeliveryLoop(): Promise {
async function main(): Promise {
console.log("[watcher] 🦞 Clawe Watcher starting...");
console.log(`[watcher] Convex: ${config.convexUrl}`);
- console.log(`[watcher] Squadhub: ${config.squadhubUrl}`);
+ console.log(
+ `[watcher] Mode: ${config.watcherToken ? "multi-tenant (WATCHER_TOKEN)" : "single-tenant (env vars)"}`,
+ );
+ if (!config.watcherToken) {
+ console.log(`[watcher] Squadhub: ${config.squadhubUrl}`);
+ }
console.log(`[watcher] Notification poll interval: ${POLL_INTERVAL_MS}ms`);
console.log(
`[watcher] Routine check interval: ${ROUTINE_CHECK_INTERVAL_MS}ms\n`,
diff --git a/packages/backend/convex/tenants.ts b/packages/backend/convex/tenants.ts
index 10f7642..325276d 100644
--- a/packages/backend/convex/tenants.ts
+++ b/packages/backend/convex/tenants.ts
@@ -1,6 +1,11 @@
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
-import { resolveTenantId, resolveTenantIdMut } from "./lib/auth";
+import {
+ getUser,
+ resolveTenantId,
+ resolveTenantIdMut,
+ validateWatcherToken,
+} from "./lib/auth";
const DEFAULT_TIMEZONE = "America/New_York";
@@ -66,3 +71,127 @@ export const completeOnboarding = mutation({
});
},
});
+
+// Create a new tenant within an account
+export const create = mutation({
+ args: {
+ accountId: v.id("accounts"),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const tenantId = await ctx.db.insert("tenants", {
+ accountId: args.accountId,
+ status: "provisioning",
+ createdAt: now,
+ updatedAt: now,
+ });
+ return tenantId;
+ },
+});
+
+// Get tenant for the current authenticated user
+// Resolves: user → accountMembers → account → tenants
+export const getForCurrentUser = query({
+ args: {},
+ handler: async (ctx) => {
+ const user = await getUser(ctx);
+
+ const membership = await ctx.db
+ .query("accountMembers")
+ .withIndex("by_user", (q) => q.eq("userId", user._id))
+ .first();
+
+ if (!membership) {
+ return null;
+ }
+
+ return await ctx.db
+ .query("tenants")
+ .withIndex("by_account", (q) => q.eq("accountId", membership.accountId))
+ .first();
+ },
+});
+
+// Update tenant provisioning status
+export const updateStatus = mutation({
+ args: {
+ tenantId: v.id("tenants"),
+ status: v.union(
+ v.literal("provisioning"),
+ v.literal("active"),
+ v.literal("stopped"),
+ v.literal("error"),
+ ),
+ squadhubUrl: v.optional(v.string()),
+ squadhubToken: v.optional(v.string()),
+ squadhubServiceArn: v.optional(v.string()),
+ efsAccessPointId: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const tenant = await ctx.db.get(args.tenantId);
+ if (!tenant) {
+ throw new Error("Tenant not found");
+ }
+
+ await ctx.db.patch(args.tenantId, {
+ status: args.status,
+ ...(args.squadhubUrl !== undefined && {
+ squadhubUrl: args.squadhubUrl,
+ }),
+ ...(args.squadhubToken !== undefined && {
+ squadhubToken: args.squadhubToken,
+ }),
+ ...(args.squadhubServiceArn !== undefined && {
+ squadhubServiceArn: args.squadhubServiceArn,
+ }),
+ ...(args.efsAccessPointId !== undefined && {
+ efsAccessPointId: args.efsAccessPointId,
+ }),
+ updatedAt: Date.now(),
+ });
+ },
+});
+
+// Store Anthropic API key in tenant record
+export const setAnthropicKey = mutation({
+ args: {
+ machineToken: v.optional(v.string()),
+ apiKey: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const tenantId = await resolveTenantIdMut(ctx, args);
+ const tenant = await ctx.db.get(tenantId);
+ if (!tenant) {
+ throw new Error("Tenant not found");
+ }
+
+ await ctx.db.patch(tenantId, {
+ anthropicApiKey: args.apiKey,
+ updatedAt: Date.now(),
+ });
+ },
+});
+
+// List all active tenants with their squadhub connection info
+// Requires a valid WATCHER_TOKEN for system-level auth
+export const listActive = query({
+ args: {
+ watcherToken: v.string(),
+ },
+ handler: async (ctx, args) => {
+ validateWatcherToken(ctx, args.watcherToken);
+
+ const tenants = await ctx.db
+ .query("tenants")
+ .withIndex("by_status", (q) => q.eq("status", "active"))
+ .collect();
+
+ return tenants
+ .filter((t) => t.squadhubUrl && t.squadhubToken)
+ .map((t) => ({
+ id: t._id,
+ squadhubUrl: t.squadhubUrl!,
+ squadhubToken: t.squadhubToken!,
+ }));
+ },
+});
From ad9cac03b46a62a6c4fd71f3a6c53d6ab88d0ec9 Mon Sep 17 00:00:00 2001
From: Guy Ben-Aharon
Date: Mon, 16 Feb 2026 14:39:43 +0200
Subject: [PATCH 4/8] fix: refactor codebase
---
.env.example | 26 +
.gitignore | 4 +
apps/web/package.json | 3 +
.../app/(dashboard)/_components/nav-user.tsx | 3 +-
.../_components/business-settings-form.tsx | 1 -
.../src/app/api/auth/[...nextauth]/route.ts | 3 +
apps/web/src/app/api/auth/jwks/route.ts | 3 +
apps/web/src/app/api/auth/token/route.ts | 11 +
.../web/src/app/api/business/context/route.ts | 2 -
apps/web/src/app/auth/login/page.tsx | 133 ++
apps/web/src/app/layout.tsx | 25 +-
apps/web/src/app/page.tsx | 27 +-
.../app/setup/_components/setup-user-menu.tsx | 3 +-
apps/web/src/app/setup/business/page.tsx | 2 +-
apps/web/src/app/setup/complete/page.tsx | 2 +-
.../user-menu/user-menu-content.tsx | 4 +-
apps/web/src/hooks/use-onboarding-guard.ts | 37 +-
apps/web/src/hooks/use-user-menu.ts | 21 +-
apps/web/src/lib/auth/nextauth-config.ts | 107 ++
apps/web/src/providers/auth-provider.tsx | 252 +++
apps/web/src/providers/convex-provider.tsx | 11 +-
apps/web/src/proxy.ts | 28 +
package.json | 2 +-
packages/backend/convex/_generated/api.d.ts | 10 +-
packages/backend/convex/activities.ts | 6 +-
packages/backend/convex/agents.ts | 18 +-
.../backend/convex/auth.config.cognito.ts | 10 +
.../backend/convex/auth.config.nextauth.ts | 13 +
packages/backend/convex/auth.config.ts | 16 +
packages/backend/convex/businessContext.ts | 40 +-
packages/backend/convex/channels.ts | 6 +-
packages/backend/convex/dev-jwks/jwks.json | 12 +
packages/backend/convex/dev-jwks/private.pem | 28 +
packages/backend/convex/dev-jwks/public.pem | 9 +
packages/backend/convex/documents.ts | 10 +-
packages/backend/convex/lib/auth.ts | 107 +-
packages/backend/convex/messages.ts | 6 +-
packages/backend/convex/notifications.ts | 10 +-
packages/backend/convex/routines.ts | 10 +-
packages/backend/convex/schema.ts | 2 -
packages/backend/convex/tasks.ts | 26 +-
packages/backend/convex/tenants.ts | 27 +-
packages/backend/convex/users.ts | 42 +-
packages/backend/package.json | 4 +-
packages/cli/src/commands/business-get.ts | 1 -
packages/cli/src/commands/business-set.ts | 6 -
packages/cli/src/index.ts | 4 +-
pnpm-lock.yaml | 1622 ++++++++++++++++-
scripts/convex-deploy.sh | 41 +
turbo.json | 8 +-
50 files changed, 2498 insertions(+), 306 deletions(-)
create mode 100644 apps/web/src/app/api/auth/[...nextauth]/route.ts
create mode 100644 apps/web/src/app/api/auth/jwks/route.ts
create mode 100644 apps/web/src/app/api/auth/token/route.ts
create mode 100644 apps/web/src/app/auth/login/page.tsx
create mode 100644 apps/web/src/lib/auth/nextauth-config.ts
create mode 100644 apps/web/src/providers/auth-provider.tsx
create mode 100644 apps/web/src/proxy.ts
create mode 100644 packages/backend/convex/auth.config.cognito.ts
create mode 100644 packages/backend/convex/auth.config.nextauth.ts
create mode 100644 packages/backend/convex/auth.config.ts
create mode 100644 packages/backend/convex/dev-jwks/jwks.json
create mode 100644 packages/backend/convex/dev-jwks/private.pem
create mode 100644 packages/backend/convex/dev-jwks/public.pem
create mode 100755 scripts/convex-deploy.sh
diff --git a/.env.example b/.env.example
index c3204c3..8376d0b 100644
--- a/.env.example
+++ b/.env.example
@@ -32,6 +32,32 @@ ENVIRONMENT=dev
# Production: http://squadhub:18789 (Docker internal network)
SQUADHUB_URL=http://localhost:18790
+# =============================================================================
+# AUTHENTICATION
+# =============================================================================
+
+# Authentication provider: "nextauth" (local/self-hosted) or "cognito" (cloud)
+# NextAuth uses auto-login with committed dev keys — zero config for local dev.
+AUTH_PROVIDER=nextauth
+
+# NextAuth settings (only when AUTH_PROVIDER=nextauth)
+NEXTAUTH_SECRET=clawe-dev-secret-change-in-production
+NEXTAUTH_URL=http://localhost:3000
+
+# Google OAuth credentials (for NextAuth Google provider)
+# Required for self-hosted deployments. Get from Google Cloud Console.
+# GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
+# GOOGLE_CLIENT_SECRET=your-client-secret
+
+# Auto-login email for local dev (skips Google OAuth, uses Credentials provider)
+# Remove or leave empty to require Google sign-in
+AUTO_LOGIN_EMAIL=dev@clawe.local
+
+# AWS Cognito settings (only when AUTH_PROVIDER=cognito)
+# COGNITO_USER_POOL_ID=us-east-1_xxxxxxxxx
+# COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx
+# COGNITO_DOMAIN=your-domain.auth.us-east-1.amazoncognito.com
+
# =============================================================================
# ADVANCED (usually don't need to change)
# =============================================================================
diff --git a/.gitignore b/.gitignore
index 89b056f..54db792 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,12 +40,16 @@ yarn-error.log*
# Misc
.DS_Store
*.pem
+!packages/backend/convex/dev-jwks/*.pem
# Claude Code local settings (personal permissions)
.claude/settings.local.json
convex/_generated
+# auth.config.ts is committed as the NextAuth version (default for local dev).
+# scripts/convex-deploy.sh overwrites it for cloud deployments.
+
# Local data directory (Clawe config)
.data/
diff --git a/apps/web/package.json b/apps/web/package.json
index 8b031cc..e32f630 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -36,11 +36,14 @@
"@tiptap/suggestion": "^3.15.3",
"@xyflow/react": "^12.10.0",
"ai": "^6.0.77",
+ "aws-amplify": "^6.16.2",
"axios": "^1.13.4",
"convex": "^1.21.0",
"framer-motion": "^12.29.0",
+ "jose": "^6.1.3",
"lucide-react": "^0.562.0",
"next": "16.1.0",
+ "next-auth": "5.0.0-beta.30",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
diff --git a/apps/web/src/app/(dashboard)/_components/nav-user.tsx b/apps/web/src/app/(dashboard)/_components/nav-user.tsx
index de1cd63..4a691c3 100644
--- a/apps/web/src/app/(dashboard)/_components/nav-user.tsx
+++ b/apps/web/src/app/(dashboard)/_components/nav-user.tsx
@@ -17,7 +17,7 @@ import { useUserMenu } from "@/hooks/use-user-menu";
export const NavUser = () => {
const { isMobile } = useSidebar();
- const { guestMode, user, displayName, initials } = useUserMenu();
+ const { guestMode, user, displayName, initials, signOut } = useUserMenu();
return (
@@ -58,6 +58,7 @@ export const NavUser = () => {
align="end"
sideOffset={4}
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
+ onSignOut={signOut}
/>
diff --git a/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx b/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx
index 3df5e21..f86e8c7 100644
--- a/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx
+++ b/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx
@@ -60,7 +60,6 @@ export const BusinessSettingsForm = () => {
targetAudience: targetAudience || undefined,
tone: tone || undefined,
},
- approved: true,
});
setIsDirty(false);
} finally {
diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..982fb19
--- /dev/null
+++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,3 @@
+import { handlers } from "@/lib/auth/nextauth-config";
+
+export const { GET, POST } = handlers;
diff --git a/apps/web/src/app/api/auth/jwks/route.ts b/apps/web/src/app/api/auth/jwks/route.ts
new file mode 100644
index 0000000..ede5038
--- /dev/null
+++ b/apps/web/src/app/api/auth/jwks/route.ts
@@ -0,0 +1,3 @@
+import jwks from "@clawe/backend/dev-jwks/jwks.json";
+
+export const GET = () => Response.json(jwks);
diff --git a/apps/web/src/app/api/auth/token/route.ts b/apps/web/src/app/api/auth/token/route.ts
new file mode 100644
index 0000000..f6d88a8
--- /dev/null
+++ b/apps/web/src/app/api/auth/token/route.ts
@@ -0,0 +1,11 @@
+import type { NextRequest } from "next/server";
+
+export function GET(request: NextRequest) {
+ const cookieName =
+ request.nextUrl.protocol === "https:"
+ ? "__Secure-authjs.session-token"
+ : "authjs.session-token";
+ const token = request.cookies.get(cookieName)?.value ?? null;
+
+ return Response.json({ token });
+}
diff --git a/apps/web/src/app/api/business/context/route.ts b/apps/web/src/app/api/business/context/route.ts
index 7a45abf..57da4d3 100644
--- a/apps/web/src/app/api/business/context/route.ts
+++ b/apps/web/src/app/api/business/context/route.ts
@@ -55,7 +55,6 @@ export const GET = async (request: Request) => {
description: context.description,
favicon: context.favicon,
metadata: context.metadata,
- approved: context.approved,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
@@ -103,7 +102,6 @@ export const POST = async (request: Request) => {
description: body.description,
favicon: body.favicon,
metadata: body.metadata,
- approved: body.approved,
});
return NextResponse.json({
diff --git a/apps/web/src/app/auth/login/page.tsx b/apps/web/src/app/auth/login/page.tsx
new file mode 100644
index 0000000..53294d1
--- /dev/null
+++ b/apps/web/src/app/auth/login/page.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import Image from "next/image";
+import { useMutation } from "convex/react";
+import { api } from "@clawe/backend";
+import { Button } from "@clawe/ui/components/button";
+import { Spinner } from "@clawe/ui/components/spinner";
+import { useAuth } from "@/providers/auth-provider";
+
+const AUTO_LOGIN_EMAIL = process.env.NEXT_PUBLIC_AUTO_LOGIN_EMAIL;
+
+export default function LoginPage() {
+ const router = useRouter();
+ const { isAuthenticated, isLoading, signIn } = useAuth();
+ const getOrCreateUser = useMutation(api.users.getOrCreateFromAuth);
+ const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
+
+ // Auto-login when AUTO_LOGIN_EMAIL is set (local dev convenience)
+ useEffect(() => {
+ if (!AUTO_LOGIN_EMAIL) return;
+ if (isLoading || isAuthenticated || autoLoginAttempted) return;
+ setAutoLoginAttempted(true);
+ signIn(AUTO_LOGIN_EMAIL);
+ }, [isLoading, isAuthenticated, autoLoginAttempted, signIn]);
+
+ // After authentication, create/fetch user and redirect
+ useEffect(() => {
+ if (!isAuthenticated) return;
+
+ const ensureUser = async () => {
+ try {
+ await getOrCreateUser();
+ } catch {
+ // User creation may fail if Convex auth isn't ready yet — that's ok,
+ // the onboarding guard will handle it on the next page load.
+ }
+ router.replace("/");
+ };
+
+ ensureUser();
+ }, [isAuthenticated, getOrCreateUser, router]);
+
+ return (
+
+ {/* Left side - Login content */}
+
+ {/* Logo */}
+
+
+ Clawe
+
+
+
+ {/* Centered content */}
+
+
+ {isLoading || isAuthenticated ? (
+
+
+
+ {isAuthenticated ? "Signing you in..." : "Loading..."}
+
+
+ ) : (
+ <>
+
+ Welcome to Clawe
+
+
+
+ >
+ )}
+
+
+
+
+ {/* Right side - Illustration */}
+
+
+ );
+}
+
+const GoogleIcon = () => (
+
+);
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 6b4764a..c8ddeb9 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -3,6 +3,7 @@ import localFont from "next/font/local";
import { Montserrat, Space_Grotesk } from "next/font/google";
import "@clawe/ui/globals.css";
import "./globals.css";
+import { AuthProvider } from "@/providers/auth-provider";
import { ConvexClientProvider } from "@/providers/convex-provider";
import { QueryProvider } from "@/providers/query-provider";
import { ThemeProvider } from "@/providers/theme-provider";
@@ -41,17 +42,19 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} ${montserrat.variable} ${spaceGrotesk.variable}`}
>
-
-
- {children}
-
-
-
+
+
+
+ {children}
+
+
+
+