Core Concepts
Sentinels

Sentinels

As your agent generates code, wouldn't it be useful to have something watching over its shoulder? A narrator explaining what's happening, a cost tracker catching runaway spending, a QA reviewer checking code quality—all running in parallel without slowing down the main work.

That's what sentinels do. They are parallel observers that watch the event stream from your hank and react to it by calling their own LLMs, writing to separate output files, and maintaining their own state. The main agent keeps working, unaware it's being observed.

What is a sentinel?

A sentinel is a secondary agent that watches your main workflow's event stream. When events match its trigger criteria, the sentinel fires—calling an LLM with the events it saw and writing the output to a specified location.

Sentinel Architecture

The main agent keeps working and doesn't wait for sentinels. If a sentinel errors or takes too long, the main workflow continues unaffected. This separation means you can layer sophisticated monitoring and analysis on top of any codon without risking your core execution.

Key Insight: Sentinels are a full parallel execution framework, not just logging. They have their own LLM calls, their own conversation history, their own cost tracking, and can produce structured output validated against Zod schemas.

Why use sentinels?

Use sentinels for tasks that would otherwise complicate your main agent's logic:

Narration: Generate human-readable summaries of agent activity—useful for monitoring long-running hanks or explaining agent behavior to stakeholders.

Cost Monitoring: Track token usage in real-time. Alert when spending exceeds thresholds and detect inefficient patterns before they drain your budget.

Error Detection: Watch for patterns like three consecutive failures or specific error types. Analyze root causes while the agent is still running, not after it has given up.

Code Review: Review generated code as it's written, providing QA feedback in parallel rather than as a separate step after completion.

Data Extraction: Pull structured data from the event stream, such as tool call patterns, action categories, or searchable indexes.

Validation: Check generated artifacts against rules or schemas, catching issues early without blocking the main workflow.

Sentinel configuration

Sentinels are configured as JSON files and attached to codons. Here's a simple narrator sentinel:

Text
{
  "id": "narrator",
  "name": "Activity Narrator",
  "description": "Provides human-readable summaries of agent activities",
  "model": "anthropic/claude-haiku-4-5",
  "trigger": {
    "type": "event",
    "on": ["assistant.action", "tool.result"]
  },
  "execution": {
    "strategy": "debounce",
    "milliseconds": 3000
  },
  "userPromptText": "Summarize the following agent activities:\n\n<%= JSON.stringify(it.events, null, 2) %>\n\nProvide a brief, clear summary of what happened.",
  "llmParams": {
    "temperature": 0.3,
    "maxOutputTokens": 4096
  }
}

Then, attach it to a codon:

Text
{
  "hank": [
    {
      "id": "generate-code",
      "name": "Generate Code",
      "model": "sonnet",
      "continuationMode": "fresh",
      "promptText": "Generate a TypeScript API client...",
      "sentinels": [
        {
          "sentinelConfig": "./sentinels/narrator.sentinel.json"
        }
      ]
    }
  ]
}

Triggers

Triggers determine when a sentinel fires. They come in two types: event triggers that react to specific events, and sequence triggers that detect patterns across multiple events.

Event triggers

Event triggers fire when specific event types occur. You can match multiple event types and add conditions:

Text
{
  "trigger": {
    "type": "event",
    "on": ["assistant.action", "tool.result"],
    "conditions": [
      {
        "operator": "equals",
        "path": "isError",
        "value": true
      }
    ]
  }
}

This trigger fires only on assistant.action or tool.result events where isError is true.

Event types you can listen to include:

Event TypeDescription
assistant.actionAgent thinking, tool use, or messages
tool.resultResults from tool executions
file.updatedFile created, modified, or deleted
codon.startedCodon execution begins
codon.completedCodon execution ends
token.usageToken consumption updates
errorError events
*Wildcard—matches any event type

Condition operators:

OperatorDescriptionExample
equalsExact match{"operator": "equals", "path": "isError", "value": true}
notEqualsNot equal{"operator": "notEquals", "path": "status", "value": "skipped"}
inValue is in an array{"operator": "in", "path": "type", "value": ["error", "warning"]}
notInValue is not in an array{"operator": "notIn", "path": "tool", "value": ["bash"]}
containsString contains or array includes{"operator": "contains", "path": "message", "value": "timeout"}
matchesRegex match{"operator": "matches", "path": "path", "value": "\\.ts$"}
greaterThanNumeric comparison{"operator": "greaterThan", "path": "totalCost", "value": 0.5}
lessThanNumeric comparison{"operator": "lessThan", "path": "duration", "value": 100}

