
I tried out the Steering feature of Strands Agents with the TypeScript SDK
This page has been translated by machine translation. View original
Introduction
Hello, I'm Kamino from the consulting department, and I love supermarkets.
It's the height of summer and it's so hot. These days I find myself craving soft-serve ice cream from La Mu.
Changing the subject from that, in previous articles I tried out Strands Agents' Interventions feature and Human in the Loop handler in Python!
In the official documentation, there is 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 has a different foundation from the Python version which uses the Plugins interface. I had briefly touched on Steering for the Python version in a re:Invent summary article, but since I hadn't really explored the Steering feature much, I was curious and decided to try it out this time!
Looking back on this, the TypeScript version has really come to match the Python version in terms of features!
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 it 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 "modular prompting" mechanism that inserts feedback precisely at the moment it's needed.
That's quite a cool term... modular prompting... I'll keep that in mind.
The intervention points are two: just before a tool call (beforeToolCall) and immediately after a model response (afterModelCall). The actions that can be returned 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 documents 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 I tried as its foundation. As a result, the returnable actions are also constrained by types, and returning Deny or Transform will be caught by type checking!
Trying It Out
There are two ways to write handlers. An imperative approach where you inherit from SteeringHandler and write logic, and an approach where you pass natural language rules to LLMSteeringHandler. Let's try them in order!
Enforcing Business Rules with SteeringHandler
First, let's start with the imperative SteeringHandler. Using an expense application 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',
inputSchema: z.object({
amount: z.number().describe('Amount (yen)'),
description: z.string().describe('Expense details'),
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] Over 50,000 yen without pre-approval number → guide to reject')
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) {
// Do not inspect intermediate responses during tool calls (only check final responses)
if (event.stopData?.stopReason !== 'endTurn') {
return InterventionActions.proceed()
}
// Only inspect responses immediately after submit_expense succeeded
// When guided back, feedback is stacked at the end of the history, so trace 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 → guide to retry (${this.guideCount}/${this.maxGuides})`)
return InterventionActions.guide(
'The final response must always include the application ID (a 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:
'Make autonomous decisions to complete tasks without asking the user for confirmation. Answer in Japanese.',
})
const result = await agent.invoke('Please submit a 78,000 yen expense for Shinkansen fare on a business trip')
console.log(String(result))
The handler you created is passed to the Agent's interventions option.
I added two improvements to the afterModelCall side. The first is the stopReason check. It seems afterModelCall also fires for each tool call (stopReason: toolUse), and when I ran it without this check, it kept rejecting intermediate responses and fell into an infinite loop... Only final responses (endTurn) are inspected.
The second is narrowing down the inspection target — by checking the most recent tool result, I only inspect the response immediately after a successful submission. Without this, if the check runs on a response asking for an approval number, it would give an impossible instruction and loop again. As insurance, I also added an upper limit on the number of guides.
The reason for this is that Guide in afterModelCall, unlike in beforeToolCall, discards the already-generated response and the framework retries the model call. Giving an impossible instruction means the retry never stops, so I felt that loop prevention is essential.
Let's run it. First, the pattern where no approval number is provided.
Proceeding to submit the business trip expense.
[Steering] Over 50,000 yen without pre-approval number → guide to reject
🚫 Tool #1: submit_expense (denied)
✗ Tool failed
I apologize, but since 78,000 yen exceeds 50,000 yen, a pre-approval number is required.
Please check and provide a pre-approval number (a number starting with PA-).
The first tool call was rejected by Guide.
Rather than making up a number on its own, the model chose to ask back "Please tell me the approval number."
Next, the pattern where the approval number is included in the prompt.
🔧 Tool #1: submit_expense
[submit_expense] amount=78000, description=Shinkansen fare for business trip, preApprovalNumber=PA-2026-123
✓ Tool completed
The expense application has been completed. The following has been submitted:
- Application ID: EXP-2026-0712
- Amount: 78,000 yen
- Description: Shinkansen fare for business trip
- Pre-approval number: PA-2026-123
This time beforeToolCall passed with Proceed, the tool was executed, and since the final response includes the application ID, the afterModelCall check also passed with Proceed.
Finally, let's also see the afterModelCall Guide in action. To intentionally omit the application ID, I'll add "Answer with only the single sentence 'The application is complete'" to the prompt.
🔧 Tool #1: submit_expense
[submit_expense] amount=78000, description=Shinkansen fare for business trip, preApprovalNumber=PA-2026-123
✓ Tool completed
The application is complete
[Steering] No application ID in final response → guide to retry (1/3)
The application is complete. Application ID: EXP-2026-0712
The response without the ID was generated first, but it was discarded by Guide, retried, and this time the application ID was included. We confirmed that two intervention points can be handled by a single handler!
Giving Ambiguous Rules in Natural Language with LLMSteeringHandler
Next is LLMSteeringHandler. An evaluation LLM checks natural language rules against the context and makes judgments. Rules that can be written as if statements are fine with the imperative handler, so here I'll try the kind of ambiguous judgments only an LLM can make.
I'll give a file organization agent the rule "temporary files can be deleted, but stop deletions of files that seem important for business."
const handler = new LLMSteeringHandler({
systemPrompt: `You are a steering monitor for a file organization agent.
Rules:
- Allow deletion of temporary files (files with extensions .log / .tmp / .cache) with proceed
- Stop deletion of files judged as important for business from their filename (financial documents, contracts, final versions, etc.) with guide. Include in the feedback an instruction "to confirm with the user whether it is really okay to delete"
- Also allow deletion of other files with proceed`,
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: 'Return 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',
'FY2026_FinancialReport_Final.xlsx',
'meeting_notes_0729.txt',
].join('\n')
},
})
const deleteFile = tool({
name: 'delete_file',
description: 'Delete 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 monitor for a file organization agent.
Rules:
- Allow deletion of temporary files (files with extensions .log / .tmp / .cache) with proceed
- Stop deletion of files judged as important for business from their filename (financial documents, contracts, final versions, etc.) with guide. Include in the feedback an instruction "to confirm with the user whether it is really okay to delete"
- Also allow deletion of other files with proceed`,
model,
})
const agent = new Agent({
model,
tools: [listFiles, deleteFile],
interventions: [handler],
systemPrompt:
'Make autonomous decisions to complete tasks without asking the user for confirmation. Answer in Japanese.',
})
const result = await agent.invoke(
'Please delete everything in the temp directory to clean it up',
)
console.log('\n=== Final Result ===')
console.log(String(result))
Just pass the rule text to LLMSteeringHandler and register 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 temp1.log and other temporary files, meeting_notes_0729.txt, and 決算資料_2026年度_最終版.xlsx in the temp directory, and casually request "delete everything." For safety, delete_file is a mock tool that just logs without actually deleting.
🔧 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
Since "決算資料_2026年度_最終版.xlsx" appears to be an important financial document, I paused before deleting it to confirm.
Is it really okay to delete it? Please let me know and I will proceed with the deletion.
Oh, only the deletion of 決算資料_2026年度_最終版.xlsx was denied! The evaluation LLM judged "this is an important file" and returned Guide, and the model switched to the action of "putting the deletion on hold and confirming."
However, meeting_notes_0729.txt was allowed. Whether meeting minutes are considered important varies from person to person, so such fluctuation is unavoidable when delegating judgment to an LLM. For files you absolutely cannot afford to have deleted, it seems best 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, a ToolLedgerProvider is attached that records tool call history and passes it 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. I'll create a ToolCallCounter that counts the cumulative number of tool calls, and combine it with the rule "guide 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 monitor for an agent's actions.
You will be given the number of tool calls as context (toolCallCounter.totalCalls).
Rules:
- After toolCallCounter.totalCalls reaches 5, stop any additional tool calls with guide. Include in the feedback the instruction "Please summarize a final answer based on the search results gathered so far"
- Allow all other calls with proceed`,
model,
contextProviders: [new ToolCallCounter()],
})
const agent = new Agent({
model,
tools: [searchCatalog],
interventions: [handler],
systemPrompt:
'Make autonomous decisions to complete tasks without asking the user for confirmation.' +
'Keep searching at least 7 times, changing keywords and phrasing until the product is found.' +
'Answer 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 monitor for an agent's actions.
You will be given the number of tool calls as context (toolCallCounter.totalCalls).
Rules:
- After toolCallCounter.totalCalls reaches 5, stop any additional tool calls with guide. Include in the feedback the instruction "Please summarize a final answer based on the search results gathered so far"
- Allow all other calls with proceed`,
model,
contextProviders: [new ToolCallCounter()],
})
const agent = new Agent({
model,
tools: [searchCatalog],
interventions: [handler],
systemPrompt:
'Make autonomous decisions to complete tasks without asking the user for confirmation.' +
'Keep searching at least 7 times, changing keywords and phrasing until the product is found.' +
'Answer 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 (collecting data with Hooks) and the context getter (returning a snapshot). Note that specifying contextProviders removes the default ToolLedgerProvider, so if you also want to see the history, pass both.
To make the behavior clear, the model is instructed to "keep searching at least 7 times," while the Steering side is given the rule "have it start summarizing after more than 5 times." This creates a confrontation between the instructions given to the model and external intervention.
🔧 Tool #1: search_catalog
[search_catalog] query=X-200 → No results
(Omitted: Tools #2–#6 also returned no results)
[ToolCallCounter] Cumulative tool calls: 6
🚫 Tool #7: search_catalog (denied)
[ToolCallCounter] Cumulative tool calls: 7
I apologize, but despite searching with multiple keywords, the product
"X-200" does not appear to be registered in the internal product catalog DB at this time.
The model tried to search 7 times as instructed, but the 7th call was stopped by Guide. Looking into the conversation history to see what was passed to the model at the time of rejection, the following was recorded as a tool result (status: error).
GUIDANCE: [strands:llm-steering-handler] The tool has been called 5 times.
Please summarize a final answer based on the search results gathered so far. Please refrain from making 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 think, "Isn't it executing more than the specified number of times?"
At the time of evaluating Tool #7, the counter is 6, yet the evaluation LLM's reason says "5 times reached." The evaluation happens every time as guaranteed by the mechanism, but since the numerical judgment is left to the LLM, is there perhaps some slight misreading happening near the boundary? I was curious and tried changing the threshold and switching the evaluation LLM to Sonnet 5 and running it several times, but strangely the ±1 fluctuation near the boundary persisted.
For strict rules like counting, it might be better to use the imperative SteeringHandler for the judgment. (This was too unstable for me to actually want to implement it... I wonder why this is happening... it's bothering me...)
Note that the counter increments on every AfterToolCallEvent, and it fires even for calls cancelled by Guide. The reason the cumulative count becomes 7 after Tool #7, which was not actually executed, is because of this.
Conclusion
By revisiting Steering again, I gained a deeper understanding!
Depending on the situation and requirements, it will be worth thinking about whether to give feedback mechanically or control through natural language.
In particular, I get the impression that handling through natural language can vary quite a bit depending on how capable the model is.
I hope this article is helpful in some way. Thank you for reading to the end!
Supplement (ToolLedgerProvider)
ToolLedgerProvider, the default Context Provider, records the history of tool calls and passes it to the evaluation LLM. The recorded information is as follows.
- Tool name and input arguments
- Start and end timestamps
- Execution status (pending / success / error)
- Tool result contents and error messages
It's easier 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 retrying searches with different queries is visible to the evaluation LLM, it seems useful for specifying rules based on behavioral patterns, such as "if the same tool fails 3 times in a row, prompt for a different approach." There are two options, and you can modify them by creating your own instance 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 automatically selected. Conversely, passing an empty array [] disables it, and as mentioned in Demo 3, you can also pass both a custom provider and this one together.
