control.paths step branches, and a control.wait step suspends the run and resumes it
later with the earlier steps’ outputs restored.
1. Triggers
Four types are offered. An automation can carry several, of mixed types.
“Supplies” is the set of slots that trigger can fill — see §6. A token addressing a slot
its trigger cannot fill is refused at save rather than resolving to nothing at run time
(
automationReference.service.ts, SLOTS_BY_TRIGGER).
Shapes
Three trigger types are superseded
button, webhook and api remain in the type so a saved automation still round-trips,
but none of them is offered or read any more. None is a thing that happens to your data —
each is a way of asking for a run, which is what made them read like one more “when” while
actually being consent, or in the webhook’s case nothing at all.
The two entry points they stood for still exist, as properties of the automation rather than
as triggers:
POST /api/automation/hook/{publicId}— authenticated by the automation’s own webhook secret, because the caller is outside HERO and has no session. Enabled by generating a secret (automation.webhook), not by adding a trigger.- The API entry point — authenticated by the caller’s personal token, so the run records who asked while still executing as the automation’s owner.
Idempotency-Key; a repeat of a key that already ran returns the original run
with replayed: true rather than running the workflow twice.
Limits
- Schedules run no more often than every 5 minutes, and two schedules on the same
automation must be ≥5 minutes apart (
MIN_INTERVAL_MS). - Connectors are polled, not pushed, at the same 5-minute floor, 10 messages per poll
(
MIN_POLL_INTERVAL_MS,MAX_MESSAGES_PER_POLL). - A webhook body must be JSON — an object or an array (many services batch) — and is
capped at 1 MB (
MAX_PAYLOAD_BYTES); an oversized body is refused before the queue. The secret is sent asX-Hero-Webhook-SecretorAuthorization: Bearer …, compared in constant time, and only its hash is stored — the plaintext is shown once, at creation or rotation.
2. Events
Thirty-two exist in the type. Thirty are offered;row.enteredView and row.leftView are
in the schema but the bridge never emits them, so they are refused at save with a reason
rather than accepted and left dead.
The list lives once, in packages/script-shared/src/types/Automation.ts as
AUTOMATION_EVENTS. The save schema, the trigger picker and the AI composer’s prompt are all
derived from it.
Alongside it, EVENT_SOURCES declares how each event reaches the bridge — stream,
emitted, or none. Declaring it is the point: the recurring failure in this feature is an
event that exists in the type, is offered in the picker, saves without complaint and is
emitted by nothing at all. none is confined to the two unimplemented events.
Rows and cells
Clauses and terms
These ride the block projection the editor already writes on every save — no separate scan. Only blocks of typeCLAUSE_BLOCK and TERM_DEFINITION are considered; everything else is
ignored.
term.defined covering both insert and edit is deliberate: a definition being written and
being rewritten are the same event to anyone watching for it.
Signatures
Driven by theDocumentSignatures projection, which the collaboration server rewrites on
every persist of a document containing a signature block. So “the projection changed”
only means someone typed — each branch has to name a real state change.
completedAt is derived, not stored intent: it is the latest signedAt when there is at
least one signature line and every line is signed, and unset otherwise (completionOf).
That makes document.signaturesVoided “the document is no longer fully signed”, which is
what the trigger picker calls it. Voiding is the usual cause and the only one reachable while
the document is locked, but deleting the last signature blocks from an unlocked document
reaches it too. The event key is unchanged for saved automations; only the label is honest.
signature.signed compares the before and after projections, so a block being added or
renamed is not mistaken for a signing. That comparison needs change-stream pre-images,
which are enabled on DocumentSignatures only (§8).
Documents
Checked in that order, first match wins.
document.updated is the fallback, so a move, a
publish or a rename does not also fire it. document.renamed sits before the fallback for
the same reason document.moved does: renaming is a deliberate act, and folding it into
“updated” makes it untriggerable.
Workspace and access
All four are emitted, not derived: they are reported by the invitation, workspace and
document services, which know who acted. They fill the
member slot.
Structure
The shape of the data changing, rather than the data in it.
Creating a table creates its columns, so a new table reports itself and then each column —
the same way it reports each row. Truthful rather than tidy.
project.created is creation only: a project has no isDeleted, so there is no soft
delete to read, and a hard removal cannot be told apart from the cron tidying up after one.
Versions
Both carry who did it (
actorId) and which version (versionId, versionName) —
the only events besides the void that name an actor.
document.newVersion covers every route that produces a version a person can see in the
history: the editor’s Save version, the AI’s create_document_snapshot, and the
Signed — <date> version a completed signing preserves. It does not fire for the AI’s
own pre-edit undo checkpoints (isAiManaged), which are hidden from the history — firing on
those would announce a version for every AI edit, and an automation that edits a document
could then trigger itself through its own checkpoint.
These two are the only events not derived from a change stream:
- Versions live in
EditorYjsSnapshots, which nothing watches — and each row holds a whole Y.js state blob, so putting it on the realtime stream to reach the bridge would push megabytes at every subscriber on every save. - A restore writes nothing on the server at all. The editor applies the old content through the collaboration session, which reaches the server as an ordinary document change, indistinguishable from a large paste.
A restore of a signed version fires no signature events. Restoring brings the version’s signatures back with it, so the projection gains asignedAton every line and acompletedAt— which reads exactly like people signing. Nothing was signed; a rollback happened, anddocument.restoreVersionsays so. Sosignature.signed,document.fullySignedanddocument.signaturesVoidedare suppressed for that document for 30 seconds from the moment the restore is reported, and anything already queued for it is dropped. Dropping the queued run is the part that matters: the client applies the restore to the shared document and only then tells the server, so the projection write normally reaches the change stream first. It survives that because dispatch waits out the 5-second debounce, and the report lands inside it. The restore also re-locks the document if the version it brought back was fully signed, and hands the live audit slots back to the records that version refers to.
Limit — a restore is reported by the client, not derived.
POST /api/snapshot/{snapshotId}/restore is called by the editor after the content lands.
It is authenticated and checks edit access on the document, but a client that dies mid-restore
reports nothing, and the event is only as trustworthy as the session that sent it. Everything
else in this document is observed server-side.
Deletes are soft
Deleting anything setsisDeleted: true; the real removal happens ~48 hours later when a cron
sweeps. The bridge therefore fires on the soft delete and ignores Mongo delete
operations entirely. A hard delete is garbage collection, not something a person did, and
firing on it would repeat the event two days after anyone cared.
3. Filters
An event trigger may narrow itself:tableId, columnId, and documentIds — a list, read through
triggerDocumentIds, which folds in the superseded single documentId so an automation saved
before the picker existed keeps narrowing exactly what it did. Each is compared with ===
against the same field on the fired event’s context. An absent key means “don’t care”, and an
empty documentIds means every document — the filter is opt-in.
A trigger may also carry projectId, outside the filter, which narrows it to one project.
It was offered by the type from the beginning and read by nothing, so an automation scoped to
one project fired for every project in the workspace; it is honoured now.
Which events offer which picker is declared, not inferred:
DOCUMENT_SCOPED_EVENTS— the clause, term, signature and document events that fire about a document which already exists.document.createdanddocument.duplicatedare absent on purpose: the document they fire for does not exist yet, so there is nothing to pick from.TABLE_SCOPED_EVENTS— the row, cell and column events, plustable.deleted.
folderId filter. Matching one means walking a document’s parent chain, and a
filter the bridge silently ignores is worse than one that does not exist: it narrows nothing
while looking like it narrows something.
Trap — a filter key the event never populates matches nothing.filter.tableIdondocument.updatedmeans the trigger never fires: document events carry notableId, so the comparison fails every time. Filter on a key the event actually has.
Trap — filters take bare ids, neverWhich ids each event carries:hero:URIs."tableId": "hero:table/0fcb…"is compared literally against a raw database id and matches nothing. Saving normalises the known structural keys (tableId,columnId,documentId,rowId,viewId,blockId) back to bare ids, so this is handled — but only for those keys.hero:tokens belong in prose.
Every event carries a
workspaceId, and a run is refused without one — matching everyone
is how one customer’s row edit once ran another customer’s automation, and a loud log beats
quietly restoring that.
4. Conditions
One condition per automation, top-level — never inside a trigger, where it would be silently dropped. It is evaluated once, before the first action; false means the run does not proceed. Individual steps andcontrol.paths branches carry their own conditions, of the same
three kinds.
Three kinds, and they are not interchangeable.
fields — for document triggers
Looks up the document that fired and tests its own fields.
- Fields:
name,parentId,type,isPublished,projectId,userId - Operators:
is,is_not,contains,starts_with,matches_regex,is_empty,is_not_empty conjunction:and|or- No predicates is vacuously true
matches_regexis anchored —Contractdoes not matchEmployment Contract; use.*Contract.*. A malformed pattern is no-match, not a crash.is/is_notcompare booleans as booleans, everything else as text. Afalseis not empty.- No
documentIdon the trigger context → false. Used on a row trigger, it matches nothing.
rowFilter — for row and cell triggers
The same engine that powers saved table views, so a condition and a view can never disagree.
- No
tableId+rowIdon the trigger context → false. Used on a document trigger, it matches nothing. - Text comparisons are case-insensitive.
Trap — a relation column never matches inside an automation.
Relation cells store linked cell ids, not text, so the engine needs a label resolver to
compare them. Both view layers pass one; neither automation path does, and unresolvable
is treated as excluded (deliberately — the alternative made every negated operator match
every row). So a condition on a relation column evaluates false and the automation never
runs, and a table.updateRows filtered on one matches no rows and silently edits nothing.
Filter on a scalar column instead.
value — compares any two resolved values
is, is_not, contains, not_contains, starts_with, ends_with,
matches_regex, is_empty, is_not_empty, gt, gte, lt, lte.
The half-filled row
There is no “wait” trigger.row.created fires the moment a blank row appears, so
“once it has a name” is a rowFilter condition on the column that must be filled — not a
delay.
5. What a run is told
Every run carries a trigger context. AI steps are handed it as prose before their instructions, under “What fired this run”, so a step never has to guess which of several triggers went off.
Signature events additionally get, rendered from the above:
- every signature line with its signatory name and whether/when it was signed;
- on a void, who voided it and everyone who had already signed before it was voided — the current state is empty by then, so the affected signers only exist in the pre-image.
Limit — only three events supplyactorId.document.signaturesVoided,document.newVersionanddocument.restoreVersion. The first has to work for it: the write that clears a document’s signatures comes from the collaboration server, which does not know who asked, so the signing flow stampsvoidedByon the projection before clearing — the clearing is the change the bridge sees. The other two are emitted directly by code that already knows the user. Nothing equivalent exists for row, cell, clause or the other document events, sohero:trigger/actoron those resolves to an empty string even though the save layer permits it.
Limit —actorIdis a Firebase uid, not a name. There is no tool that maps a uid to a person, so an AI step cannot turn it into “Alex”. The verified account email lives on theSignatureAuditsrecord and would have to be surfaced separately.
6. Addressing the trigger from a step
In prose — an AI step’s instructions, a title, an email body, a webhook body — these resolve at run time:hero:trigger/event is the one slot a rule needs to tell two triggers apart, and the only way
to: every other slot describes what the event was about, and two events about the same
document look identical through them.
Two rules that bite:
- Structural id fields take bare ids; prose takes tokens.
"tableId","columnId","documentId","rowId","viewId"and"blockId"are matched literally against database ids.rowRefanddocumentRefare the deliberate exceptions — they hold ahero:URI. hero:trigger/previous.*is empty unless pre-images are on for that collection. It is never approximated from the current value.
{{…}} template syntax anywhere in HERO. A {{trigger.email}} in a recipient
field is posted to the mail provider as a literal address.
7. Ordering, repetition and loops
Debounce — 5 seconds. Changes are collapsed per (automation, event, entity). An edit burst produces one run, not one per keystroke; the change stream coalesces anyway, so one run per burst is the honest reading. The event is part of the key because without it a rename and an update to the same document shared a slot, and an automation triggering on both had one cancel the other. The emitted events skip the debounce entirely — each is one deliberate act. Causation. A change an automation caused is not ignored — chaining is the point — but it carries where it came from. Each run records which entity it wrote and under which run id; a change to that entity within 60 seconds inherits the causation chain. A run is refused before it is created when:- the chain is deeper than 3, or
- the automation already appears in the chain (a cycle).
8. Pre-images
Some questions need the state before a write. MongoDB only answers them whenchangeStreamPreAndPostImages is enabled on the collection, and that costs a stored copy of
every version.
Enabled on DocumentSignatures only. That buys:
signature.signeddistinguishing a real signing from a block being added or renamed;- the prior-signer list on a void, which exists nowhere else once the signatures are cleared.
updatedFields, which names exactly the fields whose values
actually changed — verified: a persist that rewrites completedAt to the same value reports
only updatedAt, so a keystroke cannot fire a signature event.
Not enabled on Rows, Cells, Documents or Blocks: deletes are soft, so the “before” state is
still readable in the database for 48 hours, and the events that would need a pre-image are
already answered by updatedFields.
The command is not part of MongoDB’s Stable API v1, and the app connects with strict: true
on purpose, so it runs on its own short-lived connection. If it fails the streams still open
and the affected comparisons degrade rather than being silently approximated — the warning at
boot names the collection.
9. Checklist for “it never fired”
- Is the automation enabled? 5 consecutive failed runs disable it and notify the owner — a silently dead automation is worse than a noisy one.
- Does the filter key exist on that event?
tableIdon a document event matches nothing (§3). - Is there a
hero:URI in a filter? It is compared literally (§3). - Is the trigger scoped to the wrong project?
projectIdis honoured now (§3). - Does the condition kind match the trigger kind?
fieldson a row trigger, orrowFilteron a document trigger, matches nothing (§4). - Does the condition filter a relation column? It always evaluates false (§4).
- Is it
row.enteredView/row.leftView? Not implemented; refused at save. - Is the trigger a
button,webhookorapitype? Superseded and no longer read; the webhook and API entry points are properties of the automation now (§1). - Did an automation cause the change? Depth >3 or a repeat in the chain is refused (§7).
- Was it within 5 seconds of another change to the same entity and event? Debounced into one run (§7).
- Is it a signature event within 30 seconds of a version restore? Suppressed — a restore is not a signing (§2).
- Is it
document.updatedafter a move, publish or rename? Those fire the specific event only (§2). - Is it
document.newVersionon an AI edit? AI undo checkpoints are not versions (§2).