All conditions must be met for the trigger to fire (AND logic). You can use dot notation for nested paths (e.g., "path": "exitStatus.type") to access nested data within an event.

Sequence triggers

While event triggers react to single events, sequence triggers detect patterns across multiple events. They are stateful, remembering what they've seen to find patterns that emerge over time:

Text
{
  "id": "error-detector",
  "name": "Error Pattern Detector",
  "trigger": {
    "type": "sequence",
    "interestFilter": {
      "on": ["tool.result"]
    },
    "pattern": [
      {
        "type": "tool.result",
        "conditions": [{"operator": "equals", "path": "isError", "value": true}]
      },
      {
        "type": "tool.result",
        "conditions": [{"operator": "equals", "path": "isError", "value": true}]
      },
      {
        "type": "tool.result",
        "conditions": [{"operator": "equals", "path": "isError", "value": true}]
      }
    ],
    "options": {
      "consecutive": true
    }
  },
  "execution": {
    "strategy": "immediate"
  },
  "model": "anthropic/claude-haiku-4-5",
  "userPromptText": "The agent encountered 3 consecutive errors. Analyze the pattern..."
}

This sentinel fires when three consecutive tool.result events have isError: true. The interestFilter defines which events the sentinel should track; pattern defines the sequence to detect within that history.

Consecutive vs non-consecutive: By default, pattern steps must occur back-to-back (consecutive: true). Set consecutive: false to allow other events between pattern steps, which is useful for finding "A followed eventually by B" rather than "A immediately followed by B."

Pattern wildcards: Use "type": "*" in a pattern step to match any event type that passes your interestFilter.

History management: To prevent unbounded memory growth, sequence triggers maintain a history of up to 1000 relevant events.

Sequence Trigger Flow

Execution strategies

When a trigger fires, the execution strategy controls how and when the sentinel makes an LLM call.

Immediate

Fire on every trigger match. Best for critical alerts or when you need a real-time response.

Text
{
  "execution": { "strategy": "immediate" }
}

Debounce

Wait for a quiet period, then fire with a batch of all accumulated events. Ideal for narration, where you want to summarize bursts of activity rather than every single event.

Text
{
  "execution": {
    "strategy": "debounce",
    "milliseconds": 3000
  }
}

The sentinel collects matching events until 3 seconds have passed with no new events, then fires once with the entire batch.

Count

Execute after N matching events. Useful for batch processing.

Text
{
  "execution": {
    "strategy": "count",
    "threshold": 10
  }
}

This sentinel fires every 10 matching events.

Time Window

Execute on a fixed schedule with all events from that period. Best for periodic summaries.

Text
{
  "execution": {
    "strategy": "timeWindow",
    "milliseconds": 30000
  }
}

This sentinel fires every 30 seconds. The timer is not affected by how long an LLM call takes; it uses fixed intervals to prevent drift. If one execution runs long, the next window will fire on schedule.

Execution Strategies

Prompts and templates

Sentinel prompts use Eta (opens in a new tab), a JavaScript template engine. This gives you access to JavaScript expressions within your prompts. The template context is available as the it object:

Text
{
  "userPromptText": "Summarize these events:\n\n<%= JSON.stringify(it.events, null, 2) %>"
}

Template context

PropertyTypeDescription
it.eventsServerEvent[]Array of events that triggered this execution
it.codon.idstringCurrent codon ID
it.codon.namestringCurrent codon name
it.codon.descriptionstring?Optional codon description
it.codon.startTimeDateWhen the codon started
it.world.currentTimeDateWhen the trigger was queued (not when executing)

it.world.currentTime uses the trigger's queue time, ensuring templates see when the trigger happened, even if execution is delayed.

Template examples

Iterate over events:

Text
<% for (const event of it.events) { %>
- <%= event.type %>: <%= JSON.stringify(event.data) %>
<% } %>

Access specific event data:

