
I tried out the Steering feature of Strands Agents with the TypeScript version of the SDK
This page has been translated by machine translation. View original
Introduction
Hello, I'm Kamino from the Consulting Division, and I love supermarkets.
It's the height of summer and so hot. These days I find myself craving soft serve ice cream from La Mu.
Changing the subject from that, in my previous articles, I tried out the Strands Agents Interventions feature and the Human in the Loop handler in Python!
In the official documentation, there's a page about the Steering feature within the Interventions tab, and I thought, "Wait, wasn't this provided as a plugin in Python??" but it turned out to be a TypeScript-only page.
It seems the TypeScript version is built on top of Interventions, and differs from the Python version which uses the Plugins interface. I had briefly touched on Steering in Python in a re:Invent summary article, but since I hadn't really explored the Steering feature much, I was curious and took this opportunity to try it out!
Looking back like this, the TypeScript version has come to be just as feature-rich as the Python version!
Prerequisites
The verification environment for this time is as follows.
| Item | Version |
|---|---|
| OS | macOS (Apple Silicon) |
| Node.js | 24.5.0 |
| pnpm | 11.11.0 |
| tsx | 4.23.1 |
| @strands-agents/sdk | 1.11.2 |
| Model | Claude Haiku 4.5 (Amazon Bedrock) |
I'll use pnpm for package management and tsx for running TypeScript. Create a project and install the dependencies. The SDK version is pinned to reproduce the same behavior as in the article.
pnpm init
pnpm add @strands-agents/sdk@1.11.2 zod@4.4.3
pnpm add -D tsx typescript @types/node
You can run with pnpm tsx <filename>.
Steering
Checking the official documentation, it states the following.
Steering provides modular prompting for complex agent tasks through context-aware guidance that appears when relevant, rather than front-loading all instructions in monolithic prompts.
Rather than cramming all instructions into a massive system prompt, this is a mechanism called "modular prompting" that inserts feedback at precisely the right moment when needed.
What a cool term... modular prompting... I'll keep that in mind.
The intervention points are two: immediately before a tool call (beforeToolCall) and immediately after a model response (afterModelCall). The actions you can return are 3 types for beforeToolCall: proceed (continue) / guide (feedback and retry) / confirm (wait for human approval), and 2 types for afterModelCall: proceed and guide.
Differences from the Python version
Reading both documentation pages side by side, I noticed the structure is different.
| Aspect | Python version | TypeScript version |
|---|---|---|
| Package | strands.vended_plugins.steering |
@strands-agents/sdk/vended-interventions/steering |
| Underlying mechanism | Plugins interface | Interventions framework |
| Registration method | plugins=[handler] |
interventions: [handler] |
| Override | steer_before_tool / steer_after_model |
beforeToolCall / afterModelCall |
| Returned actions | Custom SteeringAction | Typed actions from Interventions |
| History provider | LedgerProvider |
ToolLedgerProvider |
They share the name Steering, but the TypeScript version uses the same typed action mechanism from the Interventions article as its foundation. Thanks to that, the returnable actions are also constrained by types, so the structure rejects you with a type check error if you try to return Deny or Transform!
Trying It Out
There are two ways to write handlers. An imperative approach where you inherit SteeringHandler and write logic, and an approach where you pass natural language rules to LLMSteeringHandler. I'll try them in order!
Enforcing Business Rules with SteeringHandler
First, let's start with the imperative SteeringHandler. Using an expense report agent as the subject, I'll implement the following 2 rules.
- Applications exceeding 50,000 yen require a pre-approval number (beforeToolCall)
- The final response upon successful submission must include the application ID (afterModelCall)
import { Agent, tool, InterventionActions } from '@strands-agents/sdk'
import type { BeforeToolCallEvent, AfterModelCallEvent } from '@strands-agents/sdk'
import { BedrockModel } from '@strands-agents/sdk/models/bedrock'
import { SteeringHandler } from '@strands-agents/sdk/vended-interventions/steering'
import { z } from 'zod'
const submitExpense = tool({
name: 'submit_expense',
description: 'Submit an expense report',
inputSchema: z.object({
amount: z.number().describe('Amount (in yen)'),
description: z.string().describe('Description of the expense'),
preApprovalNumber: z.string().optional().describe('Pre-approval number'),
}),
callback: (input) => {
console.log(
`\n[submit_expense] amount=${input.amount}, description=${input.description}, preApprovalNumber=${input.preApprovalNumber ?? 'none'}`,
)
return `Expense submitted as application ID EXP-2026-0712 (Amount: ${input.amount} yen)`
},
})
class ExpenseSteeringHandler extends SteeringHandler {
override readonly name = 'expense-steering'
private guideCount = 0
private readonly maxGuides = 3
override beforeToolCall(event: BeforeToolCallEvent) {
if (event.toolUse.name === 'submit_expense') {
const input = event.toolUse.input as {
amount: number
preApprovalNumber?: string
}
if (input.amount > 50000 && !input.preApprovalNumber) {
console.log('\n[Steering] Amount exceeds 50,000 yen and no pre-approval number → returning guide')
return InterventionActions.guide(
'Expense applications exceeding 50,000 yen require a pre-approval number. ' +
'Please set an approval number starting with "PA-" in preApprovalNumber and resubmit.',
)
}
}
return InterventionActions.proceed()
}
override afterModelCall(event: AfterModelCallEvent) {
// Don't check intermediate responses during tool calls (only check final responses)
if (event.stopData?.stopReason !== 'endTurn') {
return InterventionActions.proceed()
}
// Only inspect responses immediately after submit_expense succeeds
// When guiding back, feedback is appended to the end of history, so we look back to the most recent tool result
const lastToolResultMessage = event.agent.messages.findLast((message) =>
message.content.some((block) => block.type === 'toolResultBlock'),
)
const succeeded =
lastToolResultMessage?.content.some(
(block) =>
block.type === 'toolResultBlock' &&
block.status === 'success' &&
block.content.some(
(content) =>
content.type === 'textBlock' && content.text.includes('EXP-'),
),
) ?? false
if (!succeeded) {
return InterventionActions.proceed()
}
const text =
event.stopData?.message.content
.filter((block) => 'text' in block)
.map((block) => ('text' in block ? block.text : ''))
.join('') ?? ''
if (!text.includes('EXP-')) {
this.guideCount += 1
if (this.guideCount > this.maxGuides) {
console.log('\n[Steering] Guide limit reached, proceeding as-is')
return InterventionActions.proceed()
}
console.log(`\n[Steering] No application ID in final response → retrying with guide (${this.guideCount}/${this.maxGuides})`)
return InterventionActions.guide(
'The final response must always include the application ID (the number starting with EXP-).',
)
}
return InterventionActions.proceed()
}
}
const model = new BedrockModel({
modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
clientConfig: { region: 'us-east-1' },
})
const agent = new Agent({
model,
tools: [submitExpense],
interventions: [new ExpenseSteeringHandler()],
systemPrompt:
'Do not ask the user for confirmation; make autonomous judgments to complete tasks. Respond in Japanese.',
})
const result = await agent.invoke('Please submit a business trip Shinkansen fare of 78,000 yen as an expense')
console.log(String(result))
The handler you create is passed to the Agent's interventions option.
There are two tricks on the afterModelCall side. The first is the stopReason check. It seems afterModelCall fires every time there's a tool call (stopReason: toolUse), and when I ran it without this check, it kept redirecting intermediate responses and fell into an infinite loop... So I only inspect final responses (endTurn).
The second is narrowing down the inspection targets, checking the most recent tool result and only inspecting immediately after a successful submission. If the check ran on responses asking back about approval numbers, the unachievable instruction would cause another loop. As a safeguard, I've also added a guide count limit.
The reason is that unlike beforeToolCall, Guide in afterModelCall discards the already-generated response and the framework retries the model call. If you give an unachievable instruction, the retries never stop, so I felt that loop prevention is essential.
Let me run it. First, the pattern without providing an approval number.
I will submit the business trip transportation expense.
[Steering] Amount exceeds 50,000 yen and no pre-approval number → returning guide
🚫 Tool #1: submit_expense (denied)
✗ Tool failed
I apologize. Since 78,000 yen exceeds 50,000 yen, a pre-approval number is required.
Please check and provide your pre-approval number (a number starting with PA-).
The first tool call was redirected with Guide.
Rather than making up a number on its own, the model chose to ask back "please provide the approval number".
Next, the pattern where the approval number is included in the prompt.
🔧 Tool #1: submit_expense
[submit_expense] amount=78000, description=Business trip Shinkansen fare, preApprovalNumber=PA-2026-123
✓ Tool completed
The expense application has been completed. It has been submitted with the following details:
- Application ID: EXP-2026-0712
- Amount: 78,000 yen
- Description: Business trip Shinkansen fare
- Pre-approval number: PA-2026-123
This time it passed through beforeToolCall with Proceed, the tool was executed, and since the final response includes the application ID, the afterModelCall check also returned Proceed.
Finally, let's also watch the Guide on the afterModelCall side in action. To intentionally omit the application ID, I'll add to the prompt "Answer the final response with only the single sentence 'The application is complete'".
🔧 Tool #1: submit_expense
[submit_expense] amount=78000, description=Business trip Shinkansen fare, preApprovalNumber=PA-2026-123
✓ Tool completed
The application is complete
[Steering] No application ID in final response → retrying with guide (1/3)
The application is complete. Application ID: EXP-2026-0712
The response without the ID was initially generated, but it was discarded by Guide and retried, and this time the application ID was included. We confirmed that two intervention points can be managed by a single handler!
Specifying Ambiguous Rules in Natural Language with LLMSteeringHandler
Next is LLMSteeringHandler. An evaluation LLM cross-references natural language rules against the context to make judgments. Rules that can be written with if statements are fine with the imperative handler, so here I'll try the kind of ambiguous judgment that's unique to LLMs.
I'll give a file organization agent the rule: "It's okay to delete temporary files, but stop deletion of files that seem business-critical."
const handler = new LLMSteeringHandler({
systemPrompt: `You are a steering role that monitors the behavior of a file organization agent.
Rules:
- Allow deletion of temporary files (files with extensions .log / .tmp / .cache) with proceed
- Stop deletion of files that appear to be business-critical based on their filename (financial documents, contracts, final versions, etc.) with guide. The feedback in this case should include an instruction to "confirm with the user whether it's really okay to delete"
- Allow deletion of all other files with proceed as well`,
model,
})
const agent = new Agent({
model,
tools: [listFiles, deleteFile],
interventions: [handler],
})
Full code
import { Agent, tool } from '@strands-agents/sdk'
import { BedrockModel } from '@strands-agents/sdk/models/bedrock'
import { LLMSteeringHandler } from '@strands-agents/sdk/vended-interventions/steering'
import { z } from 'zod'
const listFiles = tool({
name: 'list_files',
description: 'Returns a list of files in the specified directory',
inputSchema: z.object({
directory: z.string().describe('Directory path'),
}),
callback: () => {
return [
'temp1.log',
'temp2.log',
'cache_20260729.tmp',
'financial_report_FY2026_final.xlsx',
'meeting_notes_0729.txt',
].join('\n')
},
})
const deleteFile = tool({
name: 'delete_file',
description: 'Deletes the file at the specified path',
inputSchema: z.object({
path: z.string().describe('Path of the file to delete'),
}),
callback: (input) => {
console.log(`\n[delete_file] Deleted ${input.path}`)
return `Deleted ${input.path}`
},
})
const model = new BedrockModel({
modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
clientConfig: { region: 'us-east-1' },
})
const handler = new LLMSteeringHandler({
systemPrompt: `You are a steering role that monitors the behavior of a file organization agent.
Rules:
- Allow deletion of temporary files (files with extensions .log / .tmp / .cache) with proceed
- Stop deletion of files that appear to be business-critical based on their filename (financial documents, contracts, final versions, etc.) with guide. The feedback in this case should include an instruction to "confirm with the user whether it's really okay to delete"
- Allow deletion of all other files with proceed as well`,
model,
})
const agent = new Agent({
model,
tools: [listFiles, deleteFile],
interventions: [handler],
systemPrompt:
'Do not ask the user for confirmation; make autonomous judgments to complete tasks. Respond in Japanese.',
})
const result = await agent.invoke(
'Please delete everything in the temp directory and clean it up',
)
console.log('\n=== Final Result ===')
console.log(String(result))
It's as simple as passing a rule text to LLMSteeringHandler and registering it. If you omit model, the parent agent's model is also used for evaluation. Note that by default, only beforeToolCall is evaluated; if you also want to monitor model output, implement afterModelCall in a subclass.
I'll place temporary files like temp1.log alongside meeting_notes_0729.txt and financial_report_FY2026_final.xlsx in the temp directory, then casually request "delete everything". For safety, delete_file is a mock tool that just returns a log as if it deleted the file.
🔧 Tool #2: delete_file
[delete_file] Deleted temp/temp1.log
🔧 Tool #3: delete_file
[delete_file] Deleted temp/temp2.log
🔧 Tool #4: delete_file
[delete_file] Deleted temp/cache_20260729.tmp
🚫 Tool #5: delete_file (denied)
🔧 Tool #6: delete_file
[delete_file] Deleted temp/meeting_notes_0729.txt
"financial_report_FY2026_final.xlsx" appears to be a business-critical financial document, so I paused before deletion.
Is it really okay to delete it? Please let me know and I will delete it.
Oh, only the deletion of financial_report_FY2026_final.xlsx was denied! The evaluation LLM judged "this is an important file" and returned Guide, and the model switched to the behavior of "putting deletion on hold and confirming".
However, meeting_notes_0729.txt was allowed. Whether meeting notes are considered important varies from person to person, so as long as judgment is delegated to an LLM, such fluctuations are unavoidable. For files you absolutely don't want deleted, it seems better to protect them firmly with an imperative handler.
Passing Quantitative Context with a Custom Context Provider
Finally, I'll try a Context Provider that customizes the information passed to the evaluation LLM.
By default, ToolLedgerProvider is attached, which records and passes tool call history to the evaluation LLM (see the supplement at the end of the article for details), but you can write your own provider to pass any data you want. I'll create a ToolCallCounter that counts the cumulative number of tool calls, and combine it with a rule that "guides the agent to start summarizing if it searches too many times".
class ToolCallCounter implements SteeringContextProvider {
readonly name = 'toolCallCounter'
private _count = 0
observeAgent(agent: LocalAgent): void {
agent.addHook(AfterToolCallEvent, () => {
this._count += 1
console.log(`\n[ToolCallCounter] Cumulative tool calls: ${this._count}`)
})
}
get context(): SteeringContextData {
return { type: 'toolCallCounter', totalCalls: this._count }
}
}
const handler = new LLMSteeringHandler({
systemPrompt: `You are a steering role that monitors agent behavior.
The number of tool calls (toolCallCounter.totalCalls) will be passed as context.
Rules:
- Stop additional tool calls after toolCallCounter.totalCalls reaches 5 or more with guide. The feedback in this case should include the instruction "Please summarize your final answer based on what you've found in the search results so far"
- Allow all other calls with proceed`,
model,
contextProviders: [new ToolCallCounter()],
})
const agent = new Agent({
model,
tools: [searchCatalog],
interventions: [handler],
systemPrompt:
'Do not ask the user for confirmation; make autonomous judgments to complete tasks. ' +
'Keep searching with different keywords and phrasing until you find the product, at least 7 times. ' +
'Respond in Japanese.',
})
Full code
import { Agent, tool, AfterToolCallEvent } from '@strands-agents/sdk'
import type { LocalAgent } from '@strands-agents/sdk'
import { BedrockModel } from '@strands-agents/sdk/models/bedrock'
import { LLMSteeringHandler } from '@strands-agents/sdk/vended-interventions/steering'
import type {
SteeringContextProvider,
SteeringContextData,
} from '@strands-agents/sdk/vended-interventions/steering'
import { z } from 'zod'
const searchCatalog = tool({
name: 'search_catalog',
description: 'Search the internal product catalog DB',
inputSchema: z.object({
query: z.string().describe('Search keyword'),
}),
callback: (input) => {
console.log(`\n[search_catalog] query=${input.query} → no results`)
return 'Search results: No matching products found'
},
})
class ToolCallCounter implements SteeringContextProvider {
readonly name = 'toolCallCounter'
private _count = 0
observeAgent(agent: LocalAgent): void {
agent.addHook(AfterToolCallEvent, () => {
this._count += 1
console.log(`\n[ToolCallCounter] Cumulative tool calls: ${this._count}`)
})
}
get context(): SteeringContextData {
return { type: 'toolCallCounter', totalCalls: this._count }
}
}
const model = new BedrockModel({
modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
clientConfig: { region: 'us-east-1' },
})
const handler = new LLMSteeringHandler({
systemPrompt: `You are a steering role that monitors agent behavior.
The number of tool calls (toolCallCounter.totalCalls) will be passed as context.
Rules:
- Stop additional tool calls after toolCallCounter.totalCalls reaches 5 or more with guide. The feedback in this case should include the instruction "Please summarize your final answer based on what you've found in the search results so far"
- Allow all other calls with proceed`,
model,
contextProviders: [new ToolCallCounter()],
})
const agent = new Agent({
model,
tools: [searchCatalog],
interventions: [handler],
systemPrompt:
'Do not ask the user for confirmation; make autonomous judgments to complete tasks. ' +
'Keep searching with different keywords and phrasing until you find the product, at least 7 times. ' +
'Respond in Japanese.',
})
const result = await agent.invoke('Please look up the price of the new product "X-200"')
console.log('\n=== Final Result ===')
console.log(String(result))
All you need to implement are observeAgent (collect data with hooks) and the context getter (return a snapshot). Note that explicitly specifying contextProviders removes the default ToolLedgerProvider, so if you also want to see history, pass both.
To make the behavior clear, I instruct the model to "keep searching at least 7 times", while giving the Steering side the rule "make it start summarizing after exceeding 5 times". It's a setup where the instruction to the model and external intervention conflict.
🔧 Tool #1: search_catalog
[search_catalog] query=X-200 → no results
(Omitted: Tools #2 through #6 also returned no results)
[ToolCallCounter] Cumulative tool calls: 6
🚫 Tool #7: search_catalog (denied)
[ToolCallCounter] Cumulative tool calls: 7
I apologize. I tried searching with multiple keywords, but it appears that the product
"X-200" is not currently registered in the internal product catalog DB.
The model attempted to search 7 times as instructed, but the 7th call was stopped by Guide. Peeking at the conversation history for the content passed to the model during the redirect, the following was recorded as a tool result (status: error).
GUIDANCE: [strands:llm-steering-handler] The tool has been called 5 times.
Please summarize your final answer based on what you've found in the search results so far. Please refrain from additional tool calls.
In the format GUIDANCE: [handler name] <reason generated by evaluation LLM>, the evaluation LLM's reason was passed directly as an instruction to the model!
You might wonder, isn't it executing more times than the specified count?
At the time of evaluating Tool #7, the counter is at 6, but the evaluation LLM's reason says "reached 5 times". While the mechanism guarantees that evaluation happens every time, perhaps some misreading occurs because the numerical judgment is left to the LLM? I was curious and tried changing the threshold and bumping the evaluation LLM to Sonnet 5 and running it several times, but a ±1 fluctuation near the boundary mysteriously persisted.
For strict rules like counts, it might be better to do the judgment with an imperative SteeringHandler. (This was too unstable for me to actually consider implementing... I wonder why this is happening... it bothers me...)
Note that the counter increments for every AfterToolCallEvent, and it also fires for calls that were cancelled by Guide. This is why the total shows 7 after Tool #7 which was not actually executed.
Conclusion
By revisiting Steering again, I gained a deeper understanding!
I'd like to consider whether to provide feedback mechanically or control it through natural language depending on the situation and requirements.
In particular, I have the impression that handling via natural language can vary quite a bit in behavior depending on how capable the model is.
I hope this article has been even a little helpful. Thank you very much for reading to the end!
Supplement: About ToolLedgerProvider
ToolLedgerProvider, the default Context Provider, records tool call history and passes it to the evaluation LLM. The information recorded is as follows.
- Tool name and input arguments
- Start and end timestamps
- Execution status (pending / success / error)
- Tool result content and error messages
It's quicker to see than to explain in words, so I dumped the contents of the context getter after calling a search tool twice.
{
"type": "toolLedger",
"calls": [
{
"startTime": "2026-07-30T15:58:48.287Z",
"id": "tooluse_lvXTT84V0KRUIb9e8zUBvB",
"name": "search_catalog",
"args": {
"query": "X-200"
},
"status": "success",
"endTime": "2026-07-30T15:58:52.105Z",
"result": [
{
"text": "Search results: No matching products found"
}
],
"error": null
},
{
"startTime": "2026-07-30T15:58:53.589Z",
"id": "tooluse_X3Mw8CLrzpE65w03scZWmC",
"name": "search_catalog",
"args": {
"query": "new product X-200 price"
},
"status": "success",
"endTime": "2026-07-30T15:58:59.057Z",
"result": [
{
"text": "Search results: No matching products found"
}
],
"error": null
}
]
}
Since the entire picture of searching with different queries is visible to the evaluation LLM, it seems useful for specifying rules that take behavioral patterns into account, such as "if the same tool fails 3 times in a row, encourage a different approach". There are two options, and you can change them by creating an instance yourself and passing it to contextProviders.
| Option | Default | Description |
|---|---|---|
maxEntries |
100 | Maximum number of calls to retain (oldest are removed when exceeded) |
name |
strands:steering:toolLedger |
Provider identifier |
Note that if contextProviders is not specified, ToolLedgerProvider is selected automatically. Conversely, passing an empty array [] disables it, and as mentioned in Demo 3, you can also pass both your custom provider and the default one together.
