Skip to content

feat: add lifecycle hooks (Stop, SessionStart, SessionEnd, UserPromptSubmit) to headless mode #1282

@cpfiffer

Description

@cpfiffer

Summary

Lifecycle hooks (Stop, UserPromptSubmit, SessionStart, SessionEnd) only fire in the interactive CLI (App.tsx). The headless mode (headless.ts) -- used by SDK subprocess consumers like lettabot -- has zero integration for these hooks.

Tool hooks already work in headless. PreToolUse, PostToolUse, and PostToolUseFailure are wired in tools/manager.ts and cli/helpers/accumulator.ts, which execute regardless of mode. This issue is only about the lifecycle hooks.

Motivation

Community user running 3 lettabot instances on a Raspberry Pi wants to POST each agent exchange to a local analytics server. The Stop hook already provides the right input data (user_message, assistant_message, preceding_reasoning), but it never fires because lettabot's SDK subprocess runs in headless mode.

This blocks any SDK consumer from using lifecycle hooks for analytics, logging, policy enforcement, or external integrations.

Ref: letta-ai/lettabot#494

Current State

Hook Interactive (App.tsx) Headless (headless.ts)
PreToolUse Yes (via tools/manager.ts) Yes (same path)
PostToolUse Yes (via tools/manager.ts + accumulator.ts) Yes (same path)
PostToolUseFailure Yes (via tools/manager.ts) Yes (same path)
Stop Yes (App.tsx:4491) No
UserPromptSubmit Yes (App.tsx:6755) No
SessionStart Yes (App.tsx:1694) No
SessionEnd Yes (App.tsx:1716) No
Notification Yes No (may not apply)

Implementation Notes

All hook infrastructure already exists in src/hooks/. The runner functions (runStopHooks, runSessionStartHooks, etc.) have clean APIs with workingDirectory parameters that default to process.cwd().

Stop hook (highest priority)

Two integration points in headless.ts:

  1. Bidirectional mode (runBidirectionalMode): After the approval loop breaks with end_turn (~line 3270), before emitting ResultMessage (~line 3504). The buffers already have assistant message, reasoning, and user message -- same extraction pattern as App.tsx:4474-4488.

  2. One-shot mode: After `stopReason === "end_turn"\ (~line 1793), before emitting the result.

Blocking semantics (exit code 2): In bidirectional mode, send hook feedback as a user message and continue the conversation loop (same as App.tsx). One-shot mode already has a turn loop for requires_approval; Stop hook blocking is analogous.

SessionStart / SessionEnd

Fire-and-forget (cannot block). Add after agent resolution / on process exit.

UserPromptSubmit (bidirectional only)

After parsing user input (~line 2969), before sending to agent. If blocked, emit error event and skip.

Scope

~50-80 lines added to headless.ts. Single file change, no new infrastructure.

Working Directory Note

Hooks load from process.cwd() by default. In SDK subprocess context, cwd is whatever the consumer sets. Global hooks (~/.letta/settings.json) work regardless. Project hooks require .letta/settings.json in the subprocess cwd.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions