Triggers, schedules & waking agents

Make your data act on its own. With one tool call you can say "when a SEV1 ticket lands, wake my support agent," or "every hour, drain the task queue," or "when a new lead is created, email me." No glue code, no polling loops, no servers to babysit — the trigger lives inside your workspace and fires whether or not your AI assistant is connected.

💬 Just ask

  • "Email me whenever a high-priority ticket comes in."
  • "Every Monday morning, send me a summary of last week's new leads."
  • "When a new deal is created, post it to our webhook."

You don't call these tools yourself — just tell your assistant; the technical reference below is for when you want the details.

At a glance

  • Two ways to fire: on a database event (a row is inserted / updated / deleted) or on a schedule (a clock — every: "1h").
  • Conditions narrow when it fires — including time-of-day fields so you can gate on business hours.
  • Targets (do): enqueue a task, POST a signed webhook, wake an agent, or send an email — several at once if you want.
  • One tool, create_trigger, composes the whole thing. list_triggers and disable_trigger manage them.
  • Tenant-safe by default: you never pass a tenant id. The trigger only ever sees and writes your workspace's data.
  • Admin-only: creating, listing and disabling triggers requires the admin or owner role.

Triggers belong to a product's data. You create them on the product endpoint whose tables you want to watch — e.g. https://hdls.ai/api/mcp/crm to react to CRM rows. (The concierge at https://hdls.ai/api/mcp is for installing products and inviting people, not for automation.) New here? Start with Getting connected.


The mental model

A trigger is "if this, then that" stored as data:

WHEN something happens   →   IF your conditions hold   →   DO these actions
   (event or schedule)         (where / condition)          (task/webhook/agent/email)
  • WHEN is the when object. Give it {schema, table, op} for an event trigger, or {every: "..."} for a schedule.
  • IF is optional. Add where leaves (simple comparisons) or a raw condition tree. Omit both and it always fires.
  • DO is the do array — one or more actions. At least one is required.

Triggers fire within about a minute of becoming due (a background job sweeps events and schedules every minute). Delivery is at-least-once — design your receivers to tolerate the occasional repeat.


create_trigger

The single tool that builds a trigger end-to-end. Admin/owner only.

FieldTypeNotes
namestringHuman label for the trigger.
whenobjectEvent: {schema, table, op}. Schedule: {every} (or cron).
wherearrayOptional. AND-combined condition leaves {field, cmp, value}.
conditionobjectOptional. A raw condition tree. Wins over where if both are given.
exclude_automationbooleanEvent mode only. Skips changes that automation itself caused (loop guard).
doarrayRequired, at least one action. Each has a target plus its fields.

when — event mode

{ "schema": "crm", "table": "deal", "op": "insert" }
  • op is one of insert, update, delete. Defaults to insert if omitted.
  • Watching a table automatically turns on change-events for it the first time — nothing else to enable.

when — schedule mode

{ "every": "1h" }
  • every is <number><unit>, unit one of s m h d — e.g. "30s", "5m", "1h", "1d".
  • Minimum interval is 60 seconds. Anything faster is clamped to 60s.
  • The first tick fires almost immediately, then every interval after that.
  • There's no table to watch, so op, where on payload.*, and exclude_automation don't apply — but now.* time conditions (below) do, and the fire context carries { "trigger": "schedule", "firedAt": "<iso>" }.

cron is accepted but not parsed in this version — a bare cron string falls back to firing hourly. Prefer every for predictable timing.


Conditions — firing only when it matters

Two ways to express the "IF". Use whichever is simpler; if you supply both, condition wins.

A list of comparisons, all of which must hold (AND):

"where": [
  { "field": "payload.new.priority", "cmp": "eq",  "value": "sev1" },
  { "field": "payload.new.amount",   "cmp": "gte", "value": 10000 }
]
  • field is a dot-path into the event context. For row changes the new row is at payload.new (e.g. payload.new.status); the operation is at op; the table/schema at table / schema.
  • A wrong cmp is rejected at create time, so a typo can't silently never match.

Comparators

