Codync
A free, open-source 1:1 alternative to Grok Bot and Muse. Message Claude Code, Codex, Cursor and 40+ coding agents as persistent bots from your iPhone, Mac or Linux desktop. Group chats, threads, appr
Reads the agent stack's shared event log, applies a severity floor, a dedup window and an hourly ceiling, and mails what is left from your own box. One scripted burst: 120 events in, 21 messages out (
Developer toolsGitHub @TAIPANBOXOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Repository information has not been collected yet.
No updates detected yet. GitHub releases will appear here.
The part of the box that writes to you: watches the agent stack's shared event log and mails the operator, with a link into their own console and never an action.
Taken from the repository README
Loading the full README.
Read the full README ↗Your agents run on your infrastructure. When one of them is heading somewhere you would want to know about, this is the part that writes to you.
heraldyx watches the NDJSON event log the TAIPANBOX agent-governance stack already writes, decides which of those events a human should hear about tonight rather than tomorrow, and mails them. The mail comes from the operator's own box, and it carries a link into the operator's own console.
It reads a file and sends mail. That is the whole of it, and the narrowness is the design: this is the one component of the box that opens a connection to something outside it, so it is the one component whose blast radius has to be small enough to state in a sentence.
The claim is checked rather than promised: scripts/one-way-out.sh runs in make gates and in CI.
The same path on it-rat.com.
flowchart LR
TF["TokenFuse: money"] --> LOG[("shared event log<br/>NDJSON, one line per event")]
WX["Wardryx: policy"] --> LOG
VX["Verdryx: quality"] --> LOG
MX["Mockryx: drills"] --> LOG
ENG["Engram: memory"] --> LOG
GX["Genaryx: the console"] --> LOG
ID["Idryx: the identity graph"] --> LOG
QX["Qryx: the hash chains"] --> LOG
CC["CostCrew: the FinOps console"] --> LOG
VY["Vouchryx: delegation"] --> LOG
TY["Typryx: typed answers"] --> LOG
AC["agent-conform: the chain verifier"] --> LOG
LOG -->|"reads, never writes"| H["heraldyx"]
LOG -->|"reads it too"| ID
LOG -->|"reads it too"| QX
SC["scopyx: web egress"] -->|"its own journal, not this log"| SJ[("scopyx's journal")]
H -->|"SMTP"| M["your mailbox"]
M -.->|"one link, a view and never an action"| C["Genaryx console<br/>sign in, then act"]
Twelve planes write this log and heraldyx is the only pure reader. Idryx and
Qryx read it as well as write it: Idryx loads it to build an identity graph and
Qryx walks it to check the prev_hash chains and the attestation on each
passport, and both also emit findings of their own. Two more planes, heraldyx
and scopyx, write a journal on their own volume rather than this log, which is
the same decision twice: a component that can write the shared log can, once
compromised, corrupt the trail it was adding to, and those two hold the two
dangerous privileges in the box, mail out and web out.
This census has now been wrong twice, in opposite directions, and both are
worth keeping. Until 2026-08-05 it was two short, saying four wrote and two
read, missing Engram among the writers and Qryx among the readers. Until
2026-08-10 it said Idryx and Qryx "never write", which was false about Qryx the
whole time (qryx --events appends agent-event NDJSON, internal/exporter)
and became false about Idryx the day it gained an event sink.
The lesson is the one the earlier correction already named and did not apply
far enough: a count copied between pictures is a count nobody measured. This
one is now taken from agent-passport/SPEC.md §6.2, the registry of who emits
what, which is the only place in the estate that is gated on being true
(@measured 2026-08-10, ten sources, of which eight write here and two keep
their own journal). It registers fifteen sources now, and
internal/stream/stream.go lists the same fifteen: twelve write here (the
original eight, then CostCrew, Vouchryx, Typryx and agent-conform) and the same
two keep their own journal. keyryx, the custody and witness service, was
registered on 2026-10-18 as reserved until its first release, and v0.1.0 came
the same day. It is the fifteenth: it writes nothing into the shared directory,
and its witness's findings file, keyryx.ndjson, is read as an entry of its
own (invariant 33 in CLAUDE.md).
Every plane already speaks one envelope (agent-passport SPEC.md 6), so heraldyx needs no integration with any of them. It holds no credential for any plane, has no API of its own, and can take no action on any agent.
Every event meets four checks, in a fixed order, and comes out of exactly one of four doors.
The numbers are from scripts/burst-demo.sh, which runs the real binary over a generated burst. It is a scenario: what a real fleet produces is unmeasured, and VALIDATION.md says so.
The floor is high, so this is roughly what an ordinary day is silent about
and what it is not.
| You get mail | Examples |
|---|---|
| immediately | budget_exhausted, run_killed, policy_deny, dlp_block, sustained_loop, taint_block, quality_drift, sim_finding, dependency_failed, slo_burn |
| in the daily summary | budget_threshold and everything else below the floor, including levels this build has never heard of |
| never | anything you did not configure a recipient for, and anything about a whole organisation rather than one agent |
That last row is a boundary of this plane, and worth stating plainly rather
than discovering. The shared envelope requires an agent_id (agent-passport
SPEC.md section 6.1), so a fact about a whole organisation has no subject to
travel under, and no producer is allowed to invent one to make it fit.
spend_spike is the current example: the money plane raises it, the console
shows it, and it never arrives here. A mail whose subject line named an agent
that had not spiked would be worse than silence.
Org-wide facts live where they have always lived, in the console and in the plane's own API. Changing that is a change to the envelope every product in the stack shares, not something this process can decide.
A quiet day still gets one mail. When nothing falls below the floor, the
daily summary has nothing to say, and before this change nothing was sent at
all: a quiet box looked exactly like a notifier that had died. Now one mail a
day says the notifier is running and gives the numbers it saw: events read,
alerts handed to the mail transport, alerts held at the hourly limit, and when
the last event was read. When the daily summary does go out it carries those
lines instead, so a busy day never gets two mails. HERALDYX_PULSE_HOURS sets
the period (default 24, 0 turns it off). The absence of the mail is the
signal: if it stops arriving, this process or its mail path is down.
Each of these has three lines in the catalog: what happened, what the box already did about it, and what happens if nobody acts. A type not listed here is not dropped; it is described honestly as one this build has no description for, and the link still opens the console at it.
| type | the mail's first sentence says the subject |
|---|---|
budget_threshold | is approaching its budget |
budget_exhausted | has exhausted its budget |
run_stalled | went quiet (with the last call and silence duration) |
run_killed | was killed |
sustained_loop | is repeating the same step |
spend_spike | is burning money far faster than it usually does (a sentence that cannot currently be shown: see the row above about org-wide facts) |
fanout_explosion | is driving far more runs at once than it usually does |
breaker_tripped | was refused by the breaker |
unit_cap_exceeded | has spent its business unit's monthly cap |
call_retained | made a call that was sent and never answered, and its money stays reserved until a person closes it (the amount held is in the subject; the mail says whether it survives a restart and whether it can be closed from the console; see below) |
retained_closed | had a retained reservation closed by a person (the subject names the reservation, observed or no charge, and the amount settled; the person's evidence reference is never mailed; see below) |
dlp_block | tried to send something that matched a secret pattern |
taint_block | was refused a tool its taint labels do not allow |
policy_deny | was denied by policy |
approval_requested | is waiting for a human decision |
approval_unanswered | is still waiting for a human decision nobody has made |
approval_timeout | presented an approval that had already expired |
identity_mismatch | presented a credential that may not speak as the agent it claimed |
identity_finding | matched an identity rule (the mail names which detector fired) |
mcp_drift |
The thirteen rows from anomaly_triaged through explainer_published come
from costcrew, and none of them describes an enforcement. That console
records: it has no gateway, no interceptor and no refusal path, so no did
above says anything was stopped and no next promises anything will be. The
one exception is a suspension, which does stop that console giving an agent
further work, and says so in those words. The sentences were written by
reading costcrew's own code, which is what this file requires of a catalog
entry, and internal/render/costcrew_test.go holds them to it.
Seven more rows followed, still from costcrew, in three batches. generated_estate_replaced is a
person's own act on the store, not a finding. The next three are a narrower
refusal than the paragraph above rules out: option_refused is the console
declining a deliverable's proposed OPTIONS, a document it reviews, never a
call it routes, and option_applied and decision_requested are what a stamp
or a request does with what survives that review. Two more are what lets
that console's own clock spend anything at all, rather than what it decided:
cadence_set is a person flipping the switch and its ceiling on the /cadence
page, and crew_ran is what one clock-driven run did with it, priced and
recorded after the run already finished. The last is what it costs to ask a
model into that same plan, beside the deterministic one a person still
chooses between: plan_asked prices the call first and, over the
supervisor's own guard or with no gateway configured, refuses it before it is
made, at no cost; made or refused, the event records the outcome and the
cost. Read against costcrew's own code the same way the thirteen before them
were.
identity_finding was in the catalog from 2026-08-10 and missing from this
table until 2026-08-25, so the count above read 18 while the build described 19.
Exactly the drift scripts/readme-numbers.sh exists to stop, in the one number
it was not yet checking. It checks this one now.
dependency_failed is the only entry here that is not about the agent.
Every other row describes something the subject did, or something this stack
refused it. This one says a dependency the box itself needs has failed
underneath a run that was behaving perfectly, so the freeze and kill link
beside it is almost certainly the wrong move, and the mail says so where that
is true.
One type covers three outcomes that want opposite responses, and the mail is
written from data.effect rather than from the type alone:
| what happened | what the mail says |
|---|---|
| the call could not be made or completed | the agent got an error; the message describes the response stage without promising a refund. An uncertain send may retain its reservation, and a broken successful response may still be charged |
| the policy plane was unreachable and this gateway fails open | the call went through with no policy applied to it. Not a failed call: a governed estate that was briefly not one |
| the policy plane was unreachable and this gateway fails closed | the call was refused, and no policy refused it. The plane could not be asked, so the same call may well be allowed once it answers |
The transport error the event carries is deliberately not in any of them. It is text from outside the perimeter arriving under a human-readable key, so it is read to pick a sentence and never rendered, and which dependency failed reaches you as this build's own words rather than as the producer's.
slo_burn reports a measurement, and nothing else happened. The quality
plane counts the eligible runs in a window, compares the share of good ones to
the objective, and works out how much of the error budget is left. It writes no
policy, demotes nothing and stops no traffic, and neither does anything else in
this box: nothing here reads a budget when it decides whether to allow a call,
and there is no autonomy tier anywhere in this stack to lower. The mail says so
plainly, because an operator told an agent has already been reined in is an
operator who does not do the thing the alert exists to prompt.
It arrives on two triggers only, and the mail is written from
data.trigger rather than from the type alone. The two are a statement about
the past and a forecast, and only one of them still has time in it:
| what happened | what the mail says |
|---|---|
| the objective is already missed over the window | the budget is spent, this is about runs that have already happened, and it does not improve on its own: it stops being true when enough good runs push the bad ones out of the window |
| the budget is going far faster than the window allows | it is not gone yet and at this rate it will be, well before the window ends, so this is the part there is still time to act on, and the runs spending it are running now |
A slow burn never reaches this bus, and that is deliberate. Severity in this
stack is fixed per event type rather than chosen wherever an event is raised, so
one type is one band of urgency, and "your budget will be gone by Friday" does
not belong in the same band as "your budget is gone". The slow figure is
computed, and it lives in verdryx's own report and JSON output where a dashboard
reads it. It is not an alert. That is the money plane's lesson from 2026-08-03
generalised, when breaker_tripped was lowered from critical: a type that pages
for the design working teaches its operator to filter the sender, and after that
the type that should have woken them does not.
Which objective the budget belongs to reaches you as this build's own words, the
same way the failed dependency above does. The numbers do not. The target, the
observed ratio, its confidence interval, the burn rate and how much budget is
left are all outside the data allowlist, so what the mail carries from the
payload is the window, and the link is where the rest of it is. Adding a key to
that allowlist is a decision for the operator of this box rather than something
a rendering change makes on its way past (see CLAUDE.md).
typryx is an optional add-on (agent-passport SPEC.md 6.2): a stack that
never wired it in emits none of the four rows above. It answers a typed
question (a choice, a score, or a yes/no) with a probability, over a box the
operator chose to add. @decided 2026-09-26: its journal moves onto the shared
bus in all three launchers, so typed_answer (info),
typed_unanswered (medium), typed_refused (high) and calibration_drift
(high) can now reach this floor. Severities are typryx's own and are not
this file's to change.
typed_refused fires before any backend is ever called, for one of several
reasons typryx's own code names: an hourly or a daily spend cap already
spent, a template id nobody registered, freeform switched off, or a
malformed question or state. The mail names which one, in typryx's own
words, because a cap the operator configured on purpose and a caller sending
a malformed question want opposite responses. A caller-supplied bad_run_id
is refused earlier still, at typryx's own API or MCP boundary, so it never
reaches this bus at all.
calibration_drift reports a measurement, never an enforcement: typryx's
own calibration check compares a template, backend and model group's Brier
score and ECE against a configured bound, and nothing here (or in typryx)
turns a crossed bound into a cap change or a refusal. The mail names the
template, its version, the backend, the model, and which of the two metrics
crossed, all read from the event's own data.bounds_crossed rather than
from a bound this file would otherwise have to keep in sync with typryx's
own configuration.
Whether a burst of typed_refused at the default floor is noisy is bounded
by mechanisms this file does not add. An hourly-cap burst from one run or
agent dedups to one message per ten-minute window, since rule.Key is
type:subject and every refusal for the same cap shares the same subject; a
burst spread across many distinct agents or runs is instead bounded by the
hourly ceiling itself, which summarises rather than mails each one once the
default 20-an-hour limit is reached. Neither is new: both already govern
every other high type in this table.
agent-conform is an optional add-on (agent-passport SPEC.md 6.2): a stack
that does not run agent-conform watch-dir emits neither of the last two rows.
That verifier checks the prev_hash chain of every stream in the bus directory
and writes what it finds to its own stream, agent-conform.ndjson. Both of its types are
described here (@claude 2026-10-04, asked for by that day's estate audit
plan), because before this they mailed as the generic fallback, which names no
stream and no line.
chain_broken (high) names the stream file, the first broken line and how many
breaks the file holds, all read from the event's own data and each checked on
its own: a file name must look like a file name (letters, digits, _, . and
-, at most 64), a line or a count must be a whole number of at least 1, and a
value that fails is left out rather than escaped. A file name left out is said
out loud, so a gap never reads as the verifier naming nothing. The mail calls a
break what it is: tamper-evidence, not a verdict. The stream was edited after it
was written, or two writers interleaved in it, and the verifier does not say
which, or who. It tells the operator to look at the verifier's own stream and at
the pod or service log of whatever runs the verifier. The two hash strings the
verifier clips into the event are never rendered. chain_unchained (low) is not
a break: prev_hash is optional, and a stream with events and none carrying one
has no chain to check. What it costs is that an edit to that stream would not
be seen, and the mail says exactly that and no more.
keyryx and its types (agent-passport SPEC.md 6.2, registered on 2026-10-18 and
released as v0.1.0 the same day) are described the same way. keyryx is the custody and witness service:
it holds the signing keys, rotates and revokes them, and reads the event streams
to say whether they still hold the bytes they held when a checkpoint was signed.
Its findings name the stream file, or the tenant, and the counts a witness
reports (lines, bytes, segments). The digests a witness clips are never mailed:
they are the console's and the witness's own log's to show. key_rotated names
the new kid, the one it replaces and the algorithm; key_revoked names the key,
its kid and the algorithm, and says the key is no longer published. A name, a
kid or a tenant is shown only when it is a plain identifier, so a producer's
sentence in one of those fields is left out. chain_restarted (medium, high when
no resume checkpoint explains it) names the stream and the line where the new
chain starts. A restart is not a break: a resume checkpoint is what a writer
writes when it reopens a stream it could not chain from.
Not covered: every alert from the verifier shares one subject (its own agent id,
and it carries no run id), and the dedup key is the type and the subject, so a
second broken stream reported by the verifier inside one dedup window (ten
minutes by default) is dropped rather than mailed. @measured rule.Decide on two
chain_broken events for different files 30 seconds apart, 2026-10-04: the
first returned notify, the second drop. Changing what the key is made of is
a rule change this rendering change does not make.
A retained call is money waiting for a person. tokenfuse retains a call it
handed to the provider when the call ended before any answer came back, because
the caller left or the connection broke. It cannot tell whether the provider did
the work, so it neither charges nor releases what it reserved for the call, and
only a person with evidence closes it. @decided 2026-10-12: both events go on
the bus so a person is told; call_retained (medium) asks a person to act soon,
and retained_closed (high) is high because a closure lifts a money control.
Severities are tokenfuse's own. agent-passport SPEC.md 6.2 registers both, and
tokenfuse emits both from v1.10.0.
call_retained puts the amount held in the subject ("... and $0.42 stays
reserved until a person closes it"), names the reservation and the model, and
says, only when the event says so, whether the reservation is in the gateway's
journal (a restart keeps it, or forgets it) and whether it is on the console's
list (past the list's cap it is still held and cannot be closed there). An
amount under a cent is written to the micro-dollar rather than as $0.00.
retained_closed names the reservation, whether the evidence was an observed
charge or no charge, and the amount settled. Who closed it is not in the event,
by the registry's design, so the mail says so and points at the console's own
console.close_retained record, joined by the reservation id.
The evidence reference is never mailed, in any form. @decided 2026-10-12:
of a retained reservation's data, only the three numbers (reservation_id,
amount_microusd, settled_microusd) may reach a mail. The evidence reference
is free text typed in the console, and tokenfuse lets a model propose a closure
with its evidence for a person to accept, so even one that looks like a plain
invoice number may be text a model wrote. A closure's mail says instead that the
reference is in the operator console, with the record of the
console.close_retained command. The model, on a retained call the one the agent
asked for, is shown only as a single token of letters, digits and _ . : / @ -,
with no space and no scheme.
Not covered: the dedup key is the type and the run, so a second retained call on
the same run inside one dedup window (ten minutes by default) is dropped rather
than mailed, and so is a second closure. @measured ./bin/heraldyx --once over
two call_retained and two retained_closed events on one run, 2026-10-12: two
mails, the first of each. The gateway still holds every one of them. And a
model name that is a bare host and path, with no scheme and no www., is shown,
and some mail clients turn that into a link.
behavior_anomaly and excessive_privilege were listed here until 2026-08-03.
They were removed because nothing raises them: both are concepts of the identity
plane, and idryx has no event writer at all. It reads this same log to build its
graph and answers through its own API. An entry describing an event that cannot
arrive is a claim nobody ever sees be wrong, which is the worst kind to keep.
Nothing can check that an entry is TRUE, which is why they are audited against
the producing plane's own code rather than its README. Four of the seventeen
that existed on 2026-08-03 were wrong, and each was wrong in a way that would
have sent an operator somewhere useless. See CLAUDE.md.
budget_threshold is the "approaching the line" signal, and it is deliberately
one band below the incident it precedes: nothing has gone wrong yet, and an
early warning that pages as loudly as an exhausted budget teaches its operator
to ignore both. Lower HERALDYX_MIN_SEVERITY to medium to have it mailed as
it happens.
docker pull ghcr.io/taipanbox/heraldyx:v0.8.2
Published on a tag, for linux/amd64 and linux/arm64. Immutable versions
only: there is no :latest and no :main, because "which build is running"
has to have an answer that does not change under the operator. A moving tag is
a deployment whose contents change without a rollout anybody recorded.
Building from source still works and is what make build does; it is no longer
what an install has to do.
Every image is signed keyless with Sigstore and carries a build-provenance
attestation and an SBOM. With cosign and gh installed:
cosign verify ghcr.io/taipanbox/heraldyx:<tag> \
--certificate-identity-regexp '^https://github.com/TAIPANBOX/heraldyx/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/taipanbox/heraldyx:<tag> -R TAIPANBOX/heraldyx
Releases through v0.2.2 have none of this; v0.2.3 is the first to carry it.
make build
mkdir -p /tmp/heraldyx
HERALDYX_EVENTS=/tmp/heraldyx/tokenfuse.ndjson \
[email protected] \
HERALDYX_MAIL_FILE=/tmp/heraldyx/mail.txt \
HERALDYX_CONSOLE_URL=https://box.example.com \
HERALDYX_MIN_SEVERITY=medium \
HERALDYX_STATE=/tmp/heraldyx/state.json \
./bin/heraldyx --once --from-now=false
Append an event whose source is tokenfuse to /tmp/heraldyx/tokenfuse.ndjson
(the file is named for the source it may carry, see the table below), run it
again, and read /tmp/heraldyx/mail.txt. HERALDYX_MAIL_FILE writes what WOULD be sent, which
is also the honest way to see this in a demo: the whole chain runs except the
last mile.
To watch the three limits work rather than read about them:
./scripts/burst-demo.sh
It builds a burst of 120 events, runs the real binary over it with the default limits, and prints what came out. The three limits are easy to describe and hard to believe until a flood goes in one end and twenty messages come out the other.
| variable | default | what it is |
|---|---|---|
HERALDYX_EVENTS | /var/lib/stack/events | files and/or directories of NDJSON events; a directory contributes every *.ndjson in it, re-read each poll so a plane deployed later is picked up. An entry ending in .ndjson names one stream file and is read only while it is a regular file: a link there is never followed and a directory there is never expanded, and the startup line names such an entry |
HERALDYX_TO | (empty) | comma-separated recipients; empty means send nothing |
HERALDYX_MIN_SEVERITY | high | info | low | medium | high | critical |
HERALDYX_BOX | agent stack | the name in the subject line |
HERALDYX_CONSOLE_URL | (empty) | base URL of your console, for the one link |
HERALDYX_STATE | /var/lib/stack/heraldyx/state.json | offsets, a digest of each log's first line, and dedup counters |
HERALDYX_CONTROL | beside the state file, control.json | the control file, holding the acknowledgements and the maintenance window; written only by --ack, --unack, --maintenance and --maintenance-end, and read by the running notifier; see "Acknowledging an alert" and "Quiet hours and maintenance windows" |
HERALDYX_MAIL_FILE | (empty) | write messages here instead of sending them |
HERALDYX_SMTP_HOST | (empty) | host:port of your mail server |
HERALDYX_SMTP_FROM | (empty) | envelope sender |
HERALDYX_SMTP_USER / _PASS | (empty) | credentials, if your server wants them |
HERALDYX_SMTP_TLS | starttls (implicit on port 465) | how the connection is encrypted. refuses to send unless the server offers STARTTLS. speaks TLS from the first byte. sends in the clear and is refused unless the host is on the loopback interface (, , ). The server's certificate is verified in both encrypted modes. |
An event is processed as the source it claims only when the file it was read
from may carry that source. By convention tokenfuse.ndjson carries
tokenfuse, wardryx.ndjson carries wardryx, and so on for every source the
agent-passport registry lists; tokenfuse-cloud.ndjson and
tokenfuse-mcp.ndjson (the control plane's and the MCP broker's own files)
carry tokenfuse, and so does tokenfuse-openai.ndjson (a second gateway on
the OpenAI wire, which keeps its own file). demo.ndjson (what taipan demo writes, with lines from six
planes) is not built in, because any co-tenant could create it: declare it with
HERALDYX_STREAMS=demo=tokenfuse|wardryx|engram|qryx|verdryx|mockryx when you
run the demo against this box. A line that claims anything else is not processed as that
source: it is not mailed, not in the daily summary, and it is counted. One alert
goes per file and claimed source, and a restart does not repeat it. A file whose
name nothing declares is read when its lines claim the file's own name, and the
box tells you once that it does not know the stream.
If your files are laid out differently, say what each may carry:
HERALDYX_STREAMS='events=tokenfuse|wardryx,mixed=engram|qryx'
A declaration adds to what a file may carry and never removes from it. A malformed one stops the process at startup and names itself.
This is the part of finding F-018 of the agent-security review of 2026-10-17
that a reader of the bus can close for itself: a bus reader cannot tell who
wrote a line, because any writer can append to any file in the directory. With
HERALDYX_CHECKPOINT_GATE=on, a stream file is read only while a signed
checkpoint covers it. A checkpoint covers a stream when it is a format-v1
record of agent-passport SPEC.md 6.5.1, signed by a key you pinned, whose
prefix (the SHA-256 of the stream's first bytes) and head (the chain hash of
its last counted line) still match the stream. The gate checks the checkpoint
file in the order the SPEC gives: each line in canonical form with only the
members it lists, the prev link of each record, and the signature of each
signed record, the newest of which decides coverage.
A stream with no covering checkpoint is not read. Its lines are counted and not mailed, and one mail goes per file, in the subject and the body, saying that the file has no signed checkpoint and which setting decides it. Nothing is dropped without a word. The gate is off by default, and a box that does not set it reads exactly as it did before.
To switch it on, write the checkpoints into HERALDYX_CHECKPOINTS as
<stream>.ckpt.ndjson, and pin each producer's public key in
HERALDYX_CHECKPOINT_KEYS:
{"<kid>": "<base64url of the 65-byte uncompressed P-256 point>"}
The process refuses to start when the gate is on and either path is missing, or when no key in the file loads, so a gate that could verify nothing does not run quietly and refuse everything.
What the gate does not check, stated plainly:
Two things must hold for the gate to mean anything, and this box cannot check either: the checkpoints directory and the stream files are written by their producers alone. A co-tenant who can write the stream and the checkpoints can copy an old checkpoint next to its own lines, and the copy covers the stream as long as the stream still holds the bytes it signed. The launcher's ownership of the directories is what closes that, and it lives outside this repository.
Finally, the gate re-reads each stream up to the newest checkpoint's count when a new checkpoint arrives, so on a large stream it costs one read of the stream per checkpoint. Measure that on a real file before you switch the gate on for a large one.
python3 scripts/smtp-sink.py & # a server that answers, on :2525
HERALDYX_SMTP_HOST=127.0.0.1:2525 HERALDYX_SMTP_TLS=plain [email protected] \
[email protected] ... ./bin/heraldyx --once --from-now=false
The sink speaks no TLS, so the mode is plain. The notifier accepts that only for a loopback host; a sink on another machine has to offer STARTTLS.
The sink prints the whole session and the message exactly as it arrived, so the
protocol, the headers and the body are observed rather than asserted. It is not
a mail server and proves nothing about deliverability; it proves that this
client talks to something that speaks SMTP, which is the part unit tests
cannot reach. See VALIDATION.md for the run.
--test-mail sends one message and exits. Installers run it while the operator
is still at the keyboard, because a wrong SMTP setting that surfaces a week
later, through an alert that never arrived, is the worst failure this component
has.
--version prints the tag this binary was built from and exits. It matters
more here than it looks: heraldyx ships as a container image and the running
one is whatever your deploy pinned, so when an alert arrives in a shape you did
not expect, the first question is which build sent it. Asking the binary
answers that without trusting a manifest to be current.
[prod-box] run-4471 has exhausted its budget
Run run-4471 (agent agent://meridian.io/...s/unit-economics-analyst) has exhausted its budget.
Spent $1.25 of $1.25 (100%).
What this box already did: Calls from this run are being refused with a hard 402.
If nobody acts: The run cannot spend again until someone raises its budget.
Answerable for it: w.zhang
Around it right now:
near the line agent://meridian.io/sre/runbook-executor 82% of budget
behaving oddly agent://meridian.io/data/query-cost-optimizer repeating the same step (14 times)
Open in your console:
what happened https://box/i/budget_exhausted:run-4471
this agent https://box/a/agent://meridian.io/finops/unit-economics-analyst (freeze, kill)
its owner https://box/o/w.zhang (everything they run)
Raised by tokenfuse at 2026-08-04 01:14:07 UTC. This mail carries identifiers and numbers only, never the content of a call.
Transcribed from render.Event's own output rather than written by hand. The previous sample showed three things this build cannot produce: a fact line reading "Occurrences 3, org acme" beside a budget event (the budget branch returns early and prints the spend sentence alone), the phrase "behaving unlike its own history" (it belonged to behavior_anomaly, removed above), and no closing sentence about what the mail carries.
Five sources, each with a rule about what it may contribute, and not one of them is free text.
"Around it right now" is built from the same event log this process already reads, so it costs no new input and cannot be stale in a way the alert is not. It lives in memory only: this is what the notifier has seen since it started, and a fresh process says less rather than describing a fleet from before a rollout. Every phrase in it comes from an event's TYPE and its numeric fields, never from text a producer wrote.
"Answerable for it" comes from the agent's passport, not from the event: the
envelope carries agent_id and on_behalf_of, and neither is the owner.
HERALDYX_PASSPORTS is optional and unset by default, and an agent with no
passport gets no owner line. This process does not invent one.
The three links are the three things an operator wants at two in the morning, and all three are views. The action happens in the console after a sign-in, and a destructive one after a passkey.
Every alert mail has a Key: line near its top, the identifier of the condition,
for example Key: policy_deny:run-1. If you already know about a condition and
do not want its repeats for a while, acknowledge that key on the box:
docker compose exec heraldyx /usr/local/bin/service --ack policy_deny:run-1 --for 4h --by ops
On Kubernetes, the same command runs in the notifier's container:
kubectl -n agent-stack exec deploy/heraldyx -c heraldyx -- /usr/local/bin/service --ack policy_deny:run-1 --for 4h --by ops
For the next four hours, alerts below critical about that key are not mailed.
Each one that would have been mailed is counted instead. When the acknowledgement ends, one mail says how
many were held, the worst severity among them and when the first was held. A
critical about an acknowledged key is always sent at once, with one line
saying it was acknowledged. Nothing holds a critical.
--for sets the length. The default is 4h, and the most is 168h, seven
days; a longer one is refused. There is no forever.--by and --note record who acknowledged and why. They are kept as text
and reported as entered, not verified.--unack <key> ends an acknowledgement at once. What it held is mailed on the
next poll.--acks lists the acknowledgements in force and exits.The commands write one file, control.json, beside the state file
(HERALDYX_CONTROL moves it). The running process only reads that file, so a
command never races the loop's own state. The daily pulse lists the
acknowledgements in force.
Like the daily summary, the mail that reports a held count is about a condition and not an agent, so the dispatch journal counts it as a gap rather than writing a record under an invented agent. The acknowledgement itself is not written to the journal in this version.
Two kinds of window hold alerts below critical. Neither one holds a critical.
Quiet hours repeat every day on your own clock:
HERALDYX_QUIET=22:00-07:00
HERALDYX_TZ=Europe/London
HERALDYX_TZ is an IANA zone name, and the hours follow the clocks, so they
stay on your wall time across a change to summer time. Without it the hours run
in the process's own zone, and the start-up log says so. A value that cannot be
read turns quiet hours off, the log says why, and the notifier still starts. The
zone database is compiled into the binary, so the image needs no tzdata.
A maintenance window is opened by hand, for work you are doing on the stack:
docker compose exec heraldyx /usr/local/bin/service --maintenance 30m --note "upgrading tokenfuse" --by ops
On Kubernetes, the same command runs in the notifier's container:
kubectl -n agent-stack exec deploy/heraldyx -c heraldyx -- /usr/local/bin/service --maintenance 30m --note "upgrading tokenfuse" --by ops
--scope <source> holds only the alerts of one source, for example
--scope tokenfuse while only that plane is being upgraded.--maintenance-end closes the window now.--maintenance 25h is refused. Opening a new
window replaces the one in force.What you will see:
Held alerts do not count toward the hourly ceiling, because nothing was sent.
Like --by and --note for an acknowledgement, the window's text is reported
as you entered it, not verified. The window itself is not written to the
dispatch journal in this version, so its opening and closing count as gaps in
the trail, the same as the daily summary.
Four short paragraphs: what happened with its numbers, what the box already did about it, what happens if nobody acts, and one link.
Identifiers and numbers only. An event's data can hold anything a
producer put there, and some producers sit next to prompts, model output and
matched secrets. Mail leaves your perimeter through a server we do not control,
so data is rendered through an allowlist of keys whose values must also look
like identifiers or numbers. A denylist would be one new producer away from
leaking.
The matched row is the one that carries the design: a live secret passes the shape check comfortably, so the KEY list is what stops it.
One link, and it is a view. The mail never carries an action. A link that acts is an unauthenticated capability held by anyone who sees or forwards the message, and mail security gateways prefetch links, which would fire the action before a human read the sentence next to it. The link opens your console at that event; you sign in there, and destructive actions ask for your passkey.
Every message this process sends leaves one record behind it: an agent-event in
the shared envelope, appended to a hash-chained NDJSON file
(sent.ndjson, beside the state file). One line per message, carrying who was
written to, what it was about, which transport carried it, and whether that
transport took it. There is one exception, named below, and the process tells
you when it happens rather than leaving you to find it.
heraldyx --journal # from the box, no shell needed
agent-conform -chain sent.ndjson # or with the estate's own checker
--journal exists because the image is distroless: there is no shell in it to
cat the file with, which is the right posture for the one process with a way
out and also means an operator would otherwise have to copy a volume out to
read their own record. It reports and never repairs, and it exits non-zero on a
broken chain so a deployment check can use it directly:
journal: /var/lib/stack/heraldyx/sent.ndjson
records: 2 (alert 2)
outcome: 2 accepted, 0 refused
chain: verifies (1 chained, 1 head(s))
last: 2026-08-02T18:08:49Z budget_exhausted:run-99 -> [email protected] (accepted)
(agent-conform is the checker in
agent-stack-go, the same module
that writes the chain.)
The broken-chain output above is a real one: a four-record journal with its third record edited.
Two words in there are chosen carefully.
"accepted", not "delivered". What this process observes is a mail server taking the message. Whether it reached a mailbox, a spam folder or a filter that drops it silently is not knowable from here.
The exception: a record needs an agent to name. The shared envelope requires
an agent_id and nothing here invents one (see the boundary further down). A
digest or a ceiling notice sent in a cycle where no single agent caused a message
has nothing to be filed under, so the mail goes out and the record does not. The
same is true of a write that simply fails, since the mail has already gone by
then. Neither is silent. The process says so on stderr:
record: 1 message(s) sent without a record just now, 1 since this process
started: no agent id to file them under, or the write failed. The mail went out
either way, and the journal is short by that many.
--journal cannot show you this and does not pretend to: a record that was
never written leaves no trace in the file it would have been written to, so the
count lives in the log of the process that knows it.
The journal names the recipients, unlike the mail itself, which carries identifiers and numbers only. The difference is where each one goes: mail leaves your perimeter through a server we do not control, and this file never leaves your box. An operator proving they were told needs the address, and "one recipient, hash a3f2" proves nothing to anybody.
It is written to heraldyx's own volume, never into the planes' event log. That log is mounted read-only here on purpose, and it stays that way.
HERALDYX_QUIET holds
alerts below critical at the same hours every day, and a maintenance window is
opened by hand. The ceiling and the digest are the other volume controls.critical is never held: it is sent at
once, one message per condition, with the dedup window still applying. The
log line at the ceiling says the same. Measured the other way on 2026-09-17
(issue #71): one summary at the ceiling, then 28 minutes of held alerts,
a critical among them, and silence.trailryx-node events --file across a read-only mount, and nothing here
grows a client, an encoder or a second binary to push it. Two things on the
trailryx side stop that being wired up today: it does not yet map this
journal's event type to a record type, and its file import keeps no cursor, so
running it twice imports the file twice. Both were measured on 2026-08-06 and
are written up in VALIDATION.md.agent_id, no subject, no mail. This is the one class of
signal the stack raises that this process cannot see, and the fix is not here.--from-now=false to read from the
beginning.make gates
which is gofmt, go vet, staticcheck, go test -race ./..., gosec,
govulncheck and ./scripts/one-way-out.sh: the same set CI runs, in the same
order, so a green local run means a green CI run.
The last one holds the architectural claim this component makes: SMTP lives in
internal/deliver and nowhere else, nothing here speaks HTTP, and the layer
that decides what to say does no I/O at all. See VALIDATION.md for what has
been verified and how.
Apache-2.0. This stack is defensive: it exists so an organization can govern and audit its own agents.
Apache-2.0, like the rest of the stack. See LICENSE.
| is talking to an MCP tool that changed under its pinned lock |
quality_drift | is producing worse output than its baseline |
sim_finding | failed a rehearsal |
dependency_failed | was affected by a failure in one of this box's own dependencies (the sentence changes with what actually happened: see below) |
slo_burn | has spent, or is fast spending, the error budget on one of its objectives (which objective, and which of the two, are both in the mail: see below) |
typed_answer | was given a typed answer with a probability behind it |
typed_unanswered | asked a typed question that came back with no usable answer |
typed_refused | asked a typed question that never reached a backend (the sentence names typryx's own reason: a cap, an unregistered template, freeform switched off, or a malformed ask; see below) |
calibration_drift | has a group of typed answers whose calibration crossed a configured bound (the template, backend, model and which metric crossed are all in the mail; see below) |
chain_broken | has an event stream whose hash chain broke (the subject and the mail name the stream file, the first broken line and how many breaks the file holds; see below) |
chain_unchained | has an event stream that holds events and no hash chain at all (the stream file and the event count are in the mail; not a break; see below) |
chain_restarted | found a hash chain starting again partway through an event stream, from agent-conform (the stream file and the line are in the mail; high when no resume checkpoint explains it; see below) |
witness_stream_rewritten | found that bytes an event stream had already held have changed, from keyryx's witness (the stream file and the counts are in the mail, never a digest; see below) |
witness_stream_truncated | found an event stream holding fewer lines than when the witness last read it (the stream file is in the mail; see below) |
witness_chain_restarted | found that an event stream's hash chain starts again, from keyryx's witness (the stream file is in the mail; high when no resume checkpoint explains it; see below) |
witness_checkpoint_invalid | found a signed checkpoint that does not verify, so it was not used as evidence (see below) |
witness_checkpoint_unsigned | found a checkpoint written without a signature; it is never counted as valid (see below) |
witness_root_missing | found an event stream that grew with no new record-plane root in the expected interval (see below) |
witness_root_equivocated | found two different record-plane roots for one size of the trail (see below) |
key_rotated | rotated a signing key (the new kid and the one it replaced are in the mail; see below) |
key_revoked | revoked a signing key (the key name, kid and algorithm are in the mail; see below) |
anomaly_triaged | was given a spend anomaly to investigate |
anomaly_explained | wrote up what caused a spend anomaly |
anomaly_accepted | had its explanation of a spend anomaly accepted |
anomaly_dismissed | had a spend anomaly dismissed |
agent_hired | was added to the roster |
agent_removed | was taken off the roster |
agent_transferred | changed hands |
agent_rebriefed | had its brief rewritten |
agent_state_changed | was moved on or off the rota |
budgets_set | had team budgets written against it |
forecast_frozen | had a forecast frozen for the period |
sprint_planned | had a sprint approved |
explainer_published | published a written explanation to a team |
generated_estate_replaced | had its generated estate replaced with a fresh one |
option_refused | wrote a deliverable whose options the console refused |
option_applied | had one of its options stamped |
decision_requested | sent one owner a decision request for the sprint |
cadence_set | had the console's cadence switch changed |
crew_ran | had a clock-driven run of the crew finish |
plan_asked | was asked to plan its sprint with a model, beside the deterministic plan |
starttlsimplicitplain127.0.0.0/8::1localhostHERALDYX_DEDUP_SECONDS | 600 | quiet window per condition |
HERALDYX_MAX_PER_HOUR | 20 | ceiling on immediate messages, 0 for none; a critical is sent regardless, and what is held is summarised every ten minutes |
HERALDYX_DIGEST_HOURS | 24 | how often the below-the-floor summary goes out, 0 for never |
HERALDYX_PULSE_HOURS | 24 | how often a box says the notifier is alive, unless the daily summary carried it in that period, 0 for never; see "A quiet day" above |
HERALDYX_QUIET | (empty, off) | daily quiet hours as HH:MM-HH:MM, for example 22:00-07:00; alerts below critical are held and reported when the hours end; see "Quiet hours and maintenance windows" |
HERALDYX_TZ | (empty, the process's own zone) | the IANA zone HERALDYX_QUIET is read in, for example Europe/London |
HERALDYX_POLL_MS | 2000 | how often to read the log |
HERALDYX_SENT | beside the state file | the dispatch journal; empty disables recording |
HERALDYX_PASSPORTS | (empty) | a directory of agent-passport JSON, read to name who is answerable; empty means alerts carry no owner |
HERALDYX_STREAMS | (empty) | what a stream file may carry beyond the built-in table, as stem=source|source, comma-separated; see "What a file may carry" |
HERALDYX_CHECKPOINT_GATE | off | on reads a stream file only while a signed checkpoint covers it, and refuses to start without HERALDYX_CHECKPOINTS and HERALDYX_CHECKPOINT_KEYS; see "The checkpoint gate" |
HERALDYX_CHECKPOINTS | (empty) | the directory of <stream>.ckpt.ndjson checkpoint files, read when the gate is on |
HERALDYX_CHECKPOINT_KEYS | (empty) | a JSON file of pinned public keys, from kid to the base64url uncompressed P-256 point, read when the gate is on; a file with no key in it refuses to start |