Text
Last file updated: <%= it.events[it.events.length - 1].data.path %>

Use conditional logic:

Text
<% if (it.events.length > 10) { %>
This was a busy period with <%= it.events.length %> events.
<% } %>

File-based prompts

For longer prompts, reference external files:

Text
{
  "userPromptFile": "./prompts/narrator-prompt.md",
  "systemPromptFile": "./prompts/narrator-system.md"
}

You can also provide an array of files, which will be concatenated:

Text
{
  "userPromptFile": ["./prompts/context.md", "./prompts/task.md"]
}

Conversational mode

By default, each sentinel execution is stateless. For sentinels that need to build on previous analysis, enable conversational mode to maintain a history.

Text
{
  "id": "conversational-narrator",
  "name": "Conversational Narrator",
  "model": "anthropic/claude-haiku-4-5",
  "trigger": { "type": "event", "on": ["assistant.action", "tool.result"] },
  "execution": { "strategy": "debounce", "milliseconds": 500 },
  "conversational": {
    "trimmingStrategy": {
      "type": "maxTurns",
      "maxTurns": 5
    }
  },
  "systemPromptText": "You are a narrator that maintains context. Build on your previous summaries without repeating yourself.",
  "userPromptText": "Summarize these events: <%= JSON.stringify(it.events, null, 2) %>"
}
⚠️

Conversational sentinels require a system prompt. You will get a validation error if you enable conversational mode without systemPromptText or systemPromptFile.

Trimming strategies

Without trimming, conversation history would grow indefinitely and eventually exceed the model's context window. Trimming strategies keep the history bounded.

maxTurns: Keep the last N user/assistant message pairs.

Text
{
  "trimmingStrategy": { "type": "maxTurns", "maxTurns": 5 }
}

maxTokens: Keep the total tokens in the history below a limit.

Text
{
  "trimmingStrategy": { "type": "maxTokens", "maxTokens": 4000 }
}

History persistence

Conversational history is saved to .hankweave/sentinels/history/{sentinel-id}-codon-{codon-id}.json. This allows sentinels to resume where they left off if the server restarts.

Error handling in conversations

The continueOnError flag controls behavior when an LLM call fails within a conversation:

Text
{
  "conversational": {
    "trimmingStrategy": { "type": "maxTurns", "maxTurns": 5 },
    "continueOnError": true
  }
}

With continueOnError: true, failed LLM calls are logged, but the conversation history is preserved for the next successful call. If false, errors may cause the sentinel to unload after repeated failures.

Structured output

For tasks like classification or data extraction, you can instruct a sentinel to generate structured JSON output that is validated against a Zod schema.

Text
{
  "id": "action-classifier",
  "name": "Action Classifier",
  "model": "anthropic/claude-haiku-4-5",
  "trigger": { "type": "event", "on": ["assistant.action"] },
  "execution": { "strategy": "immediate" },
  "userPromptText": "Classify this agent action: <%= JSON.stringify(it.events[0].data) %>",
  "structuredOutput": {
    "output": "object",
    "schemaStr": "z.object({ category: z.enum(['thinking', 'coding', 'debugging', 'testing']), confidence: z.number().min(0).max(1), reasoning: z.string() })",
    "schemaName": "ActionClassification",
    "schemaDescription": "Classification of an agent action"
  }
}

Output modes

ModeDescriptionSchema Required
objectGenerate a single JSON objectZod object schema
arrayGenerate an array of objectsZod array schema
enumGenerate one value from a listenumValues array

Enum mode example:

Text
{
  "structuredOutput": {
    "output": "enum",
    "enumValues": ["urgent", "normal", "low-priority", "ignore"]
  }
}

Schema sources

Provide schemas as an inline string or from a file.

Inline schema:

Text
{
  "structuredOutput": {
    "output": "object",
    "schemaStr": "z.object({ score: z.number(), notes: z.string() })"
  }
}

Schema from file:

Text
{
  "structuredOutput": {
    "output": "object",
    "schemaFile": "./schemas/classification.schema.ts"
  }
}

Schema files should export the Zod schema as a default expression (e.g., export default z.object(...)). The z object is automatically available.

⚠️

Structured output requires a model that supports tool calling. If your model does not, the sentinel will fail to load.

Output files

Sentinel outputs are automatically saved to files. You can configure output paths in the sentinel's configuration or override them when attaching the sentinel to a codon.

Direct output configuration

Configure output directly in the sentinel's JSON file:

Text
{
  "id": "narrator",
  "name": "Activity Narrator",
  "output": {
    "format": "text",
    "file": "narrator-output.md"
  }
}

Codon-level output override

Override output paths when attaching the sentinel to a codon:

Text
{
  "sentinelConfig": "./sentinels/narrator.sentinel.json",
  "settings": {
    "outputPaths": {
      "logFile": "narrator-output.md",
      "lastValueFile": "current-summary.md"
    }
  }
}
  • logFile: An append-only log of all outputs.
  • lastValueFile: A file containing only the latest output, replaced on each new generation. This is useful for dashboards or integrations that only need the current state.

Path conventions

  • A filename with no slashes (e.g., narrator.md) is saved to .hankweave/sentinels/outputs/{sentinel-id}/{filename}.
  • A path with slashes (e.g., outputs/narrator.md) is resolved relative to the execution directory.

If you don't specify paths, they are auto-generated.

Join string

For text output using logFile, the joinString separates appended entries.

Text
{
  "joinString": "\n\n---\n\n"
}

This field supports common escape sequences (\n, \t, \r, \\). It is not used for structured output, which is always formatted as NDJSON (one JSON object per line).

LLM parameters

Configure LLM behavior for each sentinel individually.

Text
{
  "model": "anthropic/claude-haiku-4-5",
  "llmParams": {
    "temperature": 0.3,
    "maxOutputTokens": 4096,
    "maxRetries": 3
  }
}
ParameterDefaultDescription
temperature0Randomness of the output (0.0–1.0). Default is 0 for deterministic output.
maxOutputTokens8192Maximum tokens in the response.
maxRetries2Retries on transient API failures.

Error handling

Sentinels are designed to handle errors gracefully without disrupting the main agent.

Error categories

  1. Template errors: Syntax errors in prompts. These are fatal and will cause the sentinel to unload.
  2. Configuration errors: Invalid settings detected at load time. These prevent the sentinel from loading.
  3. Corruption errors: Invalid data in a conversational history file. Behavior respects the continueOnError flag.
  4. Resource errors: Network timeouts or API failures. These are typically transient, and the sentinel will retry.

Consecutive failure tracking

Non-conversational sentinels track consecutive LLM failures. After maxConsecutiveFailures (default: 3), the sentinel unloads to prevent wasting tokens on a recurring problem.

Text
{
  "errorHandling": {
    "maxConsecutiveFailures": 5,
    "unloadOnFatalError": false
  }
}
  • maxConsecutiveFailures: Number of consecutive failures before unloading (default: 3).
  • unloadOnFatalError: Whether to unload on a fatal error, such as a broken prompt template. The default is true. Set to false during development to keep the sentinel active for debugging even if it reports fatal errors.

Successful calls reset the consecutive failure counter.

Event reporting

Control which sentinel events are broadcast over the WebSocket stream.

Text
{
  "reportToWebsocket": {
    "lifecycle": true,
    "errors": true,
    "outputs": true,
    "triggers": false
  }
}
Event TypeDefaultDescription
lifecycleONsentinel.loaded, sentinel.unloaded
errorsONsentinel.error
outputsONsentinel.output (includes content)
triggersOFFsentinel.triggered (can be very verbose)

Sentinel lifecycle

Understanding the sentinel lifecycle helps with debugging and design.

Sentinel Lifecycle

  1. Loaded: When a codon starts, its sentinels are created and validated.
  2. Active: During codon execution, sentinels process matching events.
  3. Completing: When the codon finishes, sentinels flush any buffered events and finish queued work.
  4. Unloaded: After completion or due to a fatal error, the sentinel is destroyed and its final costs are reported.

Each codon has its own independent set of sentinels. When execution moves to the next codon, the previous codon's sentinels are unloaded.

Cost tracking

Sentinels track their LLM costs separately from the main codon, so you can clearly distinguish between agent costs and monitoring costs. This information is available in sentinel.output and sentinel.unloaded events.

Common patterns

Copy these patterns as starting points for your own sentinels.

Narrator sentinel

