Skip to content

Workflow triggers

Status: Current | Last reviewed: 2026-08-11

What fires async workflows and how the rule matching works.

The shape

Event source
  ├── conversation lifecycle (start, message, end, transfer)
  ├── inbound webhook (email, SMS, PagerDuty, Salesforce)
  ├── scheduled (cron in workflow definition)
  └── direct API call (POST /api/workflows/<workflow_id>/invoke)
        │
        ▼
trigger_rules table     (filters: tenant, agent, event type, source, lifecycle phase)
        │
        ▼
async-agent queue       (RabbitMQ in cluster, in-process in Vanguard)
        │
        ▼
async-agent consumer    (loads workflow_versions, runs LangGraph)

trigger_rules table

Column Purpose
id PK
tenant_id Scope
workflow_config_id Numeric FK to workflow_configs
event_type One of CONVERSATION_START_EVENT, CONVERSATION_END_EVENT, CONVERSATION_PRE_SOURCE, CONVERSATION_POST_SOURCE, WEBHOOK, SCHEDULED, DIRECT
filters JSONB. Structured filter expression: agent_id, source, conversation type, etc.
enabled Boolean
created_at, updated_at, created_by Audit

Defined in apps/ai-manager/lib/workflow-types.ts and apps/ai-manager/lib/db-workflows.ts.

Event types

CONVERSATION_START_EVENT

Fires when conversation-api creates a conversations row. Filters can match agent ID, surface, source.

Use case: "When a call starts, run the customer-360 workflow to fetch CRM context for the agent."

CONVERSATION_END_EVENT

Fires when a conversation closes. Filters as above.

Use case: "When a call ends, generate a summary and CSAT score; write to the case."

CONVERSATION_PRE_SOURCE / POST_SOURCE

Two distinct hooks per inbound message. PRE_SOURCE fires before the message reaches the agent (use for moderation, intent classification). POST_SOURCE fires after the agent responds (use for analytics).

Use case: PII redaction node runs in PRE_SOURCE to scrub before storage.

WEBHOOK

Fires when an inbound Lambda or webhook handler POSTs to async-agent. Filters by webhook source name.

Use case: email-ingest-lambda posts an inbound email; the workflow classifies and routes it.

SCHEDULED

Cron entry in the workflow definition itself. Not in trigger_rules — the workflow knows its own schedule.

Use case: "Every hour, sync FedEx tickets from Salesforce."

DIRECT

POST /api/workflows/<workflow_id>/invoke from ai-manager, a tenant-scoped API token, or from an agent tool that triggers a workflow as a side-effect.

Use case: "Agent tool 'escalate to L2' triggers the L2-handoff workflow."

Every active, non-template workflow also exposes a tenant-scoped OpenAPI document at:

GET /api/workflows/<workflow_id>/openapi.json

The generated document describes the authenticated invoke endpoint, the Bearer tsf_* authentication scheme, the required workflows:manage API-token scope, declared input parameters, and declared End-node output parameters. Reading the spec also requires an authenticated ai-manager session with workflow access or a tenant-scoped tsf_* token carrying workflows:manage; the route resolves the workflow under the caller's tenant context.

Workflow-as-tool exposure

The Start-node inspector has two workflow-as-tool surfaces:

  • Agent Tool projects an enabled agent_tool trigger into a tool_configs row for TrueServe agents. The trigger's Tool description is the workflow-level tool description.
  • External MCP Exposure advertises the published workflow from agent-host's external /workflow/mcp endpoint using workflow_configs.expose_mcp / mcp_tool_name. Its schema is built from workflow Inputs only; local workflow variables (input_parameters[].is_input === false) are not exposed as tool arguments.

Neither surface injects a synthetic description input. Both surfaces require every workflow Input to carry its own description, because those per-parameter descriptions are emitted into the generated JSON Schema that agents read.

Filter matching

The filter blob is structured:

{
  agent_id?: number          // exact match
  source?: string            // 'voice', 'chat', 'sms', etc.
  conversation_type?: string // 'inbound', 'outbound'
  metadata?: { [k]: string } // arbitrary metadata.* match
}

Matching is conjunctive — all present filters must match. Absent filters are wildcards.

The matching code lives in apps/ai-manager/lib/db-workflows.ts (listTriggerRules with a filter argument) and apps/async-agent/app/services/trigger_resolver.py.

Conversation lifecycle plumbing

conversation-api emits lifecycle events into the same queue as webhooks. The format is:

{
  "event_type": "CONVERSATION_END_EVENT",
  "conversation_id": "...",
  "tenant_id": "...",
  "agent_id": 1004,
  "metadata": {...}
}

async-agent's consumer:

  1. Reads the event.
  2. Queries trigger_rules for matching rows.
  3. For each match, enqueues a workflow run.

Multiple matches

A single event can match multiple rules. Each match enqueues a separate run. There is no "first match wins" semantics — they all run.

If two rules with overlapping filters fire the same workflow, the workflow runs twice. That is usually unintended; deduplicate at the rule level.

Where to add or edit a rule

ai-manager -> Workflows -> pick a workflow -> open the Start node -> Enabled Triggers. The CRUD endpoints are at apps/ai-manager/app/api/trigger-rules/route.ts.

Seeding a workflow: three things the editor does for you

