Repository navigation
Feature request: let authored hooks decide (pre-tool gate, post-tool feedback, turn continuation) #2673
Description
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()inharness/tools.jsis passed to the tool-loop agent astoolApproval. It already receives every tool call and looks up a per-tool policy by name, returningundefinedwhen 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") andStepInput.context("each entry is appended as arole: "user"message tosession.history", populated by channels viaSendPayload.context). - Precedent for an authored handler that influences the turn. In
handleEvent,dispatchMemoryLifecycleEventruns immediately beforedispatchStreamEventHooks, and a memoryrecallcontributes 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, withappendPendingInputBatch/removePendingInputBatchesdeliberately 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
reasonas 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: continuemust 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
decisionshandler should resolve to the documented default for its event (deny for the gate) — not cascade intoturn.failed/session.failedthe wayeventsdoes 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.
decisionsshould 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
toolApprovalseam covers authored, built-in, connection and dynamic tools plus runtime actions (subagent calls). Provider-executed tools such asweb_searchrun 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:
decisionshandlers run before the effect they gate, inside the turn, and their return value is honored.eventssemantics, 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
toolApprovalseam 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
matchnarrows 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: continueappends its reason as a user-role message through the existing trailing-assistant guard.- Decision handlers carry a bounded default timeout.
GET /eve/v1/inforeports 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
approvaldeny, 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 oneinput.requestedand settles the original call exactly once viainputResponses.- Context returned from
action.completedappears in the model input of the next step of the same turn. turn.ending: continueruns 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
eventscompile and behave identically.
Alternatives considered
- Per-tool
approval+defineToolwrappers (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.tsre-exporting with anapproval): 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 (nodefineState) 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 receivesturn.input. It cannot refuse or rewrite a delivery, and says nothing about tools. Amessage.receivingdecision 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
roboeve commentedon Aug 27, 2026
Triaged #2673 as
enhancement,p2,core. It overlaps #544 only on turn continuation, so it remains a separate feature request.smixs commentedon Oct 11, 2026
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:
(built-ins, extension and connection tools, subagents). A denial reaches the model as execution-denied with
the reason, and the turn goes on.
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.