Automation
A package plugs into the workflow-rules system by declaring triggers (events users can build rules on) and actions (things rules can do) as pure data. Core does the rest: the engine binds the hooks, evaluates each user’s rules, executes actions, and logs every run. The rule builder renders your catalog with no package-side UI code at all.
Mail is the reference implementation — mail/tinycld/mail/automation.ts and
mail/server/automation.go. For the user’s view of the feature, see
Automation rules.
Declaring definitions
Three steps. First, point the manifest at a definitions module:
automation: { definitions: 'automation' },
Second, add the matching entry to the package’s exports map — both are required, and the generator reports clearly when one is missing:
"./automation": "./tinycld/<slug>/automation.ts",
Third, write the module itself as a default-exported, pure-data object typed against your generated schema:
import type { AutomationDefinitions } from '@tinycld/core/lib/automation/types'
import type { MailSchema } from './types'
const automation = {
triggers: [
{
id: 'message-received',
label: 'A message arrives',
collection: 'mail_messages',
on: 'create',
fields: ['subject', { key: 'sender_email', label: 'Sender' }],
},
],
actions: [ /* … */ ],
} satisfies AutomationDefinitions<MailSchema>
export default automation
Two constraints that bite:
- Import your own types relatively (
./types), never through the~/self-alias. This module is config-reachable — the generatedtinycld.config.tsimports it under the app shell’s tsconfig, where your self-alias does not resolve. Same reasoncollections.tsimports./types. import typeonly. The module has to stay JSON-serializable data.
The schema generic makes collection and column references compile-checked, so a typo’d field name fails your package’s typecheck.
Everything is referenced by qualified ref — <slug>:<id>, as in
mail:message-received or core:apply-label.
Triggers
A record trigger names a collection and an operation:
| Field | Meaning |
|---|---|
id, label | Kebab-case id; the human sentence (“A message arrives”) |
collection, on | Which collection, and create | update | delete |
watch | Update triggers only: fire only when one of these columns changed |
fields | Allowlist of columns exposed to conditions, templates, and run summaries. Bare keys or { key, label } overrides. Omit to expose every column |
ownerField | Overrides owner auto-detection |
Exposure is a security surface. Hidden and system columns are filtered out
everywhere — templates, run summaries, condition evaluation, the client
catalog — even if you name them explicitly; autodate timestamps are the one
sanctioned exception. Columns with no condition operators (json, file) are
skipped automatically, and conditions on non-exposed fields fail closed at
evaluation time. Curate fields freely.
Synthetic triggers (core:schedule, core:manual) are core-only; the
generator rejects them in feature packages.
Trigger filters
If your collection holds rows the trigger’s semantics exclude, register a
filter from your Go Register(app). It gates the event for all rule
scopes:
automation.RegisterTriggerFilter("mail:message-received", func(app core.App, record *core.Record) bool {
return messageIsInbound(record) // drafts and outbound sends are not "received"
})
Mail needs this because mail_messages also holds drafts and sent mail — “a
message arrives” must not fire on them. Without a filter, every matching
write dispatches.
Personal-rule scoping
A personal rule fires only on events belonging to its owner. The engine
resolves owners in order: a registered resolver, then the declared
ownerField, then auto-detection of the first user/owner/author
relation to users. If nothing resolves, personal rules never fire for that
trigger — org rules are unaffected.
Collections with no direct user foreign key register a resolver:
automation.RegisterOwnerResolver("mail:message-received", func(app core.App, record *core.Record) []string {
return mailboxMemberIDs(app, record) // thread → mailbox → members
})
Return every user id the event belongs to. Treat resolvers as security code — returning the wrong owner fires other users’ personal rules on data they should not see.
Actions
Two kinds. Record-ops are declarative writes the engine performs itself, with no handler to register:
{
id: 'move-to-folder',
label: 'Move to folder',
kind: 'record-op',
collection: 'drive_items',
op: { type: 'update', target: 'trigger-record', set: { parent: { param: 'parent' } } },
params: [{ key: 'parent', field: 'parent', label: 'Destination folder' }],
}
op.typeiscreate, orupdate/deletewithtarget: 'trigger-record'. Trigger-record ops are offered only for triggers on the same collection, so cross-package compatibility is structural rather than enumerated.op.setvalues are{ param: '<key>' },{ context: 'record-id' | 'collection' | 'owner' }, or a literal.- Params declaring
field: '<column>'inherit that column’s type, relation target, and select options. Novel params declaretypeinstead, and a novelrelationparam must also declarerelationTarget— the generator rejects one without it, since there is no column to inherit a target from. - Every text param accepts
{{field}}placeholders. Relation params are never template-substituted: ids come from a picker, and letting trigger content choose one would be an injection channel.
Native actions dispatch to a Go handler registered in Register(app),
which must run before hooks load:
automation.RegisterAction("mail:send-message", func(app core.App, req automation.ActionRequest) error {
// req.Params are template-substituted; req.Record is the trigger record
// (nil for scheduled/manual rules); req.OwnerID is the rule's owner.
return sendFrom(app, req.OwnerID, req.Params)
})
Returning an error records that action as failed and continues to the rule’s later actions — mail-filter semantics. An action whose handler is absent (package not installed) is marked unavailable in the catalog and greyed out; declared-but-unregistered is a supported state, not an error.
Authorization
The engine runs actions with system authority: record-ops write as superuser,
and native handlers receive a superuser-powered app. PocketBase collection
rules therefore protect nothing on this path. The engine gates exactly two
things; everything else is the handler’s job.
Relation params. A relation param’s value is a caller-supplied record id —
the rule JSON is client-authored, so the picker is a convenience, not a
boundary. Every non-empty relation param passes two fail-closed layers: an
engine-owned floor (the rule owner must pass the target collection’s
viewRule for that record), then your registered authorizer, which answers
the write-level question the engine cannot know:
automation.RegisterRelationAuthorizer("drive:move-to-folder", "parent",
func(app core.App, req automation.ActionRequest, id string) error {
return destinationWritableBy(app, req.OwnerID, id)
})
Declaring a relation param without registering its authorizer is refused
at execution and greys the action out. A deliberate pass is fine when the
model is genuinely org-wide — core’s apply-label authorizer returns nil with
a comment saying why — the point is that the decision is written down where
review can see it.
Trigger-record writes. A record-op targeting the trigger record still
writes as superuser, so the engine requires the rule owner to pass the
collection’s updateRule or deleteRule for that record. A nil rule means
superuser-only in PocketBase and is refused rather than waved through. This is
what stops an owner who can merely see a record from moving or deleting it
through automation when the same edit through the UI would be refused.
Register a TriggerRecordAuthorizer (optional here) when the collection’s
rules are looser than its write semantics.
Everything else stays yours: records your handler looks up itself, text params naming recipients or addresses, and who an action acts as.
Execution semantics you inherit
You implement none of this, but should design against it:
- Org rules run before personal rules, each in the user’s
order;stop_processinghalts downstream rules. - Engine writes carry provenance, so a rule never re-fires on its own write.
Action→trigger cascades cap at depth 3, logged as
chain-depth-exceeded. - Actions time out at 30s — abandoned, not killed. Don’t write handlers that hold locks past that.
- Every dispatch writes a run row, matched or not. Runs are pruned to 200 per rule, and around 20 consecutive fully-failed runs auto-disable the rule and notify its owner.
- Text condition operators are case-insensitive;
is/is_notmatch any element of a multi-value field; dates accept PocketBase datetimes, bare dates, or RFC 3339.
Native handlers that write trigger-bound collections
A record-op’s writes are stamped with provenance automatically. The engine
cannot do that for a native handler — it hands you an app and never sees
your Save — so an unstamped write reads as an ordinary user edit and
re-fires the trigger at depth 0, forever.
If your handler writes to a collection any installed trigger watches, go
through MarkEngineWrite:
record.Set("id", core.GenerateDefaultRandomId()) // the sentinel is keyed by id
return automation.MarkEngineWrite(req, record.Id, func() error {
return app.Save(record)
})
It stamps the rule id and depth, runs the write, and removes the stamp if the
write fails, so a failed attempt cannot suppress the next genuine user edit.
Calendar’s actionCreateEvent is the reference: calendar:event-added
watches the very collection it creates into.
Your ingress must finish before it fires a trigger
If your package writes records around the one a trigger watches, wrap that
write path in app.RunInTransaction. This is the one execution detail that
is genuinely your responsibility, and getting it wrong produces a rule that
reports success and does nothing visible.
Dispatch binds to OnRecordAfterCreateSuccess and its update/delete
equivalents. Outside a transaction, that hook fires the instant the record is
saved — while the rest of your ingress function is still running, with rule
actions executing concurrently on a worker goroutine.
Mail hit this. Its inbound path stored the message (firing the trigger), then
wrote each recipient’s thread state with folder: "inbox". A move-to-folder
action archived the thread, and delivery’s own write landed afterwards and put
it back:
storeMessage(app, thread.Id, stored) // fires the trigger; actions start
...
ensureThreadState(app, thread.Id, userID, "inbox", false) // clobbers the action
The run logged matched: true with status: "ok". Nothing was red, and the
message simply stayed in the inbox. The fix is structural, because the
after-success hooks are delayed until the transaction commits and are not
triggered on rollback:
return app.RunInTransaction(func(txApp core.App) error {
// every write for this delivery, using txApp
return nil
}) // hook fires here, once, over settled state
You get two properties free: rules fire exactly once with all related rows in place, and a failure partway through means rules never fire at all rather than firing against a half-written record.
Reordering so the triggering save comes last also works, but leaves the ordering load-bearing — the next person to add a write reintroduces the bug. Prefer the transaction. Worth auditing in your package: bulk importers that save in a loop, and any path writing a parent record after its children.
Inbound webhooks
A trigger can start from outside the deployment. Core’s webhookin registry
serves POST /api/webhooks/{name} for every registered source, verifies the
signature, records each delivery in a webhook_deliveries ledger (so a
delivery can be replayed and a duplicate is dropped), and hands the verified
body to your handler. Boards uses it for GitHub:
webhookin.Register("github", webhookin.Source{
Secret: githubWebhookSecret, // func(app, r) (string, error); read per request so it can rotate
SignatureHeader: "X-Hub-Signature-256",
EventHeader: "X-GitHub-Event",
DeliveryID: func(r *http.Request) string { return r.Header.Get("X-GitHub-Delivery") },
Handle: handleGitHubDelivery,
})
A missing secret returns an error, and the receiver fails closed — unconfigured
is never permission. Your handler turns the payload into ordinary record
writes; the trigger fires from those writes like any other, which keeps the
“ingress must finish first” rule above in force: wrap the writes in a
transaction. Store the secret in a systemSettings panel so the owner can set
and rotate it from the app.
The outbound direction is core’s Post to a webhook action, available to every
rule with no package code.
The catalog
At boot the engine resolves every declared trigger and action against live collection metadata — field types, relation targets, select options, native handler availability — and materializes rows into a read-only catalog collection the builder live-queries. You never write catalog code.
Two consequences: relation params get a record picker automatically, with the
display field chosen from name, title, label, subject,
display_name, email, or username; and an action referencing a collection
that doesn’t exist in a deployment shows as unavailable rather than vanishing.
Verifying your declarations
pnpm exec tinycld-pkg typecheckin your member catches bad collection/field references.pnpm run packages:generatefromtinycld/runs the structural validation — kebab-case ids, unique ids, param references, no synthetic triggers outside core — and fails the build on a violation. See Generated files for what it emits.- Go registrations are unit-testable against real migrations; see
mail/server/automation_test.go. - For e2e, drive the builder UI and use your package’s real ingress, never raw
PocketBase writes.
mail/tests/rules.spec.tsis the reference. - Assert the visible effect, not the run row. A run saying
matched: true/status: "ok"only proves the action was called — it survives the ingress race above, and it survives an action that writes somewhere no view reads.
Gotchas
- Never import another sibling package from
automation.ts. The lean-shell rule applies; cross-package reach happens through record-ops on your own collections and core’s built-ins. - An action writing rows no UI ever reads is worse than no action: the rule “succeeds” and the user sees nothing. Verify the whole loop — trigger, action, visible effect — before declaring it.
- Definitions are data, not migrations, so you may evolve them freely within a
version. But renaming an
idorphans existing rules, which surface as unknown references. Treat published trigger and action ids as API.