# Sentinels observe event patterns 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 run. 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. The important boundary is in the name: observation is separate from action. A sentinel never edits the agent's files, never sends instructions back, and never blocks the run. 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 configuration details come from the pinned 0.10.0 schema; the [sentinel configuration reference](/0.10.0/files/reference/sentinel-config) carries the full field-by-field contract. ## 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: ```text 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 0.10.0 contract described here is the version pinned by `docs.lock`. The output format is deliberately narrow. In 0.10.0, a sentinel writes `text` or `jsonl`; the old `json` format was removed in 0.5.5. > **VersionNote:** `output.format: "json"` was removed in 0.5.5; use `text` or `jsonl` instead. ## What a sentinel is (and isn't) A sentinel is a parallel observation agent, not a logging hook. It watches events from the main workflow, 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 never blocks the main agent's execution. Think of it as 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 needs five fields: `id`, `name`, `trigger`, `execution`, and `model`. The ID is a kebab-case string. Attachment happens in `hank.json`, on the codon – there is no root-level `sentinels` setting. The minimal config below shows the required fields, and the hank fragment shows how a codon attaches a sentinel by file reference: ```json { "id": "quality-observer", "name": "Quality Observer", "trigger": { "type": "event", "on": ["codon.completed"] }, "execution": { "strategy": "immediate" }, "model": "anthropic/claude-haiku-4-5" } ``` ```json { "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`: if loading fails, the runtime skips the sentinel; when it is `true`, the codon fails instead. 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` | | 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` | | 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 `/` ID for a sentinel that must fire. Second, path rules: at 0.10.0, file references in a sentinel config and in its prompt or schema fields 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 directory. Finally, a sentinel is not an agent session with filesystem tools. It sees event data only when its Eta prompt – the template engine used for sentinel prompts – interpolates the template context. The context contains `events` (up to 1,000), the current codon (`id`, `name`, `description`, `startTime`), and `world.currentTime`. A prompt without an `it.events` interpolation receives no event data at all. ## 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](/0.10.0/files/reference/sentinel-config). 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. ```text # ./.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` | | yes | minItems 1 | | | ↳ `conditions` | `array` | | no | | | | `sequence` | `object` | | | `type` = `sequence` | | | ↳ `interestFilter` | `object` | | yes | | | | ↳ ↳ `on` | `array` | | yes | minItems 1 | | | ↳ `pattern` | `array` | | 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` | `array` | | | | | | `systemPromptText` | `string` | | no | | | | `string` | `string` | | | minLength 1 | | | `array` | `array` | | | | | | 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 | | 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` | | 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. ```json { "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: `validate-and-repair` **Status:** Success **Cost:** $ **Duration:** 197.8 seconds **Exit Status:** Success --- ... - **Artifact existence cannot be verified** — no file.updated events provided - **Row coverage cannot be verified** — no file contents or update events provided - **Correctness cannot be verified** — no file contents or update events provided ``` 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 a prompt file: prompt text and `it.events` are the inputs to the model, not a filesystem workspace. For the full set of trigger operators and strategies, use the reference page rather than assuming that a lifecycle callback has access to files. ## 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 both the ASCII and mermaid renderings of the same state machine: ![The sentinel lifecycle across a codon](/content-assets/cf45dff5691c48c0/diagrams/concepts-sentinels/1.png) The sentinel lifecycle across a codon
Diagram as text ```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:709, schemas/event-schemas.ts:1001, schemas/event-schemas.ts:1264, sentinels/sentinel.ts:576, sentinels/sentinel.ts:663, sentinels/sentinel.ts:798, sentinels/sentinel.ts:905 | | sentinel.loaded | sentinel | true | false | sentinelId, codonId, model, triggerType, executionStrategy, conversational, source, sourcePath? | schemas/event-schemas.ts:699, schemas/event-schemas.ts:999, schemas/event-schemas.ts:1262, hankweave-runtime.ts:5819 | | sentinel.output | sentinel | true | false | sentinelId, codonId, triggerNumber, outputType, content, cost, tokens, eventCount | schemas/event-schemas.ts:714, schemas/event-schemas.ts:1002, schemas/event-schemas.ts:1265, sentinels/sentinel.ts:538, sentinels/sentinel.ts:635, sentinels/sentinel.ts:762, sentinels/sentinel.ts:877 | | sentinel.triggered | sentinel | true | false | sentinelId, codonId, triggerNumber, strategy, eventCount, queueSize | schemas/event-schemas.ts:719, schemas/event-schemas.ts:1003, schemas/event-schemas.ts:1266, sentinels/sentinel.ts:392 | | sentinel.unloaded | sentinel | true | false | sentinelId, codonId, reason, errorType?, finalCost, llmCallCount | schemas/event-schemas.ts:704, schemas/event-schemas.ts:1000, schemas/event-schemas.ts:1263, sentinels/sentinel-manager.ts:851 | 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:668, schemas/event-schemas.ts:977, schemas/event-schemas.ts:1257, hankweave-runtime.ts:5465 | | archive.partial | server-state | true | true | codonId, archivedPaths, failedPaths | schemas/event-schemas.ts:673, schemas/event-schemas.ts:978, schemas/event-schemas.ts:1258, hankweave-runtime.ts:5450 | | assistant.action | agentic-backbone | true | true | codonId, action, content, toolName?, toolInput? | schemas/event-schemas.ts:572, schemas/event-schemas.ts:986, schemas/event-schemas.ts:1237, hankweave-runtime.ts:2777, hankweave-runtime.ts:2789, hankweave-runtime.ts:2824 | | budget.summary | server-state | true | true | ceiling, allocation, rows, totals | schemas/event-schemas.ts:724, schemas/event-schemas.ts:979, schemas/event-schemas.ts:1267, hankweave-runtime.ts:6478 | | checkpoint.list | server-state | true | true | runId, checkpoints, currentBranch | schemas/event-schemas.ts:637, schemas/event-schemas.ts:968, schemas/event-schemas.ts:1250, hankweave-runtime.ts:4255 | | codon.completed | server-state | true | true | codonId, success, cost, duration, exitStatus, failureReason?, failureIgnored?, budgetExceeded? | schemas/event-schemas.ts:560, schemas/event-schemas.ts:961, schemas/event-schemas.ts:1235, hankweave-runtime.ts:1886, hankweave-runtime.ts:2064, hankweave-runtime.ts:2253, hankweave-runtime.ts:3365 | | codon.extended | server-state | true | true | codonId, codonName, extensionNumber, exhaustWithPrompt, cumulativeTokens, cumulativeCost | schemas/event-schemas.ts:565, schemas/event-schemas.ts:962, schemas/event-schemas.ts:1236, hankweave-runtime.ts:2946 | | codon.started | server-state | true | true | codonId, codonName, codonDescription?, sessionId, previousSessionId?, startTime, promptMetadata? | schemas/event-schemas.ts:555, schemas/event-schemas.ts:960, schemas/event-schemas.ts:1234, hankweave-runtime.ts:316 | | error | server-state | true | true | message, codon?, fatal, severity?, context?, code? | schemas/event-schemas.ts:617, schemas/event-schemas.ts:967, schemas/event-schemas.ts:1246, hankweave-runtime.ts:975, hankweave-runtime.ts:995, hankweave-runtime.ts:1019, hankweave-runtime.ts:1062, hankweave-runtime.ts:1090, hankweave-runtime.ts:1116, hankweave-runtime.ts:1814, hankweave-runtime.ts:1867, hankweave-runtime.ts:2045, hankweave-runtime.ts:2236, hankweave-runtime.ts:2609, hankweave-runtime.ts:3981, hankweave-runtime.ts:4177, hankweave-runtime.ts:4273, hankweave-runtime.ts:4349, hankweave-runtime.ts:4512, hankweave-runtime.ts:4536, hankweave-runtime.ts:4599, hankweave-runtime.ts:4769, hankweave-runtime.ts:4804, hankweave-runtime.ts:4896, hankweave-runtime.ts:4918, hankweave-runtime.ts:5016 | | file.updated | agentic-backbone | true | true | path, filename, content, action | schemas/event-schemas.ts:587, schemas/event-schemas.ts:988, schemas/event-schemas.ts:1240, hankweave-runtime.ts:2357, hankweave-runtime.ts:3927 | | filetree.updated | agentic-backbone | true | true | tree | schemas/event-schemas.ts:592, schemas/event-schemas.ts:989, schemas/event-schemas.ts:1241, hankweave-runtime.ts:3954 | | info | server-state | true | true | message | schemas/event-schemas.ts:627, schemas/event-schemas.ts:966, schemas/event-schemas.ts:1248, hankweave-runtime.ts:1705, hankweave-runtime.ts:1728, hankweave-runtime.ts:1902, hankweave-runtime.ts:1933, hankweave-runtime.ts:1971, hankweave-runtime.ts:2080, hankweave-runtime.ts:2196, hankweave-runtime.ts:2269, hankweave-runtime.ts:2730, hankweave-runtime.ts:3082, hankweave-runtime.ts:3100, hankweave-runtime.ts:3181, hankweave-runtime.ts:3205, hankweave-runtime.ts:3474, hankweave-runtime.ts:3723, hankweave-runtime.ts:3755, hankweave-runtime.ts:4058, hankweave-runtime.ts:4328, hankweave-runtime.ts:6427 | | loop.iteration.completed | server-state | true | true | loopId, iteration, durationMs, costUsd, tokensUsed, isFinal, terminationReason? | schemas/event-schemas.ts:612, schemas/event-schemas.ts:976, schemas/event-schemas.ts:1245, hankweave-runtime.ts:6035 | | rig.output | agentic-backbone | true | true | codonId, stream, line, commandIndex | schemas/event-schemas.ts:607, schemas/event-schemas.ts:992, schemas/event-schemas.ts:1244, hankweave-runtime.ts:6109 | | rig.setup.completed | agentic-backbone | true | true | codonId, rigType, commandCount, durationMs, createdCheckpoint | schemas/event-schemas.ts:597, schemas/event-schemas.ts:990, schemas/event-schemas.ts:1242, hankweave-runtime.ts:2160 | | rig.setup.failed | agentic-backbone | true | true | codonId, failureType, exitCode?, commandIndex?, ignored | schemas/event-schemas.ts:602, schemas/event-schemas.ts:991, schemas/event-schemas.ts:1243, hankweave-runtime.ts:1791 | | rollback.archiveRestore | server-state | true | true | codonId, restoredPaths, failedPaths?, status | schemas/event-schemas.ts:678, schemas/event-schemas.ts:974, schemas/event-schemas.ts:1256, hankweave-runtime.ts:5557 | | rollback.codonCheckpoint | server-state | true | true | codonId, codonName, checkpoint, checkpointType, message | schemas/event-schemas.ts:647, schemas/event-schemas.ts:971, schemas/event-schemas.ts:1252, hankweave-runtime.ts:4684, hankweave-runtime.ts:5136, hankweave-runtime.ts:5191 | | rollback.completed | server-state | true | true | fromRun, toRun, checkpoint, codonId, codonName, checkpointType, autoRestart | schemas/event-schemas.ts:662, schemas/event-schemas.ts:972, schemas/event-schemas.ts:1255, hankweave-runtime.ts:4722, hankweave-runtime.ts:5254 | | rollback.progress | server-state | true | true | currentStep, totalSteps, message | schemas/event-schemas.ts:657, schemas/event-schemas.ts:970, schemas/event-schemas.ts:1254, hankweave-runtime.ts:5117, hankweave-runtime.ts:5156 | | rollback.rigCleanup | server-state | true | true | codonId, codonName, directories, status, successfulCleanups?, failedCleanups?, error? | schemas/event-schemas.ts:652, schemas/event-schemas.ts:973, schemas/event-schemas.ts:1253, hankweave-runtime.ts:5624, hankweave-runtime.ts:5662, hankweave-runtime.ts:5678 | | rollback.started | server-state | true | true | fromRun, fromCodon, toCodon, toCheckpoint, checkpointType, codonsToProcess | schemas/event-schemas.ts:642, schemas/event-schemas.ts:969, schemas/event-schemas.ts:1251, hankweave-runtime.ts:4645, hankweave-runtime.ts:5095 | | server.idle | server-state | true | true | reason, message | schemas/event-schemas.ts:632, schemas/event-schemas.ts:964, schemas/event-schemas.ts:1249, hankweave-runtime.ts:941, hankweave-runtime.ts:3621, hankweave-runtime.ts:3791, hankweave-runtime.ts:3812, hankweave-runtime.ts:4072, hankweave-runtime.ts:4743, hankweave-runtime.ts:5279, hankweave-runtime.ts:5291 | | state.snapshot | server-state | true | true | currentCodon?, completedCodons, fileTree, totalCost, totalTime, recentFileAccess?, isRollingBack | schemas/event-schemas.ts:550, schemas/event-schemas.ts:963, schemas/event-schemas.ts:1233, hankweave-runtime.ts:1422 | | state.transition | server-state | true | true | transitionType, runId?, codonId?, transition, resultingState | schemas/event-schemas.ts:693, schemas/event-schemas.ts:975, schemas/event-schemas.ts:1261, hankweave-runtime.ts:394 | | token.usage | server-state | true | true | codonId, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalCost, modelId?, modelUsage? | schemas/event-schemas.ts:577, schemas/event-schemas.ts:965, schemas/event-schemas.ts:1238, hankweave-runtime.ts:2646, hankweave-runtime.ts:2660 | | tool.result | agentic-backbone | true | true | codonId, toolUseId, toolName, result, truncated, originalLength, executionTimeMs, isError | schemas/event-schemas.ts:582, schemas/event-schemas.ts:987, schemas/event-schemas.ts:1239, hankweave-runtime.ts:2906 | 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 working example: it is attached to the run's final codon, and when that codon's `codon.completed` arrives, it fires immediately with one received event and writes the 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 $ and duration of ms. 3. Sweep: clean ``` The sweep observer does not receive the earlier codon's completion, because sentinel sets are attached per codon. The captured event sequence shows the ordering directly: the final `codon.completed` event is followed by `sentinel.triggered` and `sentinel.output`, and those sentinel events are journaled but not fed back into sentinels. ```jsonl {"id":"","timestamp":"","type":"codon.completed","data":{"codonId":"step-one","success":true,"cost":"","duration":"","exitStatus":{"type":"success"}}} {"id":"","timestamp":"","type":"codon.completed","data":{"codonId":"step-two","success":true,"cost":"","duration":"","exitStatus":{"type":"success"}}} {"id":"","timestamp":"","type":"sentinel.triggered","data":{"sentinelId":"sweep-observer","codonId":"step-two","triggerNumber":1,"strategy":"immediate","eventCount":1,"queueSize":0}} {"id":"","timestamp":"","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":"","tokens":{"input":102,"output":158},"eventCount":1}} ``` That capture carries a second lesson. The `sentinel.output` content 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 earlier is not a formality; without the interpolation, the sentinel fires on an empty picture. The fixture also confirms that sentinels belong on a codon: a root-level `sentinels` array is rejected. See the [sentinel configuration reference](/0.10.0/files/reference/sentinel-config) for the full sweep pattern and codon settings. For output files, the two-line rule is: * a bare filename such as `quality-observer.log` resolves to the managed directory `/.hankweave/sentinels/outputs//`, where `` is the run's execution directory; * a path containing `/`, including `./quality-observer.log`, resolves relative to `agentRoot`, the configured agent workspace, when present. The configured output path is independent of an external `-o` directory. The full `output`, `lastValueFile`, format, and codon-level `settings.outputPaths` contract is in the [sentinel configuration reference](/0.10.0/files/reference/sentinel-config). > **Pitfall:** Keep sentinel output separate from files that the main agent edits; the one-way report path avoids concurrent-edit hash mismatches. ## 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; the documentation pilots explicitly ran without them. 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](/0.10.0/files/operate/authentication-and-models). The runtime manages sentinels through `SentinelManager`. Conversational sentinel history persists under `.hankweave/sentinels/history/`; configured output files follow the output-path rule above. The `reportToWebsocket` setting controls visibility of lifecycle, error, output, and trigger events. 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 | | field | type | default | required | constraints | description | | ----------- | --------- | ------- | -------- | ----------- | ----------- | | `lifecycle` | `boolean` | | no | | | | `errors` | `boolean` | | no | | | | `outputs` | `boolean` | | no | | | | `triggers` | `boolean` | | no | | | The runtime also bounds queued work: at most 100 pending triggers and 10,000 queued events. When a cap is reached, the oldest queued trigger is dropped and an info-level log records the action. For the exact failure categories, consecutive-failure behavior, scoped keys, and reporting switches, use the [sentinel configuration reference](/0.10.0/files/reference/sentinel-config).