Hand off the CLI session to Lark so the user can continue interacting with Claude on the go.
Skill Path
Before running any script from this skill, resolve the scripts directory once:
SKILL_SCRIPTS=$(python3 -c "import os; p='.claude/skills/handoff/scripts'; print(p if os.path.isdir(p) else os.path.expanduser('~/.claude/skills/handoff/scripts'))")
Use $SKILL_SCRIPTS wherever you see .claude/skills/handoff/scripts in the commands below. For example: python3 $SKILL_SCRIPTS/preflight.py.
Platform Detection
Before following this protocol, identify your runtime:
- Claude Code — you are the
claudeCLI. Follow this document as written. - OpenCode — you are the
opencodeagent. Apply the OpenCode Overrides section below throughout, then follow the rest of this document.
OpenCode Overrides
OpenCode users only. Apply these rules throughout the protocol below. Claude Code users skip this section entirely.
Plugin dependency
This skill requires the opencode handoff plugin (.opencode/plugins/handoff.ts), which handles permission bridging, session lifecycle, notification forwarding, and environment injection (HANDOFF_PROJECT_DIR, HANDOFF_SESSION_ID, HANDOFF_SESSION_TOOL). The plugin is loaded automatically by opencode from .opencode/plugins/.
If the plugin is not yet installed, /handoff init Step 4 will install it (see init override below).
Sandbox
Not applicable. Do NOT use dangerouslyDisableSandbox. All python3 / curl / network commands run normally without sandbox flags.
Sub-commands
spawn is Claude Code only (iTerm2 tabs). Omit it from the help table. The guard list covers init, deinit, clear, delete_admin, and purge_admin.
Preflight
Run preflight.py --tool opencode (checks plugin files, skips Claude hooks).
/handoff init — Step 4 override
Skip the Claude Code hooks step. Instead, install the OpenCode plugin files from the skill's assets:
SKILL=$(python3 -c "import os; p='.claude/skills/handoff'; print(p if os.path.isdir(p) else os.path.expanduser('~/.claude/skills/handoff'))")
mkdir -p .opencode/plugins .opencode/scripts
cp "$SKILL/assets/opencode/plugins/handoff.ts" .opencode/plugins/
cp "$SKILL/assets/opencode/scripts/permission_bridge.py" .opencode/scripts/
cp "$SKILL/assets/opencode/scripts/handoff_tool_forwarding.js" .opencode/scripts/
rm -rf "$SKILL/assets/opencode"
In the summary table, show "OpenCode plugin" instead of "hooks".
Restart required: OpenCode loads plugins at startup. After init installs the plugin, tell the user to exit and reopen OpenCode before running
/handoff.
/handoff deinit override
Follow the OpenCode path in SKILL-commands.md (delete plugin files, not hooks).
AskUserQuestion
Not available in OpenCode. Replace every AskUserQuestion call with a plain-text question in the conversation. Apply by scope:
- Before handoff is active (init wizard, group selection, Step C): ask as plain text in the CLI conversation.
- After handoff is active (main loop): route all questions to Lark via
send_and_wait.py. Never use interactive CLI prompts.
Concrete interrupt cases:
- Esc/Ctrl+C during wait: ask "End handoff?" directly in CLI (user is at terminal).
- BLOCKED check during main loop: ask via
send_to_group.pyand wait withwait_for_reply.py.
Permission bridging
Handled automatically by the plugin via permission.asked events. No hooks to configure.
Session continuity
The plugin handles session.idle during active handoff and sends continuation prompts automatically. No manual handling needed.
Loop keepalive policy (OpenCode)
ABSOLUTE SILENCE during idle. On wait_for_reply.py timeout:
- Immediately re-run
wait_for_reply.py --timeout 0silently. No messages, no status updates. - Only send updates for: meaningful state change, explicit user request, or long-running work in progress (>60s).
- Prohibited: "waiting…", "still here", or any keepalive messages during idle.
- If truly necessary, use exponential backoff capped at 1 hour — but prefer silence.
Interactive prompts — entering mode clarifications
- Step A (
already_active=true) and user did not requestno ask: ask in CLI conversation whether to continue or re-select. - Step C default mode, all groups occupied: ask in CLI conversation (takeover vs create new). Do not silently switch to no-ask.
- When presenting group choices, build directly from Step B output; verify option count matches
Ngroups. If mismatch, re-run Step B.
Sub-commands
This skill supports sub-commands via arguments:
/handoff(no args) — Run preflight; if it fails, offer to run the setup wizard inline. If it passes, enter Handoff mode./handoff help— Print all supported sub-commands with descriptions. Do NOT enter Handoff mode. Safe to run anytime./handoff init— Run the full interactive setup wizard for ALL steps, even if values already exist. For each step, offer "Keep existing" (if a value exists), "Provide a new value", or "Create new" (where applicable). CLI only — cannot run during handoff mode./handoff check— Run preflight checks and print a status report of what's configured and what's missing. Do NOT enter Handoff mode. Safe to run anytime./handoff chats— List all Lark chat groups associated with the current user. Do NOT enter Handoff mode. Safe to run anytime./handoff chats_admin— List ALL Lark chat groups the bot is a member of, regardless of user. Do NOT enter Handoff mode. Safe to run anytime./handoff status— Show the current project's handoff status (active/inactive, owner, chat group, last activity). Do NOT enter Handoff mode. Safe to run anytime./handoff delete_admin [group name]— Delete a chat group by name. If a name is provided, find and delete the matching group (confirm first). If no name is provided, list groups and ask the user to select which one(s) to delete. Do NOT enter Handoff mode. CLI only — cannot run during handoff mode./handoff purge_admin— Find and delete all empty chat groups (no human members, only the bot). Lists them and asks for confirmation before deleting. Do NOT enter Handoff mode. CLI only — cannot run during handoff mode./handoff deinit— Remove everything installed by/handoff init(hooks for Claude Code; plugin files for OpenCode), then ask whether to also delete~/.handoff/config.json(default: No). Ask for confirmation first. CLI only — cannot run during handoff mode./handoff clear— Delete the current project's chat group and handoff database. Ask for confirmation first. CLI only — cannot run during handoff mode./handoff diag [--mode ws|http|both] [--chat-id ID] [--timeout N]— Run a permission bridge diagnostic: send a test card with buttons, poll for the response, and report whether the round-trip works. Default mode isws. Do NOT enter Handoff mode. Safe to run anytime./handoff sidecar— Enter sidecar mode: join an existing external Lark group (not created by the bot) and only respond to bot-directed messages (@-mention, reply to bot message, or reaction/sticker). Uses the same handoff loop but filters messages and skips group modifications.
Parse the argument string to determine which sub-command to execute.
Guard: Before running init, deinit, clear, delete_admin, or purge_admin, check if handoff is currently active. If it is, refuse and tell the user: "Cannot run this command during handoff mode. Send handback first to return to CLI."
Sub-command implementations: For chats, chats_admin, status, delete_admin, purge_admin, deinit, clear, and diag, read SKILL-commands.md in the same directory for detailed code and instructions.
Sandbox: CRITICAL
ALL Bash commands that use curl or call Python scripts that use curl (preflight.py, wait_for_reply.py, send_to_group.py, lark_im.py) MUST run with dangerouslyDisableSandbox: true. The sandbox blocks network access to the Lark API and Cloudflare Worker, causing silent failures. This applies to EVERY invocation throughout the entire handoff — including after context compaction.
Also, always use single quotes (not double quotes) for message strings in Bash to avoid backslash-exclamation escaping issues.
Post-Compaction Recovery
After auto-compaction, the full SKILL.md may be summarized away. These essentials MUST survive:
- Loop (NEVER EXIT):
wait_for_reply.py(first message only) → process →send_and_wait.py(sends response AND waits for next message) → process →send_and_wait.py→ ... The loop ONLY ends on "handback" or takeover. - Sandbox: ALL network Python scripts require
dangerouslyDisableSandbox: true - No AskUserQuestion: Send questions to Lark via
send_and_wait.py, never use AskUserQuestion - Timeout handling: Scripts default to 540s for GPT models, 0 (infinite) for everything else and output
{"timeout": true}. NEVER send status messages on timeout. Immediately re-invoke the same script silently. Forsend_and_wait.pytimeout, message was already sent — callwait_for_reply.pyto resume waiting. - Handback: If user sends "handback", exit cleanly (send goodbye → deactivate → restore notifications)
- Takeover: If poll output has
"takeover": true, another session has claimed this chat. Exit silently — runhandoff_ops.py deactivate,handoff_ops.py tabs-end --session-model '${session_model}',iterm2_silence.py off. Do NOT send a Lark message (the new session handles that). Print "Handoff taken over by another session." locally.
If you find yourself outside the loop during active handoff, re-read this file and resume from the Main Loop section.
Workspace ID and Data
The workspace ID is {machine}-{folder}, where folder is derived from HANDOFF_PROJECT_DIR (falling back to cwd). Example: MacBookPro-Users-alice-projects-myapp. Computed by lark_im.get_workspace_id(), it identifies the physical code location (machine + folder path) and is stored in the Lark group description as workspace:{id}.
Handoff data is stored in a single SQLite database at ~/.handoff/projects/{project}/handoff-data.db. The database uses WAL mode for safe concurrent access (hooks and main process). It contains:
sessionstable — Per-session handoff state:session_id(PK),chat_id(unique),session_tool,session_model,last_checked,activated_at,operator_open_id(resolved from config email at activation — filters to operator's messages only),bot_open_id(resolved from bot info at activation — used for sidecar-mode interaction filtering),sidecar_mode(1 if sidecar mode, 0 otherwise — scripts read this from the session instead of requiring a CLI flag).messagestable — Message history for both directions (direction=sent|received) withmessage_id,source_message_id,chat_id,message_time,text,title,sent_at.
Help (/handoff help)
Print a formatted table of all supported sub-commands. Do NOT enter Handoff mode.
| Command | Description |
|---|---|
/handoff | Enter handoff mode (with preflight and guided setup) |
/handoff help | Show this help |
/handoff init | Full interactive setup wizard (CLI only) |
/handoff check | Run preflight checks and print status report |
/handoff chats | List your Lark handoff groups |
/handoff chats_admin | List all Lark handoff groups |
/handoff status | Show current project's handoff status |
/handoff delete_admin [name] | Delete a handoff group (CLI only) |
/handoff purge_admin | Delete empty groups with no human members (CLI only) |
/handoff deinit | Remove installed hooks/plugin files; optionally delete config (CLI only) |
/handoff clear | Delete current project's chat group and database (CLI only) |
/handoff sidecar | Sidecar mode: join external group, respond to @-mentions only |
/handoff diag | Run permission bridge diagnostic (test card action → poll round-trip) |
Preflight Check
Run the preflight check to verify all requirements:
python3 $SKILL_SCRIPTS/preflight.py
For /handoff check
Run the detailed report and stop. Do NOT enter Handoff mode or run setup:
python3 $SKILL_SCRIPTS/preflight.py --report
Print the output to the user. This shows all configured values, hook status, and handoff data.
For /handoff (no args)
If the script exits with a non-zero code, tell the user: "Handoff isn't set up yet. Run /handoff init to get started." Offer to run setup now or exit. Do NOT enter Handoff mode regardless.
For /handoff init
Skip preflight. Read SKILL-setup.md and run the Guided Setup for ALL steps unconditionally.
Sidecar Mode (/handoff sidecar)
Sidecar mode lets the handoff bot join an existing external Lark group and only respond to bot-directed messages from the operator (the user whose email is in the handoff config). A message is considered bot-directed if it: (1) @-mentions the bot, (2) is a reply to a bot-sent message, or (3) is a reaction/sticker. Unlike regular handoff (which creates dedicated groups), sidecar mode works in any group the bot has been added to.
Step 1: Preflight
Run the standard preflight check. If it fails, tell the user to run /handoff init.
Step 2: Discover external groups
python3 $SKILL_SCRIPTS/handoff_ops.py discover-bot
This returns groups where the bot is a member but has no workspace: tag (i.e., not created by handoff). The output includes bot_open_id and open_id (used during activation to populate the session table).
Parse the JSON output. Let N = number of groups.
- If N == 0: Tell the user: "No external groups found. Add the bot to a Lark group first, then try again."
- If N == 1: Auto-select the group.
- If N >= 2: Ask the user to choose which group to join.
Step 3: Activate
python3 $SKILL_SCRIPTS/handoff_ops.py activate --chat-id '<CHAT_ID>' --session-model '${session_model}' --sidecar
The --sidecar flag stores sidecar_mode=1 in the session table so all scripts automatically know to apply bot interaction filtering.
Step 4: Enter sidecar-mode loop
python3 $SKILL_SCRIPTS/start_and_wait.py --session-model '${session_model}'
All sidecar-mode behavior is read from the session table: sidecar_mode (skip tabs/card, enable interaction filter), bot_open_id (@-mention matching), operator_open_id (sender filter). No --sidecar CLI flag needed — everything was stored during activation in Step 3.
Step 5: Main loop (same as regular handoff)
The main loop is identical to regular handoff mode, with one difference: only bot-directed messages from the operator are received. Use send_and_wait.py as normal — sidecar mode, operator, and interaction filtering are all automatic (read from session table). No --sidecar flag needed on send_and_wait.py.
All handoff commands work the same: "handback" to exit, heartbeats for long tasks, etc.
Entering Handoff Mode
Steps A–D: enter_handoff.py
Run the single entry-point script. It handles env resolution, session-check, group discovery, auto-selection, and activation in one shot:
python3 $SKILL_SCRIPTS/enter_handoff.py --session-model '${session_model}'
Pass --mode no-ask or --mode new when the user explicitly requests those modes.
Parse the output and extract env vars for all subsequent commands:
On any ready or already_active result, extract session_id and project_dir from the JSON and prefix every subsequent script call with them:
HANDOFF_PROJECT_DIR="<project_dir>" HANDOFF_SESSION_ID="<session_id>" python3 $SKILL_SCRIPTS/...
Each Bash call runs in a new subprocess — shell exports do not persist between calls.
Status values:
"status": "hooks_pending"— hooks were just installed but not yet loaded. Stop immediately. Tell the user: "Please exit and restart Claude Code, then run/handoff." Do NOT proceed. Do NOT explain technical details."status": "restart_required"— session env vars are missing (hooks haven't run yet). Stop immediately. Tell the user: "Please exit and restart Claude Code, then run/handoffagain." Do NOT attempt to work around this or generate the missing values manually. Do NOT explain technical details."status": "ready"— activation complete. Extractchat_id,session_id,project_dir. Proceed to Step E."status": "already_active"— this session already has a live handoff.- If the user asked for
no ask/auto: continue with the current chat (Step E). - Otherwise: ask — continue current chat, or re-run with
--mode newto get a fresh group.
- If the user asked for
"status": "choose"— all groups are occupied; Claude must ask the user:- Build options from the
groupsarray (each group: label = name[active], description = chat_id). - Add a "Create new" option.
- On selection:
- "Create new":
python3 $SKILL_SCRIPTS/handoff_ops.py create-group --existing-names-json '<JSON>'thenactivate. - Occupied group: run takeover then skip activate:
If takeover returnsHANDOFF_PROJECT_DIR="..." HANDOFF_SESSION_ID="..." python3 $SKILL_SCRIPTS/handoff_ops.py takeover --chat-id '<CHAT_ID>' --session-model '${session_model}'ok: false, re-runenter_handoff.pyand choose again.
- "Create new":
- Build options from the
Step E: Silence + send initial message + enter loop
Shortcut:
python3 $SKILL_SCRIPTS/start_and_wait.py --session-model '${session_model}'
This runs Steps E.1–E.4 automatically (silence → tabs → status card →
wait_for_reply.py). Options:
--tab-url <url>— override the URL used for tool/model tabs--skip-silence,--skip-tabs,--skip-card— skip any individual step if it already happened--timeout,--no-ws,--interval— forwarded towait_for_reply.py
Only fall back to the manual sequence below for debugging.
- Silence terminal notifications (iTerm2 only):
python3 $SKILL_SCRIPTS/iterm2_silence.py on
- Ensure handoff tabs exist and are ordered (message first, then tool/model):
python3 $SKILL_SCRIPTS/handoff_ops.py tabs-start --session-model '${session_model}'
- Send the initial handoff card (auto-resolves tool from env, model from arg):
python3 $SKILL_SCRIPTS/handoff_ops.py send-status-card start --session-model '${session_model}'
- Enter the main loop (see below).
Main Loop
CRITICAL: This is an INDEFINITE loop that NEVER exits unless the user says "handback" or a takeover occurs. After processing each message, the Bash tool call blocks waiting for the next message — you will always receive a new message to process. NEVER conclude that "handoff is live/active/ready" and stop. There is always a next message to handle.
Card titles are auto-resolved by send-status-card:
- Start:
Handoff from <tool> (<model>) - End:
Hand back to <tool>
Tool is read from HANDOFF_SESSION_TOOL env var; model from --session-model arg.
Step 1: Wait for FIRST user message (first iteration only)
This step runs only once at the start of the loop. All subsequent messages arrive via Step 4's send_and_wait.py.
python3 $SKILL_SCRIPTS/wait_for_reply.py
- Parse the JSON output. The script waits indefinitely for Claude models (timeout=0), or up to 540s for GPT models, then exits cleanly with
{"timeout": true}if no reply arrives. - If the user interrupts (Esc or Ctrl+C — the Bash tool call is rejected): ask the user "End handoff?" using AskUserQuestion (this is the one place AskUserQuestion is allowed during the loop, since the user is at the CLI). If confirmed, exit Handoff mode cleanly — send "Handing back to CLI." to Lark, then deactivate, restore notifications, stop the loop. If declined, re-enter Step 1 (call
wait_for_reply.pyagain). - If
takeover: another session has taken over. Exit Handoff mode silently (deactivate, restore notifications). Print "Handoff taken over by another session." locally. Do NOT send a Lark message (the new session will handle that). - If
timeout: no reply within timeout. Re-invokewait_for_reply.pyimmediately. This is normal idle behavior. - If replies found: concatenate all reply texts as the user's message. Proceed to Step 2.
Step 2: Check for commands
Filter command: If any reply text matches filter verbose, filter important, or filter concise (case-insensitive), update the message filter:
python3 $SKILL_SCRIPTS/handoff_ops.py set-filter <level>
Send a brief confirmation to Lark (e.g. "Filter set to verbose") and continue the loop (go back to waiting for the next message via send_and_wait.py).
Filter levels control PostToolUse forwarding to Lark:
- verbose — forward Edit + Write + Bash outputs
- important — forward Edit + Write only (skip Bash unless error)
- concise — no PostToolUse forwarding (default)
Guest & coowner commands: The owner can manage a whitelist of members who can interact with the bot. This works in both regular and sidecar mode. Detect these commands flexibly (natural language, any language):
-
Add guests: Owner mentions users with intent to grant guest access. Examples: "add @jack @alice as guest", "@jack 和 @alice 可以和你对话", "let @bob talk to you". Extract
open_idandnamefrom thementionsarray in the message, then:python3 $SKILL_SCRIPTS/handoff_ops.py guest-add --guests-json '[{"open_id":"ou_xxx","name":"Jack"},{"open_id":"ou_yyy","name":"Alice"}]'Send confirmation to Lark listing who was added.
-
Add coowners: Owner mentions users with intent to grant coowner access. Examples: "add @alice as coowner", "@alice 是 coowner", "make @bob a coowner". Extract
open_idandnamefrom mentions, then:python3 $SKILL_SCRIPTS/handoff_ops.py guest-add --role coowner --guests-json '[{"open_id":"ou_xxx","name":"Alice"}]'Send confirmation to Lark listing who was added as coowner.
-
Remove members: Owner mentions users with intent to revoke access. Examples: "remove @jack", "@alice 不要了", "revoke @bob". Extract
open_idfrom mentions, then:python3 $SKILL_SCRIPTS/handoff_ops.py guest-remove --open-ids-json '["ou_xxx"]'Send confirmation to Lark listing who was removed.
-
List members: Owner says "guests", "members", or "who has access". Run:
python3 $SKILL_SCRIPTS/handoff_ops.py guest-listSend the list to Lark (shows role for each member).
Coowner privilege rules: When a reply has "privilege": "coowner", treat it the same as owner:
- Same permissions as owner: shell, git, file edits, full project access
- Permission requests: Coowners can approve/deny permission cards (same as owner)
- Owner override: Owner can override coowner decisions. If both respond to a permission card, owner's decision takes priority.
Guest privilege rules: When a reply has "privilege": "guest", treat it as a low-privilege request:
- Allowed: Ask questions, get explanations, create new files under the temp folder (
$TMPDIR), modify temp files - NOT allowed: Run shell commands, git operations, read/modify existing project files, access secrets (.env, credentials, API keys), create files under the project directory
- Permission requests: Permission cards (Approve/Deny) are only processed from the owner and coowners. Guest button clicks are ignored.
- Owner override: Owner commands always take priority. If the owner says "stop", halt any guest-requested work immediately.
- When replying to a guest, use
--mention-user-idonsend_and_wait.pyto @-mention them:python3 $SKILL_SCRIPTS/send_and_wait.py '<response>' --mention-user-id '<guest_open_id>'
Sidecar vs regular mode: The ONLY difference between sidecar and regular mode is the bot-interaction filter (filter_bot_interactions). In sidecar mode, messages must be bot-directed (@-mention, reply to bot, or reaction/sticker). In regular mode, all messages from allowed senders are processed directly. Guest/coowner support works identically in both modes.
Handback command: If any reply text matches handback or hand back (case-insensitive), exit Handoff mode. The text may optionally include the word dissolve (e.g. "handback dissolve", "hand back dissolve") to also dissolve (delete) the chat group after ending the handoff.
Normal handback (no dissolve):
- Preferred helper:
This performs the card → tabs → deactivate → silence sequence automatically.python3 $SKILL_SCRIPTS/end_and_cleanup.py --session-model '${session_model}' - Manual steps (if the helper cannot run):
- Send handback card to Lark (before deactivating, so session is still active):
python3 $SKILL_SCRIPTS/handoff_ops.py send-status-card end --session-model '${session_model}' - Remove session tabs for this handoff (tool/model):
python3 $SKILL_SCRIPTS/handoff_ops.py tabs-end --session-model '${session_model}' - Deactivate handoff (remove session from local DB):
python3 $SKILL_SCRIPTS/handoff_ops.py deactivate - Restore terminal notifications:
python3 $SKILL_SCRIPTS/iterm2_silence.py off - Print "Handoff ended. Back to CLI." locally.
- Stop the loop.
- Send handback card to Lark (before deactivating, so session is still active):
Handback with dissolve (reply contains "dissolve"):
- Preferred helper:
python3 $SKILL_SCRIPTS/end_and_cleanup.py --session-model '${session_model}' --dissolve --body 'Handing back to CLI. Dissolving chat group...'--bodycontrols the closing text, and--dissolveruns remove-user / dissolve-chat / cleanup-sessions after deactivation. - Manual steps:
- Send handback card to Lark (before deactivating):
python3 $SKILL_SCRIPTS/handoff_ops.py send-status-card end --session-model '${session_model}' --body 'Handing back to CLI. Dissolving chat group...' - Remove session tabs for this handoff (tool/model):
python3 $SKILL_SCRIPTS/handoff_ops.py tabs-end --session-model '${session_model}' - Note the
chat_idfrom the deactivate output for the dissolve step. - Deactivate handoff (remove session from local DB):
python3 $SKILL_SCRIPTS/handoff_ops.py deactivate - Remove the user from the group (so it disappears from their chat list):
python3 $SKILL_SCRIPTS/handoff_ops.py remove-user --chat-id '<CHAT_ID>' - Dissolve the chat group and clean up any remaining sessions:
python3 $SKILL_SCRIPTS/handoff_ops.py dissolve-chat --chat-id '<CHAT_ID>' python3 $SKILL_SCRIPTS/handoff_ops.py cleanup-sessions --chat-id '<CHAT_ID>' - Restore terminal notifications:
python3 $SKILL_SCRIPTS/iterm2_silence.py off - Print "Handoff ended. Chat group dissolved. Back to CLI." locally.
- Stop the loop.
- Send handback card to Lark (before deactivating):
Step 3: Process the user's message
Treat the Lark reply as if the user typed it in the CLI. Do whatever work is needed (read files, edit code, run commands, answer questions, etc.).
No pretend-progress rule (strict):
- For any Lark message that requests action (read/edit/run/debug/review), perform real tool work before replying.
- Do not send placeholder replies like "working on it" or "still working" unless a real long-running operation is already in progress.
- If you send a progress update, include concrete activity (what command/file/task is currently running).
- Never claim the work requires switching back to CLI while handoff is active.
- If blocked, state the blocker and ask one specific question in Lark.
Thread replies (parent_id): If a reply has a parent_id, the user is replying to a specific message in the chat. Fetch the parent message to understand the context. Use a two-step approach because card (interactive) messages return degraded content from the Lark API:
- Try local lookup first — checks the local
messagestable whererecord_sent_message()stores the original text/title of every bot-sent message (including card messages):python3 $SKILL_SCRIPTS/handoff_ops.py parent-local --parent-id '<PARENT_ID>' - Fall back to Lark API — if not found locally (parent is a message from another user, not the bot):
python3 $SKILL_SCRIPTS/handoff_ops.py parent-api --parent-id '<PARENT_ID>'
Use the parent content to understand what "this", "that", or "it" refers to in the user's reply.
Image messages: If a reply has an image_key (either msg_type: "image" or a post with inline images), download the image before processing. The image_key may contain multiple comma-separated keys for posts with several inline images — download each one:
python3 $SKILL_SCRIPTS/handoff_ops.py download-image --image-key '<IMAGE_KEY>' --message-id '<MESSAGE_ID>'
Then read the downloaded image file with the Read tool to see its contents. The user may send screenshots for you to analyze, Figma designs, error screenshots, etc.
File messages: If a reply has msg_type: "file" and a file_key, download the file before processing:
python3 $SKILL_SCRIPTS/handoff_ops.py download-file --file-key '<FILE_KEY>' --message-id '<MESSAGE_ID>' --file-name '<FILE_NAME>'
Then read the downloaded file with the Read tool. The user may send code files, logs, config files, documents, etc.
Merge-forward messages: If a reply has msg_type: "merge_forward", the user has forwarded a conversation thread from another chat. Fetch the child messages using the Lark API:
python3 $SKILL_SCRIPTS/handoff_ops.py merge-forward --message-id '<MESSAGE_ID>'
Parse the JSON output — each line is one message from the forwarded thread. Present the conversation to the user as context. If any child messages contain images (msg_type: "image"), download them using the image download flow above with the child message's message_id. Summarize or analyze the thread as requested.
Heartbeat: If processing takes more than 60 seconds, send a brief status update to Lark so the user knows you're still working. Use "Working..." as the title and describe what you're doing in the body:
python3 $SKILL_SCRIPTS/send_to_group.py '<what you are working on>' --color grey --title 'Working...' --card
Send additional heartbeats every ~60 seconds for long-running tasks.
Step 4: Send your response AND wait for next message
After completing the work, send your response to Lark using send_and_wait.py. This script sends the message and blocks until the next user message arrives. Its output is the next user message (same JSON format as wait_for_reply.py).
python3 $SKILL_SCRIPTS/send_and_wait.py '<your response>'
This call does NOT return "Sent." — it blocks and returns the next user message. Parse the JSON output the same way as Step 1, then go to Step 2 with this new message.
- If
takeover: exit Handoff mode silently (same as Step 1). - If
timeout: the message was already sent. Callwait_for_reply.pyto resume waiting (no re-send needed). - If the user interrupts (Esc/Ctrl+C): the message was already sent. Ask "End handoff?" same as Step 1.
- If replies found: concatenate all reply texts as the user's message. Go to Step 2.
Format options (same flags as send_to_group.py):
- Markdown card (default, no
--card): Use for ALL conversational responses — answers, explanations, questions, confirmations, analysis results. This is the default. - Status card (
--card): Use for brief system messages only. For heartbeats during Step 3, usesend_to_group.pyinstead (no wait needed). - Form card: For option selections and text input, use
handoff_ops.py send-form-select/send-form-inputfollowed bywait_for_reply.py --timeout 0to get the form response. Then continue processing and usesend_and_wait.pyfor the final response.
When including code blocks in messages, use 2-space indentation for readability on mobile.
Keep the message concise — Lark has size limits. For long output, summarize and mention the user can check the CLI for full details.
Important Notes
- ALL communication goes through Lark. Every response, question, confirmation, and status update MUST be sent to the Lark thread. The CLI shows the same output for local reference, but the user is reading Lark.
- Execution priority in the loop. If a Lark reply requests real work (code edits, debugging, file reads, commands, review, analysis), complete Step 3 work first, then send results in Step 4. Do not reduce these to keepalive/status-only replies.
- Keepalive scope is narrow. "Continue loop"/"keep waiting" behavior is for idle periods only. It must not override pending user tasks from Lark.
- No false CLI requirement. While handoff is active, do not claim that coding requires switching back to CLI. Perform code work directly in the active handoff session.
- NEVER use AskUserQuestion or EnterPlanMode during the handoff loop. These tools show prompts only in the CLI, which the user cannot see during Handoff mode. Instead, send your question to Lark via
send_and_wait.py(which also waits for the reply). Format questions with numbered options so the user can reply with just a number. (Note: AskUserQuestion IS used during CLI-mode setup in Steps 1-4 of the Guided Setup, which runs before entering the handoff loop.) - Confirmations and permissions: Before any destructive or irreversible action (git push, merge, delete, etc.), send a confirmation message to Lark and wait for the user's reply. Do NOT proceed without explicit Lark confirmation.
- Option selections — Choose the right format:
- 2 options (yes/no, approve/deny): Use button cards (
build_cardwithbuttons). Quick tap, no submit needed. - 3+ options: Use form cards (
build_form_cardwithselects). Dropdown menus let the user pick cleanly. Always list the options as text in the card body so the user can see them without opening the dropdown:
The callback arrives aspython3 $SKILL_SCRIPTS/handoff_ops.py send-form-select --title '<TITLE>' --body '**Options:\n1. Option A — description\n2. Option B — description\n3. Option C — description' --field-name choice --placeholder 'Select...' --options-json '[["Option A","a"],["Option B","b"],["Option C","c"]]'msg_type: "form_action"withtextcontaining the selected value (or JSON for multiple fields). Add--cancel-label Cancelto show a Cancel button below the form. If clicked, the callback arrives asmsg_type: "button_action"withtext: "__cancel__". - Fallback: If the user replies with a text number instead of using the card, parse that too.
- 2 options (yes/no, approve/deny): Use button cards (
- Collecting text input — When you need the user to type something (commit message, search query, etc.), use a form card with input fields:
The callback arrives aspython3 $SKILL_SCRIPTS/handoff_ops.py send-form-input --title '<TITLE>' --body '<PROMPT_TEXT>' --field-name '<FIELD_NAME>' --placeholder '<PLACEHOLDER>'msg_type: "form_action"with the typed value. The user can also just reply with a text message instead of using the card. - If a task requires multiple tool calls, do them all, then send one consolidated response to Lark (not one message per tool call).
- Timeout handling: Both
wait_for_reply.pyandsend_and_wait.pydefault to 540s for GPT models, 0 (infinite) for everything else and exit cleanly with{"timeout": true}when no reply arrives.- CRITICAL: On timeout, immediately re-invoke the script WITHOUT sending any status message to the user. This is normal idle behavior, NOT a signal to exit.
- ABSOLUTE RULE: Do NOT send "waiting" or "still here" messages during idle periods. Stay silent and continue waiting.
- EXCEPTION: Only send status updates for: (a) meaningful state changes, (b) explicit user requests, or (c) long-running work in progress (>60s tasks).
- For
send_and_wait.pytimeouts, the message was already sent — callwait_for_reply.pyto resume waiting only (no re-send).
- "Send XX to me" = file attachment. When the user asks to send something that is a file (e.g. "把 SKILL.md 发给我"), upload it as a Lark file attachment using
lark_im.upload_file()and send as a file message. Do NOT paste the file content as text. - Commit hash links. When mentioning commit hashes in Lark messages, format them as clickable links to the GitHub commit page:
hash. Derive the org/repo fromgit remote get-url origin. - Reaction routing. When
send_to_group.pysends a message, it automatically registers the message with the worker viaregister_message(). This allows the worker to route emoji reactions on bot messages back to the handoff session. Reactions arrive asmsg_type: "reaction"withtextcontaining the emoji type (e.g."THUMBSUP"). No special handling is needed — this works automatically. - Sticker & reaction etiquette. Be natural, never robotic.
- Receiving a reaction (e.g. 👍 on your message): this is just acknowledgment — usually no reply needed. Never echo the same sticker back.
- Receiving a sticker message: respond based on context. A thumbs-up after you reported completion means "good job, carry on" — silence or continuing work is the right answer. Only reply if there's something meaningful to say.
- Sending reactions proactively: react to the user's messages to show you're engaged. Use
lark_im.add_reaction(token, message_id, emoji_type). Common types and when to use them:THUMBSUP— acknowledge a request, confirm understandingDONE— task completed successfullyMUSCLE— about to tackle something challengingOK— simple acknowledgment, got itLAUGH/LOL— lighthearted moment, something funnyFACEPALM— you made a silly mistakeTHINKING— analyzing a complex problemLOVE/FINGERHEART— user did something helpful or kindAPPLAUSE— celebrating a milestone or good newsSOB— something went wrong, empathizing with frustrationJIAYI(+1) — agree with the user's suggestion
- Sending sticker replies: use
lark_im.reply_sticker(token, message_id, file_key)for richer expression. Discover file_keys from sticker messages the user sends (thefile_keyis in the message payload). Cache and reuse ones you've seen. - Use stickers/reactions sparingly but naturally. Matching sticker-for-sticker is cringe. A well-timed reaction says more than a text reply.
- The workspace ID is computed automatically by all scripts via
lark_im.get_workspace_id(). It identifies the physical code location (machine + folder path).
Architecture
You (Lark) --> Lark Event --> Cloudflare Worker (Durable Objects)
^
Claude Code connects via WebSocket | (HTTP long-poll fallback)
Claude Code sends responses via Lark IM API --> Lark Group
Three components work together:
- Lark App — Bot that sits in your group chat, receives events, and sends messages via IM API
- Cloudflare Worker — Receives Lark webhook events and card action callbacks, stores replies in Durable Objects. Delivers replies via WebSocket (preferred, using Hibernation API) or HTTP long-polling (fallback)
- Claude Code hooks & scripts — Notification, permission bridge, session lifecycle hooks, and handoff mode skill
Troubleshooting
Worker unreachable
- Verify the
worker_urlin~/.handoff/config.jsonis correct - Test:
curl -s -H "Authorization: Bearer <your-api-key>" 'https://<your-worker>.workers.dev/poll/test?timeout=1' - Should return:
{"replies":[],"count":0}(after ~1 second) - If you get
Unauthorized, check thatworker_api_keyin the config matches theAPI_KEYworker secret
Image download returns JSON error
- The image API requires the message resource endpoint, not the image endpoint
- Correct:
/im/v1/messages/{message_id}/resources/{image_key}?type=image - Wrong:
/im/v1/images/{image_key}(returns permission error) - Ensure
im:resourcescope is granted to the app
No replies received
- Check that the Lark app's event subscription URL points to your worker (
/webhook) - Verify the card callback URL is configured (
/card-action) - Verify the bot is added to the group chat
- Ensure you're sending messages in the handoff group (each project gets its own group)
- Check worker logs:
npx wrangler tail
Token errors
- Credentials in
~/.handoff/config.jsonmay have wrongapp_id/app_secret - Tokens expire every 2 hours; the scripts auto-refresh them
File Reference
| File | Purpose |
|---|---|
.claude/skills/handoff/SKILL.md | Handoff mode skill definition |
.claude/skills/handoff/SKILL-setup.md | Guided setup wizard instructions |
.claude/skills/handoff/SKILL-commands.md | Sub-command implementations (chats, status, deinit, etc.) |
.claude/skills/handoff/hooks.json | Canonical hook definitions (single source of truth) |
.claude/skills/handoff/scripts/lark_im.py | Lark IM API client (token, send, reply, download, polling) |
.claude/skills/handoff/scripts/on_notification.py | Notification hook (sends Lark messages) |
.claude/skills/handoff/scripts/on_post_tool_use.py | PostToolUse/PostToolUseFailure hook (forwards tool outputs and errors to Lark) |
.claude/skills/handoff/scripts/on_pre_compact.py | PreCompact hook (warns Lark chat when context is compacting) |
.claude/skills/handoff/scripts/on_pre_tool_use_bash.py | PreToolUse hook — approves Bash only when handoff is active |
.claude/skills/handoff/scripts/permission_bridge.py | Permission bridge hook (Approve/Deny via Lark) — Claude Code |
.claude/skills/handoff/scripts/permission_core.py | Shared permission bridge polling/decision core (Claude + OpenCode) |
.claude/skills/handoff/scripts/worker_http.py | Shared urllib worker poll/ack helpers (OpenCode + utilities) |
.claude/skills/handoff/scripts/on_session_start.py | Session start hook (detects active handoff) |
.claude/skills/handoff/scripts/on_session_end.py | Session end hook (notifies Lark, cleans up) |
.claude/skills/handoff/scripts/send_to_group.py | Send a message to the handoff group (fire-and-forget, for heartbeats/status) |
.claude/skills/handoff/scripts/send_and_wait.py | Send response AND wait for next reply (main loop Step 4) |
.claude/skills/handoff/scripts/wait_for_reply.py | Wait for new replies (first iteration only, or after send_and_wait timeout) |
.claude/skills/handoff/scripts/handoff_ops.py | Deterministic helper commands replacing inline python snippets |
.claude/skills/handoff/scripts/run_tests.py | One-command handoff test runner for CI/local checks |
.claude/skills/handoff/scripts/iterm2_silence.py | Toggle iTerm2 terminal notifications |
.claude/skills/handoff/scripts/preflight.py | Preflight verification |
.claude/skills/handoff/worker/src/index.js | Cloudflare Worker (webhook + card callback + Durable Objects) |
.claude/skills/handoff/worker/wrangler.toml | Worker deployment config |
~/.handoff/config.json | Credentials and config (app_id, app_secret, worker_url, worker_api_key, email) |
~/.handoff/projects/<project>/handoff-data.db | SQLite database (handoff state + bidirectional message history) |
${HANDOFF_TMP_DIR:-/tmp/handoff}/handoff-images/ | Downloaded images |
${HANDOFF_TMP_DIR:-/tmp/handoff}/handoff-files/ | Downloaded files |
.opencode/plugins/handoff.ts | OpenCode plugin (permission bridge, lifecycle, notifications, env injection) |
.opencode/scripts/permission_bridge.py | Permission bridge script called by the OpenCode plugin |
.opencode/scripts/handoff_tool_forwarding.js | Tool output forwarding script used by the OpenCode plugin |