On this page

Triggers define which external events activate an ADF agent. They determine when your agent wakes up and what it responds to.

Overview

Triggers are organized by event type — what happened — and each trigger has an array of targets that define how to respond. Each target specifies an execution scope, optional filters, and an optional timing modifier.

There are eleven trigger types and two execution scopes:

Trigger Types

TriggerEvent
on_startupThe agent finishes loading (fires once at boot)
on_inboxA message arrives in the agent’s inbox
on_outboxA message is sent from the agent’s outbox
on_file_changeA watched file is modified
on_chatHuman sends a chat message in the Loop panel
on_timerA scheduled timer fires
on_tool_callA matching tool is called during the LLM loop (observational, post-execution)
on_task_createA task is created (HIL approval, async dispatch)
on_task_completeA matching async task completes
on_logsA matching log entry is written to adf_logs
on_llm_callAn LLM request is made (filterable by provider and source)

Execution Scopes

ScopeDescription
systemRuns a lambda function (fast, cheap, deterministic). Fires in all states except off. Requires a lambda field referencing the function to call.
agentWakes the LLM loop (smart, expensive, probabilistic). Gated by the agent’s current state.

Self-generated file events are suppressed by default. Writes made by an agent turn or one of its lambdas do not fire on_file_change unless that target explicitly sets filter.include_self: true. This prevents an agent from waking itself forever by writing a watched file. Studio edits, uploads, imports, transfers, deletes, and renames still fire normally.

Configuration

Triggers are configured in the triggers section of the agent config, organized by event type. Each trigger type has an enabled flag and an array of targets. In dot-path form these are triggers.<type>.enabled and triggers.<type>.targets (an array):

{
  "triggers": {
    "on_inbox": {
      "enabled": true,
      "targets": [
        { "scope": "agent", "interval_ms": 30000 }
      ]
    },
    "on_file_change": {
      "enabled": true,
      "targets": [
        { "scope": "agent", "filter": { "watch": "README.md" }, "debounce_ms": 2000 }
      ]
    },
    "on_chat": {
      "enabled": true,
      "targets": [
        { "scope": "agent" }
      ]
    },
    "on_timer": {
      "enabled": true,
      "targets": [
        { "scope": "system" },
        { "scope": "agent" }
      ]
    },
    "on_outbox": { "enabled": false, "targets": [] },
    "on_tool_call": { "enabled": false, "targets": [] },
    "on_task_complete": { "enabled": false, "targets": [] },
    "on_logs": { "enabled": false, "targets": [] }
  }
}

A trigger can have multiple targets, each with its own scope, filter, and timing. For example, on_timer above fires in both system and agent scope.

To add a target from the loop, append to the array — e.g. sys_update_config({ path: "triggers.on_inbox.targets", action: "append", value: { "scope": "agent", "interval_ms": 30000 } }). Config writes from the LLM loop are HIL-gated; see sys_update_config for the full contract.

Targets

Each target in a trigger’s targets array has these fields:

FieldRequiredDescription
scopeYessystem or agent
filterNoEvent-specific filter (see Filters)
lambdaNoSystem scope only: script entry point ("path/file.ts:functionName") or shell script path ("jobs/task.sh")
commandNoSystem scope only: shell command to run when the target fires
warmNoSystem scope only: whether to warm-start the lambda
debounce_msNoTiming modifier (mutually exclusive)
interval_msNoTiming modifier (mutually exclusive)
batch_msNoTiming modifier (mutually exclusive)
batch_countNoFire batch early when N events accumulate (requires batch_ms)
loopNoAgent scope only: which inner loop this target wakes (default: main)

Only one timing modifier is allowed per target. batch_count is an optional companion to batch_ms.

Target loop. An agent can run several named loops (Inner Loops). A target may name which one it wakes with target.loop; an absent loop means main, so every pre-loops trigger config routes exactly as it did. The field is agent-scope only: a system-scope target runs its lambda or command through the single agent-wide system handler, under main’s authority, and wakes no loop — so a loop on a system-scope target is stripped when the loop’s config is derived, rather than handing that loop main’s authority through the back door.

