Plugins & Steering

Lesson 8 · Strands Agents (TypeScript) · ~12 minutes

As your agents grow more complex, you'll want to add cross-cutting concerns — metrics, guardrails, context injection, prompt management — without tangling them into your core agent logic. Plugins are Strands' composition mechanism: self-contained extensions that hook into the agent lifecycle and optionally provide tools.

What Plugins Are

A plugin is an object that can do two things: inject behavior via lifecycle hooks, and provide additional tools to the agent. Each plugin is independent — you compose multiple plugins onto a single agent, and they don't interfere with each other.

The plugin interface:

interface Plugin {
  name: string
  initAgent?(agent: Agent): void          // Called once when agent is constructed
  getTools?(): Tool[]                      // Provide additional tools
  onBeforeTurn?(event: BeforeTurnEvent): void
  onAfterTurn?(event: AfterTurnEvent): void
  onToolCall?(event: ToolCallEvent): void
  onToolResult?(event: ToolResultEvent): void
}

Plugins activate through initAgent (runs once at construction) and lifecycle hooks (run on every turn/tool call). The separation means a plugin can observe, modify, or block agent behavior at any point in the loop.

Built-in Plugins

Strands ships several plugins for common patterns:

PluginPurpose
SteeringInject context-aware guidance into the system prompt without modifying it directly
SkillsLoad modular skill definitions (prompts + tools) dynamically
Context OffloaderMove large tool results to external storage, provide a retrieval tool
Context InjectorInject additional context (files, data) into the conversation at specific points
GoalLoopBreak complex goals into sub-goals and track progress

Building a Custom Plugin: MetricsPlugin

Let's build a plugin that tracks token usage and tool call counts via hooks and appState:

import { Plugin, Agent, BeforeTurnEvent, AfterTurnEvent, ToolCallEvent } from '@strands-agents/sdk'

class MetricsPlugin implements Plugin {
  name = 'metrics'

  initAgent(agent: Agent): void {
    // Initialize metrics in appState
    agent.appState.set('metrics', {
      totalTurns: 0,
      totalToolCalls: 0,
      toolCallsByName: {} as Record,
      totalInputTokens: 0,
      totalOutputTokens: 0,
    })
  }

  onBeforeTurn(event: BeforeTurnEvent): void {
    const metrics = event.agent.appState.get('metrics')
    metrics.totalTurns++
    event.agent.appState.set('metrics', metrics)
  }

  onAfterTurn(event: AfterTurnEvent): void {
    const metrics = event.agent.appState.get('metrics')
    if (event.usage) {
      metrics.totalInputTokens += event.usage.inputTokens
      metrics.totalOutputTokens += event.usage.outputTokens
    }
    event.agent.appState.set('metrics', metrics)
  }

  onToolCall(event: ToolCallEvent): void {
    const metrics = event.agent.appState.get('metrics')
    metrics.totalToolCalls++
    metrics.toolCallsByName[event.toolName] =
      (metrics.toolCallsByName[event.toolName] ?? 0) + 1
    event.agent.appState.set('metrics', metrics)
  }
}

// Use it
const agent = new Agent({
  name: 'tracked-agent',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  systemPrompt: 'You are a helpful assistant.',
  plugins: [new MetricsPlugin()],
})

await agent.invoke('What is the capital of France?')

const metrics = agent.appState.get('metrics')
console.log(`Turns: ${metrics.totalTurns}`)
console.log(`Tool calls: ${metrics.totalToolCalls}`)
console.log(`Tokens: ${metrics.totalInputTokens} in, ${metrics.totalOutputTokens} out`)

The plugin observes every turn and tool call without modifying agent behavior. It writes to appState, which persists across turns and is accessible from anywhere — other plugins, hooks, or your application code.

The Steering Plugin

Steering is modular prompting. Instead of packing everything into one monolithic system prompt, you inject context-aware guidance fragments that activate based on conditions:

