Skip to content

Hooks

Hooks are how you look inside the loop without forking it — Strands' HookRegistry / HookProvider / HookEvent trio, in Swift. Every Agent owns a registry (agent.hooks); the conversation manager and the retry strategy are themselves hook providers, which is why they can live outside the loop's core.

Events

Event When You can…
BeforeInvocationEvent agent(…) / agent.stream(…) begins read messages; set cancel to abort with a reason
MessageAddedEvent a user, assistant or tool-result message is appended log, persist, mirror to a UI
BeforeModelCallEvent just before model.stream inspect projectedInputTokens vs contextWindow, edit systemPrompt, cancel
AfterModelCallEvent after the assistant message is assembled (or the call failed) read stopResponse / error; set retry = true to call the model again
BeforeToolsEvent before the message's tool uses are executed read the assistant message; cancel
BeforeToolCallEvent before one tool runs swap selectedTool (mock/deny/route), cancelTool with a message the model will read
AfterToolCallEvent after one tool ran rewrite result, inspect error
AfterToolsEvent after all tool results are appended metrics, haptics
AfterInvocationEvent the invocation ends — with a result or an error clean-up

After… events run their callbacks in reverse registration order (shouldReverseCallbacks), so a provider that wraps a step unwinds in the right order — same rule as Strands.

Registering a callback

// snippet: compile
import Loopl

func observe(_ agent: Agent) async {
    await agent.hooks.addCallback(BeforeToolCallEvent.self) { event in
        if event.toolUse.name == "http_get" {
            event.cancelTool = "Network tools are disabled in this session."   // the model reads this as the tool result
        }
    }
    await agent.hooks.addCallback(AfterModelCallEvent.self) { event in
        if let stop = event.stopResponse { print("model stopped: \(stop.stopReason)") }
    }
}

Callbacks are @Sendable (inout Event) async throws -> Void: mutate the event to influence the loop, throw to abort it.

A reusable provider

// snippet: compile
import Loopl

/// Counts tool calls and messages — the shape of SlidingWindowConversationManager and ModelRetryStrategy.
final class Telemetry: HookProvider, @unchecked Sendable {
    private(set) var toolCalls = 0
    private(set) var messages = 0

    func registerHooks(_ registry: HookRegistry) {
        registry.addCallback(AfterToolCallEvent.self) { [self] _ in toolCalls += 1 }
        registry.addCallback(MessageAddedEvent.self)  { [self] _ in messages += 1 }
    }
}

func make(_ model: any Model) -> Agent {
    Agent(model: model, hooks: [Telemetry()])
}

Pass providers at init (Agent(model:…, hooks: [...])) or later with agent.hooks.addHook(provider). Registration order is: conversation manager → retry strategy → your providers — identical to Strands' Agent.__init__.

Hooks are the extension point, not subclassing

Agent is an actor, not a class to subclass. Anything Strands does with a plugin — token budgets, tool allow-lists, persistence, tracing — is a HookProvider here. The loop itself stays untouched.