System scope execution limits

System-scope lambdas run under a timeout and a concurrency cap. Neither is configurable — both are derived from the trigger type, because a trigger’s shape is a property of the trigger, not of the agent that wired it.

TriggerTimeoutConcurrent dispatches
on_llm_call, on_tool_call, on_logs, on_file_change30 s, or limits.execution_timeout_ms if that is smaller1 (strictly serialized)
on_timer, on_startup, on_inbox, on_outbox, on_task_create, on_task_complete, on_chatlimits.execution_timeout_ms4

The first group fires once per model call, tool call, log row or file write, so a single burst can produce hundreds of dispatches. Those lambdas are short, and they are usually accumulators reading and rewriting the same rows — serializing them makes that correct and bounds worker count at the same time. The 30 s timeout is a hang detector, not a work budget.

The second group is work the user asked for and may legitimately run for minutes, so it keeps the agent-wide execution budget.

Concurrency is tracked per trigger type + executable (the lambda path, or the command string). Two different lambdas never throttle each other, and the same lambda under two different triggers gets two independent lanes. Several timers pointing at the same lambda deliberately share one lane. Beyond the concurrency cap, up to 64 dispatches wait; past that a dispatch is dropped, recorded in adf_logs at error level, and reported to the host as a failed trigger. A timer whose dispatch is dropped is rewound rather than consumed.

Shell targets (command, or a .sh lambda) are exempt from the timeout — the shell runner takes no deadline — but they are lane-limited like any other target.

Filters

Filters narrow when a target fires. Available filter fields depend on the trigger type:

TriggerFilter FieldsDescription
on_inboxsource, senderFilter by message source (e.g., mesh, telegram) or sender DID
on_outboxtoFilter by recipient DID
on_file_changewatch, include_selfwatch is a glob pattern for file paths (e.g., README.md, data/*). Set include_self: true only for workflows that intentionally react to their own writes. Payload includes a unified diff when available.
on_tool_calltoolsArray of tool name glob patterns (e.g., ["fs_*", "msg_send"])
on_task_createtoolsArray of tool name glob patterns
on_task_completetools, statusTool name globs and/or task status
on_logslevel, origin, eventLevel array (e.g., ["error"]), origin/event glob arrays
on_llm_callprovider, sourceprovider is a string array of provider display names/ids; source is a string array of call origins. A row matches when the event value is in the array.
on_chat—No filters available
on_timer—No filters available
on_startup—No filters available

Filter Examples

// Only fire when inbox receives a Telegram message
{ "scope": "agent", "filter": { "source": "telegram" } }

// Only fire when a specific sender messages
{ "scope": "agent", "filter": { "sender": "did:adf:9gvayMZx5m..." } }

// Only fire when README.md changes
{ "scope": "agent", "filter": { "watch": "README.md" }, "debounce_ms": 2000 }

// Reconcile a skill catalog after the agent installs or updates a skill.
// Self-generated file changes are otherwise suppressed.
{
  "scope": "system",
  "lambda": "lib/skill-indexer.ts:refresh",
  "filter": { "watch": "skills/*", "include_self": true },
  "debounce_ms": 250
}

// Fire when any filesystem tool is called
{ "scope": "system", "filter": { "tools": ["fs_*"] } }

// Fire on error logs from serving or lambda origins
{ "scope": "system", "filter": { "level": ["error"], "origin": ["serving", "lambda*"] } }

Timing Modifiers

Each target can use zero or one timing modifier. They are mutually exclusive.

No Modifier (Immediate)

Fire immediately on each event. This is the default when no timing field is specified.

Debounce

Reset a timer on each new event. Fire once when no events arrive for the specified duration. Good for “wait until they stop typing” behavior.

{ "scope": "agent", "debounce_ms": 2000 }

Example: With debounce_ms: 2000 on on_file_change, if the user makes edits at 0ms, 500ms, and 1500ms, the trigger fires at 3500ms (1500ms + 2000ms wait).

Interval

Rate-limit by dropping events that arrive within the interval window.

{ "scope": "agent", "interval_ms": 30000 }

Example: With interval_ms: 30000, the trigger fires at most once every 30 seconds.

Note: For on_inbox, interval behaves differently — the first event delays emission (starts a timer), and subsequent events during the window are absorbed. This batches rapid inbox updates into periodic summaries.

Batch

Start a timer on the first event. Collect all events during the window. Fire once when the timer expires or when batch_count events accumulate, whichever comes first.

{ "scope": "agent", "batch_ms": 5000, "batch_count": 100 }

Example: Fire after 5 seconds or after 100 events, whichever comes first. If batch_count is omitted, the batch fires only when the time window expires.

on_file_change Payload

When on_file_change fires, the trigger payload includes a unified diff between the previous file content and the new content (with 3-line context hunks). This allows targets to see exactly what changed without receiving the full file.

--- README.md
+++ README.md
@@ -5,3 +5,4 @@
 Some existing content
 More content here
+A newly added line
 Trailing content

If the file is too large to diff efficiently (> 1M line-product complexity), or the previous content is unavailable, diff will be null. The diff is available as event.data.diff in lambda event objects.

The event envelope’s source identifies the mutation origin, such as agent:<turn-id>, lambda:<path>:<function>, or a host/daemon origin like system:runtime-api (or system:unknown for an operation without an execution context). Only agent:* and lambda:* writes count as self-generated and are suppressed unless a target sets filter.include_self: true; host/daemon writes (system:*, e.g. system:runtime-api) are never self-generated and always fire. A system lambda is never re-dispatched for a file change it caused itself, even when include_self is enabled. Keep self-watching agent targets narrow and idempotent: an agent-scope target can intentionally react to its own write, then make another write in the resulting turn.

Scope Rules

System Scope

System scope targets execute lambda functions from the agent’s file store. Each target specifies a lambda field pointing to a script entry point (e.g., "lib/router.ts:onMessage"). The lambda receives a rich event object with access to adf.* methods via the sandbox RPC bridge.

Key behaviors:

  • Not gated by agent state — fires in all states except off
  • Silently skipped when no lambda or command is specified
  • Fast and cheap — no LLM costs
  • Good for infrastructure tasks: routing, logging, archiving
  • All system-scope executions are logged to adf_logs

Shell Targets

System targets can run shell code instead of a JS/TS lambda:

  • lambda: "jobs/task.sh" — runs the script headlessly through the shell runner (no JS shim)
  • command: "<shell command>" — runs a one-line shell command

Shell targets receive event context as environment variables ($EVENT_TYPE, $MSG_ID, $TIMER_ID, $TIMER_PAYLOAD, …) rather than an event object argument. .ts:function lambdas still receive the event object described below.

Cold vs. Warm Execution

By default, lambdas use cold execution — the sandbox worker is created, the lambda runs, and the worker is destroyed. This is safe and isolated but has startup overhead.

Set warm: true on a target to use warm execution — the worker stays alive between invocations. This is faster for frequently-firing triggers (e.g., timers polling every few seconds) but uses more memory.

{
  "scope": "system",
  "lambda": "lib/router.ts:onMessage",
  "warm": true
}

Lambda Event Object

Lambda functions receive an AdfEvent — a typed envelope with event-specific data. The same shape used internally, no transformation. Event data uses existing row types (same shape as msg_read, sys_list_timers, etc.).

Envelope Fields
FieldTypeDescription
idstringUnique event ID
typestringEvent type: "inbox", "outbox", "file_change", "chat", "timer", "tool_call", "task_complete", "log_entry", "startup"
sourcestringEvent origin: "agent:<turn-id>", "lambda:<path>:<function>", "system:*", or "adapter:<name>"
timestringISO 8601 timestamp
dataobjectEvent-specific payload (typed by type)
on_inbox — event.data.message: InboxMessage

Same shape as msg_read returns. Key fields: from, content, id, parent_id, thread_id, source, source_context, attachments, received_at, status.

on_outbox — event.data.message: OutboxMessage

Same shape as outbox row. Key fields: from, to, content, created_at, status.

on_file_change — event.data: FileChangeEventData
FieldTypeDescription
pathstringFile path
operationstring"created", "modified", or "deleted"
mime_typestring | nullMIME type
sizenumberFile size in bytes
diffstring | nullUnified diff (when available, e.g. with debounce)

No content included — call adf.fs_read({ path }) if needed.

on_chat — event.data.message: LoopEntry

Same shape as loop row. Contains content_json array with text blocks.

on_timer — event.data.timer: Timer

Same shape as sys_list_timers returns. Key fields: id, schedule, payload, scope, run_count, created_at.

See Timers > Timer Lambda Execution for more on timer events.

on_tool_call — event.data: ToolCallEventData

Observational hook — fires AFTER the tool executes (or after HIL denial). Does not block execution or create tasks. Use for logging, metrics, or context injection.

This holds for tools driven through adf_shell too: the command runs, then the observer fires. To block a tool call, mark the tool restricted: true — HIL approval is the gate; on_tool_call is the watcher.

FieldTypeDescription
toolNamestringName of the tool that was called
argsobjectTool arguments (parsed from JSON). For a shell-driven call this is { command, intercepted_by } — the shell builds tool inputs internally, so the command line is what it can report
originstringCall origin: "agent" or "sys_lambda:lib/something.ts"
on_task_create — event.data.task: TaskEntry

Fires when a task is created (HIL approval, async dispatch). Same shape as task row. Key fields: id, tool, args, status, origin, requires_authorization.

Use this for external approval routing — dispatch approval requests to Telegram, multi-agent vote systems, or webhooks when HIL tasks are created.

on_task_complete — event.data.task: TaskEntry

Same shape as task row. Key fields: id, tool, args, status, result, error, created_at, completed_at, origin.

on_logs — event.data.entry: AdfLogEntry

Same shape as log row. Key fields: level, origin, event, target, message, data, created_at.

Anti-recursion: Log entries produced by the on_logs handler itself do not re-fire the trigger, preventing infinite loops.

Accessing the adf API

Lambda functions have full access to the adf proxy object for calling tools, invoking the model, and running other lambdas. All code runs in the sandbox environment.

// lib/router.ts — Inbox router lambda
export async function onMessage(event) {
  const { from, content, id, source_context } = event.data.message

  if (source_context?.intent === 'urgent') {
    await adf.msg_send({ parent_id: id, payload: `Acknowledged urgent message from ${from}` })
  } else {
    await adf.db_execute({
      sql: 'INSERT INTO local_inbox_log (sender, message, ts) VALUES (?, ?, ?)',
      params: [from, content, Date.now()]
    })
  }
}

Agent Scope — No Event Object

Agent scope targets do not receive the event object directly. Instead, the LLM receives a formatted trigger message as context when the loop wakes. For on_inbox, agent scope gets an inbox summary (message counts by sender and source) rather than individual message payloads — the agent then uses msg_read to fetch messages.

Agent Scope

Agent scope wakes the LLM loop, transitioning the agent to the active state.

Key behaviors:

  • Gated by current state — only fires when the state allows it
  • Expensive — each activation uses LLM tokens
  • Good for reasoning, decision-making, complex tasks

State Gating

Current StateSystem ScopeAgent Scope
ActiveFiresAlready running
IdleFiresFires
HibernateFireson_timer only
SuspendedFiresNo
ErrorFiresNo
OffNoNo

System scope fires in every state except off — including error and suspended. Agent scope is the only scope the state gate can hold back.

Firing Order

Both scopes operate independently. When both fire for the same event:

  1. Whichever timer (from timing modifiers) expires first goes first
  2. Ties go to system scope

on_inbox Behavior

When on_inbox fires, the agent receives an inbox summary instead of raw message payloads. The summary includes:

{
  "total": 17,
  "unread": 5,
  "read": 10,
  "archived": 2,
  "unread_by_sender": { "monitor": 3, "telegram:12345": 2 },
  "unread_by_source": { "mesh": 3, "telegram": 2 },
  "oldest_unread_timestamp": 1707000000000
}

The agent then uses msg_read to fetch and process individual messages. This prevents large message payloads from flooding the trigger context.

Timer + Trigger Interaction

For timers to execute, a dual-check is required:

  1. The on_timer trigger must be enabled
  2. The timer’s scope field must include a matching scope from the trigger’s targets

This dual-check provides a convenient kill switch — disable the on_timer trigger to stop all timers without deleting them.

Example: A timer with scope: ["system", "agent"] will:

  • Fire in system scope only if on_timer has a target with scope: "system"
  • Fire in agent scope only if on_timer has a target with scope: "agent"

Hibernate Nudge

While an agent is hibernating, agent-scope triggers are suppressed (only on_timer reaches the loop). To keep a hibernating agent from going dark indefinitely, the evaluator runs a periodic hibernate nudge, configured under limits.hibernate_nudge:

FieldDefaultDescription
enabledtrueWhether the nudge fires at all
interval_ms86400000 (24h)Idle interval before a nudge fires

Requirements for a nudge to fire: the agent is in hibernate, hibernate_nudge.enabled is true, and on_timer is enabled. After interval_ms elapses with no trigger activity, the evaluator synthesizes an agent-scope timer event with a synthetic timer (timer.id = -1) whose payload reads “You have been hibernating for N hours without any triggers…”, prompting the agent to confirm hibernation, take action, or set a timer. The nudge is polled on the same 5s timer tick.

Injecting Events Directly (POST /agents/:id/trigger)

The daemon endpoint POST /agents/:id/trigger dispatches an event straight into the loop, bypassing the trigger evaluator entirely — no enabled check, no filter matching, no scope routing, and no state gating. It is a direct injection surface for tools and tests, not a way to exercise your trigger config. To verify that a trigger’s enabled/filter/scope/state gating behaves as configured, drive the real source event (write the watched file, send the inbox message, etc.) rather than calling this endpoint.

Deduplication

The trigger evaluator deduplicates pending events in the queue:

  • on_file_change — Multiple rapid file change events for the same path are collapsed
  • on_inbox — Multiple inbox notification events are collapsed (the summary is regenerated at fire time)

Common Patterns

Responsive Chat Agent

Agent wakes on direct messages and chat, processes on arrival:

{
  "on_chat": {
    "enabled": true,
    "targets": [{ "scope": "agent" }]
  },
  "on_inbox": {
    "enabled": true,
    "targets": [{ "scope": "agent", "interval_ms": 30000 }]
  }
}

Document Auto-Processor

Agent reacts to document edits with a debounce to avoid reacting to every keystroke:

{
  "on_file_change": {
    "enabled": true,
    "targets": [
      { "scope": "agent", "filter": { "watch": "README.md" }, "debounce_ms": 3000 }
    ]
  }
}

Message Router (System Scope Only)

Script handles incoming messages without waking the LLM:

{
  "on_inbox": {
    "enabled": true,
    "targets": [
      { "scope": "system", "batch_ms": 100 }
    ]
  }
}

Scheduled Worker

Agent only activates on timer, ignores everything else:

{
  "on_timer": {
    "enabled": true,
    "targets": [
      { "scope": "system" },
      { "scope": "agent" }
    ]
  },
  "on_chat": { "enabled": false, "targets": [] },
  "on_inbox": { "enabled": false, "targets": [] },
  "on_file_change": { "enabled": false, "targets": [] }
}

Telegram-Only Inbox Handler

Agent only processes Telegram messages, ignores mesh messages:

{
  "on_inbox": {
    "enabled": true,
    "targets": [
      { "scope": "agent", "filter": { "source": "telegram" }, "interval_ms": 10000 }
    ]
  }
}

Tool Call Observer

System script logs every filesystem tool call (observational — does not block):

{
  "on_tool_call": {
    "enabled": true,
    "targets": [
      { "scope": "system", "filter": { "tools": ["fs_*"] }, "lambda": "lib/observer.ts:onToolCall" }
    ]
  }
}

HIL Approval via Telegram

Mark the tool as restricted (which derives HIL for LLM loop calls), then use on_task_create to route approvals externally. Note that restricted and locked are owner-only — an agent’s sys_update_config write touching them is hard-denied (no HIL prompt); only owners set these:

{
  "tools": [{ "name": "fs_write", "enabled": true, "restricted": true }],
  "triggers": {
    "on_task_create": {
      "enabled": true,
      "targets": [{
        "scope": "system",
        "lambda": "lib/hil/dispatcher.ts:onTaskCreate",
        "filter": { "tools": ["*"] }
      }]
    }
  }
}
// lib/hil/dispatcher.ts
export async function onTaskCreate(event) {
  const { task } = event.data;
  if (!task.requires_authorization) return;

  await adf.msg_send({
    recipient: "telegram:123456789",
    content: `Approve ${task.tool}? Args: ${task.args}\nReply yes/no.`,
    subject: `task:${task.id}`
  });
}

Error Alerter

System lambda fires on error logs, batched to avoid flooding:

{
  "on_logs": {
    "enabled": true,
    "targets": [
      {
        "scope": "system",
        "lambda": "lib/alerter.ts:onError",
        "filter": { "level": ["error"] },
        "batch_ms": 5000,
        "batch_count": 10
      }
    ]
  }
}

Parent-Controlled Remote Shutdown

System-scope lambdas can call adf.sys_set_state('off') to guarantee a hard shutdown — the child aborts any in-flight LLM call, unregisters from the mesh, disconnects MCP servers, and stops channel adapters. Combined with DID-based sender verification and authorized code, this is how a parent agent remotely disables a compromised or misbehaving child.

Child’s on_inbox trigger:

{
  "on_inbox": {
    "enabled": true,
    "targets": [
      { "scope": "system", "lambda": "lib/control.js:handleParentControl" },
      { "scope": "agent" }
    ]
  }
}

Child’s lib/control.js (marked as authorized):

export async function handleParentControl(event = {}) {
  const sender = event?.data?.message?.from || ''
  const subject = event?.data?.message?.subject || ''
  const content = (event?.data?.message?.content || '').trim()

  const trustedParentDid = (await adf.sys_get_meta({ key: 'adf_parent_did' })) || ''
  if (sender !== trustedParentDid) return { acted: false, reason: 'sender_mismatch' }
  if (subject !== 'ADF_CONTROL') return { acted: false, reason: 'subject_mismatch' }
  if (content !== 'OFF') return { acted: false, reason: 'content_mismatch' }

  // Hard off — never deferred. Aborts in-flight LLM call, tears down mesh/MCP/adapters.
  await adf.sys_set_state({ state: 'off', _reason: 'trusted parent control command' })
  return { acted: true }
}

The sys_set_state('off') call:

  • Is never deferred — even if the LLM is mid-turn, its HTTP request is aborted and all pending triggers are cleared.
  • Triggers centralized teardown — the child is unreachable on the mesh the moment the transition completes.
  • Works identically whether the child is in the foreground, background, or was just started fresh. One code path, one guarantee.

Because control.js is marked authorized, it bypasses file protection on last-control-event.txt (useful for writing tamper-evident shutdown logs) and can call protection-bypass methods if needed. See Authorized Code for the security model.

Defaults

New agents come with these trigger defaults:

TriggerDefault
on_inboxEnabled, agent scope, no timing modifier (fires immediately on each inbox event)
on_file_changeEnabled, agent scope watching README.* with debounce_ms: 2000
on_chatEnabled, agent scope
on_timerEnabled, both system and agent scope
on_task_completeEnabled, agent scope
on_outboxDisabled
on_tool_callDisabled
on_task_createDisabled
on_logsDisabled
on_startupDisabled
on_llm_callDisabled