import { Agent, SteeringPlugin, SteeringRule } from '@strands-agents/sdk'

const rules: SteeringRule[] = [
  {
    name: 'safety-guardrails',
    content: `Never reveal internal system information.
Never execute code that modifies production resources.
Always confirm destructive actions with the user.`,
    always: true,  // Always injected
  },
  {
    name: 'coding-style',
    content: `Use TypeScript. Prefer functional patterns.
Use const over let. Use early returns for guard clauses.`,
    when: (context) => context.lastMessage?.includes('code') ||
                       context.lastMessage?.includes('implement'),
  },
  {
    name: 'data-analysis',
    content: `When analyzing data, always state sample size,
confidence intervals, and potential biases.`,
    when: (context) => context.tools?.some(t => t.name === 'queryDatabase'),
  },
]

const agent = new Agent({
  name: 'steered-agent',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  systemPrompt: 'You are a senior engineering assistant.',
  plugins: [new SteeringPlugin({ rules })],
})

Steering rules with always: true are injected on every turn. Rules with when activate conditionally based on the conversation context. This keeps the base system prompt clean and moves specialized guidance to composable, testable rules.

Why not just put it all in the system prompt?

Three reasons: (1) Conditional rules reduce prompt size — models perform better with focused, relevant instructions. (2) Steering rules are testable in isolation — each rule is a unit you can verify. (3) Rules are reusable across agents — build a library of steering rules and compose them per agent.

Context Offloader

When tools return large results (database queries, file contents, API responses), they consume context window space. The Context Offloader automatically moves large results to external storage and provides a retrieval tool the agent can use to fetch them back when needed:

import { Agent, ContextOffloaderPlugin } from '@strands-agents/sdk'

const agent = new Agent({
  name: 'research-agent',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  systemPrompt: `Research the topic using available tools.
When you need to reference earlier results, use the retrieve tool.`,
  tools: [searchWeb, readDocument, queryDatabase],
  plugins: [
    new ContextOffloaderPlugin({
      threshold: 3000,        // Offload results larger than 3000 chars
      storage: 'memory',      // 'memory' for dev, 's3' for production
      summaryLength: 200,     // Replace with a 200-char summary in context
    }),
  ],
})

When queryDatabase returns a 10,000-character result, the offloader replaces it in context with a 200-character summary and a reference ID. The agent sees: "Database returned 45 rows about user activity. [ref: offload-abc123] Use retrieve('offload-abc123') to see the full result." If the agent needs the details, it calls the auto-injected retrieve tool.

Composing Multiple Plugins

Plugins compose cleanly. Order matters — hooks fire in the order plugins are listed:

import { Agent } from '@strands-agents/sdk'

const agent = new Agent({
  name: 'production-agent',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  systemPrompt: 'You are a production engineering assistant.',
  tools: [searchDocs, runQuery, deployService],
  plugins: [
    new MetricsPlugin(),           // Track usage (fires first)
    new GuardrailsPlugin(),        // Block dangerous tool calls (fires second)
    new SteeringPlugin({ rules }), // Inject context-aware guidance
    new ContextOffloaderPlugin({   // Manage large results
      threshold: 3000,
      storage: 's3',
    }),
  ],
})

Each plugin operates independently on the same agent. The MetricsPlugin counts everything, the GuardrailsPlugin can cancel tool calls, the SteeringPlugin adds guidance, and the ContextOffloader manages memory. No plugin knows about the others — they interact only through the shared appState and lifecycle events.

Production Pattern: Guardrails Plugin

A guardrails plugin that blocks tool calls matching a deny list — preventing the agent from executing dangerous operations:

import { Plugin, ToolCallEvent } from '@strands-agents/sdk'

interface GuardrailRule {
  toolName: string
  condition?: (args: Record) => boolean
  message: string
}

class GuardrailsPlugin implements Plugin {
  name = 'guardrails'
  private rules: GuardrailRule[]