A workflow_configs row is not a runnable workflow, and each of the missing pieces looks fine in the UI until you try to use it. A seed script (or any direct-SQL author) has to do all three by hand.

  1. The editor reads workflow_versions, not workflow_configs.config. Seeding only the latter opens an empty graph at /workflows/<id>:draft. Sync the draft version's config from the workflow_configs row — insert a version_number: 0, status: 'draft' row if none exists.
  2. A draft-only workflow can never run. The runner resolves the current published version, so an unpublished workflow is invisible to every caller while reading as correct everywhere in the UI — no error, no run, nothing in the run list. Call publish_workflow_version(wid, …), guarded on no published version already existing so a re-run of the seed doesn't stack versions. 135_medtech_journey_c.sql §2 is the pattern to copy.
  3. Every enabled trigger's contributions must already be in input_parameters. CONTRIBUTIONS in lib/triggers/inject.ts is the canonical list (conversation_start → ani, dnis, agent_id, start_time; conversation_end → those plus conversation_id, end_time, end_reason, session_escalated, end_params), and validateWorkflowTriggers makes a missing one a publish-blocking error. The editor merges them the moment you enable a trigger; a hand-written config that enables conversation_end without declaring all seven simply fails to publish.

Publishing also re-projects the config's triggers into trigger_rules — it deletes every is_derived = true row for that workflow and re-inserts the declared set. So a hand-inserted trigger_rules row duplicating a declared trigger must be is_derived = true, or the delete misses it and the workflow ends up with two matching rules and runs twice per event. Conversely, a rule you want to survive publishes (one not declared in the config) must stay is_derived = false.

Lifecycle hooks on the agent

Some triggers can also be defined inline on agent_versions.config.lifecycle:

lifecycle: {
  on_start: { workflow_id: 42 },
  on_complete: { workflow_id: 17 }
}

These resolve to anonymous trigger rules at conversation creation. They are convenient for per-agent hooks; use a trigger_rules row when the binding spans multiple agents.

Lifecycle outputs → agent prompt merge fields

When a workflow is attached to an agent's conversation lifecycle (the agent editor's "Audio Input → When the conversation starts" picker), its outputs become top-level {{<name>}} merge fields in the agent's prompts — a workflow output named summary renders as {{summary}}, not {{lifecycle.summary}}.

Those outputs are the workflow's config.output_parameters — the rows declared in the workflow editor's "End — Output parameters" section. This is the single source of truth:

  • The agent-editor picker (LifecycleInspectorForm.tsx) reads config.output_parameters to list the available merge fields, and the prompt-editor typeahead (useAgentPromptSources) offers them by their bare name.
  • The async-agent direct execute endpoint resolves those output_parameters before returning sync workflow outputs. The orchestrator's lifecycle merge (conversation_lifecycle.py merge_lifecycle_outputs) writes each returned output directly into session_metadata keyed by its declared name — the highest-priority resolver layer, so they win over config defaults just like a test X-Template-Variables override. (_fetch_response_variables loads the declared list from config.output_parameters, falling back to the dead legacy config.response_variables only for pre-migration configs.)
  • The on_start lifecycle hook runs synchronously before the pipeline is built so those fields are available to the initial prompt and greeting. The orchestrator waits up to 30 seconds by default (PRE_CONVERSATION_WORKFLOW_TIMEOUT_SECONDS, or agent config lifecycle.on_start_timeout_seconds / lifecycle.timeout_seconds, can override it). Once that pre-conversation run is handled, the pipeline WorkflowExecutor skips the same on_start workflow and only keeps on_complete work. For Google ADK Live stages, the resolved editor template.inline is also promoted to the service system_instruction, because ADK builds its live Agent from that field rather than from the later Pipecat LLMContext.

Top-level, not namespaced. The old {{lifecycle.<name>}} / {{lifecycle_end.<name>}} forms are no longer produced. Outputs are promoted to bare top-level names. A name that collides with a reserved call-identity/system key (ani, dnis, agent_id, agentId, session_id, conversationId, tenant_id, tenantId, channel, current_date, currentDateTime, …) or with an already-set key is suffixed with a counter (ani → ani1) so a workflow can never shadow the real caller ANI/agent id. on_start and on_complete share the top-level namespace.

response_variables is a dead legacy field. No editor UI writes it; everything reads output_parameters. Don't reintroduce a separate response-variables editor — extend output_parameters.

Phone/caller variables (ani, dnis)

{{ani}} (caller number) and {{dnis}} (called number) are always available at runtime — the orchestrator populates them top-level in session_metadata from gRPC metadata / the X-Caller-Phone SIP header / session_variables, independent of any lifecycle workflow. The prompt-editor typeahead offers them unconditionally, and the Test sidebar's Template Variables form always exposes input fields for them so a tester can set caller values for a test run (passed through as X-Template-Variables).

source_filter convention for conversation triggers

A workflow shows up in the agent-editor lifecycle picker only if publish projected an active trigger_rules row with trigger_type='conversation' and source_filter='conversation_pre' (start) / 'conversation_post' (end) — the values listWorkflows({conversationLifecycle}) JOINs on and the manual /api/trigger-rules route enforces (CONVERSATION_PRE_SOURCE/CONVERSATION_POST_SOURCE). The publisher's projection (lib/triggers/project.ts) must stamp these exact values — not a service name.

See also