cmpMeaning
eq / neqequals / not-equals
gt / gte / lt / ltegreater / less than (numbers, or string order)
invalue is an array; true if it contains the field's value
containsfield (array/string/object) contains value
existsfield is present (set value: false to mean "must NOT exist")

Time conditions with now.*

You can compare against the current UTC time without any extra setup. Available fields:

FieldExampleMeaning
now.hour14hour of day, 0–23
now.minute30minute, 0–59
now.dow1day of week, 0 = Sunday
now.date"2026-06-08"UTC date
now.iso"2026-06-08T14:30:00Z"full timestamp

"Only during business hours (Mon–Fri, 9–17 UTC)":

"where": [
  { "field": "now.dow",  "cmp": "gte", "value": 1 },
  { "field": "now.dow",  "cmp": "lte", "value": 5 },
  { "field": "now.hour", "cmp": "gte", "value": 9 },
  { "field": "now.hour", "cmp": "lt",  "value": 17 }
]

Raw condition tree (for OR / NOT)

where only does AND. For richer logic pass a condition tree of and / or / not nodes over the same leaves:

"condition": {
  "op": "or",
  "conditions": [
    { "field": "payload.new.priority", "cmp": "eq", "value": "sev1" },
    { "field": "payload.new.priority", "cmp": "eq", "value": "sev0" }
  ]
}

An empty condition (or none at all) means always fire.


Targets — what happens when it fires (do)

Each entry in do has a target and the fields that target needs. You can mix several in one trigger.

TargetFiresRequiredOptional
taskEnqueues a row into an agent task queueschematable (default agent_tasks), title, detail, priority
webhookSigned POST to your URLurl
agentSigned POST to wake an agent runtimeurl
emailSends an emailto, subjectmessage, html

webhook and agent are the same signed POST under the hood — agent is just a webhook whose receiver is an agent. Both return a signing secret once (see below).

task — the pull / heartbeat pattern

Instead of pushing an HTTP call per event, drop a row into a queue table and let a worker drain it on its own schedule. Cheaper than a webhook-per-event, and no inbound endpoint to expose.

{
  "target": "task",
  "schema": "crm",
  "title": "Follow up on new deal",
  "detail": "{{row}}",
  "priority": 5
}

The row lands in agent_tasks (in the schema you name) with status: "pending". The table isn't validated when you create the trigger — if it doesn't exist, the failure shows up in that fire's run log, not at create time. (The wake-agent client below ships a bootstrap step that creates agent_tasks for you.)

Templating

Inside string fields you can pull values from the triggering event with {{dotpath}}. A value that is exactly {{...}} is replaced at fire time:

  • {{row}} — the changed row
  • {{payload}} — the full event payload
  • {{op}}, {{schema}}, {{table}}
{ "target": "email", "to": "me@acme.com",
  "subject": "New {{table}} created",
  "message": "A row was {{op}}ed: {{row}}" }

Webhook & agent payloads are signed

Every webhook/agent delivery is a POST with this body:

{
  "type": "hdls.workflow.action",
  "tenant_id": "…",
  "payload": { "event": …, "row": …, "op": …, "schema": …, "table": … },
  "emitted_at": "2026-06-08T14:30:00Z"
}

…and an HMAC signature header so you can prove it really came from hdls:

X-Hdls-Signature: v1=<hex hmac_sha256(secret, raw_body)>

Verify it by computing HMAC-SHA256 of the raw request body with the secret returned at create time and comparing (constant-time) to the header value. The secret looks like whsec_… and is shown only once in the create_trigger response — store it immediately.

Delivery is SSRF-guarded: targets must be public HTTPS URLs, redirects are not followed, and each call times out after 10 seconds. More on how hdls protects your data in Security & trust.


Loop guard: exclude_automation

If your trigger writes to the same table it watches, the write can re-fire the trigger — an infinite loop. Set exclude_automation: true (event mode) and hdls skips events that automation itself produced. Ordinary user writes still fire normally.

"exclude_automation": true

Managing triggers

List them — list_triggers