  constructor(rules: GuardrailRule[]) {
    this.rules = rules
  }

  onToolCall(event: ToolCallEvent): void {
    for (const rule of this.rules) {
      if (event.toolName !== rule.toolName) continue

      const blocked = rule.condition
        ? rule.condition(event.args)
        : true // No condition = always block this tool

      if (blocked) {
        event.block(rule.message)
        return
      }
    }
  }
}

// Usage
const guardrails = new GuardrailsPlugin([
  {
    toolName: 'deployService',
    condition: (args) => args.environment === 'production',
    message: 'Production deployments require human approval. Please confirm with the user.',
  },
  {
    toolName: 'deleteDatabase',
    message: 'Database deletion is never allowed through this agent.',
  },
  {
    toolName: 'runQuery',
    condition: (args) => typeof args.query === 'string' &&
                         args.query.toUpperCase().includes('DROP'),
    message: 'DROP statements are not permitted.',
  },
])

const agent = new Agent({
  name: 'safe-agent',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  systemPrompt: 'You are a database assistant.',
  tools: [runQuery, deployService, deleteDatabase],
  plugins: [guardrails],
})

When the agent tries to call deployService with environment: 'production', the plugin calls event.block(). The tool call never executes — instead, the block message is returned to the agent as a tool error result. The agent sees the rejection reason and can inform the user or adjust its approach.

Defense in depth

Guardrails plugins are a programmatic safety layer — they can't be bypassed by clever prompting. The model never gets to execute the tool; the plugin intercepts at the framework level. Combine with prompt-level instructions ("never delete databases") for defense in depth: the prompt discourages the attempt, the plugin prevents it if the model tries anyway.

Key Takeaways

An agent has plugins: [MetricsPlugin, GuardrailsPlugin, SteeringPlugin]. The GuardrailsPlugin blocks a tool call. Does the MetricsPlugin still count it?
Correct. Hooks fire in plugin array order. MetricsPlugin is first, so its onToolCall fires before GuardrailsPlugin gets to block it. The metrics correctly reflect that the agent attempted the call — useful for tracking blocked attempts and detecting potential prompt injection.
Plugin hooks fire in the order plugins appear in the array. Since MetricsPlugin is listed first, its onToolCall handler runs before GuardrailsPlugin's. The metric captures the attempt. If you wanted metrics to exclude blocked calls, you'd need to put MetricsPlugin after GuardrailsPlugin and check the event state.
Why does the Steering plugin use conditional rules (when) instead of putting all guidance in the system prompt?
Correct. Three benefits: (1) Smaller prompts = better model performance (less noise to attend to). (2) Each rule is independently testable — you can verify "coding-style activates when user mentions code." (3) Rules are reusable — build a library of steering rules and compose different sets per agent.
There's no hard character limit on system prompts, but longer prompts degrade model performance. Conditional steering keeps each turn's prompt focused on what's relevant right now. Plus, individual rules are testable ("does this rule activate in this context?") and reusable across agents.
The Context Offloader replaces a 10,000-character tool result with a 200-character summary. What happens if the agent needs the full result later?
Correct. The offloader stores the full result externally and injects a retrieve tool into the agent. The summary in context includes a reference ID. When the agent needs details, it calls retrieve('offload-abc123') and gets the full result back — on demand, not wasting context when it's not needed.
The Context Offloader doesn't discard data — it moves it to external storage (memory or S3). It auto-injects a retrieve tool that the agent can call with the reference ID to get the full result back. This is demand-loading: full data available when needed, not wasting context when it's not.
📖 Primary Source

Plugins — Strands Agents Docs — Full API reference for the plugin interface, built-in plugins, and extension patterns. ~10 min read.

💬 Questions? Ask me about designing custom plugins, composing steering rules for complex agents, context offloading strategies, or building guardrails for production deployments.
← Back Next →