When a coding agent runs, it produces a stream of events: codons start and complete, files change, tools run, costs accumulate. Sometimes you want something watching that stream while the agent works – checking quality, tracking cost, narrating progress – without adding instructions to the agent's prompt or interrupting its work. That is what a sentinel does. A sentinel is a separate observation agent attached to a codon, the unit of work in a hank. It watches the event stream, makes its own LLM calls when a pattern it cares about appears, and writes a report for a later consumer.
Observation is separate from action, and the boundary is precise. A sentinel never edits the agent's files and never sends instructions back to the agent. While a codon runs, it does not block the main agent; the one place sentinel work can hold up the run is the codon boundary, where queued observation finishes before the next codon starts (we walk through that in the lifecycle section). This page builds up how that works: why sentinels exist instead of hooks, what one is and is not, how it decides to fire, how its lifecycle fits around a codon, and the design rules that keep observation safe. The field-by-field configuration contract lives in the sentinel configuration reference; the schemas behind it are pinned by docs.lock, the resolved artifact record for this documentation build.
Why not hooks?#
A hook usually means a callback at a fixed lifecycle point. That model works when the question is "run this at codon start," but it is a poor fit for "when these event types appear together, under these conditions, produce a report." A sentinel watches patterns in the event stream and can combine event types with conditions, so its rule is declarative: when X, then Y, then Z. The shape of a sentinel's work, end to end, is:
pseudocode:
sentinel: trigger(event-pattern) → accumulate(strategy) → fire(llm) → write(output-file)
A sentinel is also configured separately from the main workflow. A codon attaches a file reference or an inline configuration in its sentinels array, so you can add or remove an observer without changing the codon's prompt or agent logic. That separation is what makes observers cheap to experiment with: a cost watcher, a code reviewer, or a narrator can sit alongside the main agent, and changing how you monitor the work never changes the agent's task.
The output format is deliberately narrow: a sentinel writes text or jsonl.
What a sentinel is (and isn't)#
A sentinel is a parallel observation agent, not a logging hook. It observes events from the main workflow and fires a report: it makes its own LLM calls when a pattern matches and writes to its own output files – a full parallel execution framework in miniature. What it does not do matters just as much: it runs no tools, sends no instructions back to the main agent, and does not block the main agent's execution. It is a note-taker, not an editor.
That boundary defines the safe data flow, which is one way: the main agent edits files X and Y, the sentinel watches and writes analysis to file A, and the agent or a later codon reads file A. Do not route a sentinel's output into a file that the main agent is editing; concurrent-edit protection can report a hash mismatch when another process changes that file.
Required configuration and attachment#
A sentinel's required structural fields are id (a kebab-case string), name, trigger, execution, and model. That is not the whole requirement: the schema also demands at least one of userPromptFile or userPromptText, and a config carrying only the five structural fields fails validation and never loads. Attachment happens in hank.json, on the codon – there is no root-level sentinels setting. The fragment below shows the structural fields and how a codon attaches a sentinel by file reference; the complete observer configuration later in this page supplies the user prompt:
{
"id": "quality-observer",
"name": "Quality Observer",
"trigger": { "type": "event", "on": ["codon.completed"] },
"execution": { "strategy": "immediate" },
"model": "anthropic/claude-haiku-4-5"
}
{
"hank": [
{
"id": "step-two",
"sentinels": [
{ "sentinelConfig": "sentinels/sweep-observer.json" }
]
}
]
}
The wrapper accepts either a sentinel config file path, as above, or an inline config object. Its optional settings can include failCodonIfNotLoaded, outputPaths, and reportToWebsocket. failCodonIfNotLoaded defaults to false, and it covers config loading only: a missing, unparseable, or schema-invalid config file or reference fails the codon when the flag is set. A sentinel skipped for other reasons – a model absent from the provider registry, an unconfigured or unhealthy provider, or a per-sentinel instantiation error – is only logged, and the codon runs without it even with the flag set. The full field list, including prompt, output, and error-handling options, is:
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
id | string | yes | minLength 1; pattern ^[a-z0-9-]+$ | ||
name | string | yes | minLength 1 | ||
description | string | no | |||
trigger | event | sequence | yes | |||
execution | immediate | debounce | count | timeWindow | yes | |||
systemPromptFile | string | array<string> | no | System prompt file(s), relative to this config file's directory (or the hank directory for inline configs), using '/' separators. Must stay inside the hank directory; absolute paths and symlinks are rejected. | ||
systemPromptText | string | no | |||
userPromptFile | string | array<string> | no | User prompt file(s), relative to this config file's directory (or the hank directory for inline configs), using '/' separators. Must stay inside the hank directory; absolute paths and symlinks are rejected. | ||
userPromptText | string | no | |||
conversational | object | no | |||
errorHandling | object | no | |||
llmParams | object | no | |||
model | string | yes | The full model ID to use (e.g., "anthropic/claude-3-5-sonnet-20241022", "openai/gpt-4-turbo"). | ||
structuredOutput | object | no | |||
joinString | string | no | String to join entries in text-based log file. Only valid for text output. Supports escape sequences: \n (newline), \t (tab), \r (carriage return), \ (backslash). Defaults to '\n---\n' for visual separation. | ||
reportToWebsocket | object | no | |||
output | object | no | |||
$schema | string | no | JSON Schema URL for editor support |
Two load-time behaviors are worth knowing before you rely on a sentinel. First, model validation: a full model ID containing /, such as anthropic/claude-haiku-4-5, is checked against the provider registry at load, and if it is absent the sentinel is skipped with an info-level log. A model without / is not registry-checked at load; it loads but fails on its first LLM call with No LLM provider available. Use a registry <provider>/<model> ID for a sentinel that must fire. Second, path rules: file references in a sentinel config, and the config's own prompt and schema file references, are strict portable relative POSIX paths. They cannot climb above the hank root or traverse a symlink, and for a file-based config, prompt and schema references resolve from that config's own directory.
Finally, a sentinel is not an agent session with filesystem tools. It sees only the event data its prompt template interpolates, which is the subject of the next section.
How a sentinel decides to fire#
Two configuration objects control firing. A trigger selects events and optional conditions; an execution strategy decides when matching events become one LLM call. The captures and schema strips below show the contracts for triggers, strategies, prompts, model parameters, structured output, and conversational mode, all pinned from the schema and linked in the sentinel configuration reference. The first capture is a real sentinel log, included because it demonstrates the model-ID behavior from the previous section: haiku passes validation but fails at firing with No LLM provider available, pi/google/gemini-2.5-flash is not found in the registry, and google/gemini-2.5-flash fires, writing to the managed sentinel path shown in the log header.
# ./.hankweave/sentinels/outputs/quality-observer/quality-observer.log
---
Yes, it contains exactly the line `hello from hankweave` and nothing else.
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
event | object | type = event | |||
↳ on | array<string> | yes | minItems 1 | ||
↳ conditions | array<object | object | contains | matches | object> | no | |||
sequence | object | type = sequence | |||
↳ interestFilter | object | yes | |||
↳ ↳ on | array<string> | yes | minItems 1 | ||
↳ pattern | array<object> | yes | minItems 1 | ||
↳ options | object | no | |||
↳ ↳ consecutive | boolean | no |
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
immediate | object | strategy = immediate | |||
debounce | object | strategy = debounce | |||
↳ milliseconds | integer | yes | > 0; max 300000 | ||
count | object | strategy = count | |||
↳ threshold | integer | yes | > 0; max 1000 | ||
timeWindow | object | strategy = timeWindow | |||
↳ milliseconds | integer | yes | > 0; max 3600000 |
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
userPromptText | string | no | |||
string | string | minLength 1 | |||
array<string> | array<string> | ||||
systemPromptText | string | no | |||
string | string | minLength 1 | |||
array<string> | array<string> |
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
temperature | number | no | min 0; max 2 | Temperature for response generation (0=deterministic, 2=creative) | |
maxOutputTokens | integer | no | > 0; max 100000 | Maximum tokens in the response | |
maxRetries | integer | no | min 0; max 5 | Number of retry attempts for failed LLM calls |
Omitted llmParams fields fall back to temperature 0, maxOutputTokens 8192 and maxRetries 2; the strip above gives each field's accepted bounds.
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
output | enum | yes | object | array | enum | ||
schemaStr | string | no | Inline Zod schema code | ||
schemaFile | string | no | minLength 1 | Path to a Zod schema file, relative to this config file's directory (or the hank directory for inline configs), using '/' separators. Must stay inside the hank directory; absolute paths and symlinks are rejected. | |
enumValues | array<string> | no | minItems 1 | ||
schemaName | string | no | |||
schemaDescription | string | no |
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
trimmingStrategy | maxTurns | maxTokens | yes | |||
continueOnError | boolean | no |
With the mechanics in view, here is a complete event-observer configuration. It fires on codon.completed for one codon, uses immediate execution, and asks the model to summarize only status, failure reasons, and budget fields present in the event. Note the prompt's explicit instruction that artifact contents are not available – the config encodes the observation boundary rather than leaving the model to guess.
{
"id": "quality-observer",
"name": "Quality Observer",
"description": "Observes the validate-and-repair completion event. Reports only status and budget fields present in that event; cannot read or verify output artifacts.",
"trigger": {
"type": "event",
"on": [
"codon.completed"
],
"conditions": [
{
"operator": "equals",
"path": "codonId",
"value": "validate-and-repair"
}
]
},
"execution": {
"strategy": "immediate"
},
"model": "anthropic/claude-haiku-4-5",
"userPromptText": "The codon `validate-and-repair` completed. Here are the triggering completion events:\n\n<%= JSON.stringify(it.events, null, 1) %>\n\nSummarize only status, failure reasons and budget fields actually present. These completion events do not provide file contents or file.updated events. State explicitly that artifact existence, row coverage and correctness cannot be verified from this input. Never treat missing file-update events as proof of missing files. Observe and report only.",
"output": {
"format": "text",
"file": "quality-observer.log"
}
}
The captured report from that observer shows both the value and the limit of the separation. The observer summarizes completion status and budget fields, then states plainly that artifact existence, coverage, and correctness cannot be verified from its input:
…
---
# Codon Completion Summary
**Status:** Success
**Cost:** $<cost>
**Duration:** 117,863 ms (~1m 58s)
---
## Verification Limitations
Artifact existence, row coverage, and correctness **cannot be verified** from this input. No file-update events were provided. The absence of file-update events does not indicate missing files.
To check those properties, use a rig – a setup or checker that reads artifacts – or a codon that reads them; a sentinel completion log is not proof. The same rule applies to the prompt: prompt text and it.events are the inputs to the model, not a filesystem workspace. Sentinel prompts are Eta templates rendered with a context of it.events (up to 1,000 triggering events), it.codon, and it.world.currentTime. Only it.codon.id is the observed codon's runtime id; it.codon.name and it.codon.description come from the sentinel's own configuration, it.codon.startTime is the time the sentinels were loaded for that codon, and it.world.currentTime is when the trigger was queued. A prompt without an it.events interpolation receives no event data at all – the sweep example below shows what that looks like in a real capture. For the full set of trigger operators and strategies, use the sentinel configuration reference.
The sentinel lifecycle across a codon#
Each codon has its own set of sentinels, and loading the next codon's set unloads the previous one. Within a codon, the lifecycle is easiest to reason about as four states: Loaded → Active → Completing → Unloaded. The figure shows the same state machine:
Read the diagram as text
sentinel lifecycle:
load config → Loaded
↓
observe routed events and queue matching work → Active
↓
agent exits → Completing (drain sentinel work)
↓
capture final state and cost → Unloaded → next codon
During codon execution, sentinel work is fire-and-forget. Event emission does not wait for a sentinel, so the main agent continues while the sentinel evaluates its queue. The runtime routes server-state and agentic-backbone events – runtime status and agent/tool activity – to sentinels. Connection-state events such as server.ready, pong, and history.batch are excluded, and so are sentinel events themselves, which prevents a sentinel from observing its own reports. The sentinel event types, with their payloads, are:
| id | category | journaled | sentinelRouted | payloadFields | receipts |
|---|---|---|---|---|---|
| sentinel.error | sentinel | true | false | sentinelId, codonId, errorType, message, retriable, consecutiveFailureCount | schemas/event-schemas.ts:821, schemas/event-schemas.ts:1115, schemas/event-schemas.ts:1378, sentinels/sentinel.ts:576 |
| sentinel.loaded | sentinel | true | false | sentinelId, codonId, model, triggerType, executionStrategy, conversational, source, sourcePath? | schemas/event-schemas.ts:811, schemas/event-schemas.ts:1113, schemas/event-schemas.ts:1376, hankweave-runtime.ts:6201 |
| sentinel.output | sentinel | true | false | sentinelId, codonId, triggerNumber, outputType, content, cost, tokens, eventCount | schemas/event-schemas.ts:826, schemas/event-schemas.ts:1116, schemas/event-schemas.ts:1379, sentinels/sentinel.ts:538 |
| sentinel.triggered | sentinel | true | false | sentinelId, codonId, triggerNumber, strategy, eventCount, queueSize | schemas/event-schemas.ts:831, schemas/event-schemas.ts:1117, schemas/event-schemas.ts:1380, sentinels/sentinel.ts:392 |
| sentinel.unloaded | sentinel | true | false | sentinelId, codonId, reason, errorType?, finalCost, llmCallCount | schemas/event-schemas.ts:816, schemas/event-schemas.ts:1114, schemas/event-schemas.ts:1377, sentinels/sentinel-manager.ts:908 |
At the codon boundary, the agent's exit moves the codon to completing-sentinels. This is the blocking part of the lifecycle: debounce timers fire, count buffers flush, time-window work is processed, and queued LLM calls finish. After the runtime emits codon.completed, it performs a second drain for sentinels watching that event, so a sentinel conditioned on codon.completed completes before the next codon starts. Final sentinel states and costs are captured after that second drain, then the sentinels are unloaded.
Two consequences follow. The cost carried by the codon.completed event itself does not include sentinel work triggered by that event. And in replay mode, which reuses recorded codon output, sentinels are intentionally skipped because they make real, nondeterministic LLM calls. For reference, the routable event categories a sentinel can trigger on are:
| id | category | journaled | sentinelRouted | payloadFields | receipts |
|---|---|---|---|---|---|
| archive.completed | server-state | true | true | codonId, archivedPaths | schemas/event-schemas.ts:780, schemas/event-schemas.ts:1091, schemas/event-schemas.ts:1371, hankweave-runtime.ts:5784 |
| archive.partial | server-state | true | true | codonId, archivedPaths, failedPaths | schemas/event-schemas.ts:785, schemas/event-schemas.ts:1092, schemas/event-schemas.ts:1372, hankweave-runtime.ts:5769 |
| assistant.action | agentic-backbone | true | true | codonId, action, content, toolName?, toolInput?, toolUseId? | schemas/event-schemas.ts:684, schemas/event-schemas.ts:1100, schemas/event-schemas.ts:1351, hankweave-runtime.ts:3210 |
| budget.summary | server-state | true | true | ceiling, allocation, rows, totals | schemas/event-schemas.ts:836, schemas/event-schemas.ts:1093, schemas/event-schemas.ts:1381, hankweave-runtime.ts:7086 |
| checkpoint.list | server-state | true | true | runId, checkpoints, currentBranch | schemas/event-schemas.ts:749, schemas/event-schemas.ts:1082, schemas/event-schemas.ts:1364, hankweave-runtime.ts:4612 |
| codon.completed | server-state | true | true | codonId, success, cost, duration, exitStatus, failureReason?, failureIgnored?, budgetExceeded? | schemas/event-schemas.ts:672, schemas/event-schemas.ts:1075, schemas/event-schemas.ts:1349, hankweave-runtime.ts:3792 |
| codon.extended | server-state | true | true | codonId, codonName, extensionNumber, exhaustWithPrompt, cumulativeTokens, cumulativeCost | schemas/event-schemas.ts:677, schemas/event-schemas.ts:1076, schemas/event-schemas.ts:1350, hankweave-runtime.ts:3378 |
| codon.started | server-state | true | true | codonId, codonName, codonDescription?, sessionId, previousSessionId?, startTime, promptMetadata? | schemas/event-schemas.ts:667, schemas/event-schemas.ts:1074, schemas/event-schemas.ts:1348, hankweave-runtime.ts:385 |
| error | server-state | true | true | message, codon?, fatal, severity?, context?, code? | schemas/event-schemas.ts:729, schemas/event-schemas.ts:1081, schemas/event-schemas.ts:1360, hankweave-runtime.ts:1214 |
| file.updated | agentic-backbone | true | true | path, filename, action, sha256, bytes, source | schemas/event-schemas.ts:699, schemas/event-schemas.ts:1102, schemas/event-schemas.ts:1354, hankweave-runtime.ts:3027 |
| filetree.updated | agentic-backbone | true | true | tree | schemas/event-schemas.ts:704, schemas/event-schemas.ts:1103, schemas/event-schemas.ts:1355, hankweave-runtime.ts:3039 |
| info | server-state | true | true | message | schemas/event-schemas.ts:739, schemas/event-schemas.ts:1080, schemas/event-schemas.ts:1362, hankweave-runtime.ts:2102 |
| loop.iteration.completed | server-state | true | true | loopId, iteration, durationMs, costUsd, tokensUsed, isFinal, terminationReason? | schemas/event-schemas.ts:724, schemas/event-schemas.ts:1090, schemas/event-schemas.ts:1359, hankweave-runtime.ts:6415 |
| rig.output | agentic-backbone | true | true | codonId, stream, line, commandIndex | schemas/event-schemas.ts:719, schemas/event-schemas.ts:1106, schemas/event-schemas.ts:1358, hankweave-runtime.ts:6489 |
| rig.setup.completed | agentic-backbone | true | true | codonId, rigType, commandCount, durationMs, createdCheckpoint | schemas/event-schemas.ts:709, schemas/event-schemas.ts:1104, schemas/event-schemas.ts:1356, hankweave-runtime.ts:2604 |
| rig.setup.failed | agentic-backbone | true | true | codonId, failureType, exitCode?, commandIndex?, ignored | schemas/event-schemas.ts:714, schemas/event-schemas.ts:1105, schemas/event-schemas.ts:1357, hankweave-runtime.ts:2213 |
| rollback.archiveRestore | server-state | true | true | codonId, restoredPaths, failedPaths?, status | schemas/event-schemas.ts:790, schemas/event-schemas.ts:1088, schemas/event-schemas.ts:1370, hankweave-runtime.ts:5925 |
| rollback.codonCheckpoint | server-state | true | true | codonId, codonName, checkpoint, checkpointType, message | schemas/event-schemas.ts:759, schemas/event-schemas.ts:1085, schemas/event-schemas.ts:1366, hankweave-runtime.ts:5048 |
| rollback.completed | server-state | true | true | fromRun, toRun, checkpoint, codonId, codonName, checkpointType, autoRestart | schemas/event-schemas.ts:774, schemas/event-schemas.ts:1086, schemas/event-schemas.ts:1369, hankweave-runtime.ts:5075 |
| rollback.progress | server-state | true | true | currentStep, totalSteps, message | schemas/event-schemas.ts:769, schemas/event-schemas.ts:1084, schemas/event-schemas.ts:1368, hankweave-runtime.ts:5469 |
| rollback.rigCleanup | server-state | true | true | codonId, codonName, directories, status, successfulCleanups?, failedCleanups?, error? | schemas/event-schemas.ts:764, schemas/event-schemas.ts:1087, schemas/event-schemas.ts:1367, hankweave-runtime.ts:5998 |
| rollback.started | server-state | true | true | fromRun, fromCodon, toCodon, toCheckpoint, checkpointType, codonsToProcess | schemas/event-schemas.ts:754, schemas/event-schemas.ts:1083, schemas/event-schemas.ts:1365, hankweave-runtime.ts:5010 |
| server.idle | server-state | true | true | reason, message | schemas/event-schemas.ts:744, schemas/event-schemas.ts:1078, schemas/event-schemas.ts:1363, hankweave-runtime.ts:1180 |
| state.snapshot | server-state | true | true | currentCodon?, completedCodons, fileTree, totalCost, totalTime, recentFileAccess?, isRollingBack | schemas/event-schemas.ts:662, schemas/event-schemas.ts:1077, schemas/event-schemas.ts:1347, hankweave-runtime.ts:1676 |
| state.transition | server-state | true | true | transitionType, runId?, codonId?, transition, resultingState | schemas/event-schemas.ts:805, schemas/event-schemas.ts:1089, schemas/event-schemas.ts:1375, hankweave-runtime.ts:463 |
| token.usage | server-state | true | true | codonId, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalCost, modelId?, modelUsage? | schemas/event-schemas.ts:689, schemas/event-schemas.ts:1079, schemas/event-schemas.ts:1352, hankweave-runtime.ts:3075 |
| tool.result | agentic-backbone | true | true | codonId, toolUseId, toolName, result, truncated, originalLength, executionTimeMs, isError | schemas/event-schemas.ts:694, schemas/event-schemas.ts:1101, schemas/event-schemas.ts:1353, hankweave-runtime.ts:3338 |
One payload note for the table above: the base shape marks assistant.action's toolName and toolUseId optional, but when action is "tool_use" the runtime's wire validator requires a non-empty toolUseId and a present toolName; for thinking and message actions both may be absent. toolUseId is the key that joins the action to tool.result.toolUseId and file.updated.source.toolUseId.
The five sentinel event types – sentinel.loaded, sentinel.unloaded, sentinel.triggered, sentinel.output, and sentinel.error – are journaled and broadcast to WebSocket clients, but, as noted above, are not routed back to sentinels.
Designing with sentinels#
Start with who will use the report. A sentinel that writes a file the main agent is also editing can cause concurrent-edit protection to reject the edit, so give the observer its own report file and ask the agent or a later codon to read it. Both contributions stay available without two processes editing the same file.
That separation also makes observers swappable. Keep the event trigger and template shape, then change the prompt for a laziness check, mock detection, data-hygiene review, or shell-error watch. The sweep observer in fixtures/sentinel-sweep/ is a worked example: it is attached to the run's final codon, and when that codon's codon.completed arrives, it fires immediately with the one received event and writes a single end-of-run sweep report:
---
# Sweep Report
1. Received 1 event of type `codon.completed`.
2. Codon `step-two` completed successfully with a cost of $<cost> and duration of <ms>ms.
3. Sweep: clean
Captured: 2026-09-04 · runtime: hankweave@0.10.0 (the later, Eta-templated run of this fixture).
The sweep observer does not receive the earlier codon's completion, because sentinel sets are attached per codon. A second capture from the same fixture shows the event ordering directly – the final codon.completed event followed by sentinel.triggered and sentinel.output:
{"id":"<id>","timestamp":"<ts>","type":"codon.completed","data":{"codonId":"step-one","success":true,"cost":"<n>","duration":"<n>","exitStatus":{"type":"success"}}}
{"id":"<id>","timestamp":"<ts>","type":"codon.completed","data":{"codonId":"step-two","success":true,"cost":"<n>","duration":"<n>","exitStatus":{"type":"success"}}}
{"id":"<id>","timestamp":"<ts>","type":"sentinel.triggered","data":{"sentinelId":"sweep-observer","codonId":"step-two","triggerNumber":1,"strategy":"immediate","eventCount":1,"queueSize":0}}
{"id":"<id>","timestamp":"<ts>","type":"sentinel.output","data":{"sentinelId":"sweep-observer","codonId":"step-two","triggerNumber":1,"outputType":"text","content":"I don't see any events in your message. You've described a scenario involving codon completion events and asked me to write a sweep report, but no actual event data has been provided for me to analyze.\n\nTo write the three-line report you've requested, I would need to see the events themselves. Could you please share:\n\n- The event log or event stream from the two-codon run\n- Details about each `codon.completed` event (success/failure status, costs if applicable)\n\nOnce you provide the events, I can generate:\n1. Count of codon.completed events and their success status\n2. Total cost calculation\n3. Sweep status assessment\n\nPlease paste the events and I'll write your report.","cost":"<n>","tokens":{"input":102,"output":158},"eventCount":1}}
Captured: 2026-09-04 · runtime: hankweave@0.10.0 (the first, untemplated run of this fixture).
These are two different captured runs of the same fixture, not one run. The sentinel.output content in the second shows the model reporting that it received no events – because the first version of the sweep prompt did not interpolate it.events. The template context rule from the previous section is not a formality; without the interpolation, the sentinel fires on an empty picture. The templated report is likewise not evidence that the observer inspected every earlier codon or the whole filesystem: it summarizes the single codon.completed event its prompt rendered. The fixture also confirms that sentinels belong on a codon: a root-level sentinels array fails validation with Unrecognized field(s) at hank root: sentinels. See the sentinel configuration reference for the full sweep pattern and codon settings.
For output files, the two-line rule is:
- a bare filename such as
quality-observer.logresolves to the managed directory<execution>/.hankweave/sentinels/outputs/<sentinel-id>/, where<execution>is the run's execution directory; - a path containing
/, including./quality-observer.log, resolves relative toagentRoot, the configured agent workspace, when present.
The full output, lastValueFile, format, and codon-level settings.outputPaths contract is in the sentinel configuration reference.
Error handling and guardrails#
The design rules above lead to a practical division of labor. Use a sentinel for a check that fits in a paragraph: "don't use any," "every function needs a docstring," or "stay on task." Use a separate codon when the review needs architecture, related files, or trade-off reasoning. If a sentinel's finding must affect execution, have it write an output file and reference that file in a later codon's prompt or rig – the codon boundary is what separates observation from action.
Sentinels are optional infrastructure, not a prerequisite for an effective hank. Model selection has its own two-plane behavior – codon self-test versus provider health-check – and sentinel key overrides belong with authentication and model selection.
The runtime manages sentinels through SentinelManager. Conversational sentinel history persists under .hankweave/sentinels/history/; configured output files follow the output-path rule above. For WebSocket visibility, reportToWebsocket.outputs: false suppresses sentinel.output emission and reportToWebsocket.errors: false suppresses sentinel.error emission, each enabled unless explicitly false, and the output file is written independently of the outputs switch. The lifecycle and triggers keys are accepted by the schema but have no implemented suppression gate: sentinel.loaded, sentinel.unloaded, and sentinel.triggered are emitted whenever the lifecycle operation or trigger occurs. The output-file, error-handling, and WebSocket reporting contracts are:
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
format | enum | no | text | jsonl | ||
file | string | no | |||
lastValueFile | string | no |
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
maxConsecutiveFailures | integer | no | min 1 | Maximum consecutive failures before unloading sentinel. Default: 3 | |
unloadOnFatalError | boolean | no | Whether to unload on fatal errors. Default: true |
The strip's schema text for maxConsecutiveFailures needs scope: it is compared only for a regular error of a non-conversational immediate sentinel. A debounce, count, or timeWindow sentinel logs the error and keeps going, and a fatal corruption error increments the counter but is never compared with the threshold. The sentinel configuration reference owns the full unload policy.
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
lifecycle | boolean | no | |||
errors | boolean | no | |||
outputs | boolean | no | |||
triggers | boolean | no |
The runtime also bounds queued sentinel work, dropping the oldest queued trigger under pressure; the exact thresholds and the failure categories, consecutive-failure behavior, and scoped environment keys are owned by the sentinel configuration reference.