Skip to main content
How a HERO automation gets started, what it is told when it does, and every way it can silently not fire. An automation is three parts:
Any trigger can start it. The condition is checked once, before the first action. The actions then run in order, each able to read the ones before it — except where a 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.
Both accept an 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 as X-Hero-Webhook-Secret or Authorization: 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 bridgestream, 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 type CLAUSE_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 the DocumentSignatures 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.
So both are emitted directly by the code that performs them, and neither is debounced — each is one deliberate act, not an edit burst. Causation still applies, which matters because the AI can save a version itself.
A restore of a signed version fires no signature events. Restoring brings the version’s signatures back with it, so the projection gains a signedAt on every line and a completedAt — which reads exactly like people signing. Nothing was signed; a rollback happened, and document.restoreVersion says so. So signature.signed, document.fullySigned and document.signaturesVoided are 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 sets isDeleted: 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:
Honoured keys: 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.created and document.duplicated are 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, plus table.deleted.
There is no 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.tableId on document.updated means the trigger never fires: document events carry no tableId, so the comparison fails every time. Filter on a key the event actually has.
Trap — filters take bare ids, never 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.
Which ids each event carries: 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 and control.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_regex is anchoredContract does not match Employment Contract; use .*Contract.*. A malformed pattern is no-match, not a crash.
  • is / is_not compare booleans as booleans, everything else as text. A false is not empty.
  • No documentId on 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.
Operators depend on the column’s effective type (a computed column uses its formula’s result type):
  • No tableId + rowId on 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

Both sides are prose and both go through the same token resolution as an email body. That is what it is for: it is the only condition kind that can test something a step produced during the run, because the other two start by loading the row or document that fired and so can only ever see the trigger. Operators: 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 supply actorId. document.signaturesVoided, document.newVersion and document.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 stamps voidedBy on 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, so hero:trigger/actor on those resolves to an empty string even though the save layer permits it.
Limit — actorId is 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 the SignatureAudits record 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. rowRef and documentRef are the deliberate exceptions — they hold a hero: URI.
  • hero:trigger/previous.* is empty unless pre-images are on for that collection. It is never approximated from the current value.
There is no {{…}} 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).
Refused at enqueue rather than at run time, so a self-retriggering write does not mint a failed run for every write it makes. Queue limits. A run is deduplicated by (automation, row) — or (automation, document) for an event trigger, or the idempotency key — rather than by automation alone, or row 2’s button click would be rejected while row 1 was still running. Beyond that: 3 attempts per run, 3 concurrent runs overall, 2 per automation, and 20 chained runs per 60-second window. Budgets (per run, overridable per automation):

8. Pre-images

Some questions need the state before a write. MongoDB only answers them when changeStreamPreAndPostImages is enabled on the collection, and that costs a stored copy of every version. Enabled on DocumentSignatures only. That buys:
  • signature.signed distinguishing 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.
Everything else is derived from 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”

  1. Is the automation enabled? 5 consecutive failed runs disable it and notify the owner — a silently dead automation is worse than a noisy one.
  2. Does the filter key exist on that event? tableId on a document event matches nothing (§3).
  3. Is there a hero: URI in a filter? It is compared literally (§3).
  4. Is the trigger scoped to the wrong project? projectId is honoured now (§3).
  5. Does the condition kind match the trigger kind? fields on a row trigger, or rowFilter on a document trigger, matches nothing (§4).
  6. Does the condition filter a relation column? It always evaluates false (§4).
  7. Is it row.enteredView / row.leftView? Not implemented; refused at save.
  8. Is the trigger a button, webhook or api type? Superseded and no longer read; the webhook and API entry points are properties of the automation now (§1).
  9. Did an automation cause the change? Depth >3 or a repeat in the chain is refused (§7).
  10. Was it within 5 seconds of another change to the same entity and event? Debounced into one run (§7).
  11. Is it a signature event within 30 seconds of a version restore? Suppressed — a restore is not a signing (§2).
  12. Is it document.updated after a move, publish or rename? Those fire the specific event only (§2).
  13. Is it document.newVersion on an AI edit? AI undo checkpoints are not versions (§2).

Where this lives