Summarize agent activity in a human-readable format.

Text
{
  "id": "narrator",
  "name": "Activity Narrator",
  "model": "anthropic/claude-haiku-4-5",
  "trigger": { "type": "event", "on": ["assistant.action", "tool.result"] },
  "execution": { "strategy": "debounce", "milliseconds": 3000 },
  "userPromptText": "Summarize what the agent just did:\n\n<%= JSON.stringify(it.events, null, 2) %>"
}

Cost alert sentinel

Catch runaway spending before it becomes a problem.

Text
{
  "id": "cost-alert",
  "name": "Cost Alert",
  "model": "anthropic/claude-haiku-4-5",
  "trigger": {
    "type": "event",
    "on": ["token.usage"],
    "conditions": [
      { "operator": "greaterThan", "path": "totalCost", "value": 1.0 }
    ]
  },
  "execution": { "strategy": "immediate" },
  "userPromptText": "High cost alert! The agent has spent over $1.00. Analyze this spending pattern..."
}

Code review sentinel

Review TypeScript files as they're written.

Text
{
  "id": "qa-review",
  "name": "QA Review",
  "model": "anthropic/claude-haiku-4-5",
  "trigger": {
    "type": "event",
    "on": ["file.updated"],
    "conditions": [
      { "operator": "matches", "path": "path", "value": "\\.ts$" }
    ]
  },
  "execution": { "strategy": "debounce", "milliseconds": 10000 },
  "systemPromptText": "You are a senior developer reviewing code. Be concise and constructive.",
  "userPromptText": "Review these file changes:\n\n<% for (const e of it.events) { %>- <%= e.data.path %>\n<% } %>"
}

Periodic summary sentinel

Generate summaries on a fixed schedule, regardless of event volume.

Text
{
  "id": "periodic-summary",
  "name": "30-Second Summary",
  "model": "anthropic/claude-haiku-4-5",
  "trigger": { "type": "event", "on": ["*"] },
  "execution": { "strategy": "timeWindow", "milliseconds": 30000 },
  "userPromptText": "Summarize all activity from the last 30 seconds..."
}

Common mistakes

Avoid these frequent issues when configuring sentinels.

⚠️

Don't: Create a conversational sentinel without a system prompt.

Text
{
  "conversational": { "trimmingStrategy": { "type": "maxTurns", "maxTurns": 5 } },
  "userPromptText": "..."
  // Missing systemPromptText or systemPromptFile!
}

Instead: Always provide a system prompt to give the conversation context.

⚠️

Don't: Use joinString with structured output.

Text
{
  "structuredOutput": { "output": "object", "schemaStr": "..." },
  "joinString": "\n---\n"  // Invalid!
}

Instead: Remove joinString. Structured output always uses NDJSON format.

⚠️

Don't: Use a model that doesn't support tool calling for structured output.

Instead: Use a model that supports structured output via tool calls, such as Anthropic's Claude models.

⚠️

Don't: Create an immediate trigger for high-frequency events.

Text
{
  "trigger": { "type": "event", "on": ["*"] },
  "execution": { "strategy": "immediate" }
}

Instead: Use debounce or timeWindow strategies, or add conditions to filter the events.

Attaching sentinels to codons

Attach sentinels to a codon using the sentinels array in your hank.json file.

Text
{
  "id": "generate-code",
  "sentinels": [
    {
      "sentinelConfig": "./sentinels/narrator.sentinel.json"
    },
    {
      "sentinelConfig": "./sentinels/cost-tracker.sentinel.json",
      "settings": {
        "failCodonIfNotLoaded": true,
        "outputPaths": {
          "logFile": "costs.jsonl"
        }
      }
    }
  ]
}

Codon-level settings

SettingDefaultDescription
failCodonIfNotLoadedfalseFail the codon if this sentinel cannot be loaded.
outputPaths.logFileautoOverride the log file path for this sentinel instance.
outputPaths.lastValueFilenoneEnable and set the path for the current-value file.
reportToWebsocketfrom configOverride the sentinel's WebSocket reporting settings.

Set failCodonIfNotLoaded: true for critical sentinels, like a cost monitor, to ensure you don't run without observability.

Related pages

Next steps

Now that you understand sentinels, you can: