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_tooltrigger into atool_configsrow 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/mcpendpoint usingworkflow_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:
- Reads the event.
- Queries
trigger_rulesfor matching rows. - 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.
- The editor reads
workflow_versions, notworkflow_configs.config. Seeding only the latter opens an empty graph at/workflows/<id>:draft. Sync the draft version'sconfigfrom theworkflow_configsrow — insert aversion_number: 0, status: 'draft'row if none exists. - 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. - Every enabled trigger's contributions must already be in
input_parameters.CONTRIBUTIONSinlib/triggers/inject.tsis the canonical list (conversation_start→ani,dnis,agent_id,start_time;conversation_end→ those plusconversation_id,end_time,end_reason,session_escalated,end_params), andvalidateWorkflowTriggersmakes a missing one a publish-blocking error. The editor merges them the moment you enable a trigger; a hand-written config that enablesconversation_endwithout 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) readsconfig.output_parametersto 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_parametersbefore returning sync workflow outputs. The orchestrator's lifecycle merge (conversation_lifecycle.pymerge_lifecycle_outputs) writes each returned output directly intosession_metadatakeyed by its declared name — the highest-priority resolver layer, so they win over config defaults just like a testX-Template-Variablesoverride. (_fetch_response_variablesloads the declared list fromconfig.output_parameters, falling back to the dead legacyconfig.response_variablesonly for pre-migration configs.) - The
on_startlifecycle 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 configlifecycle.on_start_timeout_seconds/lifecycle.timeout_seconds, can override it). Once that pre-conversation run is handled, the pipelineWorkflowExecutorskips the sameon_startworkflow and only keepson_completework. For Google ADK Live stages, the resolved editortemplate.inlineis also promoted to the servicesystem_instruction, because ADK builds its liveAgentfrom that field rather than from the later PipecatLLMContext.
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_variablesis a dead legacy field. No editor UI writes it; everything readsoutput_parameters. Don't reintroduce a separate response-variables editor — extendoutput_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.