Framework Adapter
This page walks through the integrations/strands/ reference implementation: a Python HookProvider for AWS Strands Agents. It covers how to construct FlowAgentsHooks, what telemetry it emits, how the policy gate binds to the canonical engine contract, and the documented limitations of this spike.
Everything in this page is grounded in the files under integrations/strands/. No behavior is inferred or aspirational unless explicitly labeled as direction.
Harness adapters vs. framework adapters
Harness adapters (Claude Code, Codex, Kiro, opencode, pi) integrate with coding-agent runtimes that have their own hook format: JSON on stdin, exit codes, and lifecycle events named by the harness. Each harness adapter normalizes its runtime’s hook payloads into the canonical Flow Agents telemetry taxonomy and delegates to scripts/telemetry/telemetry.sh.
Framework adapters are in-process packages. Strands Agents is not a coding-agent harness — it is a general-purpose Python agent SDK. Its hook surface (HookProvider / HookRegistry) is class-based and synchronous. There is no stdin/stdout protocol and no process exit codes as block signals. Hook callbacks receive typed Python event objects and can mutate them in place.
Despite the surface differences, the same canonical event taxonomy is used. The JSONL output from FlowAgentsHooks is structurally identical to the output produced by claude-telemetry-hook.js and codex-telemetry-hook.js.
Constructing FlowAgentsHooks
FlowAgentsHooks is the main entry point. It implements the Strands HookProvider protocol via duck typing, so strands-agents is not required at import time.
from flow_agents_strands import FlowAgentsHooks
hooks = FlowAgentsHooks(
workspace=".", # root of your project (reads .flow-agents/)
agent_name="my-agent", # appears in telemetry events
)
Constructor parameters (all optional):
| Parameter | Default | Purpose |
|---|---|---|
sink_path |
<workspace>/.flow-agents/.telemetry/full.jsonl |
JSONL telemetry output path or directory |
workspace |
os.getcwd() |
Root of the workspace; used to discover .flow-agents/ |
agent_name |
"strands-agent" |
Agent identifier embedded in telemetry events |
runtime |
"strands" |
Runtime label embedded in telemetry events |
policy_gate |
PolicyGate() |
Optional custom PolicyGate instance (for testing) |
FlowAgentsHooks is usable without strands-agents installed. Telemetry emission and steering_context() work in any Python environment. The register_hooks method (which wires callbacks into a HookRegistry) requires strands-agents and raises ImportError if the SDK is absent.
Wiring into an Agent
from strands import Agent
from strands.models import BedrockModel
from flow_agents_strands import FlowAgentsHooks
hooks = FlowAgentsHooks(workspace=".")
# Load steering context BEFORE constructing the agent.
# Strands' BeforeInvocationEvent does not expose a mutable system prompt,
# so steering must be injected at construction time.
system_prompt = (
"You are a helpful assistant.\n"
+ hooks.steering_context()
)
model = BedrockModel(model_id="anthropic.claude-3-5-sonnet-20241022-v2:0")
agent = Agent(model=model, system_prompt=system_prompt, hooks=[hooks])
result = agent("List the files in this directory.")
register_hooks is called by the Strands runtime when hooks=[hooks] is passed to Agent. It registers five callbacks:
| Strands event | Canonical event | What fires |
|---|---|---|
AgentInitializedEvent |
agentSpawn |
emit_session_start() — records session.start |
BeforeInvocationEvent |
userPromptSubmit |
emit("userPromptSubmit") — records turn.user |
AfterInvocationEvent |
stop |
emit_session_end(duration_s=…) — records session.end |
BeforeToolCallEvent |
preToolUse |
Telemetry + policy gate (config-protection) |
AfterToolCallEvent |
postToolUse |
emit_tool_result(…) — records tool.result |
This mapping is the STRANDS_TO_CANONICAL dict exposed at module level by integrations/strands/flow_agents_strands/telemetry.py:
STRANDS_TO_CANONICAL = {
"AgentInitializedEvent": "agentSpawn",
"BeforeInvocationEvent": "userPromptSubmit",
"AfterInvocationEvent": "stop",
"BeforeToolCallEvent": "preToolUse",
"AfterToolCallEvent": "postToolUse",
"AfterModelCallEvent": "postToolUse", # closest analogue; no tool name
"MessageAddedEvent": "userPromptSubmit",
}
Telemetry emitted
Events are written to .flow-agents/.telemetry/full.jsonl by default. The record shape matches build_base_event() in scripts/telemetry/telemetry.sh:
{
"schema_version": "0.3.0",
"timestamp": "1718000000000",
"session_id": "<uuid>",
"event_id": "<uuid>",
"event_type": "tool.invoke",
"agent": { "name": "my-agent", "runtime": "strands", "version": "unknown" },
"hook": {
"event_name": "preToolUse",
"source": "strands",
"stop_hook_active": null,
"raw_input": null
},
"tool": { "name": "edit", "normalized_name": "fs_write", "input": { ... } }
}
Canonical names map to schema event_type values via _CANONICAL_TO_SCHEMA in telemetry.py:
| Canonical name | Schema event_type |
|---|---|
agentSpawn |
session.start |
userPromptSubmit |
turn.user |
preToolUse |
tool.invoke |
permissionRequest |
tool.permission_request |
postToolUse |
tool.result |
stop |
session.end |
Telemetry is always fail-open: if the JSONL file cannot be written (OSError), the exception is swallowed silently. Telemetry must never block agent work.
Policy gate: config-protection
The config-protection policy binds to the canonical Node.js engine via subprocess. The binding is in integrations/strands/flow_agents_strands/policy.py in the PolicyGate class.
Primary mode — engine subprocess:
On BeforeToolCallEvent, if the tool name is a write-like tool (one of edit, write, fs_write, apply_patch, create_file, str_replace_editor), the gate serializes the event to a canonical JSON payload and spawns:
echo '{"hook_event_name":"PreToolUse","tool_name":"edit","tool_input":{"path":"biome.json"}}' \
| node scripts/hooks/run-hook.js config-protection config-protection.js
The engine exits 2 (block) or 0 (allow). Exit code 2 causes event.cancel_tool to be set to the block reason from stderr. Strands cancels the call and surfaces the message as the tool result. All other exit codes fail open.
The engine is located by _find_engine_paths() in this priority order:
FLOW_AGENTS_ENGINE_PATHenvironment variable (explicit override).- Relative to the package source file:
../../../../scripts/hooks/run-hook.js(works from a repo checkout). - Walked up from
os.getcwd()looking fornode_modules/@kontourai/flow-agents/scripts/hooks/run-hook.js(npm-installed package).
Fallback mode — Python evaluation:
If node is not on PATH or run-hook.js cannot be located, the gate degrades to a built-in Python implementation of the same logic and emits a one-time RuntimeWarning. The Python fallback uses the same PROTECTED_FILES frozenset as config-protection.js and is auditable. This is not silent: the warning is printed once to stderr.
Custom protected set:
If a PolicyGate is constructed with a custom protected_files frozenset, Python evaluation is used directly (the engine subprocess cannot receive a runtime-custom set). This path is intended for tests and local override only.
Workflow steering
Strands’ BeforeInvocationEvent does not expose a mutable system prompt at callback time.
The spike approach: call hooks.steering_context() at Agent construction time and append the result to the system prompt. steering_context() reads the current workflow state from .flow-agents/ and returns a text block. It also emits a turn.user telemetry event so the injection is recorded in the JSONL log.
This is a one-shot snapshot. It does not re-evaluate on every turn the way workflow-steering.js does at UserPromptSubmit. See the Limitations section for the productization path.
Documented limitations
The following limitations are from integrations/strands/README.md and reflect the current spike state. They are not defects to be worked around silently — they are honest gaps.
-
Node.js subprocess dependency: The primary policy binding spawns a Node.js subprocess for each
BeforeToolCallEventinvolving a write-like tool. Ifnodeis not on PATH or the package is not installed, the gate degrades to the Python fallback with a one-timeRuntimeWarning. To force the subprocess path, setFLOW_AGENTS_ENGINE_PATHto the absolute path ofrun-hook.js. -
Steering seam: Strands does not allow mutating the system prompt from
BeforeInvocationEvent. The workaround (steering_context()at Agent construction) is a one-shot snapshot; it does not re-evaluate on every turn. Productization would require either a custom Strands model wrapper that injects context per-turn, or upstream SDK support for mutable system-prompt context in the invocation event. -
session.usage event omitted: The JS harness emits a
session.usageevent on stop with token counts. The StrandsAfterInvocationEventdoes not expose token-usage data in the hook payload, so this event is not emitted. -
No analytics channel: The harness adapters write to two channels (full + analytics) with different redaction profiles. This spike writes only to the
fullchannel. -
No Console/HTTP sink: The bash transport supports POSTing events to a Console endpoint. This adapter writes JSONL only.
-
Runtime version is “unknown”: Strands does not expose its version through the hook event;
agent.versionis hardcoded to"unknown". -
No subagent/delegation event: The Strands SDK does not have a built-in delegation tool; the
subagentStart/subagentStoptelemetry path is not wired. -
Quality-gate policy omitted:
quality-gate.jsinvokes ruff/biome after edits. There is no clear Strands analogue yet.
Conformance status
The Strands adapter is L0 plus config protection via BeforeToolCallEvent cancellation. Its current status is:
conformance_status: L0 (+ config-protection via BeforeToolCallEvent)
host: AWS Strands Agents
event_coverage:
agentSpawn: AgentInitializedEvent (full fidelity)
userPromptSubmit: BeforeInvocationEvent (no per-turn injection — spike limitation)
preToolUse: BeforeToolCallEvent (full fidelity, cancellable)
postToolUse: AfterToolCallEvent (full fidelity)
stop: AfterInvocationEvent (full fidelity)
permissionRequest: no native equivalent
subagentStart: no native equivalent
subagentStop: no native equivalent
policy_coverage:
workflow_steering: partial — injected once at Agent construction, not per-turn
quality_gate: omitted — no current Strands analogue
stop_goal_fit: omitted — AfterInvocationEvent used for telemetry only
config_protection: wired at BeforeToolCallEvent (blocking via event.cancel_tool)
Running tests
The spike ships 50 unit tests that require no Strands SDK:
cd integrations/strands
python3 -m unittest discover
Related references
integrations/strands/flow_agents_strands/hooks.py—FlowAgentsHooksandregister_hooksintegrations/strands/flow_agents_strands/telemetry.py—TelemetrySink,STRANDS_TO_CANONICALintegrations/strands/flow_agents_strands/policy.py—PolicyGate, engine subprocess bindingintegrations/strands/flow_agents_strands/steering.py—SteeringContextintegrations/strands/README.md— spike README with quickstart and full limitations list- Runtime Hook Surface spec §6.2 — framework adapter contract and minimum viable adapter pseudocode
- Conformance — how to self-certify using the conformance kit
TypeScript native-import adapter (integrations/strands-ts/)
@kontourai/flow-agents-strands is the first native-import consumer of the policy engine contract. Where the Python adapter spawns a subprocess for each BeforeToolCallEvent policy check, the TS adapter calls config-protection.js’s exported run() function directly — zero subprocess overhead on the hot path.
The shipped native callback surface is telemetry plus config-protection blocking. The adapter’s workflow steering, quality-gate, and stop-goal-fit checks are conformance-shim-only through bin/conformance-shim.mjs; it is not a claim that production FlowAgentsHooks runs all four policies directly in Strands TS callbacks.
Key differences from the Python adapter
| Python adapter | TypeScript adapter | |
|---|---|---|
| Engine binding | subprocess (node run-hook.js …) |
require("config-protection.js").run() — in-process |
| Strands SDK | register_hooks(registry) → registry.add_callback |
registerHooks(registry) → registry.addCallback |
| Cancel signal | event.cancel_tool = reason |
event.cancel = reason (TS variant) |
| Conformance | L0 + config-protection | L2-targeted policy coverage via conformance shim |
| Test framework | stdlib unittest (Python) | node:test (no extra deps) |
TypeScript capability states
| Capability | State | Public behavior |
|---|---|---|
| Telemetry callbacks | shipped | Production callbacks emit canonical JSONL events. |
| Config-protection hot path | shipped | Native run() import blocks via event.cancel. |
| Workflow steering | structural-only | L2-targeted checks run through the conformance shim; production callbacks emit telemetry only. |
| Quality-gate | structural-only | L2-targeted checks run through the conformance shim. |
| Stop-goal-fit | structural-only | L2-targeted checks run through the conformance shim. |
| Analytics channel, Console/HTTP sink, subagent events, permission requests, token usage | unavailable | These gaps are not wired in the TypeScript adapter. |
Constructing FlowAgentsHooks (TypeScript)
import { FlowAgentsHooks } from "@kontourai/flow-agents-strands";
const hooks = new FlowAgentsHooks({
workspace: ".", // root of your project
agentName: "my-agent",
// engineRoot: "/path/to/flow-agents" // optional: explicit engine path
});
Event mapping
The TS adapter exports STRANDS_TO_CANONICAL matching the Python adapter’s dict:
| Strands TS Event | Canonical event |
|---|---|
BeforeInvocationEvent |
userPromptSubmit |
AfterInvocationEvent |
stop |
BeforeToolCallEvent |
preToolUse |
AfterToolCallEvent |
postToolUse |
AgentInitializedEvent |
agentSpawn |
Conformance
The TS adapter runs its L2-targeted policy coverage through bin/conformance-shim.mjs:
node packaging/conformance/run-conformance.js \
--adapter-cmd "node integrations/strands-ts/bin/conformance-shim.mjs" \
--level L2
This is the validation path for the canonical fixture contract through the shim while the shipped native adapter remains telemetry plus native config-protection. Current status: the L2 target is not passing. The runner reports 18/20 fixtures passing with highest achieved level L0; stop-goal-fit--warn-active-delivery.json and workflow-steering--reground-session-start.json remain failing. Treat runner output as the current status for that target; do not read it as a production callback capability. See integrations/strands-ts/README.md for the full conformance declaration and limitations.