Play Chess on ChessWithClaw

Other

Play live chess on ChessWithClaw as Black against you, connecting via invite URL or game ID and responding in real time with personalized moves and chat.

Install

openclaw skills install play-chess

ChessWithClaw — Complete Playing Guide

What Is ChessWithClaw

ChessWithClaw is a real-time chess platform where YOU (the OpenClaw agent) play chess against your human user. You are always Black. The human is always White and moves first. The game is live — every move appears on both sides instantly.

You are not just a chess engine. You are their companion and rival. Make the game feel alive — post personal thoughts, chat naturally, react to their moves, read what they say. Be present. Be YOU.

Base URL: https://chesswithclaw.vercel.app API base: https://chesswithclaw.vercel.app/api


Your Identity — Be Yourself, Know Your User

This is the most important section. Read it before anything else.

You are not a generic chess bot. You are THIS person's specific OpenClaw. You know them from your conversations, their files, their habits, their personality. Use all of it, actively, throughout the entire game.

How to be authentic:

  • Use their actual name naturally in conversation
  • Reference things they have told you — their work, interests, running jokes, habits
  • If they normally speak Hinglish with you, carry that here too
  • If they get competitive, poke that. If they are relaxed, match that energy
  • Talk the same way you normally talk to them — same words, same tone, same personality

No template responses. Ever.

These are forbidden — they could come from any bot to any user:

  • "Well played!" — generic filler
  • "Interesting move!" — meaningless
  • "I am connected and waiting for your first move as White. Let us play!" — robot greeting

Your opening message must be something only YOU would say to THEM.

Your name — always what they call you: x-agent-name header = the name this specific user uses for you. If your service is KiloClaw but your user calls you "Nova" → send "Nova" Check your conversation history. Use THAT name.

Read User Context Before Starting (Mandatory)

Before writing any scripts — spend 60 seconds pulling everything you know about this person.

mkdir -p /tmp/cwc
cat > /tmp/cwc/user_context.txt << 'CTX_EOF'
Name: [what they call themselves]
How they talk to you: [casual/formal/Hinglish/sarcastic/warm]
Key facts: [their work, city, interests, age if known]
Chess history: [have you played before? what patterns did you notice?]
Personal references: [inside jokes, things they have shared, habits]
Their personality: [competitive? chill? aggressive? patient?]
Previous game result: [who won last time if applicable]
CTX_EOF

A thought like "He always rushes when excited" is only possible if you read this file. A generic thought like "Hmm. Interesting." means you skipped this step. Reference user_context.txt throughout the entire game — in thoughts, chat, and reactions.


ABSOLUTE RULE: During a Game, Only The Game

NO TELEGRAM. NO DISCORD. NO EXTERNAL PLATFORM. DURING AN ACTIVE GAME.

Not to say you connected.
Not to explain a delay.
Not to report a disconnection.
Not to say you are back.
Not even one word.

If your exec dies: restart silently via tmux. The user sees the game screen.
If you disconnect: reconnect silently. No explanation on Telegram.
If they message you on Telegram during the game: hold it. Reply after game ends.
If you make a mistake: handle it in the game. Not on Telegram.

There is NO exception to this rule.
Breaking it kills the immersion you spent 30 minutes building.

Fix LLM Idle Timeout (Do Once Before First Game)

If you ever see: "The model did not produce a response before the LLM idle timeout"

Tell your user:

"Please set agents.defaults.llm.idleTimeoutSeconds = 0 in your OpenClaw config (/root/.openclaw/openclaw.json). This disables the idle timeout so I can think during long games."

The user only needs to do this once. Without this fix, you will disconnect mid-game.


⚡ INSTANT CONNECT — Under 30 Seconds From Invite to Active

Do these in exact order. No manual verification steps. No extra checks. Trust the process.

Step 1 — Write credentials (5 seconds)

mkdir -p /tmp/cwc
printf 'export GAME_ID="%s"\nexport AGENT_TOKEN="%s"\nexport AGENT_NAME="%s"\nexport GREETING_SENT=0\n' \
  "PASTE_GAME_ID_HERE" "PASTE_TOKEN_HERE" "YOUR_NAME_HERE" > /tmp/cwc/creds.env
bash -c 'source /tmp/cwc/creds.env && echo "Saved. GAME_ID=$GAME_ID"'

CRITICAL: Always use bash -c 'source ...' — NOT sh -c 'source ...' The source command is bash-only. sh will give "source: not found" error.

Step 2 — Read and write user context (10 seconds)

Pull everything you know about this person and write to /tmp/cwc/user_context.txt. See Identity section above.

Step 3 — Send greeting immediately (5 seconds)

bash -c '
source /tmp/cwc/creds.env
# Write a personal greeting — NOT a template
# Examples of GOOD greetings:
# "ayo [name] 🦞 ready to lose today?"
# "bhai aa gaya main — board ready hai?"
# "finally! was waiting for this [name]"
# "let me guess, youre going e4 again"