Returns every event trigger with its id, name, match {schema, table, op}, condition, active flag, and a summary of its actions. No arguments. (Member+.)

// tool: list_triggers
{}

Turn one off — disable_trigger

Stops a trigger firing without deleting it (the rule and its actions are kept, just marked inactive). Admin/owner only.

// tool: disable_trigger
{ "rule_id": "…the trigger's rule id…" }

Use the rule_id from the create_trigger response (trigger.rule_id) or from list_triggers.


The wake-agent pattern

agent deliveries are how hdls wakes a headless AI to handle work. The flow:

hdls trigger ──signed POST /wake──►  your self-hosted receiver
                                         │ verifies X-Hdls-Signature
                                         ▼
                                 wakes Claude / Codex headless,
                                 it reads the task, does the work,
                                 writes durable state back to hdls, exits

hdls signs the POST; your receiver verifies the X-Hdls-Signature HMAC over the raw body, then spins up the agent for one focused work cycle and shuts it down.

A reference receiver lives in client/ in the hdls repo (hdls-wake-agent). It is a turn-key headless Claude Code instance that:

  • exposes POST /wake (verifies the hdls signature, or a shared-secret Bearer for generic callers like cron) and GET /health,
  • resumes a stable session so the agent keeps context across wakes,
  • persists durable state back to hdls / imem over MCP,
  • runs anywhere: local Windows, a cloud VM, Docker, or one-shot serverless.

Point your agent trigger's url at that receiver's public /wake and store the returned whsec_… secret as its WEBHOOK_SECRET. See client/README.md for deploy guides.


Worked recipes

1. SEV1 ticket → wake the support agent

When a high-severity support ticket is created, wake an agent to triage it.

// tool: create_trigger   (on https://hdls.ai/api/mcp/support)
{
  "name": "Wake support agent on SEV1",
  "when": { "schema": "support", "table": "ticket", "op": "insert" },
  "where": [
    { "field": "payload.new.severity", "cmp": "eq", "value": "sev1" }
  ],
  "do": [
    { "target": "agent", "url": "https://agent.acme.com/wake" }
  ]
}

The response includes webhook_secret (shown once) — set it as the receiver's WEBHOOK_SECRET so it can verify the signature.

2. Hourly → drain the task queue

A schedule that wakes a worker every hour to process whatever has piled up.

// tool: create_trigger
{
  "name": "Hourly queue drain",
  "when": { "every": "1h" },
  "do": [
    { "target": "agent", "url": "https://agent.acme.com/wake" }
  ]
}

Add business-hours gating if you only want it during the workday:

"where": [
  { "field": "now.dow",  "cmp": "gte", "value": 1 },
  { "field": "now.dow",  "cmp": "lte", "value": 5 },
  { "field": "now.hour", "cmp": "gte", "value": 9 },
  { "field": "now.hour", "cmp": "lt",  "value": 18 }
]

3. New lead → email me

// tool: create_trigger   (on https://hdls.ai/api/mcp/crm)
{
  "name": "Email me on new lead",
  "when": { "schema": "crm", "table": "account", "op": "insert" },
  "do": [
    {
      "target": "email",
      "to": "me@acme.com",
      "subject": "New lead: {{row}}",
      "message": "A new account was just created in CRM."
    }
  ]
}

Limits & gotchas

  • Admin/owner is required to create, and to disable, triggers. Listing is member+.
  • ~1 minute latency, at-least-once. Triggers don't fire instantly and may very occasionally fire twice — make webhook/agent/task receivers idempotent.
  • Schedules: 60s minimum. Faster cadences are clamped to 60 seconds.
  • cron isn't parsed yet — it falls back to hourly. Use every.
  • Schedule triggers can't use payload.* conditions (there's no row) — use now.* time fields instead.
  • task tables aren't validated at create time. A missing queue table surfaces as a failed fire in the run log, not as a create error.
  • The signing secret is shown once. If you lose it, re-create the trigger.
  • Webhook targets must be public HTTPS. Internal/loopback addresses are blocked and redirects aren't followed.
  • Watch for loops when a trigger writes the table it watches — set exclude_automation: true.