Two layers of hooks

  • SDK-level (event stream): subscribe to `tool_start`, `tool_update`, `tool_end`, `file_modified`, `permission_request`, and `error`. Use these for logging, metrics, UI rendering, audit trails, and approval gates.
  • CLI-level (filesystem hooks): drop scripts into `~/.autohand/hooks/` to run before or after every prompt the CLI handles. Use these for organisation-wide policy, secret scrubbing, or routing every prompt through a logging pipeline.

The Swift SDK additionally exposes a `HookManager` that the `PermissionManager` consults for in-process approval decisions.

Hook into the event stream

Wrap a `streamPrompt` loop in a small dispatcher. Each branch is a hook: keep them fast, push slow work to a background queue.

TypeScript

import { AutohandSDK } from '@autohandai/agent-sdk';

const sdk = new AutohandSDK({ cwd: '.' });
await sdk.start();

for await (const event of sdk.streamPrompt({ message: prompt })) {
  switch (event.type) {
    case 'tool_start':
      logger.info('tool.start', { tool: event.toolName });
      break;
    case 'tool_end':
      logger.info('tool.end', { tool: event.toolName, durationMs: event.durationMs });
      break;
    case 'file_modified':
      cache.invalidate(event.path);
      break;
    case 'permission_request': {
      const dangerous = ['run_command', 'delete_path'].includes(event.tool);
      const allowed = !dangerous;
      await sdk.permissionResponse({ requestId: event.requestId, allowed });
      break;
    }
    case 'error':
      metrics.increment('agent.error', { code: event.code });
      break;
  }
}

Python

from autohand_sdk import AutohandSDK

async with AutohandSDK(cwd=".") as sdk:
    async for event in sdk.stream_prompt(prompt):
        t = event["type"]
        if t == "tool_start":
            logger.info("tool.start", extra={"tool": event.get("tool_name")})
        elif t == "tool_end":
            logger.info("tool.end", extra={"tool": event.get("tool_name")})
        elif t == "file_modified":
            cache.invalidate(event.get("path"))
        elif t == "permission_request":
            dangerous = event.get("tool") in {"run_command", "delete_path"}
            await sdk.respond_to_permission(
                event["request_id"],
                decision="deny" if dangerous else "allow",
                allowed=not dangerous,
            )
        elif t == "error":
            metrics.increment("agent.error")

Go

for event := range events {
    switch e := event.(type) {
    case autohand.ToolStartEvent:
        log.Info("tool.start", "tool", e.ToolName)
    case autohand.ToolEndEvent:
        log.Info("tool.end", "tool", e.ToolName)
    case autohand.FileModifiedEvent:
        cache.Invalidate(e.Path)
    case autohand.PermissionRequestEvent:
        dangerous := e.Tool == "run_command" || e.Tool == "delete_path"
        scope := autohand.ScopeOnce
        _ = sdk.PermissionResponse(ctx, e.RequestID, !dangerous, scope)
    case autohand.ErrorEvent:
        metrics.Increment("agent.error")
    }
}

Java

sdk.streamPrompt(new PromptParams(prompt), event -> {
    if (event instanceof Events.ToolStartEvent tse) {
        logger.info("tool.start tool={}", tse.toolName());
    } else if (event instanceof Events.ToolEndEvent tee) {
        logger.info("tool.end tool={}", tee.toolName());
    } else if (event instanceof Events.FileModifiedEvent fme) {
        cache.invalidate(fme.path());
    } else if (event instanceof Events.PermissionRequestEvent pre) {
        boolean dangerous = pre.tool().equals("run_command") || pre.tool().equals("delete_path");
        if (dangerous) {
            sdk.denyPermission(pre.requestId(), DecisionScope.ONCE);
        } else {
            sdk.allowPermission(pre.requestId(), DecisionScope.ONCE);
        }
    } else if (event instanceof Events.ErrorEvent err) {
        metrics.increment("agent.error");
    }
});

Swift

let hookManager = HookManager()
let permissionManager = PermissionManager(
    hookManager: hookManager,
    mode: .interactive
)
Runner.setPermissionManager(permissionManager)

for try await event in Runner.runStream(agent: agent, prompt: prompt) {
    switch event.type {
    case .toolStart:
        logger.info("tool.start (event.toolName ?? "")")
    case .toolEnd:
        logger.info("tool.end (event.toolName ?? "")")
    case .error:
        metrics.increment("agent.error")
    default:
        break
    }
}

Filesystem hooks

The Autohand CLI looks for executable scripts in `~/.autohand/hooks/`. Two slots run on every prompt:

  • `pre-prompt`: receives the user prompt on stdin. Exit non-zero to block the prompt; print to stdout to rewrite it.
  • `post-prompt`: receives the final assistant response on stdin. Use it for audit logging, redaction, or downstream notifications.

This is the right layer for org-wide policy that should apply to every SDK consumer, including ad-hoc CLI use.

bash

#!/usr/bin/env bash
# ~/.autohand/hooks/pre-prompt
set -euo pipefail

prompt="$(cat)"

if grep -qE 'AKIA[0-9A-Z]{16}' <<< "$prompt"; then
  echo "blocked: AWS access key in prompt" >&2
  exit 1
fi

echo "$prompt"

Best practices

  • Treat the event stream as your hook surface. It is consistent across SDKs and forward-compatible with new event types.
  • Keep handlers fast. Push slow work (network calls, disk writes) to a background queue.
  • Use filesystem hooks for policy that has to apply to every CLI run, not just SDK code paths.
  • Always handle `error` events and `permission_request` in `interactive` mode.
  • Avoid mutating the prompt mid-stream. If you need to redirect the agent, call `abort()` and start a new run.