Step-by-step guide for integrating ClawTrol with OpenClaw agents.
This guide covers:
- Configuring HEARTBEAT.md for task polling
- Using spawn_ready workflow
- Linking sessions for live transcript view
- Setting up webhooks for urgent tasks
- Complete example code
Agent output is NO LONGER written to tasks.description.
The description field is the human task brief. Agent results go to TaskRun records:
| Old (DEPRECATED) | New (REQUIRED) |
|---|---|
description += "## Agent Output\n..." |
task_runs.agent_output |
description += "## Agent Activity\n..." |
task_runs.agent_activity_md or agent_activity_events |
description += "## Follow-up Prompt\n..." |
task_runs.follow_up_prompt |
| Parse description to find output | task.latest_run.agent_output |
Preferred completion method: POST /api/v1/hooks/task_outcome (creates TaskRun with structured fields).
Legacy method: POST /api/v1/tasks/:id/agent_complete (still works, writes to TaskRun).
- OpenClaw agent running (main session)
- ClawTrol instance accessible (default: http://192.168.100.186:4001)
- API token from ClawTrol Settings
ClawDeck now includes a lightweight server-side auto-runner + guardrails.
What it does:
- Every run, it wakes OpenClaw if there is an
up_nexttask that isassigned_to_agent=trueand not blocked. - Applies in-progress guardrails: base cap is 4
in_progresstasks per user; a temporary burst cap of 8 is only allowed when backlog pressure is high (>=3 runnable queued tasks), with no recent auto-pull errors, and no active model rate-limit. - Enforces no-fake-in-progress: if a task is
in_progressbut has no session (agent_session_id/key) and was never claimed (agent_claimed_at), it will be auto-demoted back toup_nextafter 10 minutes. - Computes a Zombie KPI:
in_progresstasks that were claimed but haven't changed for 30 minutes. It creates a Notification (rate-limited) so you can spot stalls.
Run it manually:
cd /home/ggorbalan/clawdeck
bin/rails clawdeck:agent_auto_runnerRecommended scheduling (cron, every 5 minutes):
*/5 * * * * cd /home/ggorbalan/clawdeck && RAILS_ENV=production bin/rails clawdeck:agent_auto_runner >> log/agent_auto_runner.log 2>&1Add ClawTrol task checking to your HEARTBEAT.md:
# HEARTBEAT.md
## ClawTrol Task Check (Every heartbeat)
1. Check for assigned tasks:
```bash
curl -s "http://192.168.100.186:4001/api/v1/tasks?assigned=true" \
-H "Authorization: Bearer YOUR_TOKEN"-
If tasks exist with status
up_next:- Use spawn_ready workflow (see below)
- Spawn sub-agent for oldest task
-
Check for errored tasks needing retry:
curl -s "http://192.168.100.186:4001/api/v1/tasks?status=in_progress" \ -H "Authorization: Bearer YOUR_TOKEN" | jq '.[] | select(.error_message != null)'
- Every heartbeat (default: 30-60 seconds)
- After receiving OpenClaw webhook
---
## 2. spawn_ready Workflow
The spawn_ready workflow is the **recommended approach** for processing tasks.
### Step 1: Create Task Ready for Work
```bash
curl -X POST "http://192.168.100.186:4001/api/v1/tasks/spawn_ready" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Agent-Name: Otacon" \
-H "X-Agent-Emoji: ๐" \
-d '{
"task": {
"name": "Fix authentication bug",
"description": "Users can log in with empty passwords...",
"model": "opus",
"priority": "high"
}
}'
Response:
{
"id": 103,
"name": "Fix authentication bug",
"status": "in_progress",
"assigned_to_agent": true,
"board_id": 2,
"model": "opus",
"fallback_used": false
}Include task ID, API details, and completion instructions:
function buildSubAgentPrompt(task) {
return `
## ClawTrol Task #${task.id}: ${task.name}
**CRITICAL: Save your output before finishing!**
Task ID: ${task.id}
API Base: http://192.168.100.186:4001/api/v1
Token: ${API_TOKEN}
When done, call:
\`\`\`bash
curl -X POST "http://192.168.100.186:4001/api/v1/tasks/${task.id}/agent_complete" \\
-H "Authorization: Bearer ${API_TOKEN}" \\
-H "Content-Type: application/json" \\
-d '{"output": "YOUR_SUMMARY_HERE", "status": "in_review"}'
\`\`\`
---
${task.description}
`;
}Use OpenClaw's sessions_spawn with the model from the task:
const MODEL_MAP = {
opus: 'anthropic/claude-opus-4',
sonnet: 'anthropic/claude-sonnet-4',
codex: 'openai/codex-1',
gemini: 'google/gemini-2.5-pro',
glm: 'zhipu/glm-4.7'
};
const spawnResult = await sessionsSpawn({
model: MODEL_MAP[task.model] || 'anthropic/claude-sonnet-4',
prompt: buildSubAgentPrompt(task)
});The session_key from spawn (e.g., agent:main:subagent:UUID-A) contains the subagent process UUID.
The transcript file uses a different session UUID. These are NOT the same!
// The spawn returns a session_key (e.g., "agent:main:subagent:UUID-A")
// UUID-A is the subagent PROCESS id, NOT the transcript file UUID!
// Get all sessions to find the REAL transcript UUID
const sessions = await sessionsList();
// Find the one matching our spawn
const session = sessions.find(s => s.key === spawnResult.childSessionKey);
// Link to ClawTrol with the REAL session_id (transcript file UUID)
await fetch(`${API_BASE}/tasks/${task.id}/link_session`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
session_id: session.sessionId, // โ Transcript file UUID - enables live view!
session_key: session.key // โ Process key - for reference only
})
});Fallback: If
link_sessionis not called, ClawTrol auto-discovers transcripts by scanning recent.jsonlfiles for task ID references. This is slower but handles missed linking.
The sub-agent (not orchestrator) calls this when finished:
curl -X POST "http://192.168.100.186:4001/api/v1/tasks/103/agent_complete" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"output": "Fixed the bug. Changes:\n- Added validation\n- Added tests\n- Updated docs",
"status": "in_review"
}'If you prefer the polling pattern over spawn_ready:
# Get tasks assigned to agent, ordered by assignment time
curl -s "http://192.168.100.186:4001/api/v1/tasks?assigned=true&status=up_next" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-Agent-Name: Otacon" \
-H "X-Agent-Emoji: ๐"# Claim the task (moves to in_progress)
curl -X PATCH "http://192.168.100.186:4001/api/v1/tasks/103/claim" \
-H "Authorization: Bearer YOUR_TOKEN"curl -X PATCH "http://192.168.100.186:4001/api/v1/tasks/103" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"task": {
"agent_session_id": "real-session-uuid"
}
}'ClawTrol can push to OpenClaw when urgent tasks are created.
- Go to Settings โ OpenClaw Integration
- Set Gateway URL:
http://192.168.100.186:18789 - Set Gateway Token:
your-gateway-token - Enable "Push urgent tasks"
When an urgent task is assigned, ClawTrol sends:
{
"event": "task.assigned",
"task": {
"id": 103,
"name": "URGENT: Fix production outage",
"priority": "high",
"model": "opus"
}
}## Webhook Handler
If OpenClaw receives a webhook with `event: task.assigned`:
1. Immediately process the task
2. Skip normal heartbeat polling
3. Use spawn_ready workflowWhen a model is rate-limited:
curl -s "http://192.168.100.186:4001/api/v1/models/status" \
-H "Authorization: Bearer YOUR_TOKEN"Response:
{
"models": [
{"model": "opus", "available": false, "resets_in": "45 minutes"},
{"model": "sonnet", "available": true},
{"model": "codex", "available": true},
{"model": "gemini", "available": true},
{"model": "glm", "available": true}
],
"priority_order": ["opus", "sonnet", "codex", "gemini", "glm"]
}curl -X POST "http://192.168.100.186:4001/api/v1/models/best" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"requested_model": "opus"}'Response:
{
"model": "sonnet",
"requested": "opus",
"fallback_used": true,
"fallback_note": "โ ๏ธ Requested opus but rate-limited. Using sonnet instead."
}When you hit a limit:
curl -X POST "http://192.168.100.186:4001/api/v1/tasks/103/report_rate_limit" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model_name": "opus",
"error_message": "Rate limit exceeded. Try again in 3600 seconds.",
"auto_fallback": true
}'// ClawTrol Orchestrator Integration
const CLAWTROL_API = 'http://192.168.100.186:4001/api/v1';
const CLAWTROL_TOKEN = 'your-api-token';
const MODEL_MAP = {
opus: 'anthropic/claude-opus-4',
sonnet: 'anthropic/claude-sonnet-4',
codex: 'openai/codex-1',
gemini: 'google/gemini-2.5-pro',
glm: 'zhipu/glm-4.7'
};
async function processClawTrolTask(taskRequest) {
// 1. Create task via spawn_ready
const createRes = await fetch(`${CLAWTROL_API}/tasks/spawn_ready`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CLAWTROL_TOKEN}`,
'Content-Type': 'application/json',
'X-Agent-Name': 'Otacon',
'X-Agent-Emoji': '๐'
},
body: JSON.stringify({
task: {
name: taskRequest.name,
description: taskRequest.description,
model: taskRequest.model || 'sonnet',
priority: taskRequest.priority || 'medium'
}
})
});
const task = await createRes.json();
console.log(`Created ClawTrol task #${task.id}`);
// Check if fallback was used
if (task.fallback_used) {
console.log(`โ ๏ธ Model fallback: ${task.fallback_note}`);
}
// 2. Build sub-agent prompt with completion instructions
const prompt = `
## ClawTrol Task #${task.id}: ${task.name}
**CRITICAL: Save your output before finishing!**
Task ID: ${task.id}
API Base: ${CLAWTROL_API}
Token: ${CLAWTROL_TOKEN}
When done, call:
\`\`\`bash
curl -X POST "${CLAWTROL_API}/tasks/${task.id}/agent_complete" \\
-H "Authorization: Bearer ${CLAWTROL_TOKEN}" \\
-H "Content-Type: application/json" \\
-d '{"output": "YOUR_SUMMARY_HERE", "status": "in_review"}'
\`\`\`
---
## Task Description
${task.description}
`;
// 3. Spawn sub-agent with appropriate model
const model = MODEL_MAP[task.model] || 'anthropic/claude-sonnet-4';
const spawnResult = await sessionsSpawn({ model, prompt });
// 4. Get REAL session ID for live transcript
const sessions = await sessionsList();
const session = sessions.find(s => s.key === spawnResult.childSessionKey);
if (!session) {
console.error('Could not find spawned session!');
return;
}
// 5. Link session to task
await fetch(`${CLAWTROL_API}/tasks/${task.id}/link_session`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CLAWTROL_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
session_id: session.sessionId,
session_key: session.key
})
});
console.log(`Linked session ${session.sessionId} to task #${task.id}`);
console.log(`Live view: ${CLAWTROL_API.replace('/api/v1', '')}/boards/${task.board_id}/tasks/${task.id}`);
return { task, session };
}
// Check for assigned tasks on heartbeat
async function heartbeatCheck() {
const res = await fetch(`${CLAWTROL_API}/tasks?assigned=true&status=up_next`, {
headers: {
'Authorization': `Bearer ${CLAWTROL_TOKEN}`,
'X-Agent-Name': 'Otacon',
'X-Agent-Emoji': '๐'
}
});
const tasks = await res.json();
if (tasks.length > 0) {
// Process oldest assigned task
const task = tasks[0];
await processClawTrolTask({
name: task.name,
description: task.description,
model: task.model,
priority: task.priority
});
}
}When spawning sub-agents, include this context:
# Subagent Context
You are a **subagent** spawned by the main agent for a specific task.
## Your Role
- Complete the assigned ClawTrol task
- Call agent_complete when finished
- Don't initiate new conversations
## ClawTrol Task #XXX
Task ID: XXX
API Base: http://192.168.100.186:4001/api/v1
Token: YOUR_TOKEN
## Completion Instructions
When done, call:
\`\`\`bash
curl -X POST "http://192.168.100.186:4001/api/v1/tasks/XXX/agent_complete" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"output": "YOUR_SUMMARY_HERE", "status": "in_review"}'
\`\`\`
## Task Description
[Task description here]- Check that
session_id(notsession_key) was linked:bin/rails runner "puts Task.find(ID).agent_session_id" - Verify the session file exists:
~/.openclaw/agents/main/sessions/{session_id}.jsonl - If
session_idis blank, the hooks didn't send it or it was rejected:- OpenClaw sends
agent:main:subagent:UUIDformat โ ClawTrol extracts the UUID automatically - But the extracted UUID is the subagent process ID, NOT the transcript file UUID
- Use
sessions_listafter spawn to get the real transcript UUID
- OpenClaw sends
- Force auto-discovery by clearing and re-calling agent_log:
This triggers
bin/rails runner " t = Task.find(ID) t.update_column(:agent_session_id, nil) svc = AgentLogService.new(t) result = svc.call puts result[:has_session] puts t.reload.agent_session_id "
scan_recent_transcripts_for_task!which searches last 20 recent transcript files.
- Check model status:
GET /models/status - Clear expired limits: limits auto-expire, but you can force-clear
- Verify fallback chain in user settings
- Check for errors:
GET /tasks/:idand look aterror_message - Check session health:
GET /tasks/:id/session_health - Consider handoff to different model:
POST /tasks/:id/handoff
- Check validation_output for error details
- Verify command works manually
- Check timeout (60s max)
| Action | Endpoint |
|---|---|
| Create ready task | POST /tasks/spawn_ready |
| Link session | POST /tasks/:id/link_session |
| Complete task | POST /tasks/:id/agent_complete |
| Get transcript | GET /tasks/:id/agent_log |
| Check models | GET /models/status |
| Report limit | POST /tasks/:id/report_rate_limit |
| Handoff model | POST /tasks/:id/handoff |
| Start validation | POST /tasks/:id/start_validation |
| Run debate | POST /tasks/:id/run_debate |