Agent Control Patterns for SDK Platforms
Core Control Levers
Control agents through five primary mechanisms:
allowedTools— what tools the agent can callpermissionMode— whether to bypass permission promptsmaxTurns— hard iteration capmaxBudgetUsd— hard spend caphooks— programmatic allow/deny on every tool call
Pattern: Minimal Tool Allow-List
Give the agent only the tools it needs. Fewer tools = more predictable behavior:
// For a code-writing agent: read and edit only
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep"]
// For a test runner: also needs Bash
allowedTools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"]
// For a read-only analysis agent
allowedTools: ["Read", "Glob", "Grep"]
Pattern: File Pre-Creation
Pre-create the output file before running the agent. Instruct the agent to use Edit instead of Write:
// 1. Scaffold the file before SDK run
writeFileSync(testFilePath, "// Scaffolded by platform\n", "utf8");
// 2. Augment the prompt with file path instruction
function buildPromptWithFilePath(userPrompt: string, testFilePath: string): string {
return [
userPrompt,
"",
"---",
`IMPORTANT: A file has been pre-created at: ${testFilePath}`,
"Use the Edit tool to modify this file. Do NOT create a new file with Write.",
].join("\n");
}
// 3. Read the file back after SDK completes
const code = readFileSync(testFilePath, "utf8");
Why: Combining createFileRestrictionHook(testFilePath) with pre-creation gives you full control over what the agent writes and where.
Pattern: Constants File
Never hardcode SDK config inline. Use a dedicated *.const.ts:
// agent-sdk.const.ts
export const SDK_MODEL = "claude-sonnet-4-5-20250929" as const;
export const SDK_MAX_BUDGET_USD = 2; // $2 hard cap
export const SDK_MAX_TURNS = 50; // 50 iterations max
export const SDK_PERMISSION_MODE = "bypassPermissions" as const;
export const SDK_ALLOWED_TOOLS = ["Read", "Write", "Edit", "Bash", "Glob", "Grep"] as const;
// Per-tool timeouts (used in hooks)
export const ESLINT_TIMEOUT_MS = 30_000;
export const TSC_TIMEOUT_MS = 60_000;
export const LINT_OUTPUT_TRUNCATION_LIMIT = 2_000;
export const TSC_OUTPUT_TRUNCATION_LIMIT = 3_000;
Pattern: System Prompt Engineering
System prompts control agent strategy. Structure them with explicit phases:
const systemPrompt = `
You are a test generation agent. Follow these phases strictly:
## Phase 1: Analyze
Read the source file. Identify all exported functions/classes.
## Phase 2: Generate
Write comprehensive tests to the pre-created test file using Edit.
Cover: happy path, edge cases, error cases.
## Phase 3: Verify
Run the tests with Bash. Do NOT run more than 3 times.
## Constraints
- ONLY edit the pre-created test file
- Do NOT install packages
- Do NOT modify source files
`.trim();
Pattern: Conditional Hook Composition
Build the hook list dynamically — some hooks may not be available:
const fileRestrictionHook = createFileRestrictionHook(params.testFilePath);
const lintFixHook = createLintFixHook(params.workingDirectory, params.testFilePath);
const typecheckHook = createTypecheckHook(params.workingDirectory, params.testFilePath);
const postEditHooks = [
...(lintFixHook ? [lintFixHook] : []),
...(typecheckHook ? [typecheckHook] : []),
];
const hooks = {
PreToolUse: [
{ matcher: "Write|Edit", hooks: [fileRestrictionHook] },
{ matcher: "Read", hooks: [envProtectionHook] },
],
PostToolUse: [
{ matcher: "Bash", hooks: [testPruneHook] },
...(postEditHooks.length > 0 ? [{ matcher: "Write|Edit", hooks: postEditHooks }] : []),
],
};
Pattern: Run Metadata
Always track and persist run metadata for observability:
if (isResultMessage(message)) {
const metadata = {
durationMs: message.duration_ms,
costUsd: message.total_cost_usd,
numTurns: message.num_turns,
modelUsage: message.modelUsage,
subtype: message.subtype, // "success" | "error_max_turns" | "error_during_execution"
};
logger.info("SDK run complete", metadata);
// Prepend to output file as a JSDoc comment
const comment = [
"/**",
` * @generated Agent SDK`,
` * @duration ${(message.duration_ms / 1000).toFixed(1)}s`,
` * @cost $${message.total_cost_usd.toFixed(4)}`,
" */",
"",
].join("\n");
const existing = readFileSync(outputPath, "utf8");
writeFileSync(outputPath, comment + existing, "utf8");
}
Pattern: AbortSignal with Cleanup
Forward cancellation from external sources to the SDK:
async run(params: { abortSignal?: AbortSignal }): Promise<Result> {
const abortController = new AbortController();
if (params.abortSignal) {
params.abortSignal.addEventListener("abort", () => abortController.abort());
}
try {
for await (const message of query({ ..., options: { ..., abortController } })) {
if (abortController.signal.aborted) break;
// ...
}
} finally {
// cleanup if needed
}
}
Pattern: Verbose Logging Toggle
Use an env-controlled verbose flag to switch between minimal and debug logging:
export const isSdkVerboseLogging = process.env.SDK_VERBOSE === "true";
// In your MessageLogger
logMessage(message: SDKMessage): void {
if (!this.verbose) return; // skip in production
console.log("[SDK]", message.type, ...);
}
Advanced Control Patterns
For more patterns from these references:
references/hook-strategy.md— hook strategy matrix, minimum safe set, graduated levels (security→control→quality→guidance), composition best practices, anti-patterns to avoidreferences/velocity-control.md— velocity governor (rate-limit tool calls per minute), output validator (heuristic placeholder detection),maxTurnsas circuit breaker, budget guard hook, auto-checkpoint infinallyblock
Example:
examples/complete-runner.ts— production-ready runner with Inversify DI, conditional hook composition, null-filtering, augmented prompt, full message iteration, metadata prepend