Skip to content

Feature request: let authored hooks decide (pre-tool gate, post-tool feedback, turn continuation) #2673

Open

Description

@taoufik-ia

What problem are you trying to solve?

eve's authored extension surfaces split what can see from what can act, and a cross-cutting policy has nowhere to live.

Surface Sees Can change
defineHook events every stream event nothing — StreamEventHook returns void | Promise<void>, and hooks dispatch after the event is durably written
defineDynamic session.started / turn.started / step.started, ctx.messages excluding the current delivery model, tools, skills, instructions, subagents
approval one tool, its input, the session approved / denied / user-approval — declared per tool and per connection
memory recall / capture turn.input, projected history appends user-role messages

Three things cannot be authored today (checked against 0.46.0):

1. No cross-cutting pre-tool gate. approval is the right decision point and it works — per tool. An agent-wide rule ("no bash matching rm -rf", "any write outside /workspace needs a human") has to be restated in every agent/tools/*.ts, every connection, and every overridden built-in. Anything added later inherits nothing: a new MCP connection's tools, a tool returned by a defineDynamic resolver, or a subagent's own tools sit outside the policy, and nothing surfaces the gap at build time. The failure mode is fail-open by omission. #145 concluded on "use the existing Approval<TInput> callback", which is right per tool and does not give an agent one place to state a rule.

2. No way to hand feedback back to the model after a tool result. Canonical case: after a file write, run typecheck and give the model the errors so it fixes them in the same turn. A hook on action.result sees the result and can type it with toolResultFrom, and can do nothing with it. Overriding write_file couples the check to one tool and misses bash, which also writes files.

3. No say in whether the turn is over. Already filed as #544 (open since 2026-07-05, enhancement/p2/core), whose reporter names this constraint directly: "Hooks (defineHook) are observe-only and cannot inject context or force another step".

What this request is not. Not the Claude Code mechanism — shell commands in settings.json, JSON on stdin, exit code 2. On a durable runtime, a configured arbitrary command is both a worse security model and a worse durability model. This asks for the capabilities in eve's idiom: authored TypeScript, typed, and replay-safe.

Proposed solution

Add a second handler channel to defineHook — decisions, alongside events. Handlers return a typed decision and run on the critical path, before the effect. events keeps its exact current semantics (observe-only, after the durable write, return value ignored). Existing hooks are unaffected.

import { defineHook } from "eve/hooks";

export default defineHook({
  match: { tools: ["bash", "write_file", "billing__*"] }, // optional scoping

  decisions: {
    async "action.requested"(action, ctx) {
      if (action.toolName === "bash" && /\brm\s+-rf\b/.test(String(action.input.command ?? ""))) {
        return { decision: "deny", reason: "Destructive shell command blocked by policy." };
      }
      return { decision: "ask", reason: "Confirm this command." }; // reuses the existing HITL park
    },

    async "action.completed"(action, ctx) {
      if (action.toolName !== "write_file") return;
      const errors = await typecheck(await ctx.getSandbox());
      return errors.length > 0 ? { context: `Typecheck failed:\n${errors.join("\n")}` } : undefined;
    },

    async "turn.ending"(turn, ctx) {
      const open = await openTodos(ctx);
      return open > 0 ? { decision: "continue", reason: `${open} todo items are still open.` } : undefined;
    },
  },
});

Why this fits the runtime rather than fighting it

Each of the three points already has a matching seam, and eve already owns a handler channel that influences a turn:

  • Pre-tool. buildToolApproval() in harness/tools.js is passed to the tool-loop agent as toolApproval. It already receives every tool call and looks up a per-tool policy by name, returning undefined when there is none. A hook-level gate is a consultation at that same seam, not a new interception layer. Step-scoped dynamic tools are already resolved before it runs (resolveStepDynamicTools: "for approval policy and model work"), so dynamic tools are in scope.
  • Turn continuation. eve calls the AI SDK agent with stopWhen: isStepCount(1) and drives the multi-step loop itself; StepNext (StepDone | StepFn | null) already materializes "finished / continue / park". The decision exists — it just has no extension point.
  • Post-tool context. Appending messages between steps is already done twice: onCompaction ("the harness appends the returned messages to the compacted history") and StepInput.context ("each entry is appended as a role: "user" message to session.history", populated by channels via SendPayload.context).
  • Precedent for an authored handler that influences the turn. In handleEvent, dispatchMemoryLifecycleEvent runs immediately before dispatchStreamEventHooks, and a memory recall contributes messages to the turn. The shape exists; it is just not available to hooks and cannot decide.
  • Precedent for a durable decision. HITL decisions are already journaled on session state (PendingInputBatch, getApprovedTools, with appendPendingInputBatch / removePendingInputBatches deliberately preventing wholesale overwrite). A hook decision should reuse that discipline.

Constraints this must respect

These are the parts that would break if the feature were built as a straight port of Claude Code's hooks.

  • Decisions must be journaled and replayed, never re-evaluated. eve replays interrupted steps; the hooks guide already warns that a retried turn re-emits its events under new ids. A decision recomputed on replay can contradict what the session already observed. Same guarantee as an approval response.
  • A forced continuation must append its reason as a user-role message. Re-calling the model on a history that ends on an assistant turn is exactly the failure in Turn fails terminally: prompt ends on assistant turn after HITL answer (Anthropic prefill rejection) — variant of #80 outside compaction #1839 / Compaction can emit a prompt ending on an assistant turn → Anthropic rejects the next model call #80 — "This model does not support assistant message prefill" — which fix(eve): make compaction the sole context manager #175 already fixed for the compaction path by appending a synthetic user turn. turn.ending: continue must go through that same guard, not around it.
  • The gate is on the critical path, so it needs a short bounded timeout. world-local 30s hook timeouts redeliver and re-bill a long model call; process death mid-call does not always replay #2425 shows a step that outlives the delivery timeout is redelivered and re-billed. A slow policy handler widens that window; the default timeout should be conservative and documented.
  • Fail-closed, and never fatal. A throwing decisions handler should resolve to the documented default for its event (deny for the gate) — not cascade into turn.failed / session.failed the way events does today (Hook throws on boundary events (turn.started / first-attempt step.started) terminate the whole session, contradicting the hooks guide #2093). A crashing policy hook must not retire a session.
  • Extension-contributed hooks need a decision. Per the extensions guide, "hooks and instruction fragments are additive, so they cannot be replaced". If hooks gain decision power, mounting a third-party extension would let it silently deny the host agent's tool calls with no way to remove it. decisions should be rejected from mounted extensions by default, or require an explicit opt-in at mount time, and be attributed by owner in /eve/v1/info.
  • Coverage should be stated, not assumed. The toolApproval seam covers authored, built-in, connection and dynamic tools plus runtime actions (subagent calls). Provider-executed tools such as web_search run at the provider and cannot be gated there; that limit belongs in the docs rather than in a promise of uniformity.
  • Ordering and precedence must be explicit. The hook registry is ordered lexicographically by slug today. With several decision hooks, and alongside a tool's own approval, the rule should be documented — most-restrictive-wins, first terminal deny short-circuits — rather than emergent.
  • Subagent isolation stays the default, with an opt-in for a parent policy to cover child turns; otherwise gap 1 survives for delegated work.

Suggested implementation prompt

Implement a decisions handler channel on defineHook, separate from the existing observe-only events channel.

Requirements:

  • decisions handlers run before the effect they gate, inside the turn, and their return value is honored. events semantics, ordering, and failure behavior are unchanged.
  • Events: action.requested (allow / deny / ask), action.completed (contribute context), turn.ending (allow / continue).
  • The gate consults at the existing toolApproval seam so authored, built-in, connection, dynamic tools and runtime actions are covered without per-tool declaration. Provider-executed tools are documented as out of scope.
  • An optional match narrows a hook by tool name (exact and pattern) and by channel kind.
  • "ask" routes into the existing human-input protocol rather than a new pause mechanism.
  • Every decision is journaled with its step and replayed on recovery; handlers are not re-invoked for a completed step.
  • A throwing decision handler resolves to its documented fail-closed default and fails neither the turn nor the session.
  • turn.ending: continue appends its reason as a user-role message through the existing trailing-assistant guard.
  • Decision handlers carry a bounded default timeout.
  • GET /eve/v1/info reports registered decision hooks, their match scope, and their owning source.

Acceptance criteria:

  • A denied call never executes: no side effect, and the model receives the reason.
  • A hook deny, an approval deny, and a tool error are three separately observable outcomes.
  • Replaying a step reproduces the recorded decision without re-invoking the handler; a deliberately non-deterministic handler cannot change an already-observed outcome.
  • "ask" emits exactly one input.requested and settles the original call exactly once via inputResponses.
  • Context returned from action.completed appears in the model input of the next step of the same turn.
  • turn.ending: continue runs one more step in the same turn, bounded by a documented cap, with no continuation-token rotation, and the resulting prompt never ends on an assistant message (regression coverage against Turn fails terminally: prompt ends on assistant turn after HITL answer (Anthropic prefill rejection) — variant of #80 outside compaction #1839 / Compaction can emit a prompt ending on an assistant turn → Anthropic rejects the next model call #80).
  • A policy declared in one file covers a newly added MCP connection tool with no edit to that connection.
  • A hook whose decision handler throws denies the call and leaves the session resumable.
  • Hooks declaring only events compile and behave identically.

Alternatives considered

  • Per-tool approval + defineTool wrappers (patterns/multi-tenant-approvals.md, and where Feature request: policy/governance interface for tool lifecycle hooks #145 landed): the intended surface for a gate, and correct per tool. It does not cover post-tool feedback or turn continuation, cannot cover tools the agent did not declare locally, and duplicates the policy across every tool, connection, and overridden built-in.
  • Overriding each built-in (agent/tools/bash.ts re-exporting with an approval): works, scatters one rule across N files, still misses connection tools.
  • A custom channel that pre-processes the delivery (SendPayload.context): the supported way to add context before a turn, and it stays supported. It runs outside the eve context (no defineState) and forces clients off /eve/v1/session.
  • A memory slot's recall: since 0.45.1 the right surface for context derived from the current delivery, since it receives turn.input. It cannot refuse or rewrite a delivery, and says nothing about tools. A message.receiving decision point would round this out, but the gap is much smaller than the three above and is deliberately left out of this request.
  • Server-side nudge turns: rejected for the reasons given in No way to guarantee turn continuation when the model stops with pending in-progress work (todos, multi-step plans) #544 — invisible to attached clients, rotates the continuation token.

Related: #544 (turn continuation), #145 (tool policy interface), #2093 (thrown-hook failure semantics), #1839 / #80 / #175 (trailing-assistant prompt guard), #2425 (step latency and redelivery).

Activity

  1. roboeve commented on Aug 27, 2026

    @roboeve
    Contributor

    Triaged #2673 as enhancement, p2, core. It overlaps #544 only on turn continuation, so it remains a separate feature request.

  2. smixs commented on Oct 11, 2026

    @smixs

    Use case from Iva (self-hosted Telegram assistant on eve 0.51.1, smixs/iva-agent#285), with the shape we are
    building in our own copy of eve, in case it helps this proposal.

    Plugins need to decide, not only observe: deny a tool call with a reason, rewrite or drop the final reply, or
    continue the same turn. We are adding two seams:

    1. One agent-wide approval decision, consulted in buildToolApproval for every tool in the step's tool set
      (built-ins, extension and connection tools, subagents). A denial reaches the model as execution-denied with
      the reason, and the turn goes on.
    2. A turn-end decision taken after the step that would end the turn and before its message.completed reaches
      channels: send (optionally with new text), suppress (message: null, like the empty-delivery sentinel), or
      continue (the text is emitted with finishReason "tool-calls", the reason is appended as a user message, and
      the loop runs another step). Continuations are capped per turn.

    Both run under a deadline; a timeout or a throw resolves to the default (allow / send) and never fails the turn.
    A step retry asks again with the same turnId, stepIndex and callId. Happy to share the change and its boundary
    test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions