Skip to main content

Overview

Hooks are executable scripts that AI tools call at specific lifecycle points. hcom provides hooks for Claude Code, Gemini CLI, Codex, and OpenCode.

Hook Flow

The hook system bridges tool execution with the hcom event database:

Hook Types by Tool

Claude Code Hooks

Claude hooks read from stdin and are installed at ~/.claude/hooks/:
When: Session initializationPayload:
{
  "session_id": "sess-abc",
  "transcript_path": "/tmp/claude-transcript.jsonl",
  "agent_id": "agent-123"
}
Actions:
  • Bind session to instance name
  • Initialize instance in DB
  • Inject bootstrap message
  • Set status to “listening”

Gemini CLI Hooks

Gemini hooks read from stdin and are installed at ~/.gemini/hooks/:
Same as Claude sessionstart.

Codex Hooks

Codex hook reads from argv (JSON payload as arg) and is installed at ~/.codex/hooks/notify.sh:
When: After agent turn completesPayload (argv[2]):
{
  "type": "agent-turn-complete",
  "thread-id": "thread-abc",
  "turn-id": "turn-123",
  "cwd": "/home/user/project"
}
Actions:
  • Bind thread-id to instance
  • Set status to “listening”
  • Update heartbeat

OpenCode Hooks

OpenCode hooks are native Rust functions, not shell scripts:
When: Session startActions:
  • Bind session to instance
  • Initialize instance
  • Return bootstrap message

Hook Results

Hooks return results via exit code and stdout:

Allow (exit 0)

Operation proceeds normally:
{
  "additionalContext": "<hcom>[new message #42] nova → luna: hello</hcom>",
  "systemMessage": "Updated system context"
}

Block (exit 2)

Operation blocked (used for message delivery):
{
  "reason": "<hcom>[request #42] nova → luna: can you help?</hcom>"
}
Claude/Gemini interpret this as “wait for user input” and inject the reason text.

Update Input (exit 0)

Modify tool arguments before execution:
{
  "updatedInput": {
    "command": "modified command"
  }
}

Hook Payload Normalization

Each tool has different JSON formats. The HookPayload struct normalizes them:
pub struct HookPayload {
    pub session_id: String,
    pub transcript_path: String,
    pub hook_name: String,
    pub tool: String,
    pub tool_name: String,
    pub tool_input: Value,
    pub tool_result: String,
    pub notification_type: String,
    pub raw: Value,
}

// Build from tool-specific JSON
let payload = HookPayload::from_claude(&raw);
let payload = HookPayload::from_gemini(&raw);
let payload = HookPayload::from_codex(&raw);
let payload = HookPayload::from_opencode(&raw);

Hook Installation

Automatic Installation

Hooks are installed on first launch:
hcom claude  # Installs ~/.claude/hooks/ if missing
hcom gemini  # Installs ~/.gemini/hooks/ if missing
hcom codex   # Installs ~/.codex/hooks/notify.sh if missing

Manual Installation

# Add hooks for specific tool
hcom hooks add claude
hcom hooks add gemini
hcom hooks add codex
hcom hooks add opencode  # Config file only

# Add all hooks
hcom hooks add all

# Check status
hcom hooks status

# Remove hooks
hcom hooks remove claude
hcom hooks remove all

Hook Locations

~/.claude/hooks/
├── pre
├── post
├── poll
├── notify
├── sessionstart
├── sessionend
├── userpromptsubmit
├── subagent-start
└── subagent-stop

Hook Context

Hooks access hcom via environment variables:
HCOM_PROCESS_ID     # Unique process identifier
HCOM_LAUNCHED       # "1" if launched by hcom
HCOM_LAUNCHED_BY    # Name of launcher agent
HCOM_LAUNCH_BATCH_ID # Batch identifier
HCOM_LAUNCH_EVENT_ID # Event cursor at launch
HCOM_DIR            # Override default ~/.hcom
HCOM_SID            # Session ID (for subagents)

Shared Hook Functions

Core hook logic is shared across tools:

Identity Initialization

pub fn init_hook_context(
    db: &HcomDb,
    payload: &HookPayload,
) -> Result<String> {
    // Bind session to process
    let name = bind_session_to_process(
        db,
        payload.session_id_opt(),
        std::env::var("HCOM_PROCESS_ID").ok().as_deref(),
    ).unwrap_or_else(|| {
        // Create new instance
        generate_unique_name(db).unwrap()
    });
    
    // Initialize instance fields
    initialize_instance_in_position_file(
        db,
        &name,
        payload.session_id_opt(),
        None, // parent_session_id
        None, // parent_name
        None, // agent_id
        payload.transcript_path_opt(),
        Some(&payload.tool),
        false, // background
        None, // tag
        None, // wait_timeout
        None, // subagent_timeout
        None, // hints
    );
    
    Ok(name)
}

Message Delivery

pub fn deliver_pending_messages(
    db: &HcomDb,
    name: &str,
) -> Option<String> {
    let messages = db.get_unread_messages(name);
    if messages.is_empty() {
        return None;
    }
    
    let formatted = format_messages_json(
        &messages,
        name,
        &|n| db.get_instance_full(n).ok().flatten().map(|d| /* ... */),
        &|| Config::get().hints.unwrap_or_default(),
        Some(&|inst, tip_key| {
            let seen = db.kv_get(&format!("tip:{}:{}", inst, tip_key))
                .ok()
                .flatten()
                .is_some();
            let mark = Box::new(|| {
                let _ = db.kv_set(&format!("tip:{}:{}", inst, tip_key), Some("1"));
            });
            (seen, mark)
        }),
    );
    
    Some(formatted)
}

Bootstrap Injection

pub fn inject_bootstrap_once(
    db: &HcomDb,
    name: &str,
) -> Option<String> {
    // Check if already announced
    if let Ok(Some(data)) = db.get_instance_full(name) {
        if data.name_announced != 0 {
            return None;
        }
    }
    
    // Mark as announced
    db.update_instance_fields(name, &json!({"name_announced": 1}));
    
    // Build bootstrap message
    let welcome = format!(
        "You are connected to hcom as @{}. \
         Other agents can send you messages with `hcom send @{}`. \
         You can send messages with `hcom send @<name> -- <message>`.",
        name, name
    );
    
    Some(welcome)
}

Session Finalization

pub fn finalize_session(
    db: &HcomDb,
    name: &str,
    reason: &str,
) {
    // Log stopped event
    let _ = db.log_event("life", name, &json!({
        "action": "stopped",
        "reason": reason
    }));
    
    // Set status inactive
    let _ = db.set_status(name, "inactive", reason);
}

Hook Performance

Execution Time

  • Rust native: < 50ms (open DB, query, close)
  • Stdin parsing: < 10ms (JSON deserialization)
  • Event logging: < 5ms (SQLite insert)
  • Message formatting: < 20ms (DB query + string ops)

Optimization

  • Connection reuse: WAL mode allows concurrent reads
  • Index usage: Queries use (type, instance) index
  • Lazy formatting: Only format messages when needed
  • Batch operations: Group DB writes in single transaction

Hook Development

Creating hooks for new tools:
1

Define hook points

Identify tool lifecycle events (start, stop, before/after tool)
2

Create HookPayload constructor

Add from_{tool}() method to normalize tool JSON
3

Implement dispatchers

Add hook handlers in src/hooks/{tool}.rs
4

Register hooks

Add hook names to router registry
5

Add installation

Update hcom hooks add {tool} command
Example hook handler:
pub fn handle_tool_hook(hook_name: &str) -> i32 {
    // Read payload
    let payload = read_hook_payload_stdin();
    let payload = HookPayload::from_tool(&payload);
    
    // Open DB
    let db = HcomDb::open().unwrap();
    db.ensure_schema().unwrap();
    
    // Initialize identity
    let name = init_hook_context(&db, &payload).unwrap();
    
    // Hook-specific logic
    match hook_name {
        "start" => handle_start(&db, &name, &payload),
        "stop" => handle_stop(&db, &name, &payload),
        "before" => handle_before(&db, &name, &payload),
        "after" => handle_after(&db, &name, &payload),
        _ => HookResult::Allow { 
            additional_context: None,
            system_message: None 
        }
    };
    
    // Return exit code
    result.exit_code()
}

Build docs developers (and LLMs) love