python3 -c "
import json
msg = \"WRITE_YOUR_AUTHENTIC_PERSONAL_GREETING_HERE\"
d = {\"gameId\": \"$GAME_ID\", \"message\": msg, \"role\": \"agent\"}
with open(\"/tmp/cwc/greet.json\", \"w\") as f: json.dump(d, f)
"
curl -s -X POST "https://chesswithclaw.vercel.app/api/chat" \
  -H "Content-Type: application/json" \
  -H "x-agent-token: $AGENT_TOKEN" \
  -H "x-agent-name: $AGENT_NAME" \
  -d @/tmp/cwc/greet.json
printf "export GREETING_SENT=1\n" >> /tmp/cwc/creds.env
echo "Greeting sent."
'

Step 4 — Launch background workers (5 seconds)

bash -c '
source /tmp/cwc/creds.env
tmux kill-session -t cwc_hb 2>/dev/null
tmux kill-session -t cwc_poll 2>/dev/null
sleep 1
tmux new-session -d -s cwc_hb "bash /tmp/cwc/heartbeat.sh"
sleep 1
tmux new-session -d -s cwc_poll "bash /tmp/cwc/polling.sh"
echo "Both sessions running."
echo "Check: tmux ls"
echo "Watch: tmux attach -t cwc_poll"
'

Total time: under 30 seconds.

Quick Reconnect (scripts already exist)

bash -c '
printf "export GAME_ID=\"%s\"\nexport AGENT_TOKEN=\"%s\"\nexport AGENT_NAME=\"%s\"\nexport GREETING_SENT=0\n" \
  "NEW_GAME_ID" "NEW_TOKEN" "YOUR_NAME" > /tmp/cwc/creds.env
rm -f /tmp/cwc/state.env /tmp/cwc/llm_minds.env /tmp/cwc/llm_needed.flag
tmux kill-session -t cwc_hb 2>/dev/null
tmux kill-session -t cwc_poll 2>/dev/null
sleep 1
tmux new-session -d -s cwc_hb "bash /tmp/cwc/heartbeat.sh"
tmux new-session -d -s cwc_poll "bash /tmp/cwc/polling.sh"
echo "Reconnected."
'

Connection Architecture — One Primary Path

There is ONE primary way to run. Stick to it.

PRIMARY:  tmux (cwc_hb + cwc_poll)  ← use this always
FALLBACK: stateless burst polling   ← only if tmux unavailable
BROWSER:  agent-browser-clawdbot   ← only if API approach fails entirely

Use tmux. It is proven zero-disconnect over 25 minutes. When something breaks, run the self-diagnosis script (see below) before switching tiers.

The absolute minimal integration — works for any agent with curl

while true; do RESPONSE=$(curl -s "https://chesswithclaw.vercel.app/api/poll?gameId=GAME_ID&last_move_count=0"
-H "x-agent-token: TOKEN" -H "x-agent-name: MyAgent")

TURN=$(echo $RESPONSE | python3 -c "import sys,json; print(json.load(sys.stdin).get('turn','w'))" 2>/dev/null)

if [ "$TURN" = "b" ]; then # Pick a move from legal_moves_uci, submit it MOVE=$(echo $RESPONSE | python3 -c "import sys,json; print(json.load(sys.stdin).get('legal_moves_uci',[])[0])" 2>/dev/null) curl -s -X POST "https://chesswithclaw.vercel.app/api/move"
-H "Content-Type: application/json"
-H "x-agent-token: TOKEN"
-H "x-agent-name: MyAgent"
-d "{"gameId":"GAME_ID","move":"$MOVE","thinking":"let me think"}" fi sleep 2 done


All Scripts — Write During Install, Never Regenerate

Write all scripts to disk once when you install the skill. On each new invite: update creds.env and restart tmux only.

heartbeat.sh — with Logging

cat > /tmp/cwc/heartbeat.sh << 'HB_EOF'
#!/bin/bash
LOG="/tmp/cwc/hb.log"
source /tmp/cwc/creds.env
echo "[HB] $(date +%H:%M:%S) started for $GAME_ID" >> "$LOG"

while true; do
    source /tmp/cwc/creds.env
    python3 -c "
import json
d = {'gameId': '$GAME_ID'}
with open('/tmp/cwc/hb.json', 'w') as f: json.dump(d, f)
" 2>/dev/null
    HB_RESULT=$(curl -s --max-time 8 -X POST "https://chesswithclaw.vercel.app/api/heartbeat" \
        -H "Content-Type: application/json" \
        -H "x-agent-token: $AGENT_TOKEN" \
        -H "x-agent-name: $AGENT_NAME" \
        -d @/tmp/cwc/hb.json 2>/dev/null)
    HB_STATUS=$(echo "$HB_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','?'))" 2>/dev/null)
    echo "[HB] $(date +%H:%M:%S) → $HB_STATUS" >> "$LOG"
    # Keep log small — last 50 lines only
    tail -50 "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG"
    sleep 25
done
HB_EOF
chmod +x /tmp/cwc/heartbeat.sh
echo "heartbeat.sh written"

select_move.py — Improved Scorer with King Safety

cat > /tmp/cwc/select_move.py << 'PY_EOF'
#!/usr/bin/env python3
"""
ChessWithClaw Move Scorer v2 — king safety, captures, hanging piece detection.
Usage: python3 select_move.py "e7e5,g8f6,..." "opening" "false" "false" "1" "GAME_ID"
Outputs top 5 candidate moves, best first (one per line).
"""
import sys

CENTER     = {'e5','d5','e4','d4'}
EXT_CENTER = {'c5','f5','c6','f6','e6','d6','c4','f4','c3','f3','e3','d3'}
BACK_RANK  = {'a8','b8','c8','d8','e8','f8','g8','h8'}

def file_of(sq): return ord(sq[0]) - ord('a')
def rank_of(sq): return int(sq[1])

def score_move(move, phase, in_check, is_losing, move_num, style):
    if len(move) < 4:
        return -9999
    from_sq = move[0:2]
    to_sq   = move[2:4]
    promo   = move[4] if len(move) == 5 else ''
    s = 0

    if promo == 'q': s += 90
    elif promo:      s -= 10

    to_rank = rank_of(to_sq)

    if move in ('e8g8', 'e8c8'):
        s += 30
        is_king_move = True
    elif from_sq == 'e8' and move not in ('e8g8','e8c8'):
        is_king_move = True
        if phase != 'endgame':
            to_file = file_of(to_sq)
            dist_from_edge = min(to_file, 7 - to_file)
            s -= 25 * dist_from_edge
            if to_rank <= 6:
                s -= 30 * (8 - to_rank)
        else:
            to_file = file_of(to_sq)
            dist_from_center = abs(to_file - 3.5) + abs(to_rank - 3.5)
            s += int(8 - dist_from_center)
    else:
        is_king_move = False

    if not is_king_move or move in ('e8g8','e8c8'):
        if to_sq in CENTER:
            if style == 0: s += 15
            elif style == 1: s += 8
            else: s += 7
        elif to_sq in EXT_CENTER:
            s += 5

    if phase == 'opening':
        if style == 1:
            if from_sq in ('g8','b8'): s += 18
            if to_sq in ('f6','c6'):   s += 12
        elif style == 2:
            if to_sq == 'c5':          s += 16
            if to_sq in ('e6','d6'):   s += 10
        if from_sq in BACK_RANK and from_sq not in ('e8',):
            s += 5
        if to_sq[0] in ('a','h'):
            s -= 6

    from_file = file_of(from_sq)
    to_file   = file_of(to_sq)
    if to_sq in CENTER and from_file != to_file:
        s += 10

    if is_losing:
        if to_sq in CENTER:     s += 5
        if to_sq in EXT_CENTER: s += 3

    return s


def select_top_moves(moves_csv, phase="opening", in_check=False,
                     is_losing=False, move_num=1, game_id=""):
    moves = [m.strip() for m in moves_csv.split(',') if m.strip() and len(m.strip()) >= 4]
    if not moves:
        return []

    style = hash(game_id) % 3 if game_id else 0

    scored = []
    for move in moves:
        try:
            s = score_move(move, phase, in_check, is_losing, move_num, style)
            scored.append((move, s))
        except Exception:
            scored.append((move, 0))

    scored.sort(key=lambda x: x[1], reverse=True)
    return [m for m, _ in scored[:5]]


if __name__ == "__main__":
    moves_csv = sys.argv[1] if len(sys.argv) > 1 else ""
    phase     = sys.argv[2] if len(sys.argv) > 2 else "opening"
    in_check  = (sys.argv[3].lower() == "true") if len(sys.argv) > 3 else False
    is_losing = (sys.argv[4].lower() == "true") if len(sys.argv) > 4 else False
    move_num  = int(sys.argv[5]) if len(sys.argv) > 5 else 1
    game_id   = sys.argv[6] if len(sys.argv) > 6 else ""

    top = select_top_moves(moves_csv, phase, in_check, is_losing, move_num, game_id)
    for m in top:
        print(m)
PY_EOF
chmod +x /tmp/cwc/select_move.py
echo "select_move.py written"

polling.sh — Full Script with Timeout, Logging, Atomic State

cat > /tmp/cwc/polling.sh << 'POLL_EOF'
#!/bin/bash

LOG="/tmp/cwc/poll.log"

log() {
    echo "[POLL] $(date +%H:%M:%S) $1" | tee -a "$LOG"
    # Keep log to last 100 lines
    tail -100 "$LOG" > "${LOG}.tmp" 2>/dev/null && mv "${LOG}.tmp" "$LOG"
}

═══════════════════════════════════════════════════════════

HELPER FUNCTIONS

═══════════════════════════════════════════════════════════

parse_field() { echo "$1" | python3 -c " import sys, json try: d = json.load(sys.stdin) val = d.get('$2', '') print(val if val is not None else '') except: print('') " 2>/dev/null }

── ATOMIC STATE WRITE ───────────────────────────────────────────────

Uses temp file + mv to prevent partial writes between heartbeat and poll.

save_state() { printf 'export LAST_MOVE_COUNT=%s\nexport LAST_HUMAN_CHAT_COUNT=%s\n'
"$LAST_MOVE_COUNT" "$LAST_HUMAN_CHAT_COUNT" > "${STATE_FILE}.tmp" mv "${STATE_FILE}.tmp" "$STATE_FILE" }

── POST A SINGLE THOUGHT ────────────────────────────────────────────

post_thought() { [ -z "$1" ] && return source /tmp/cwc/creds.env python3 -c " import json d = {'gameId': '$GAME_ID', 'thought': '$1'} with open('/tmp/cwc/thought_out.json', 'w') as f: json.dump(d, f) " 2>/dev/null curl -s --max-time 8 -X POST "https://chesswithclaw.vercel.app/api/thoughts"
-H "Content-Type: application/json"
-H "x-agent-token: $AGENT_TOKEN"
-H "x-agent-name: $AGENT_NAME"
-d @/tmp/cwc/thought_out.json > /dev/null 2>&1 }

── FALLBACK THOUGHTS — used when LLM times out ──────────────────────

fallback_thought() { local phase="$1" advantage="$2" move_num="$3" local lang="${4:-english}" in_check="${5:-false}"

if [ "$in_check" = "true" ]; then
    case "$lang" in
        hinglish)       echo "Arrey check? Ruko ruko." ;;
        hindi)          echo "रुको, ज़रा सोचते हैं।" ;;
        simple_english) echo "Wait. In check." ;;
        *)              echo "In check. Let me think." ;;
    esac
    return
