Function hooks — gald3r's own contract¶
Published: 2026-09-12. This is gald3r's own documented contract for function hooks:
Koa-style middleware composed over a typed engine interface. It is an independent gald3r design,
implemented in gald3r_engine/crash/fnhooks. It coexists
with — and does not replace — gald3r's existing shell-out hooks (the ones described on
crash.md and driven by
hooks.yaml).
Provenance¶
The idea of exposing a coding-agent harness's lifecycle as composable function middleware,
rather than fire-and-forget shell hooks, is publicly discussed in
anthropics/claude-code#91870, opened
2026-09-03 and sitting in community-feedback stage as of this document's publication date. That
proposal is unshipped: no such API exists in any released Claude Code build as of
2026-09-12. gald3r takes no dependency on it — nothing in fnhooks references an external
type, wire format, or endpoint. This document credits the provenance of the idea; it does not
claim compatibility with anything Anthropic ships, now or later. The unshipped state of that
proposal is the opportunity, not a reason to wait: gald3r ships this model first, on its own
terms, so it — not a later arrival — is what a "harness hook" means to anyone building against
gald3r.
Why shell hooks were not enough¶
Today's hooks (gald3r_engine/hooks) are a shell-out: a script receives an event on
stdin/argv, runs, and exits with a code. Its only vocabulary is the process boundary — it can
block the guarded operation (block_on_failure) and that is close to the ceiling of what it can
do. As gald3r verbs get used more, and Throne, gald3r_ide, and Longship all become real
surfaces, extension needs two things a shell hook cannot give it: the ability to compose
(more than one extension acting on the same event without stepping on each other) and the
ability to reach into the runtime (call real methods, not just exit 0/1).
The model, in one signature¶
A function hook is exactly three arguments: ($, e, next).
| Argument | What it is |
|---|---|
$ |
The engine interface — the only door to side effects a hook has. Every capability a hook can exercise is a noun exposed on $; there is no other way for a hook to affect the world. A hook's effects are therefore statically determinable from the calls it makes on $. |
e |
An immutable plain event value. Reading it never mutates shared state. "Changing" an event always produces a new event (Event.With), never an in-place edit. |
next |
The continuation for this dispatch. Before it is ever called, next already carries: signal (what happens if this hook does nothing), event (the event as of this point in the chain), origin (the registration that runs if next is called), and a type predicate (next.Is(pattern)) — so a hook can decide how to behave without invoking the rest of the chain first. Calling next.Call(e) actually runs everything registered after this hook, down to the core handler. |
Plain event payloads¶
NewEvent, Event.With and Event.Get recursively copy payload containers.
Supported values are nil, booleans, strings, numeric scalars (including complex
numbers), maps, slices and arrays, including named concrete types. Map keys and
interface contents follow the same rules. Typed nil maps and slices remain nil;
shared acyclic inputs are copied independently. Container identity and spare slice
capacity are not part of the event contract.
Pointers (including typed nil pointers), unsafe pointers, functions, channels,
structs, cyclic containers and nesting beyond 256 levels are rejected with
ErrInvalidEventValue. NewEventChecked and WithChecked return that error without
publishing a partial event. Existing NewEvent and With signatures panic with
that sentinel for invalid input, treating it as a programmer error. Use checked
forms for fallible input. Do not mutate input concurrently while a snapshot is
being constructed. Get returns a fresh copy; modifying it changes no event until
it is explicitly passed to With. This protects event data, not other native Go
side effects or arbitrary values returned in Result.Value.
Registration order is nesting¶
Hooks register onto a Bus in order. Dispatch composes them onion-style: the
first-registered hook is the outermost wrapper. It runs first, and its own call to
next.Call is what invokes every hook registered after it, all the way down to the core
handler at the center.
That single ordering rule is also the authority model: an administrator's hook, registered
first, wraps and can override everything registered after it — inspect what a later hook did,
veto it outright, or rewrite what flows downstream. Core defaults, registered last, hold the
least authority: they are the innermost link, reached only if every wrapping hook chooses to
call next.
Register(A) Register(B) Dispatch
| | |
v v v
A (outermost) -> B (wrapped by A) -> core (innermost, reached only if B calls next)
Five placements, one signature¶
Today's separate pre-/post- hook types collapse into one signature because every placement
below is just a calling convention against ($, e, next). Each has its own test in
placements_test.go.
| Placement | Shape | Effect |
|---|---|---|
| before | call $, then return next.Call(e) unmodified |
Runs before the wrapped chain; observes nothing after it |
| after | res, err := next.Call(e), then call $, then return (res, err) |
Runs after the wrapped chain completes |
| during | call $ to start something, call next.Call(e), call $ again to stop it |
Wraps the entire execution of the wrapped chain, not just one edge of it |
| instead | never call next; return this hook's own Result |
Fully replaces the wrapped chain for this dispatch (short-circuit) |
| modifying | return next.Call(e.With(key, value)), or rewrite the returned Result.Value |
Changes what flows through the chain without replacing the chain itself |
engine.create: the fold that is a blast door¶
$ is never constructed directly. It is folded, left to right, from an ordered list of
CreateStep values:
eng := fnhooks.Create(
fnhooks.WithNoun("fs.write", writeImpl),
fnhooks.WithNoun("log.emit", logImpl),
fnhooks.Withhold("fs.write"), // a later step can remove an earlier one
)
Each step receives the noun set every prior step produced and returns the noun set for the next
step — it may add new nouns (WithNoun) or withhold (delete) a noun a prior step added
(Withhold). Because every hook registered against the result only ever sees the final folded
noun set, a step near the end of the fold that withholds a noun removes that capability from
every hook registered beneath it, with no cooperation required from those hooks. That is the
blast door: an organization (or a platform overlay) composes its own late-fold Withhold steps
to hand a deliberately reduced $ to a lower-trust registration tier.
The trust.BuildEngine wrapper runs registration steps once, then applies a final
Withhold step to the actual accumulated nouns. User primitives retain only the safe
defaults and their explicit grants; external-origin consent is checked before construction.
This restricts capabilities available through Engine.Call. It does not sandbox native Go
code, which can perform I/O independently of that interface.
Wildcard hooks¶
A hook registers against an event-name glob pattern, not just an exact name: "*" matches every
event, "tool.*" matches every event in that namespace. Wildcard registration reuses the
same glob matcher the shell-hook system already ships
(gald3r_engine/hooks.MatchesTool) rather than a second implementation, and composes in
registration order exactly like an exact-name hook — there is no separate wildcard dispatch
path.
Coexistence with shell hooks (unchanged)¶
fnhooks imports gald3r_engine/hooks only for its glob matcher. HookDefinition,
HookLoader, and HookExecutor are untouched: hooks.yaml-driven shell hooks still dispatch
through gald3r_engine/crash/dispatch's existing ComponentHook participant exactly as before.
Function hooks are an additional, coexisting path — a project may use either, both, or
neither.
Project hook files and trust records¶
Project hooks are JSON files in .gald3r/hooks/functions/*.json. Their file name determines
stable registration order. A hook contains an id, an event glob match, and an actions
array. The finite action vocabulary is log, read, modify, next, return, and
return-denied. Each program has one control action: next continues to the wrapped core and
may have log or read actions after it; return and return-denied stop the operation.
return carries a JSON-shaped tool result (success, output, and optional error), so a
replacement has the same typed result shape as a real tool call.
{
"id": "audit-read",
"match": "tool.call",
"actions": [
{"op": "log", "value": {"message": "tool operation begins"}},
{"op": "modify", "value": {"tool_input": {"path": "README.md"}}},
{"op": "next"},
{"op": "read", "value": {"noun": "context.read"}}
]
}
The loader rejects a present malformed, unknown, stale, or unconsented hook before a tool runs.
Use gald3r function-hook local-author <id> .gald3r/hooks/functions/<id>.json --source ...
for a local-author record. For external bytes, use
gald3r function-hook external-consent <id> .gald3r/hooks/functions/<id>.json --source ... --consented-by ....
Origin rows live in
.gald3r/config/CRASH_PRIMITIVE_ORIGINS.md; external consent and explicit grants live beside it
in CRASH_EXTERNAL_CONSENT.md and CRASH_PRIMITIVE_GRANTS.md.
At runtime tool.call spans the guarded operation. A modified tool name and input pass through
the ordinary hygiene, tool-set, permission, legacy pre_tool_use, registry default-hook, and
sandbox checks before a handler runs. The outer adapter emits the final outcome and legacy
post_tool_use for both a core result and a function-hook replacement.
Each project primitive receives its own user-level, folded $ engine. The available host
adapters are log.emit and context.read; unsupported safe-default names are absent rather
than advertised as working capabilities. Every adapter call itself emits engine.call.<noun>
and remains subject to the final withholding fold. This controls the declarative runtime surface;
it does not provide OS isolation for native Go code or shell hooks.
Render and interaction events — deferred by design¶
ui.render / ui.press-style events are the visual-extensibility half of this model: they are
what make it matter for a desktop harness (Throne, gald3r_ide, Longship), not just a CLI one.
They are intentionally not part of this first cut — they land once those surfaces have a
render tree worth hooking into. Nothing in the model blocks that follow-up: event names are
free-form strings, so a future package registers "ui.render"/"ui.press" on the same Bus
with the same ($, e, next) contract, once there is a real consumer to justify it.
Reference implementation¶
gald3r_engine/crash/fnhooks/doc.go— the package doc this page is derived from.gald3r_engine/crash/fnhooks/engine.go—$,Noun,CreateStep,Create,WithNoun,Withhold.gald3r_engine/crash/fnhooks/event.go— the immutableEventvalue and its type predicate.gald3r_engine/crash/fnhooks/next.go— theNextcontinuation.gald3r_engine/crash/fnhooks/bus.go—Busregistration and onion-composed dispatch.gald3r_engine/crash/fnhooks/placements_test.go— the five placements, one test each.