UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

350 lines (297 loc) • 18.7 kB
--- summary: "Override the model, shape the prompt, and persist plugin-owned session state" read_when: - You are overriding the provider or model for an agent turn - You are adding prompt context or narrowing the tool surface for a turn - You need authorized post-policy prompt enrichment - You are persisting session extensions or queuing next-turn injections title: "Prompt and session hooks" sidebarTitle: "Prompt and session" --- Model resolution, prompt construction, finalization, and durable plugin-owned session state. Part of the [Plugin hooks](/plugins/hooks) guide. ## Debug runtime hooks Use `before_model_resolve` to switch provider or model for an agent turn - it runs before model resolution. `llm_output` describes an attempt's output when the runtime emits it; `assistantTexts` can be empty and `lastAssistant` absent, so the event alone does not prove a successful final answer. For proof of the effective session model, inspect runtime registrations, then use `openclaw sessions` or the Gateway session/status surfaces. To debug provider payloads, start the Gateway with `--raw-stream` and `--raw-stream-path <path>` to write raw model stream events to a jsonl file. ## Prompt and model hooks Use the phase-specific hooks for new plugins: - `before_model_resolve`: receives only the current prompt and attachment metadata. Return `providerOverride` or `modelOverride`. - `agent_turn_prepare`: receives the current prompt, prepared session messages, and queued injections consumed for this session. Return `prependContext` or `appendContext`. - `before_prompt_build`: receives the prepared prompt and session messages. Harnesses may also supply `currentUserMessage`, the current request before history/context projection, and `currentUserMessageId`, its native admission identity. The ID stays stable across rebuilds and retries of one admitted request and differs between admissions. Use the explicit request for intent detection when available; `prompt` may contain reconstructed history. Do not parse envelope markers to recover request boundaries. An explicit empty string means no textual request, including image-only input or a continuation without a retained request. It must not fall back to history. Codex runtime refresh retains the original recorder's text and identity. Without a recorder, Codex supplies current text but no admission ID; equal text and a correlation run ID alone do not identify an admission. Omitted fields preserve existing harness behavior. Return `prependContext`, `appendContext`, `systemPrompt`, `prependSystemContext`, `appendSystemContext`, or `toolsAllow`. `toolsAllow` can only narrow the host-resolved tool surface for the current turn; `[]` submits no optional tools, while omitting it leaves the existing surface unchanged. Restrictions returned by multiple hooks are intersected. The embedded runner and Copilot harness apply this field to their turn-scoped submitted tool surfaces. The Codex app-server harness rejects restrictive values because its dynamic tools are thread-scoped and Codex `turn/start` has no tool-surface override; use the embedded or Copilot runtime when a plugin requires this policy. - `before_prompt_build` with `{ requiresToolAuthority: true }`: runs in a second, post-policy phase. Use it when prompt enrichment reads data through a tool-backed capability and the same turn must be allowed to call that tool. See [Authorized prompt enrichment](/plugins/hooks/prompt-and-session#authorized-prompt-enrichment). - `heartbeat_prompt_contribution`: runs only for heartbeat turns and returns `prependContext` or `appendContext`. Intended for background monitors that need to summarize current state without changing user-initiated turns. On the embedded and CLI prompt-preparation paths, ordering is: drain queued injections → `agent_turn_prepare` → heartbeat contribution (if applicable) → ordinary `before_prompt_build` → finalized tool policy → authorized prompt enrichment. `agent_turn_prepare` and queued-injection draining are not wired into the Codex or Copilot prompt paths. For multiple registrations, the first defined provider/model override and `systemPrompt` win. Context additions concatenate in priority order, and tool restrictions intersect. A nested ordinary `before_prompt_build` dispatch on the same runner is skipped while its outer dispatch is active; other hook families and independent turns remain available. Message-consuming prompt hooks receive a detached model-context snapshot. Mutating nested messages does not change the caller's history, including when a handler retains its input after returning. Registrations within one dispatch share that snapshot in priority order; prepare, ordinary prompt-build, authorized enrichment, and subsequent prompt rebuilds receive separate snapshots. Storage-only native prompt text and tool-result details are excluded from these snapshots. ### Handler lifetime Each `before_prompt_build` handler receives a read-only `ctx.hookInvocation.assertActive()` capability in both ordinary and authorized prompt phases. It throws after the runner stops awaiting that individual handler, including timeout, return, or error, before the next handler starts. Call it after awaited work and immediately before a synchronous side effect whose result must still be eligible for this hook invocation. Other handlers have independent capabilities, even within the same prompt dispatch. This capability checks only the handler's result-acceptance lifetime. It grants no tool authorization, does not cancel underlying work, and does not guarantee that a later model call consumes the context. The field is optional for SDK compatibility; a plugin that requires it must handle an unsupported host explicitly instead of substituting its own copy of the timeout budget. ### Authorized prompt enrichment Register `before_prompt_build` with `requiresToolAuthority: true` when a plugin must verify the finalized per-turn tool policy before retrieving context: ```typescript api.on( "before_prompt_build", async (event, ctx) => { const authority = ctx.toolAuthority; if (!authority?.allows("memory_search")) { return; } const recalledContext = await recallForPrompt(event.prompt); authority.assertActive(); return { prependContext: recalledContext }; }, { requiresToolAuthority: true }, ); ``` The host excludes this handler from the ordinary prompt-build phase. After all ordinary hooks and tool restrictions settle, a supported runtime invokes it with `ctx.toolAuthority` bound to that exact active turn and finalized tool surface. Embedded, CLI, Copilot, and Codex runtimes support this phase. If a runtime cannot prove the authority, it skips the handler. Treat `toolAuthority` as an ephemeral capability: - `allows(toolName)` checks a canonical tool id against the finalized surface and also verifies that the capability is still active. - `assertActive()` rejects after abort, cancellation, run replacement, lifecycle rotation, or hook dispatch completion. Call it after awaited work and before committing plugin-owned side effects. - `fingerprint` is opaque cache-partitioning input. It is not a bearer token or authorization proof; never persist, transmit, or compare it as authority. - Return only `prependContext` or `appendContext` from this phase. It cannot replace the system prompt or change `toolsAllow` after policy has settled. The host revalidates authority after each awaited handler and discards stale enrichment. A retained `toolAuthority` object fails closed after dispatch. This option requires a host that implements the post-policy phase. Published plugins must set `package.json` `openclaw.compat.pluginApi` to a range beginning with the first OpenClaw version they build against for this contract. Older hosts skip incompatible packages during discovery and reject incompatible installs or updates. Do not publish a package that uses this option while claiming compatibility with an older plugin API; an older host may otherwise treat an unknown option as an ordinary pre-policy hook. On the embedded and CLI runners, `before_agent_run` runs after prompt construction and before model submission, including `llm_input` observation. On the embedded path it also precedes prompt-local image loading. It receives the current user input as `prompt`, plus loaded session history in `messages` and the active system prompt. Return `{ outcome: "block", reason, message? }` to stop the run before the model reads the prompt. `reason` is internal; `message` is the user-facing replacement. Only `pass` and `block` outcomes are supported; unsupported decision shapes fail closed. When a run is blocked, OpenClaw stores only the replacement text in `message.content` plus non-sensitive block metadata such as the blocking plugin id and timestamp. The original user text is not retained in transcript or future context. Internal block reasons are treated as sensitive and excluded from transcript, history, broadcast, log, and diagnostics payloads. Observability should use sanitized fields such as blocker id, outcome, timestamp, or a safe category. Hooks that expose `event.runId`, such as `agent_end` and `before_agent_finalize`, receive it when OpenClaw can identify the active run; the same value is also on `ctx.runId`. Prompt hooks do not all have an event `runId` field, so use their typed context for correlation. Cron-driven runs can also expose `ctx.jobId` (the originating cron job id) when supplied by the emitter, so hooks can scope metrics, side effects, or state to a specific scheduled job. Do not assume every agent event carries it. `ctx.jobId` is not part of the `before_tool_call` tool context. For `before_prompt_build`, `ctx.inputProvenance` carries the host-classified origin of the turn's user-role input on the embedded, CLI, Codex, and Copilot prompt paths. Its `kind` is `external_user`, `inter_session`, or `internal_system`. Optional source fields are `originSessionId`, `sourceSessionKey`, `sourceChannel`, and `sourceTool`. The field is optional. It is absent when the producer does not supply a classification, including ordinary human turns on some paths. Absence does not prove human origin. Other hook events may omit it even when prompt hooks receive it. `ctx.trigger === "user"` is a run trigger, not an origin classification. Inter-session deliveries such as `sessions_send`, `subagent_settle`, and `subagent_announce` can retain that trigger. Use typed provenance to distinguish those inputs; do not parse prompt prefixes. Provenance describes origin and does not grant authority to use tools or access another session. For channel-originated runs, `ctx.channel` and `ctx.messageProvider` identify the provider surface such as `discord` or `telegram`, while `ctx.channelId` is the conversation target identifier when OpenClaw can derive one from the session key or delivery metadata. When sender identity is available, agent hook contexts also include: - `ctx.senderId` - channel-scoped sender ID (e.g. Feishu `open_id`, Discord user ID). Populated when the run originates from a user message with known sender metadata. - `ctx.chatId` - transport-native conversation identifier (e.g. Feishu `chat_id`, Telegram `chat_id`). Populated when the originating channel provides a native conversation ID. - `ctx.channelContext.sender.id` - the same sender ID as `ctx.senderId`, under a channel-owned object plugins can extend with channel-specific fields. - `ctx.channelContext.chat.id` - the same conversation ID as `ctx.chatId`, under a channel-owned object plugins can extend with channel-specific fields. Core only defines the nested `id` fields. Channel plugins that pass richer sender or chat metadata through the inbound helper can augment `PluginHookChannelSenderContext` or `PluginHookChannelChatContext` from `openclaw/plugin-sdk/channel-inbound`: ```ts declare module "openclaw/plugin-sdk/channel-inbound" { interface PluginHookChannelSenderContext { unionId?: string; userId?: string; } } ``` Channel plugins pass those fields through the inbound SDK helper: ```ts buildChannelInboundEventContext({ // ... channelContext: { sender: { id: senderOpenId, unionId, userId }, chat: { id: chatId }, }, }); ``` These fields are optional and absent for system-originated runs (heartbeat, cron, exec-event). `ctx.senderExternalId` remains as a deprecated source-compatibility field for older plugins. Core does not populate it; new channel-specific sender identities should live under `ctx.channelContext.sender` through module augmentation. `agent_end` is an observation hook. Channel-backed paths generally run it fire-and-forget after the turn, while local one-shot paths can wait for the hook promise before process cleanup so trusted plugins can flush terminal observability or capture state. The hook runner applies a 30 second default per-handler timeout so a wedged plugin or embedding endpoint cannot leave the hook promise pending forever. A timeout is logged and OpenClaw continues; it does not cancel plugin-owned network work unless the plugin also uses its own abort signal. Use `model_call_started` and `model_call_ended` for provider-call telemetry that should not receive raw prompts, history, responses, headers, request bodies, or provider request IDs. These hooks include stable metadata such as `runId`, `callId`, `provider`, `model`, optional `api`/`transport`, terminal `durationMs`/`outcome`, and `upstreamRequestIdHash` when OpenClaw can derive a bounded provider request-id hash. When the runtime has resolved context-window metadata, the hook event and context also include `contextTokenBudget`, the effective token budget after model configuration, fixed model contracts, and runtime discovery, plus `contextWindowSource` and `contextWindowReferenceTokens` when a lower cap was applied. These provider-call hooks are emitted only by the embedded model-call path. A harness exposing `llm_input` / `llm_output` does not automatically expose the same provider-call telemetry. In external harnesses, LLM events describe adapter-visible input and output, not necessarily the raw provider request or complete native history. `before_agent_finalize` runs only when a harness is about to accept a natural final assistant answer. It is not the `/stop` cancellation path and does not run when the user aborts a turn. Return `{ action: "revise", reason }` to ask the harness for one more model pass before finalization, `{ action: "finalize", reason? }` to force finalization, or omit a result to continue. Handlers have a 15s default budget; on timeout, OpenClaw logs the failure and keeps decisions from other handlers. With no revision decision, normal finalization continues. Multiple `revise` reasons are combined; any `finalize` decision overrides revision requests. This hook requires a finalization integration: the embedded runner and native hook relay provide it, but the Copilot harness does not dispatch it. Codex native `Stop` hooks are relayed into this hook as OpenClaw `before_agent_finalize` decisions. When returning `action: "revise"`, plugins can include `retry` metadata to bound repeated revision requests within a run: ```typescript type BeforeAgentFinalizeRetry = { instruction: string; idempotencyKey?: string; maxAttempts?: number; }; ``` `instruction` is appended to the revision reason sent to the harness. `idempotencyKey` lets the host count retries across equivalent finalize decisions within a run; without a key, it hashes the instruction. `maxAttempts` defaults to one extra pass for that key. Use a plugin-specific key to avoid sharing a budget with another plugin. A harness can apply a tighter overall revision limit; the embedded runner allows at most three. Conversation access and prompt mutation have separate permission gates; see [Permissions and scope](/plugins/hooks#permissions-and-scope) before enabling these hooks. ### Session extensions and next-turn injections Workflow plugins can persist small JSON-compatible session state with `api.session.state.registerSessionExtension(...)` and update it through the Gateway `sessions.pluginPatch` method. Session rows project registered extension state through `pluginExtensions`, letting Control UI and other clients render plugin-owned status without learning plugin internals. `api.registerSessionExtension(...)` still works but is deprecated in favor of the `api.session.state` namespace. Use `api.session.workflow.enqueueNextTurnInjection(...)` when a plugin needs durable context queued for the next prompt build (the top-level `api.enqueueNextTurnInjection(...)` is a deprecated alias with the same behavior). On the embedded and CLI prompt-preparation paths, OpenClaw drains queued injections before prompt hooks. It drops expired entries and entries whose plugin is inactive or has prompt injection disabled. `idempotencyKey` deduplicates unexpired pending entries for the same plugin and session; the key can be reused after consumption. Drained entries are reused across retries within the active run, but consuming an entry is not a receipt that the model saw it: a later failure can prevent submission. This is the right seam for approval resumes, policy summaries, background monitor deltas, and command continuations that should be visible to the model on the next turn but should not become permanent system prompt text. Pass `agentId` with an unscoped `sessionKey`, such as `global`, when multiple agents are configured. Enqueueing, consumption, and plugin session state stay in that agent's store; the owner selector is not part of the persisted injection. Consumption stays bound to the selected stored session. A reset or a conflicting legacy and qualified identity prevents the drain from consuming another conversation's queue. Reading or consuming a legacy alias does not rename its stored session key. Cleanup semantics are part of the contract. Session extension cleanup and runtime lifecycle cleanup callbacks receive `reset`, `delete`, `disable`, or `restart`. The host removes the owning plugin's persistent session extension state and pending next-turn injections for reset/delete/disable; restart keeps durable session state while cleanup callbacks let plugins release scheduler jobs, run context, and other out-of-band resources for the old runtime generation. Disable cleanup preserves model-locked sessions owned by that plugin's harness. Restart preserves extension state and pending injections, but can clear stale promoted top-level session fields.