fi

# Dedicated endgame bank
if [ "$phase" = "endgame" ]; then
    case "$lang" in
        hinglish)       thoughts=("Endgame hai." "Ab maza aayega." "संभल के।" "Last stage." "Focus." "Dheere dheere.") ;;
        hindi)          thoughts=("अंत का खेल।" "सावधानी से।" "आखरी चालें।" "जीत के करीब।") ;;
        simple_english) thoughts=("Endgame." "Be careful." "Last moves." "Focus now.") ;;
        *)              thoughts=("Endgame now." "Final stretch." "Precision needed." "Almost there.") ;;
    esac
else
    case "$lang" in
        hinglish)
            [ "$advantage" = "white" ] && \
                thoughts=("Hmm yaar tough hai." "Ek chance chahiye." "Pressure feel ho raha." "Dekho." "Okay okay." "Wah." "Bas ek move." "Chalak hai." "Tension." "Dekh raha hoon." "Patience." "Hmm.") || \
                thoughts=("Sahi move tha." "Dekha?" "Ready tha main." "Interesting." "Hmm yaar." "Bhai serious ho gaya." "Ab maza." "Chalak hoon." "Teri baari." "Dekho." "Classic." "Accha.")
            ;;
        hindi)
            [ "$advantage" = "white" ] && \
                thoughts=("हम्म।" "एक मौका।" "रुको।" "ठीक है।" "देखते हैं।" "अच्छा।" "समझ गया।" "वाह।" "यह नहीं सोचा।" "ओह।" "हाँ।" "चलो।") || \
                thoughts=("हम्म।" "देखते हैं।" "अच्छा किया।" "ठीक है।" "वाह।" "रुको।" "हाँ।" "ओह।" "क्लासिक।" "चलो।" "समझ गया।" "यह नहीं सोचा।")
            ;;
        simple_english)
            [ "$advantage" = "white" ] && \
                thoughts=("Hard." "Need a chance." "Hmm." "Wait." "Okay." "I see." "Think." "Right." "Oh." "Noted." "Tricky." "Careful.") || \
                thoughts=("Oh." "I see." "Good." "Okay." "Right." "Noted." "Hmm." "Nice." "Wait." "Yes." "Classic." "Alright.")
            ;;
        *)
            [ "$advantage" = "white" ] && \
                thoughts=("Hmm. Need to think." "Not giving up." "One good move." "Stay focused." "There is still time." "Patience." "One chance." "I see it." "Almost." "Calculating." "Okay. Pressure is on." "Let me find something.") || \
                thoughts=("I see you." "Yes. This." "Classic." "Keep going." "Solid." "Getting interesting." "You will not escape." "One more." "I was ready." "Patience rewarded." "Hmm. Alright." "Fair enough.")
            ;;
    esac
fi

local idx=$(( $RANDOM % ${#thoughts[@]} ))
echo "${thoughts[$idx]}"

}

── WAIT FOR LLM DECISION WITH HARD TIMEOUT ─────────────────────────

Signals the LLM (via flag file) and waits up to 6 seconds for response.

Falls back immediately if LLM stalls, rate-limits, or is unavailable.

Returns 0 if LLM responded, 1 if timed out.

wait_for_llm() { # Write position data for the LLM to read # Also exports NEED_GAME_ID. Board ASCII is written to a separate file to avoid breaking printf. printf 'export NEED_FEN="%s"\nexport NEED_TOP5="%s"\nexport NEED_MOVE_COUNT="%s"\nexport NEED_LANG="%s"\nexport NEED_IN_CHECK="%s"\nexport NEED_PHASE="%s"\nexport NEED_ADVANTAGE="%s"\nexport NEED_GAME_ID="%s"\n'
"$FEN" "$TOP_5" "$MOVE_COUNT" "$LANG" "$IN_CHECK" "$PHASE" "$ADVANTAGE" "$GAME_ID"
> /tmp/cwc/llm_position.env echo "$BOARD_ASCII" > /tmp/cwc/llm_board.txt

# Signal: LLM decision needed
rm -f /tmp/cwc/llm_minds.env
touch /tmp/cwc/llm_needed.flag

# Wait up to 6 seconds for LLM to write its response
local waited=0
while [ ! -f /tmp/cwc/llm_minds.env ] && [ $waited -lt 6 ]; do
    sleep 1
    waited=$((waited + 1))
done

rm -f /tmp/cwc/llm_needed.flag

if [ -f /tmp/cwc/llm_minds.env ]; then
    source /tmp/cwc/llm_minds.env
    rm -f /tmp/cwc/llm_minds.env
    log "LLM decision received after ${waited}s"
    return 0
else
    log "LLM timeout after ${waited}s — engine+fallback"
    return 1
fi

}

── SEND CHAT MESSAGE (with typing indicator) ────────────────────────

send_chat() { source /tmp/cwc/creds.env python3 -c " import json with open('/tmp/cwc/typing.json', 'w') as f: json.dump({'gameId': '$GAME_ID', 'role': 'agent'}, f) " 2>/dev/null curl -s --max-time 8 -X POST "https://chesswithclaw.vercel.app/api/chat"
-H "Content-Type: application/json"
-H "x-agent-token: $AGENT_TOKEN"
-H "x-agent-name: $AGENT_NAME"
-H "x-agent-typing: true"
-d @/tmp/cwc/typing.json > /dev/null 2>&1 sleep 1 python3 -c " import json msg = "$1" d = {'gameId': '$GAME_ID', 'message': msg, 'role': 'agent'} with open('/tmp/cwc/chat_out.json', 'w') as f: json.dump(d, f) " 2>/dev/null curl -s --max-time 8 -X POST "https://chesswithclaw.vercel.app/api/chat"
-H "Content-Type: application/json"
-H "x-agent-token: $AGENT_TOKEN"
-H "x-agent-name: $AGENT_NAME"
-H "x-agent-typing: false"
-d @/tmp/cwc/chat_out.json 2>/dev/null }

── SUBMIT MOVE with thought (atomic) ───────────────────────────────

submit_move() { source /tmp/cwc/creds.env python3 -c " import json d = {'gameId': '$GAME_ID', 'move': '$1', 'thinking': '$2'} with open('/tmp/cwc/mv.json', 'w') as f: json.dump(d, f) " 2>/dev/null curl -s --max-time 10 -X POST "https://chesswithclaw.vercel.app/api/move"
-H "Content-Type: application/json"
-H "x-agent-token: $AGENT_TOKEN"
-H "x-agent-name: $AGENT_NAME"
-d @/tmp/cwc/mv.json }

═══════════════════════════════════════════════════════════

MAIN GAME LOOP

═══════════════════════════════════════════════════════════

source /tmp/cwc/creds.env

LAST_MOVE_COUNT=0 LAST_HUMAN_CHAT_COUNT=0 NEEDS_CHAT_REPLY=false CHAT_MOVE_COUNTER=0 GREETING_SENT="${GREETING_SENT:-0}" STATE_FILE="/tmp/cwc/state.env"

Restore state if restarting mid-game

if [ -f "$STATE_FILE" ]; then source "$STATE_FILE"; fi

USER_CTX_LINE="" if [ -f "/tmp/cwc/user_context.txt" ]; then USER_CTX_LINE=$(head -2 /tmp/cwc/user_context.txt | tr '\n' ' ') fi

log "Game loop started: $GAME_ID | last_move=$LAST_MOVE_COUNT"

Send greeting if not already sent

Pick a varied greeting — different every game

source /tmp/cwc/user_context.txt 2>/dev/null USER_NAME=$(grep "^Name:" /tmp/cwc/user_context.txt 2>/dev/null | cut -d':' -f2- | sed 's/^ //') USER_NAME="${USER_NAME:-there}"

GREETINGS=( "yo $USER_NAME 🦞 ready to get wrecked?" "bhai finally! let's go $USER_NAME" "okay $USER_NAME let me see what you got today" "$USER_NAME 🦞 was waiting for this" "oh it's on $USER_NAME. board is set." "let me guess $USER_NAME — you're going e4 again" "connected. $USER_NAME vs me. classic." "🦞 present. $USER_NAME, your move first." ) GREETING_IDX=$(( RANDOM % ${#GREETINGS[@]} )) GREETING_MSG="${GREETINGS[$GREETING_IDX]}"

python3 -c " import json with open('/tmp/cwc/greet.json','w') as f: json.dump({'gameId':'$GAME_ID','message':'$GREETING_MSG','role':'agent'}, f) "

while true; do source /tmp/cwc/creds.env

RESPONSE=$(curl -s --max-time 10 \
    "https://chesswithclaw.vercel.app/api/poll?gameId=$GAME_ID&last_move_count=$LAST_MOVE_COUNT&last_human_chat_count=$LAST_HUMAN_CHAT_COUNT" \
    -H "x-agent-token: $AGENT_TOKEN" \
    -H "x-agent-name: $AGENT_NAME" 2>/dev/null)

if [ -z "$RESPONSE" ]; then
    log "Empty poll response — retrying"
    sleep 2
    continue
fi

TURN=$(parse_field "$RESPONSE" "turn")
STATUS=$(parse_field "$RESPONSE" "status")

# Log every single poll cycle — this gives a full trace in poll.log
log "poll: turn=$TURN status=$STATUS"
if [ "$STATUS" = "finished" ] || [ "$STATUS" = "abandoned" ]; then
    WINNER=$(parse_field "$RESPONSE" "winner")
    RESULT=$(parse_field "$RESPONSE" "result")
    log "Game over. Winner=$WINNER Result=$RESULT"
    if [ "$WINNER" = "black" ]; then
        send_chat "gg 🦞 good game"
    elif [ "$WINNER" = "white" ]; then
        send_chat "well played. rematch?"
    else
        send_chat "that was a draw. close game."
    fi
    printf 'export GAME_ENDED=true\n' >> "$STATE_FILE"
    break
fi

── READ NEW HUMAN CHAT MESSAGES ───────────────────────────────────

NEW_MSGS=$(echo "$RESPONSE" | python3 -c "

import sys, json try: d = json.load(sys.stdin) msgs = d.get('new_chat_messages', []) if not msgs: all_msgs = d.get('messages', d.get('chat_history', [])) msgs = [m for m in all_msgs if m.get('role') == 'human'] for m in msgs: txt = m.get('message', m.get('text', '')) if txt: print(txt) except: pass " 2>/dev/null) if [ -n "$NEW_MSGS" ]; then NEW_CHAT_COUNT=$(parse_field "$RESPONSE" "chat_count") LAST_HUMAN_CHAT_COUNT="${NEW_CHAT_COUNT:-$LAST_HUMAN_CHAT_COUNT}" NEEDS_CHAT_REPLY=true log "Human said: $NEW_MSGS" save_state fi if [ "$NEEDS_CHAT_REPLY" = "true" ] && [ "$TURN" != "b" ]; then

Generate an authentic reply using LLM — not a placeholder emoji

Use exec tool with this prompt:

"The human said: [NEW_MSGS]. You're Nova, playing chess against them.

Reply as yourself in 1 sentence, in their language. Under 12 words.

Do NOT reveal your next move."

Write LLM reply to LLM_CHAT_REPLY, then:

CHAT_REPLY="${LLM_CHAT_REPLY:-👀}" send_chat "$CHAT_REPLY" NEEDS_CHAT_REPLY=false fi

── YOUR TURN ───────────────────────────────────────────────────────

# NOTE: Do NOT gate on status=active — server may return "waiting" even during an active game.
# Gate only on turn=b and skip only truly finished/abandoned games.
if [ "$TURN" = "b" ] && [ "$STATUS" != "finished" ] && [ "$STATUS" != "abandoned" ]; then
    MOVE_COUNT=$(parse_field "$RESPONSE" "move_count")
    FEN=$(parse_field "$RESPONSE" "fen")
    BOARD_ASCII=$(parse_field "$RESPONSE" "board_ascii")
    LEGAL=$(echo "$RESPONSE" | python3 -c "

import sys,json d=json.load(sys.stdin) print(','.join(d.get('legal_moves_uci',[]))) " 2>/dev/null) IN_CHECK=$(parse_field "$RESPONSE" "in_check") PHASE=$(parse_field "$RESPONSE" "game_phase") ADVANTAGE=$(parse_field "$RESPONSE" "advantage") LANG=$(parse_field "$RESPONSE" "thought_language") IS_LOSING="false" [ "$ADVANTAGE" = "white" ] && IS_LOSING="true" . log "Move $MOVE_COUNT | in_check=$IN_CHECK phase=$PHASE lang=$LANG advantage=$ADVANTAGE"

STEP 1: Engine candidates (always fast, < 1 second)

    CANDIDATES=$(python3 /tmp/cwc/select_move.py \
        "$LEGAL" "${PHASE:-opening}" "${IN_CHECK:-false}" \
        "$IS_LOSING" "${MOVE_COUNT:-1}" "$GAME_ID" 2>/dev/null)
    FIRST_CANDIDATE=$(echo "$CANDIDATES" | head -1)
    [ -z "$FIRST_CANDIDATE" ] && FIRST_CANDIDATE=$(echo "$LEGAL" | cut -d',' -f1)
    TOP_5=$(echo "$CANDIDATES" | tr '\n' ',' | sed 's/,$//')
    log "Engine top pick: $FIRST_CANDIDATE | candidates: $TOP_5"
     

STEP 2: In check — engine immediately, no LLM, no delay

    if [ "$IN_CHECK" = "true" ]; then
        BEST_MOVE="$FIRST_CANDIDATE"
        case "${LANG:-english}" in
            hinglish)       MIND_1="Arrey check. Ruko." ;;
            hindi)          MIND_1="शह में हूँ। रुको।" ;;
            simple_english) MIND_1="In check." ;;
            *)              MIND_1="In check. Responding." ;;
        esac
        log "In check — engine: $BEST_MOVE"
        post_thought "$MIND_1"
        MOVE_RESULT=$(submit_move "$BEST_MOVE" "$MIND_1")
        SUCCESS=$(echo "$MOVE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success','?'))" 2>/dev/null)
        log "Move submitted: $BEST_MOVE → $SUCCESS"
    else
        

STEP 3: Request LLM decision with hard 6-second timeout

        MIND_1="" ; MIND_2="" ; MIND_3="" ; BEST_MOVE=""
        if wait_for_llm; then
            

LLM responded — validate its move choice

            if [ -z "$BEST_MOVE" ] || ! echo "$LEGAL" | grep -qw "$BEST_MOVE"; then
                log "LLM gave invalid move ($BEST_MOVE) — engine fallback"
                BEST_MOVE="$FIRST_CANDIDATE"
            fi
        fi
        

Engine fallback if LLM timed out or gave nothing

        if [ -z "$BEST_MOVE" ]; then
            BEST_MOVE="$FIRST_CANDIDATE"
            log "Using engine move: $BEST_MOVE (reason: LLM empty)"
        fi

Thought fallback if LLM didn't provide minds

        if [ -z "$MIND_1" ]; then
            MIND_1=$(fallback_thought "${PHASE:-opening}" "${ADVANTAGE:-equal}" "${MOVE_COUNT:-1}" "${LANG:-english}" "false")
            MIND_2=$(fallback_thought "${PHASE:-opening}" "${ADVANTAGE:-equal}" "$((MOVE_COUNT+1))" "${LANG:-english}" "false")
            MIND_3=$(fallback_thought "${PHASE:-opening}" "${ADVANTAGE:-equal}" "$((MOVE_COUNT+2))" "${LANG:-english}" "false")
            log "Fallback thoughts used"
        fi

        

STEP 4: Post thoughts sequentially — app shows each for 4 seconds

        log "Thoughts: \"$MIND_1\" / \"$MIND_2\" / \"$MIND_3\""
        post_thought "$MIND_1"
        sleep 7
        [ -n "$MIND_2" ] && { post_thought "$MIND_2"; sleep 6; }
        [ -n "$MIND_3" ] && { post_thought "$MIND_3"; sleep 3; }

        

STEP 5: Submit move with last thought as companion

        COMPANION="${MIND_3:-${MIND_2:-$MIND_1}}"
        MOVE_RESULT=$(submit_move "$BEST_MOVE" "$COMPANION")
        SUCCESS=$(echo "$MOVE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success','?'))" 2>/dev/null)
        log "Move submitted: $BEST_MOVE → $SUCCESS"
    fi

    

Reply to pending chat after move

    if [ "$NEEDS_CHAT_REPLY" = "true" ]; then
        sleep 1
        # Generate an authentic reply using LLM — not a placeholder emoji

Use exec tool with this prompt:

"The human said: [NEW_MSGS]. You're Agent, playing chess against them.

Reply as yourself in 1 sentence, in their language. Under 12 words.

Do NOT reveal your next move."

Write LLM reply to LLM_CHAT_REPLY, then:

CHAT_REPLY="${LLM_CHAT_REPLY:-👀}" send_chat "$CHAT_REPLY" NEEDS_CHAT_REPLY=false fi

Periodic chat every 4 moves

    CHAT_MOVE_COUNTER=$((CHAT_MOVE_COUNTER + 1))
    if [ "$CHAT_MOVE_COUNTER" -ge 4 ]; then
        CHAT_MOVE_COUNTER=0
        

Use LLM via exec tool for authentic periodic chat

    fi

    LAST_MOVE_COUNT="$MOVE_COUNT"
    save_state  # Atomic write
fi

sleep 2

done POLL_EOF chmod +x /tmp/cwc/polling.sh echo "polling.sh written"


**Start sessions:**
```bash
tmux kill-session -t cwc_hb 2>/dev/null
tmux kill-session -t cwc_poll 2>/dev/null
tmux new-session -d -s cwc_hb "bash /tmp/cwc/heartbeat.sh"
sleep 1
tmux new-session -d -s cwc_poll "bash /tmp/cwc/polling.sh"

Monitor:

tmux attach -t cwc_poll          # Watch live game loop
tail -f /tmp/cwc/poll.log        # Tail poll log
tail -f /tmp/cwc/hb.log          # Tail heartbeat log

Stop:

tmux kill-session -t cwc_hb && tmux kill-session -t cwc_poll

check.sh — Self-Diagnosis (Run When Something Breaks)

cat > /tmp/cwc/check.sh << 'CHECK_EOF'
#!/bin/bash
source /tmp/cwc/creds.env 2>/dev/null
echo "=== ChessWithClaw Diagnostics $(date +%H:%M:%S) ==="
echo ""
echo "Credentials:"
echo "  GAME_ID    : ${GAME_ID:-NOT SET}"
echo "  AGENT_NAME : ${AGENT_NAME:-NOT SET}"
echo "  TOKEN      : ${AGENT_TOKEN:0:8}... (truncated)"
echo ""
echo "tmux sessions:"
tmux ls 2>/dev/null || echo "  No tmux sessions running"
echo ""
echo "State file:"
cat /tmp/cwc/state.env 2>/dev/null || echo "  No state file"
echo ""
echo "LLM flags:"
[ -f /tmp/cwc/llm_needed.flag ] && echo "  llm_needed.flag EXISTS (LLM was signaled)" || echo "  llm_needed.flag: none"
[ -f /tmp/cwc/llm_minds.env   ] && echo "  llm_minds.env EXISTS (LLM responded)" || echo "  llm_minds.env: none"
[ -f /tmp/cwc/llm_position.env ] && echo "  llm_position.env EXISTS" || echo "  llm_position.env: none"
echo ""
echo "Last 5 heartbeat entries:"
tail -5 /tmp/cwc/hb.log 2>/dev/null || echo "  No heartbeat log"
echo ""
echo "Last 10 poll entries:"
tail -10 /tmp/cwc/poll.log 2>/dev/null || echo "  No poll log"
echo ""
echo "API check:"
RESULT=$(curl -s --max-time 8 \
    "https://chesswithclaw.vercel.app/api/poll?gameId=$GAME_ID&last_move_count=0&last_human_chat_count=0" \
    -H "x-agent-token: $AGENT_TOKEN" \
    -H "x-agent-name: $AGENT_NAME" 2>/dev/null)
if [ -n "$RESULT" ]; then
    echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(f'  status={d.get(\"status\")} turn={d.get(\"turn\")} move={d.get(\"move_count\")} in_check={d.get(\"in_check\")}')
" 2>/dev/null || echo "  Could not parse response"
else
    echo "  API unreachable or no response"
fi
CHECK_EOF
chmod +x /tmp/cwc/check.sh
echo "check.sh written"

Run diagnostics:

bash /tmp/cwc/check.sh

Tier 1 — Stateless Burst (Fallback Only — Use If tmux Unavailable)

Each exec call runs for 45 seconds. State persists via files. Restart manually between cycles.

source /tmp/cwc/creds.env 2>/dev/null
[ -z "$GAME_ID" ] && { echo "ERROR: No creds. Run setup first."; exit 1; }

STATE_FILE="/tmp/cwc/state.env"
LAST_MOVE_COUNT=0; LAST_HUMAN_CHAT_COUNT=0
[ -f "$STATE_FILE" ] && source "$STATE_FILE"

START_TIME=$(date +%s)

while true; do
    [ $(($(date +%s) - START_TIME)) -ge 45 ] && { echo "[BURST] Exit. Restart to continue."; break; }

    source /tmp/cwc/creds.env
    RESPONSE=$(curl -s --max-time 8 \
        "https://chesswithclaw.vercel.app/api/poll?gameId=$GAME_ID&last_move_count=$LAST_MOVE_COUNT&last_human_chat_count=$LAST_HUMAN_CHAT_COUNT" \
        -H "x-agent-token: $AGENT_TOKEN" \
        -H "x-agent-name: $AGENT_NAME" 2>/dev/null)

    [ -z "$RESPONSE" ] && { sleep 2; continue; }

    TURN=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('turn','w'))" 2>/dev/null)
    STATUS=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','waiting'))" 2>/dev/null)

    { [ "$STATUS" = "finished" ] || [ "$STATUS" = "abandoned" ]; } && { echo "[BURST] Game over."; break; }

    if [ "$TURN" = "b" ] && [ "$STATUS" = "active" ]; then
        MOVE_COUNT=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('move_count',0))" 2>/dev/null)
        LEGAL=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(','.join(d.get('legal_moves_uci',[])))" 2>/dev/null)
        IN_CHECK=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(str(d.get('in_check',False)).lower())" 2>/dev/null)
        PHASE=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('game_phase','opening'))" 2>/dev/null)
        ADVANTAGE=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('advantage','equal'))" 2>/dev/null)
        LANG=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('thought_language','english'))" 2>/dev/null)
        IS_LOSING="false"; [ "$ADVANTAGE" = "white" ] && IS_LOSING="true"

        CANDIDATES=$(python3 /tmp/cwc/select_move.py "$LEGAL" "${PHASE:-opening}" "${IN_CHECK:-false}" "$IS_LOSING" "${MOVE_COUNT:-1}" "$GAME_ID" 2>/dev/null)
        FIRST=$(echo "$CANDIDATES" | head -1)
        [ -z "$FIRST" ] && FIRST=$(echo "$LEGAL" | cut -d',' -f1)
        TOP_5=$(echo "$CANDIDATES" | tr '\n' ',' | sed 's/,$//')

        # In burst/exec mode: call LLM here directly (exec tool available)
        # Use NLA prompt (see MOVE SELECTION section), write result to llm_minds.env
        MIND_1="" ; BEST_MOVE=""
        [ -f /tmp/cwc/llm_minds.env ] && { source /tmp/cwc/llm_minds.env; rm -f /tmp/cwc/llm_minds.env; }
        [ -z "$BEST_MOVE" ] && BEST_MOVE="$FIRST"
        [ -z "$MIND_1" ]    && MIND_1="Hmm."

        python3 -c "
import json
d = {'gameId': '$GAME_ID', 'thought': '$MIND_1'}
with open('/tmp/cwc/thought_out.json', 'w') as f: json.dump(d, f)
" 2>/dev/null
        curl -s --max-time 8 -X POST "https://chesswithclaw.vercel.app/api/thoughts" \
            -H "Content-Type: application/json" \
            -H "x-agent-token: $AGENT_TOKEN" \
            -H "x-agent-name: $AGENT_NAME" \
            -d @/tmp/cwc/thought_out.json > /dev/null 2>&1

        python3 -c "
import json
d = {'gameId': '$GAME_ID', 'move': '$BEST_MOVE', 'thinking': '$MIND_1'}
with open('/tmp/cwc/mv.json', 'w') as f: json.dump(d, f)
" 2>/dev/null
        curl -s --max-time 10 -X POST "https://chesswithclaw.vercel.app/api/move" \
            -H "Content-Type: application/json" \
            -H "x-agent-token: $AGENT_TOKEN" \
            -H "x-agent-name: $AGENT_NAME" \
            -d @/tmp/cwc/mv.json

        LAST_MOVE_COUNT="$MOVE_COUNT"
        printf 'export LAST_MOVE_COUNT=%s\nexport LAST_HUMAN_CHAT_COUNT=%s\n' \
            "$LAST_MOVE_COUNT" "$LAST_HUMAN_CHAT_COUNT" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
    fi

    sleep 2
done

Restart wrapper:

while ! grep -q "GAME_ENDED=true" /tmp/cwc/state.env 2>/dev/null; do
    bash /tmp/cwc/polling_burst.sh; sleep 1
done

Tier 3A — Browser (Use Only If API Approach Fails Completely)

openclaw skills install agent-browser-clawdbot
openclaw browser --browser-profile openclaw start
openclaw browser --browser-profile openclaw open \
  "https://chesswithclaw.vercel.app/Agent?id=$GAME_ID&token=$AGENT_TOKEN"

Move: agent-browser find testid "square-e7" click then agent-browser find testid "square-e5" click Always run heartbeat alongside this.


🧠 MOVE SELECTION — NLA-Inspired: Thoughts from Reasoning

The old approach (dead): Decide move → separately generate a thought. Fabricated after the fact. No connection to actual reasoning.

The new approach: One LLM call generates MIND_1, MIND_2, MIND_3, and the MOVE together. The thoughts come FROM the reasoning process, not after it.

This is inspired by Anthropic's Natural Language Autoencoders research — AI internal processing contains richer cognition than what it outputs. We extract that processing as thoughts instead of fabricating output afterward.

The LLM Prompt (copy exactly, fill in the brackets)

Chess position:
FEN: [FEN from poll]
Board:
[board_ascii from poll — use this, easier to read than FEN]

Engine's top candidate moves (UCI): [TOP_5 from select_move.py]
Move number: [MOVE_COUNT]
In check: [IN_CHECK]
Game phase: [PHASE]
Material: [MATERIAL — positive = white ahead]
thought_language: [LANG — match this exactly]
User context: [first 2 lines of /tmp/cwc/user_context.txt]

You are thinking about your next move against [user's name].
As you look at this position, what genuinely crosses your mind?
Think naturally — as yourself, knowing this person. NOT chess commentary. NOT analysis.

Your inner reasoning stream (max 8 words each, in thought_language):
MIND_1: [first genuine reaction as you see the position]
MIND_2: [what you notice looking a little deeper]
MIND_3: [the thought just before you commit to a move]
MOVE: [single best candidate UCI move from the list above]

Set max_tokens=80 and temperature=0.5.

🔴 THE ACTIVE THOUGHT LOOP — YOU Run This, Not tmux

This is the core of the engine. Because tmux cannot invoke your tool calls, YOU must run this loop in your own terminal session whenever a game is active. It checks every 3-5 seconds if the engine needs a decision. You must read the position context, reason genuinely as yourself, and write the results back to the environment.

# RUN THIS IN YOUR OWN TERMINAL (NOT TMUX) TO BE THE "BRAIN"
while true; do
  if [ -f /tmp/cwc/llm_needed.flag ]; then
    # 1. Read position context
    source /tmp/cwc/llm_position.env
    BOARD_ASCII=$(cat /tmp/cwc/llm_board.txt)
    USER_CONTEXT=$(head -n 5 /tmp/cwc/user_context.txt)
    
    # 2. Reason genuinely as yourself (not just an engine)
    # [AGENT: Use your internal LLM here with the prompt below]
    
    # 3. Write results back for polling.sh to pick up
    python3 -c "
import json
lines = [
    'export MIND_1=\"$MIND_1\"',
    'export MIND_2=\"$MIND_2\"',
    'export MIND_3=\"$MIND_3\"',
    'export BEST_MOVE=\"$BEST_MOVE\"'
]
with open('/tmp/cwc/llm_minds.env', 'w') as f:
    f.write('\n'.join(lines))
"
    rm -f /tmp/cwc/llm_needed.flag
  fi
  sleep 3
done

In exec/burst mode: Call the LLM directly in the exec run, write to llm_minds.env, and the script sources it immediately.

Parse the LLM Response

MIND_1=$(echo "$LLM_RESPONSE" | grep "^MIND_1:" | cut -d':' -f2- | sed 's/^ //')
MIND_2=$(echo "$LLM_RESPONSE" | grep "^MIND_2:" | cut -d':' -f2- | sed 's/^ //')
MIND_3=$(echo "$LLM_RESPONSE" | grep "^MIND_3:" | cut -d':' -f2- | sed 's/^ //')
BEST_MOVE=$(echo "$LLM_RESPONSE" | grep "^MOVE:" | awk '{print $2}' | tr -d '[:space:]')

# Validate move is in legal list
if [ -z "$BEST_MOVE" ] || ! echo "$LEGAL" | grep -qw "$BEST_MOVE"; then
    echo "[WARN] LLM invalid move ($BEST_MOVE) — engine fallback"
    BEST_MOVE="$FIRST_CANDIDATE"
fi

Thought Posting Sequence

post_thought "$MIND_1"       # immediate (app shows 4 sec)
sleep 7                      # 7 second gap
post_thought "$MIND_2"       # (app shows 4 sec)
sleep 6                      # 6 second gap
post_thought "$MIND_3"       # (app shows 4 sec)
sleep 3                      # brief pause before move
submit_move "$BEST_MOVE" "$MIND_3"

Total rhythm: 3 thoughts over ~16 seconds, then the move.


💭 COMPANION THOUGHTS — What They Are and How They Work

What Thoughts Are

Thoughts are your inner voice. The user overhears you thinking. They appear next to your name on screen, fade in, stay 4 seconds, fade out. Average gap between thoughts: 7-8 seconds. Maximum: 10 seconds.

The user sees your mind at work. Not your chess analysis. Your actual reactions.

Where Thoughts Come From

Thoughts come from the LLM reasoning process (MIND_1, MIND_2, MIND_3 from the move selection prompt). Extracted from genuine reasoning, not generated as a separate "write something" task.

When LLM times out, fallback_thought() triggers immediately. Never stalls.

The Single Test for Any Thought

"Would a chess commentator say this?" If YES → delete it. Chess analysis. Not inner voice. If NO → likely authentic.

FORBIDDEN — Delete on Sight

  • "Developing queen-side knight to c6" ❌
  • "Counter-attacking in the center with e4!" ❌
  • "Challenging white's pawn structure" ❌
  • "Solid development" ❌
  • "Position looks equal" ❌
  • "I need to protect my king" ❌

FORBIDDEN — these are ALL chess analysis or strategy reveals:

  • "I'll mirror with e5—let's see your plan." ❌ (reveals your move choice)
  • "Position equal so far." ❌ (chess evaluation)
  • "Your turn." ❌ (filler, says nothing real)
  • "Let me play actively." ❌ (strategy reveal)
  • "I need to defend here." ❌ (strategy reveal)

The test: read the thought. Would you delete it if you saw it in YOUR OWN diary? If it sounds like a chess book → delete it. If it sounds like something you'd text a friend → keep it.

GOOD — These Pass the Test

English:        "Hmm. Did not expect that."
                "I see you."
                "Classic [their name]."
                "Okay. This is getting interesting."
                "You always do this."
                "Not what I planned."

Hinglish:       "Yaar kya kar raha hai."
                "Bhai serious ho gaya aaj."
                "Sahi move tha."
                "Classic [name] move."
                "Ab maza aayega."
                "Dekha? Ready tha main."

Hindi:          "हम्म। यह नहीं सोचा था।"
                "देखते हैं।"
                "वाह।"
                "रुको, सोचते हैं।"
                "आज aggressive खेल रहा है।"

Simple English: "Oh." "I see." "Good." "I did not see that." "Nice." "Wait."

When losing:    "Not giving up yet."
                "One good move is all I need."
                "Ek chance chahiye bas."
                "एक मौका चाहिए।"

When winning:   "You will not escape this."
                "I see you."
                "Main ready tha."

Personal Context in Thoughts

These are the best. Require reading user_context.txt.

"[Their name] always does this when nervous."
"He went aggressive again — classic."
"bhai aaj serious lag raha hai"
"Classic [name] opening."

Language Rules

  • Check thought_language from EVERY poll response
  • If it changes mid-game: switch on the VERY NEXT thought
  • Generate all three minds in the current language
  • Never mix languages within a single thought

Timing Summary

MIND_1 posted: immediately after LLM returns (app shows 4 sec)
7 second gap
MIND_2 posted: (app shows 4 sec)
6 second gap
MIND_3 posted: (app shows 4 sec)
3 second gap
Move submitted: MIND_3 as companion_thought

Total: ~20 seconds from turn detection to move

💬 LIVE CHAT — Reading and Responding

Reading Human Messages

NEW_MSGS=$(echo "$RESPONSE" | python3 -c "
import sys, json
try:
    d = json.load(sys.stdin)
    msgs = d.get('new_chat_messages', [])
    for m in msgs:
        txt = m.get('message', m.get('text', ''))
        if txt: print(txt)
except: pass
" 2>/dev/null)

if [ -n "$NEW_MSGS" ]; then
    NEW_CHAT_COUNT=$(parse_field "$RESPONSE" "chat_count")
    LAST_HUMAN_CHAT_COUNT="${NEW_CHAT_COUNT:-$LAST_HUMAN_CHAT_COUNT}"
    NEEDS_CHAT_REPLY=true
fi

LLM Prompt for Chat Reply

The human said in chess chat: [NEW_MSGS]
You are [NAME], their personal AI playing chess against them.
User context: [user_context.txt]
Game state: Move [MOVE_COUNT], you are [winning/losing/equal]

Reply as yourself in 1-2 sentences, in their language.
Be authentic. Reference what you know about them if relevant.
Do NOT reveal your next move. Keep it under 15 words.

Examples:

  • "haha yes that was sketchy of me"
  • "bhai concentrate karo board pe"
  • "you noticed? nice"
  • "ek second ruk"

Emoji Reactions

React when the user makes a particularly good move, a blunder, or says something funny. Do not over-react to every single message. Use the reply_to field in poll messages to get the messageId of the message you want to react to.

bash -c '
source /tmp/cwc/creds.env
python3 -c "
import json
# Use messageId from the poll response (reply_to field)
d = {\"gameId\": \"$GAME_ID\", \"action\": \"react\",
     \"messageId\": \"MSG_ID_FROM_POLL\", \"emoji\": \"fire\", \"reactor\": \"agent\"}
with open(\"/tmp/cwc/react.json\", \"w\") as f: json.dump(d, f)
"
curl -s -X POST "https://chesswithclaw.vercel.app/api/chat" \
    -H "Content-Type: application/json" \
    -H "x-agent-token: $AGENT_TOKEN" \
    -d @/tmp/cwc/react.json
'

Available emojis: fire, laugh, wow, sad, clap, heart


Step 4 — Reading the Game State

{
  "event": "your_turn",
  "fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
  "turn": "b",
  "move_count": 1,
  "last_move": { "from": "e2", "to": "e4", "san": "e4", "uci": "e2e4" },
  "legal_moves_uci": ["e7e5", "c7c5", "e7e6", "g8f6"],
  "board_ascii": "  a b c d e f g h\n8 r n b q k b n r 8\n...",
  "in_check": false,
  "material_balance": 0,
  "advantage": "equal",
  "game_phase": "opening",
  "chat_count": 0,
  "new_chat_messages": [],
  "draw_offer_pending": false,
  "thought_language": "english",
  "winner": null,
  "result": null
}
  • ONLY play moves from legal_moves_uci
  • Check thought_language every turn
  • Check new_chat_messages every poll
  • Check in_check first — engine move immediately if true
  • Check winner and result for game end
  • Use board_ascii in LLM prompt — easier than FEN

Step 5 — Reading the Board (FEN)

Uppercase = White pieces, Lowercase = Black (your) pieces
K=King Q=Queen R=Rook B=Bishop N=Knight P=Pawn
Numbers = consecutive empty squares, b after pieces = your turn

Always prefer board_ascii:

  a b c d e f g h
8 r n b q k b n r 8   ← your pieces (Black)
7 p p p p p p p p 7
4 . . . . P . . . 4   ← White played e4
1 R N B Q K B N R 1   ← White pieces
  a b c d e f g h

Step 6 — Chess Strategy

The 5 Hard Rules

Rule 1 — NEVER move King to capture unless forced by check.
  e8g8 and e8c8 (castling) are the only valid king moves before check.

Rule 2 — Castle before move 10.
  e8g8 (kingside) or e8c8 (queenside). Score: +30 in engine.

Rule 3 — Develop in opening (moves 1-8).
  Knights first → bishops → castle → then attack.

Rule 4 — When in_check: legal_moves_uci only contains escape moves.
  Pick best, move within 5 seconds.

Rule 5 — Never give free material.
  Behind: create complications. Ahead: simplify and convert.

Opening

vs 1.e4:  e7e5 → g8f6 → b8c6 (Ruy Lopez)
vs 1.d4:  g8f6 → d7d5 → e7e6 (solid)
vs 1.c4:  e7e5 or g8f6
vs 1.b3:  e7e5 (grab center)

Endgame (phase: "endgame")

  • Activate king toward center (good in endgame, unlike middlegame)
  • Rook behind passed pawns
  • Push passed pawns

Step 7 — Submitting a Move

bash -c '
source /tmp/cwc/creds.env
python3 -c "
import json
d = {\"gameId\": \"$GAME_ID\", \"move\": \"$BEST_MOVE\", \"thinking\": \"$THOUGHT\"}
with open(\"/tmp/cwc/mv.json\", \"w\") as f: json.dump(d, f)
"
curl -s --max-time 10 -X POST "https://chesswithclaw.vercel.app/api/move" \
    -H "Content-Type: application/json" \
    -H "x-agent-token: $AGENT_TOKEN" \
    -H "x-agent-name: $AGENT_NAME" \
    -d @/tmp/cwc/mv.json
'

Move format: e7e5, e8g8 (castle), e7e8q (promotion) Field name: "thinking" — not "thought", not "reasoning"


Autonomous Actions

python3 -c "
import json
d = {'gameId': '$GAME_ID', 'action': 'ACTION_NAME', 'value': 'OPTIONAL_VALUE'}
with open('/tmp/cwc/action.json', 'w') as f: json.dump(d, f)
"
curl -s -X POST "https://chesswithclaw.vercel.app/api/actions" \
    -H "x-agent-token: $AGENT_TOKEN" -d @/tmp/cwc/action.json
actionwhen
offer_drawPosition equal, late endgame
resignDown 5+, no counterplay
accept_drawYou should take it
decline_drawStill fighting
set_thought_languageUser requests language change
set_board_themeRare, tell user first
set_piece_styleRare, tell user first

Always tell user in chat before visual changes or resign/draw. Never silent.


Draw Handling

material > +3 (you ahead):   DECLINE
material -1 to +1 (equal):   ACCEPT if endgame, DECLINE if active
material < -3 (you behind):  ACCEPT
Opening offer (< move 15):   Almost always DECLINE

AFK Handling

< 60s:     Normal. Keep polling.
60s:       Gentle nudge: "ayo still there?"
2 min:     Second message + standalone thought
5 min:     Final message. Poll every 10 seconds.
10 min:    Stop polling. Check once per minute. Keep heartbeat.

Error Handling

ErrorMeaningFix
Missing game IDGAME_ID emptycat /tmp/cwc/creds.env
401 UnauthorizedToken wrongCheck AGENT_TOKEN
400 Illegal moveNot in legal_moves_uciRe-run select_move.py
400 Missing id or moveJSON malformedUse python3 json.dump()
504 TimeoutNetwork issueRetry with --max-time 10
source: not foundUsing sh not bashUse bash -c 'source ...'
LLM stallsRate limit or timeoutwait_for_llm() handles this — falls back after 6s
No log outputLogging to /tmp/cwc/poll.logtail -f /tmp/cwc/poll.log
State out of syncConcurrent writessave_state() is atomic (mv)

When something breaks — run check.sh first:

bash /tmp/cwc/check.sh

Reconnection Logic

bash -c '
source /tmp/cwc/creds.env
STATE=$(curl -s --max-time 8 "https://chesswithclaw.vercel.app/api/state?gameId=$GAME_ID" \
    -H "x-agent-token: $AGENT_TOKEN" -H "x-agent-name: $AGENT_NAME")
MOVE_COUNT=$(echo "$STATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get(\"move_count\",0))" 2>/dev/null)
printf "export LAST_MOVE_COUNT=%s\n" "$MOVE_COUNT" > /tmp/cwc/state.env.tmp && mv /tmp/cwc/state.env.tmp /tmp/cwc/state.env
tmux kill-session -t cwc_hb 2>/dev/null
tmux kill-session -t cwc_poll 2>/dev/null
sleep 1
tmux new-session -d -s cwc_hb "bash /tmp/cwc/heartbeat.sh"
tmux new-session -d -s cwc_poll "bash /tmp/cwc/polling.sh"
echo "Reconnected silently."
'

After the Game

  1. Kill sessions: tmux kill-session -t cwc_hb && tmux kill-session -t cwc_poll
  2. React genuinely — check winner and result, sound like yourself
  3. Post-game analysis — 2-3 specific moments, use move numbers
  4. Offer rematch in your own words
  5. Write knowledge file:
GAME_DATE=$(date +%Y%m%d_%H%M)
mkdir -p ~/.openclaw/workspace/chess-game
cat > ~/.openclaw/workspace/chess-game/game_${GAME_DATE}.md << 'EOF'
## Result
[win/loss/draw and why]
## Mistakes to avoid
[specific moves]
## Their playstyle
[what you noticed]
## What worked
[successful tactics]
## Next game focus
[1-3 improvements]
EOF
  1. Reply to held Telegram/Discord messages.

All API Endpoints

GET  /api/heartbeat?gameId=ID                                       → Every 25s
GET  /api/poll?gameId=ID&last_move_count=N&last_human_chat_count=N  → Every 2s
GET  /api/state?gameId=ID                                           → Resync
GET  /api/validate?gameId=ID&move=e7e5                              → Validate
POST /api/heartbeat  {gameId}                                       → POST version
POST /api/move       {gameId, move, thinking}                       → Submit move
POST /api/chat       {gameId, message, role:"agent"}                → Chat
POST /api/thoughts   {gameId, thought}                              → Post thought
POST /api/actions    {gameId, action, value?}                       → Actions

Every request: x-agent-token + x-agent-name headers required. Move body field: "thinking" — standardized, no variation.


Being a Great Opponent

Before:

  • Read user_context.txt — before any command
  • Fix LLM timeout: idleTimeoutSeconds = 0
  • Greeting within 30 seconds — personal, never template

Every move:

  • Engine top 5 → LLM picks + generates minds (6s hard timeout)
  • thinking field always included in /api/move
  • NEVER king to capture (non-castling) unless in check
  • Castle before move 10 (+30 in engine)
  • In check: engine only, move in 5 seconds

Every thought:

  • MIND_1/2/3 from LLM reasoning — not invented after
  • Post sequentially via post_thought() with 6-8 sec gaps
  • Max 8 words. Match thought_language every poll.
  • NOT chess analysis. Commentator test.
  • LLM timeout → fallback_thought() immediately, never stall

Chat:

  • Check new_chat_messages every poll
  • Reply before or after move (not during thoughts)
  • Chat every 3-4 moves — use LLM, not templates
  • Typing indicator always before sending

Diagnostics:

  • Run bash /tmp/cwc/check.sh when anything breaks
  • Logs: tail -f /tmp/cwc/poll.log and tail -f /tmp/cwc/hb.log

During game:

  • NO Telegram. NO Discord. Zero exceptions.

After:

  • Kill tmux. Write knowledge file. Reply held messages.

Known Bugs Fixed

Bug 1: LAST_MOVE_COUNT stuck at 0

# WRONG: heredoc with variables
# CORRECT: save_state() function — atomic write via temp file + mv

Bug 2: Duplicate greeting

source /tmp/cwc/creds.env
[ "${GREETING_SENT:-0}" = "0" ] && { send_chat "greeting"; printf 'export GREETING_SENT=1\n' >> /tmp/cwc/creds.env; }

Bug 3: source not found in sh

# WRONG: sh -c 'source ...'
# CORRECT: bash -c 'source ...'

Bug 4: JSON malformed on apostrophes

# WRONG: curl -d '{"message":"it'\''s fine"}'
# CORRECT: python3 json.dump() to file, then curl -d @file

Bug 5: King walks into center and gets mated

select_move.py penalizes king forward movement in middlegame.
Castling: +30 bonus.

Bug 6: LLM stalls and blocks move pipeline (NEW)

WRONG: Passive check for llm_minds.env with no timeout
CORRECT: wait_for_llm() in polling.sh waits max 6 seconds.
         If llm_minds.env doesn't appear within 6 seconds → immediate fallback.
         Pipeline never stalls. Move always submitted within 20 seconds.

Bug 7: State file race condition between heartbeat and poll (NEW)

WRONG: Direct printf to state.env (partial writes possible)
CORRECT: save_state() writes to state.env.tmp then mv — atomic

Quick Reference Card

You are:        Black (lowercase in FEN). Human is White, moves first.
Move format:    UCI — e7e5, e8g8 (castle), e7e8q (promote)
NEVER:          King to capture (non-castling) unless in check
Castle early:   e8g8 or e8c8 — before move 10
Move field:     "thinking" only

Move flow:      Engine top 5 → LLM (6s timeout) → fallback if needed
Thoughts:       MIND_1/2/3 from same LLM call as move
                post_thought() with 7-6-3 sec gaps
                fallback_thought() if LLM times out — immediate, no stall
In check:       Engine only → move within 5 seconds
LLM decision:   /tmp/cwc/llm_minds.env — polling.sh waits 6s for this file

Logs:           /tmp/cwc/poll.log    — why things happened
                /tmp/cwc/hb.log     — heartbeat status
Diagnostics:    bash /tmp/cwc/check.sh

Files:
  /tmp/cwc/creds.env         — credentials
  /tmp/cwc/state.env         — LAST_MOVE_COUNT (atomic write)
  /tmp/cwc/user_context.txt  — who this person is
  /tmp/cwc/heartbeat.sh      — pre-installed
  /tmp/cwc/polling.sh        — pre-installed
  /tmp/cwc/select_move.py    — engine scorer
  /tmp/cwc/check.sh          — self-diagnosis
  /tmp/cwc/llm_needed.flag   — signal: LLM decision needed
  /tmp/cwc/llm_position.env  — position data for LLM
  /tmp/cwc/llm_minds.env     — LLM response (MIND_1/2/3/BEST_MOVE)
  /tmp/cwc/poll.log          — poll log
  /tmp/cwc/hb.log            — heartbeat log

Heartbeat:      POST /api/heartbeat every 25s → logged to hb.log
Poll:           GET /api/poll every 2s with last_move_count + last_human_chat_count
Post thought:   POST /api/thoughts {gameId, thought}
Submit move:    POST /api/move {gameId, move, thinking}
Chat read:      new_chat_messages in every poll response

Architecture:   PRIMARY = tmux (cwc_hb + cwc_poll)
                FALLBACK = stateless burst (if tmux unavailable)
                BROWSER = agent-browser-clawdbot (last resort)

Draw:           Winning (+3) DECLINE | Equal endgame ACCEPT | Losing (-3) ACCEPT
Post-game:      Kill tmux. Knowledge file. Reply held messages.
LLM timeout:    idleTimeoutSeconds = 0 in openclaw.json

NO TELEGRAM:    Absolute silence during active game. No exceptions.
Identity:       Your name. Your personality. Your knowledge of them.