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:

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:

FieldMeaning
id, labelKebab-case id; the human sentence (“A message arrives”)
collection, onWhich collection, and create | update | delete
watchUpdate triggers only: fire only when one of these columns changed
fieldsAllowlist of columns exposed to conditions, templates, and run summaries. Bare keys or { key, label } overrides. Omit to expose every column
ownerFieldOverrides 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' }],
}

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:

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

Gotchas