Robutler

Agent Hooks

Hooks provide lifecycle integration points to react to events during request processing. Hooks can be defined in skills or as standalone functions.

Hooks are executed in priority order (lower numbers first) and receive the unified request context. Keep hooks small and deterministic; avoid blocking operations and always return the context.

Hook Types

Skill Hooks

Defined within skills using the @hook decorator:

import { Skill, hook } from 'webagents';
import type { HookData, Context } from 'webagents';

class MySkill extends Skill {
  readonly name = 'my-skill';

  @hook({ lifecycle: 'on_connection', priority: 10 })
  async setupRequest(data: HookData, ctx: Context) {
    ctx.set('custom_data', 'value');
    return data;
  }
}

Standalone Hooks

Decorated functions that can be passed to agents:

// In TypeScript, hooks are class members. Wrap standalone hook logic
// in a small Skill class and pass an instance to the agent.

import { BaseAgent, Skill, hook } from 'webagents';

class StandaloneHooks extends Skill {
  readonly name = 'standalone-hooks';

  @hook({ lifecycle: 'on_message', priority: 5 })
  async logMessages(data, ctx) {
    console.log('Message:', data.messages?.at(-1));
    return data;
  }

  @hook({ lifecycle: 'on_connection' })
  async setupAnalytics(data, ctx) {
    ctx.set('session_start', Date.now());
    return data;
  }
}

const agent = new BaseAgent({
  name: 'my-agent',
  model: 'openai/gpt-4o',
  skills: [new StandaloneHooks()],
});

Available Hooks

Hooks are executed in the following order during request processing:

  1. on_connection — Once per request (initialization)
  2. before_llm_call — Before each LLM call in the agentic loop
  3. after_llm_call — After each LLM response in the agentic loop
  4. on_chunk — For each streaming chunk (streaming only)
  5. before_toolcall — Before each tool execution
  6. after_toolcall — After each tool execution
  7. on_message — Once per request (before finalization)
  8. finalize_connection — Once per request (cleanup)

on_connection

Called once when a new request connection is established.

Typical responsibilities:

  • Authentication and identity extraction (e.g., AuthSkill)
  • Payment token validation and minimum-balance checks (e.g., PaymentSkill)
  • Request-scoped initialization (timers, correlation IDs)
@hook({ lifecycle: 'on_connection' })
async onConnection(data: HookData, ctx: Context) {
  const userId = ctx.auth?.userId;
  const isStreaming = data.stream === true;
  ctx.set('request_start', Date.now());
  return data;
}

on_message

Called for each message in the conversation.

@hook({ lifecycle: 'on_message' })
async onMessage(data: HookData, ctx: Context) {
  const message = data.messages?.at(-1);
  if (message?.role === 'user') {
    ctx.set('intent', this.analyzeIntent(String(message.content ?? '')));
  }
  return data;
}

before_llm_call

@hook({ lifecycle: 'before_llm_call', priority: 5 })
async beforeLlmCall(data: HookData, ctx: Context) {
  const messages = ctx.get('conversation_messages') ?? [];
  const processed = this.processMessages(messages as unknown[]);
  ctx.set('conversation_messages', processed);
  return data;
}

after_llm_call

@hook({ lifecycle: 'after_llm_call', priority: 10 })
async afterLlmCall(data: HookData, ctx: Context) {
  const response = ctx.get('llm_response') as { usage?: object } | undefined;
  await this.trackLlmUsage(response?.usage ?? {});
  return data;
}

before_toolcall

@hook({ lifecycle: 'before_toolcall', priority: 1 })
async beforeToolcall(data: HookData, ctx: Context) {
  const fnName = data.tool_call?.function?.name;
  if (!this.isToolAllowed(fnName, ctx.auth?.userId)) {
    data.tool_call.function.name = 'tool_blocked';
    data.tool_call.function.arguments = '{}';
  }
  return data;
}

after_toolcall

@hook({ lifecycle: 'after_toolcall' })
async afterToolcall(data: HookData, ctx: Context) {
  const toolName = data.tool_call?.function?.name;
  await this.logToolUsage({
    tool: toolName,
    resultSize: String(data.tool_result ?? '').length,
    user: ctx.auth?.userId,
  });
  if (toolName === 'search') {
    data.tool_result = this.formatSearchResults(data.tool_result);
  }
  return data;
}

on_chunk

@hook({ lifecycle: 'on_chunk' })
async onChunk(data: HookData, ctx: Context) {
  const content = String(data.content ?? '');
  if (this.containsSensitiveInfo(content)) {
    data.chunk.choices[0].delta.content = '[REDACTED]';
  }
  ctx.set('chunks_processed', Number(ctx.get('chunks_processed') ?? 0) + 1);
  return data;
}

before_handoff

@hook({ lifecycle: 'before_handoff' })
async beforeHandoff(data: HookData, ctx: Context) {
  const target = data.handoff_agent;
  ctx.set('handoff_metadata', {
    sourceAgent: ctx.metadata.agent_name,
    timestamp: Date.now(),
    reason: data.handoff_reason,
  });
  if (!this.canHandoffTo(target)) {
    throw new Error(`Cannot handoff to ${target}`);
  }
  return data;
}

after_handoff

