Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 21 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,28 +183,32 @@ Each agent has an isolated workspace with:

### Adding New Agents

1. Create workspace template in `docker/openclaw/templates/workspaces/{name}/`
2. Add agent to `docker/openclaw/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`
**Live (no rebuild):**

```bash
clawe agent:onboard coder Coder Developer --emoji 💻 --cron "15,45 * * * *"
```

**Build-time (persists across rebuilds):**

```bash
./scripts/add-agent.sh coder Coder 💻 Developer "15,45 * * * *"
docker compose build --no-cache openclaw && docker compose up -d
clawe agent:onboard coder Coder Developer --emoji 💻 --cron "15,45 * * * *"
```

See [docs/AGENT_ONBOARDING.md](docs/AGENT_ONBOARDING.md) for the full guide.

### Changing Heartbeat Schedules

Edit the `AGENTS` array in `apps/watcher/src/index.ts`:

```typescript
const AGENTS = [
{
id: "main",
name: "Clawe",
emoji: "🦞",
role: "Squad Lead",
cron: "0 * * * *",
},
// Add or modify agents here
];
Heartbeat schedules are stored in Convex (per agent). Update them via the CLI:

```bash
clawe agent:onboard <id> <name> <role> --cron "new schedule"
```

Or update the agent's `cronSchedule` field directly in Convex. Changes take effect on next watcher restart.

## Development

```bash
Expand Down
109 changes: 67 additions & 42 deletions apps/watcher/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/**
* Clawe Notification Watcher
*
* 1. On startup: ensures heartbeat crons are configured for all agents
* 2. Continuously: polls Convex for undelivered notifications and delivers them
* 1. On startup: registers default agents if none exist
* 2. On startup: ensures heartbeat crons are configured for all agents
* 3. Continuously: polls Convex for undelivered notifications and delivers them
*
* Agents are read from Convex — no hardcoded list.
* New agents can be added at runtime via CLI or API.
*
* Environment variables:
* CONVEX_URL - Convex deployment URL
Expand All @@ -26,35 +30,39 @@ validateEnv();

const convex = new ConvexHttpClient(config.convexUrl);

// Agent configuration
const AGENTS = [
// Default agents — only used on first run when Convex is empty
const DEFAULT_AGENTS = [
{
id: "main",
name: "Clawe",
emoji: "🦞",
role: "Squad Lead",
cron: "0,15,30,45 * * * *",
agentType: "lead" as const,
},
{
id: "inky",
name: "Inky",
emoji: "✍️",
role: "Writer",
cron: "3,18,33,48 * * * *",
agentType: "worker" as const,
},
{
id: "pixel",
name: "Pixel",
emoji: "🎨",
role: "Designer",
cron: "7,22,37,52 * * * *",
agentType: "worker" as const,
},
{
id: "scout",
name: "Scout",
emoji: "🔍",
role: "SEO",
cron: "11,26,41,56 * * * *",
agentType: "worker" as const,
},
];

Expand Down Expand Up @@ -108,31 +116,25 @@ async function withRetry<T>(
}

/**
* Register all agents in Convex (upsert - creates or updates)
* Register default agents in Convex (only if no agents exist yet)
*/
async function registerAgents(): Promise<void> {
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");
async function registerDefaultAgents(): Promise<void> {
console.log("[watcher] Checking for existing agents in Convex...");

const existingAgents = await withRetry(async () => {
return await convex.query(api.agents.list, {});
}, "Convex connection");

if (existingAgents.length > 0) {
console.log(
`[watcher] Found ${existingAgents.length} agent(s) in Convex. Skipping default registration.`,
);
return;
}

// Register remaining agents (Convex is now ready)
for (const agent of AGENTS.slice(1)) {
console.log("[watcher] No agents found. Registering defaults...");

for (const agent of DEFAULT_AGENTS) {
const sessionKey = `agent:${agent.id}:main`;

try {
Expand All @@ -141,6 +143,8 @@ async function registerAgents(): Promise<void> {
role: agent.role,
sessionKey,
emoji: agent.emoji,
agentType: agent.agentType,
cronSchedule: agent.cron,
});
console.log(
`[watcher] ✓ ${agent.name} ${agent.emoji} registered (${sessionKey})`,
Expand All @@ -153,16 +157,21 @@ async function registerAgents(): Promise<void> {
}
}

console.log("[watcher] Agent registration complete.\n");
console.log("[watcher] Default agent registration complete.\n");
}

/**
* Setup heartbeat crons for all agents (if not already configured)
* Setup heartbeat crons for all agents from Convex
*/
async function setupCrons(): Promise<void> {
console.log("[watcher] Checking heartbeat crons...");
console.log("[watcher] Syncing heartbeat crons...");

// Get agents from Convex
const agents = await withRetry(async () => {
return await convex.query(api.agents.list, {});
}, "Convex agent list");

// Retry getting cron list (waits for OpenClaw to be ready)
// Get existing crons from OpenClaw
const result = await withRetry(async () => {
const res = await cronList();
if (!res.ok) {
Expand All @@ -175,26 +184,42 @@ async function setupCrons(): Promise<void> {
result.result.details.jobs.map((j: CronJob) => j.name),
);

for (const agent of AGENTS) {
const cronName = `${agent.id}-heartbeat`;
for (const agent of agents) {
// Extract agent ID from sessionKey (agent:<id>:main)
const agentId = agent.sessionKey.split(":")[1];
if (!agentId) continue;

const cronSchedule = agent.cronSchedule;
if (!cronSchedule) {
console.log(
`[watcher] ⏭ ${agent.name} ${agent.emoji || ""} — no cron schedule, skipping`,
);
continue;
}

const cronName = `${agentId}-heartbeat`;

if (existingNames.has(cronName)) {
console.log(`[watcher] ✓ ${agent.name} ${agent.emoji} heartbeat exists`);
console.log(
`[watcher] ✓ ${agent.name} ${agent.emoji || ""} heartbeat exists`,
);
continue;
}

console.log(`[watcher] Adding ${agent.name} ${agent.emoji} heartbeat...`);
console.log(
`[watcher] Adding ${agent.name} ${agent.emoji || ""} heartbeat...`,
);

const job: CronAddJob = {
name: cronName,
agentId: agent.id,
agentId: agentId,
enabled: true,
schedule: { kind: "cron", expr: agent.cron },
schedule: { kind: "cron", expr: cronSchedule },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: HEARTBEAT_MESSAGE,
model: "anthropic/claude-sonnet-4-20250514",
model: agent.model || "anthropic/claude-sonnet-4-20250514",
timeoutSeconds: 600,
},
delivery: { mode: "none" },
Expand All @@ -203,7 +228,7 @@ async function setupCrons(): Promise<void> {
const addResult = await cronAdd(job);
if (addResult.ok) {
console.log(
`[watcher] ✓ ${agent.name} ${agent.emoji} heartbeat: ${agent.cron}`,
`[watcher] ✓ ${agent.name} ${agent.emoji || ""} heartbeat: ${cronSchedule}`,
);
} else {
console.error(
Expand All @@ -213,7 +238,7 @@ async function setupCrons(): Promise<void> {
}
}

console.log("[watcher] Cron setup complete.\n");
console.log("[watcher] Cron sync complete.\n");
}

/**
Expand Down Expand Up @@ -322,10 +347,10 @@ async function main(): Promise<void> {
console.log(`[watcher] OpenClaw: ${config.openclawUrl}`);
console.log(`[watcher] Poll interval: ${POLL_INTERVAL_MS}ms\n`);

// Register agents in Convex
await registerAgents();
// Register default agents if Convex is empty
await registerDefaultAgents();

// Setup crons on startup
// Setup crons from Convex agent data
await setupCrons();

console.log("[watcher] Starting notification delivery loop...\n");
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ services:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- CONVEX_URL=${CONVEX_URL}
- OPENCLAW_DATA_DIR=/data
- CLAWE_TEMPLATES_DIR=/opt/clawe/templates
volumes:
- openclaw-data:/data
healthcheck:
Expand Down
90 changes: 56 additions & 34 deletions docker/openclaw/scripts/init-agents.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
set -e

# Clawe Agent Initialization Script
# This script initializes agent workspaces and shared state
# Generates agent workspaces from base templates + agent-specific SOUL.md

TEMPLATES_DIR="/opt/clawe/templates"
BASE_DIR="$TEMPLATES_DIR/base"
WORKSPACES_DIR="$TEMPLATES_DIR/workspaces"
DATA_DIR="/data"

echo "🦞 Initializing Clawe agents..."
Expand All @@ -15,41 +17,61 @@ mkdir -p "$DATA_DIR/shared"
cp -r "$TEMPLATES_DIR/shared/"* "$DATA_DIR/shared/"
echo " ✓ Shared state initialized"

# Initialize Clawe (lead) workspace
echo " → Creating Clawe workspace..."
mkdir -p "$DATA_DIR/workspace/memory"
cp -r "$TEMPLATES_DIR/workspaces/clawe/"* "$DATA_DIR/workspace/"
ln -sf "$DATA_DIR/shared" "$DATA_DIR/workspace/shared"
echo " ✓ Clawe workspace initialized"

# Initialize Inky (writer) workspace
echo " → Creating Inky workspace..."
mkdir -p "$DATA_DIR/workspace-inky/memory"
cp -r "$TEMPLATES_DIR/workspaces/inky/"* "$DATA_DIR/workspace-inky/"
ln -sf "$DATA_DIR/shared" "$DATA_DIR/workspace-inky/shared"
echo " ✓ Inky workspace initialized"

# Initialize Pixel (designer) workspace
echo " → Creating Pixel workspace..."
mkdir -p "$DATA_DIR/workspace-pixel/memory"
mkdir -p "$DATA_DIR/workspace-pixel/assets"
cp -r "$TEMPLATES_DIR/workspaces/pixel/"* "$DATA_DIR/workspace-pixel/"
ln -sf "$DATA_DIR/shared" "$DATA_DIR/workspace-pixel/shared"
echo " ✓ Pixel workspace initialized"

# Initialize Scout (SEO) workspace
echo " → Creating Scout workspace..."
mkdir -p "$DATA_DIR/workspace-scout/memory"
mkdir -p "$DATA_DIR/workspace-scout/research"
cp -r "$TEMPLATES_DIR/workspaces/scout/"* "$DATA_DIR/workspace-scout/"
ln -sf "$DATA_DIR/shared" "$DATA_DIR/workspace-scout/shared"
echo " ✓ Scout workspace initialized"
# ──────────────────────────────────────────────
# init_agent <id> <name> <emoji> <role> <type>
# type: "lead" or "worker"
# ──────────────────────────────────────────────
init_agent() {
AGENT_ID="$1"
AGENT_NAME="$2"
AGENT_EMOJI="$3"
AGENT_ROLE="$4"
AGENT_TYPE="${5:-worker}"

# Workspace path: lead uses /data/workspace, workers use /data/workspace-<id>
if [ "$AGENT_TYPE" = "lead" ]; then
WS_DIR="$DATA_DIR/workspace"
else
WS_DIR="$DATA_DIR/workspace-$AGENT_ID"
fi

echo " → Creating $AGENT_NAME $AGENT_EMOJI workspace..."
mkdir -p "$WS_DIR/memory"

# Export variables for envsubst
export AGENT_ID AGENT_NAME AGENT_EMOJI AGENT_ROLE

# Generate files from base templates
for template in "$BASE_DIR/$AGENT_TYPE"/*.md; do
filename=$(basename "$template")
envsubst '${AGENT_ID} ${AGENT_NAME} ${AGENT_EMOJI} ${AGENT_ROLE}' < "$template" > "$WS_DIR/$filename"
done

# Copy agent-specific files (SOUL.md and any overrides)
if [ -d "$WORKSPACES_DIR/$AGENT_ID" ]; then
cp -r "$WORKSPACES_DIR/$AGENT_ID/"* "$WS_DIR/"
fi

# Symlink shared directory
ln -sf "$DATA_DIR/shared" "$WS_DIR/shared"

echo " ✓ $AGENT_NAME workspace initialized"
}

# ──────────────────────────────────────────────
# Initialize all agents
# ──────────────────────────────────────────────

init_agent "main" "Clawe" "🦞" "Squad Lead" "lead"
init_agent "inky" "Inky" "✍️" "Content Writer" "worker"
init_agent "pixel" "Pixel" "🎨" "Graphic Designer" "worker"
init_agent "scout" "Scout" "🔍" "SEO Specialist" "worker"

echo "✅ Agent initialization complete!"
echo ""
echo "Squad:"
echo " 🦞 Clawe (Lead) → $DATA_DIR/workspace"
echo " ✍️ Inky (Writer) → $DATA_DIR/workspace-inky"
echo " 🎨 Pixel (Designer) → $DATA_DIR/workspace-pixel"
echo " 🔍 Scout (SEO) → $DATA_DIR/workspace-scout"
echo " 🦞 Clawe (Squad Lead) → $DATA_DIR/workspace"
echo " ✍️ Inky (Content Writer) → $DATA_DIR/workspace-inky"
echo " 🎨 Pixel (Graphic Designer) → $DATA_DIR/workspace-pixel"
echo " 🔍 Scout (SEO Specialist) → $DATA_DIR/workspace-scout"
echo ""
Loading
Loading