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_triggersanddisable_triggermanage 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/crmto react to CRM rows. (The concierge athttps://hdls.ai/api/mcpis 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
whenobject. Give it{schema, table, op}for an event trigger, or{every: "..."}for a schedule. - IF is optional. Add
whereleaves (simple comparisons) or a rawconditiontree. Omit both and it always fires. - DO is the
doarray — 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.
| Field | Type | Notes |
|---|---|---|
name | string | Human label for the trigger. |
when | object | Event: {schema, table, op}. Schedule: {every} (or cron). |
where | array | Optional. AND-combined condition leaves {field, cmp, value}. |
condition | object | Optional. A raw condition tree. Wins over where if both are given. |
exclude_automation | boolean | Event mode only. Skips changes that automation itself caused (loop guard). |
do | array | Required, at least one action. Each has a target plus its fields. |
when — event mode
{ "schema": "crm", "table": "deal", "op": "insert" }
opis one ofinsert,update,delete. Defaults toinsertif omitted.- Watching a table automatically turns on change-events for it the first time — nothing else to enable.
when — schedule mode
{ "every": "1h" }
everyis<number><unit>, unit one ofsmhd— 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,whereonpayload.*, andexclude_automationdon't apply — butnow.*time conditions (below) do, and the fire context carries{ "trigger": "schedule", "firedAt": "<iso>" }.
cronis accepted but not parsed in this version — a bare cron string falls back to firing hourly. Prefereveryfor predictable timing.
Conditions — firing only when it matters
Two ways to express the "IF". Use whichever is simpler; if you supply both,
condition wins.
where — simple leaves (recommended)
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 }
]
fieldis a dot-path into the event context. For row changes the new row is atpayload.new(e.g.payload.new.status); the operation is atop; the table/schema attable/schema.- A wrong
cmpis rejected at create time, so a typo can't silently never match.
Comparators
cmp | Meaning |
|---|---|
eq / neq | equals / not-equals |
gt / gte / lt / lte | greater / less than (numbers, or string order) |
in | value is an array; true if it contains the field's value |
contains | field (array/string/object) contains value |
exists | field 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:
| Field | Example | Meaning |
|---|---|---|
now.hour | 14 | hour of day, 0–23 |
now.minute | 30 | minute, 0–59 |
now.dow | 1 | day 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.
| Target | Fires | Required | Optional |
|---|---|---|---|
task | Enqueues a row into an agent task queue | schema | table (default agent_tasks), title, detail, priority |
webhook | Signed POST to your URL | url | — |
agent | Signed POST to wake an agent runtime | url | — |
email | Sends an email | to, subject | message, 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_idfrom thecreate_triggerresponse (trigger.rule_id) or fromlist_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) andGET /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.
cronisn't parsed yet — it falls back to hourly. Useevery.- Schedule triggers can't use
payload.*conditions (there's no row) — usenow.*time fields instead. tasktables 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.