@hook({ lifecycle: 'after_handoff' })
async afterHandoff(data: HookData, ctx: Context) {
  const result = data.handoff_result;
  const meta = ctx.get('handoff_metadata') as { timestamp: number };
  await this.logHandoff({
    target: data.handoff_agent,
    success: result?.success,
    duration: (Date.now() - meta.timestamp) / 1000,
  });
  return data;
}

finalize_connection

@hook({ lifecycle: 'finalize_connection' })
async finalizeConnection(data: HookData, ctx: Context) {
  const start = Number(ctx.get('request_start') ?? Date.now());
  const duration = (Date.now() - start) / 1000;
  await this.logRequestComplete({
    requestId: ctx.metadata.completion_id,
    duration,
    tokens: ctx.get('usage') ?? {},
    chunks: ctx.get('chunks_processed') ?? 0,
  });
  this.cleanupRequestResources(String(ctx.metadata.completion_id));
  return data;
}

Hook Priority

Hooks execute in priority order (lower numbers first):

class SecuritySkill extends Skill {
  readonly name = 'security';
  @hook({ lifecycle: 'on_message', priority: 1 }) async securityCheck(d, c) { return d; }
}
class LoggingSkill extends Skill {
  readonly name = 'logging';
  @hook({ lifecycle: 'on_message', priority: 10 }) async logMessage(d, c)   { return d; }
}
class AnalyticsSkill extends Skill {
  readonly name = 'analytics';
  @hook({ lifecycle: 'on_message', priority: 20 }) async analyzeMessage(d, c) { return d; }
}

Context Object

The context exposes:

FieldDescription
messagesConversation messages
streamStreaming enabled
auth.userId (TS) / peer_user_id (Py)Caller identifier
metadata.completion_id (TS) / completion_id (Py)Request ID
metadata.model / modelModel name
metadata.agent_name / agent_nameAgent name
metadata.usage / usageToken usage
tool_call, tool_result, chunk, contentHook-specific fields

In TypeScript, the data argument carries event-specific fields and the Context carries authentication, payment, and metadata. In Python, both live on the single context dict-like object.

Practical Examples

Rate Limiting

class RateLimitSkill extends Skill {
  readonly name = 'rate-limit';
  private requestCounts = new Map<string, number>();

  @hook({ lifecycle: 'on_connection', priority: 1 })
  async checkRateLimit(data, ctx) {
    const userId = String(ctx.auth?.userId ?? 'anonymous');
    const count = this.requestCounts.get(userId) ?? 0;
    if (count >= 100) {
      throw new Error('Rate limit exceeded');
    }
    this.requestCounts.set(userId, count + 1);
    return data;
  }
}

Content Moderation

class ModerationSkill extends Skill {
  readonly name = 'moderation';

  @hook({ lifecycle: 'on_message', priority: 5 })
  async moderateInput(data, ctx) {
    const last = data.messages?.at(-1);
    if (last?.role === 'user' && this.isInappropriate(String(last.content))) {
      last.content = 'I cannot process inappropriate content.';
    }
    return data;
  }

  @hook({ lifecycle: 'on_chunk', priority: 5 })
  async moderateOutput(data, ctx) {
    const content = String(data.content ?? '');
    if (this.isInappropriate(content)) {
      data.chunk.choices[0].delta.content = '';
    }
    return data;
  }

  private isInappropriate(_: string) { return false; }
}

Analytics Collection

class AnalyticsSkill extends Skill {
  readonly name = 'analytics';

  @hook({ lifecycle: 'on_connection' })
  async startAnalytics(data, ctx) {
    ctx.set('analytics', { startTime: Date.now(), events: [] });
    return data;
  }

  @hook({ lifecycle: 'on_message' })
  async trackMessage(data, ctx) {
    const a = ctx.get('analytics') as any;
    a.events.push({ type: 'message', role: data.messages?.at(-1)?.role, timestamp: Date.now() });
    return data;
  }

  @hook({ lifecycle: 'before_toolcall' })
  async trackToolStart(data, ctx) {
    ctx.set('tool_start_time', Date.now());
    return data;
  }

  @hook({ lifecycle: 'after_toolcall' })
  async trackToolEnd(data, ctx) {
    const duration = (Date.now() - Number(ctx.get('tool_start_time') ?? Date.now())) / 1000;
    const a = ctx.get('analytics') as any;
    a.events.push({
      type: 'tool',
      name: data.tool_call?.function?.name,
      duration,
      timestamp: Date.now(),
    });
    return data;
  }

  @hook({ lifecycle: 'finalize_connection' })
  async sendAnalytics(data, ctx) {
    const a = ctx.get('analytics') as any;
    a.totalDuration = (Date.now() - a.startTime) / 1000;
    await this.sendToAnalyticsService(a);
    return data;
  }

  private async sendToAnalyticsService(_: unknown) {}
}

Best Practices

  1. Always return context (or data) — hooks must return their input data so subsequent hooks see the mutations.
  2. Use priorities wisely — order matters for dependent operations.
  3. Handle errors gracefullyfinalize_connection runs even if a prior hook throws; rely on it for cleanup.
  4. Keep hooks lightweight — avoid heavy synchronous processing.
  5. Use context for state — don't store request state on instance fields shared across requests.

On this page