Symphonix Documentation · Core 0.61.0

Triggers

A schedule, an event or a webhook — what starts an agent when nobody is talking to it.

This is the engineering reference, rendered from the repository

It is the same file the platform's own tests are written against — LGFlow/docs/guides/triggers.md — rendered here rather than copied, so it cannot fall behind the software it describes. It is written for somebody working on the platform and uses the internal project names: LGFlow is Symphonix Core, LGStudio is Symphonix Studio, and LGMonitor or LGStudioMonitor is the Symphonix Control Panel. Names that begin LGFlow and appear in code — LGFlowValidationError and the rest — are exported identifiers and are spelled exactly as the software spells them.

v0.20.

v0.19 answered which agent should do this work. This answers the question before it: what causes the flow to run at all — a clock, something that happened in the application, or a request from outside.

Trigger  ──►  Flow  ──►  Nodes  ──►  Tools · Subflows · Agents

A trigger's whole job is to decide that a run should happen and what it should start from. Once it has, that is an ordinary LGFlow run. There is no second execution engine here, and there must never be one.

Not in flow.json#

Triggers live in a file of their own:

flow.json        what the agent does
registry.json    which agents there are           (0.19)
triggers.json    what causes this flow to run     (0.20)
*.test.json      does it behave
*.eval.json      how well does it answer

A flow must stay runnable by hand, and a graph that described its own alarm clock would stop being portable between the places it might be started from — the same reason an agent's address is not in the flow that calls it.

A project with no triggers.json behaves exactly as it did before 0.20.

The file#

{
  "flow": "./flow.json",
  "triggers": [
    {
      "id": "daily-report",
      "type": "schedule",
      "schedule": "0 8 * * *",
      "timezone": "Europe/Nicosia",
      "input": { "request": "Summarise yesterday's orders" }
    },
    {
      "id": "new-order",
      "type": "event",
      "event": "order.created",
      "input": { "order": "{{event.data}}" }
    },
    {
      "id": "support-ticket",
      "type": "webhook",
      "path": "/support-ticket",
      "method": "POST",
      "input": { "request": "{{webhook.body.subject}}" }
    }
  ]
}

Every trigger has an id — it is how a run says what started it — a type, and an input. description and enabled are optional; "enabled": false leaves one in the file and stops it firing.

Written by hand, in LGStudio's Triggers panel, or asked for there in words — "send me the headlines by Slack every hour" builds this file, and the panel's Save writes it. The studio's Ask bar has the same checker the runtime does, so what it builds is what will arm.

Schedules#

Five cron fields: minute hour day-of-month month day-of-week, plus an optional IANA timezone.

"0 8 * * *"      Every day at 08:00
"30 9 * * 1-5"   Every weekday at 09:30
"0 10 * * mon"   Every Monday at 10:00
"0 7 1 * *"      Every month on day 1 at 07:00

A field may be a star, a number, a-b, a comma-separated list of those, or any of them stepped with /n; month and day-of-week also take names. Deliberately not full Vixie cron: no @daily, no L, no W, no seconds field. Anything it cannot read it refuses by name rather than guessing.

timezone means what it says: every day at 08:00 in Europe/Nicosia stays 08:00 there when the clocks change, because the next firing is worked out over real instants rather than by adding a day to a wall clock.

runtime.nextRunOf(id) says when one next comes round, which is how the studio shows it without waiting.

Events#

One name, matched exactly.

await runtime.emit("order.created", { id: 123, customer: "ALFKI" });

Every trigger listening for that name starts a run, each on its own — one failing does not stop the others, because two listeners are two runs and they have nothing to do with each other. An event nothing listens for is not an error; it is an event nothing listens for.

No patterns and no wildcards. One trigger listens for one name, which is a rule anybody can hold in their head.

Webhooks#

{ "id": "support-ticket", "type": "webhook", "path": "/support-ticket", "method": "POST" }

POST and GET only. A trigger starts a flow; it is not a way to put a REST API in front of one. Two triggers may not share a path and method — a request starting a run nobody can predict is worse than a refused file.

A path, never a URL. Where this is deployed is the deployment's business, and a host name in a portable file would be a guess. LGStudio shows a full URL only when something actually running tells it the host.

const served = await serveTriggers(runtime);   // loopback unless you say otherwise
// POST http://127.0.0.1:<port>/support-ticket
await served.close();

A body that is not JSON is answered 400 and starts nothing. A run that throws is answered 500 and the endpoint stays up.

Input mapping#

One mechanism for all three kinds, and it is the one the DSL already has — the same {{...}} a prompt or a tool argument uses. There is no second template language.

One template on its own hands over the value, so an object stays an object:

"input": { "order": "{{event.data}}" }
emit("order.created", { id: 123, customer: "ALFKI" })
  → { "order": { "id": 123, "customer": "ALFKI" } }

What there is to read depends on the kind, and the list is short on purpose:

kindmay read
scheduletrigger, now
eventevent.name, event.data, trigger, now
webhookwebhook.method, webhook.path, webhook.body, webhook.query, trigger, now

Anything else a mapping names is refused when the file is read, rather than filled with nothing at run time.

Headers are not on that list, and that is deliberate. A header is where a token lives, and a mapping that could read one could put it into a flow's state and from there into a trace.

Validation#

One implementation: triggerProblems(spec, flow?). The runtime refuses a file with it before starting anything, and LGStudio serves that same module to the browser and shows what it says beside the field at fault — the arrangement validate.js has had since the studio existed. A trigger the studio draws as valid is one the runtime will accept.

Given the flow as well, it also checks the mapping against the flow's own contract: an input the flow does not take, or one it takes that nothing sets.

Running it#

import { createTriggerRuntime, loadTriggers, loadTriggerFlow } from "lgflow/triggers";

const { spec, dir, file } = await loadTriggers("./triggers.json");
const flow = await loadTriggerFlow(spec, dir);

const runtime = await createTriggerRuntime(spec, {
  flow,
  file,
  trace: (event) => events.push(event),
  run: async (input) => buildGraph(flow, handlers, { agents, trace }).invoke(input),
});

await runtime.start();
await runtime.stop();

run is handed in. The runtime never builds a graph — that is the boundary that keeps this from becoming a second engine.

stop() clears every timer and releases every listener. A schedule's timer is unref'd, so an armed trigger never keeps a process alive that has otherwise finished, and tests leave no open handles.

Where the answer goes#

A trigger says what starts the flow. It also says where the answer goes, and without that second half a schedule only writes to a journal - which is not why anybody schedules anything.

{
  "id": "sales-summary",
  "type": "schedule",
  "schedule": "0 8 * * *",
  "input":   { "question": "Sales Report Summary" },
  "deliver": {
    "to": "webhook",
    "urlFrom": "SLACK_WEBHOOK_URL",
    "body": { "text": "Sales report:
{{outputs.query_result}}" }
  }
}

Why here and not in the flow. A flow has to stay runnable by hand. Put "post it to Slack" in the graph and every chat run posts to Slack too - but a person who asked in the chat wants the answer in the chat and nowhere else. Where the answer goes is a property of this automation, exactly as its schedule is.

body is filled by the same fillValues the input mapping uses - one template language, not two. {{outputs.query_result}} on its own hands over the value; anything longer is text. What it may read is short and the same for every kind of trigger:

outputs   what the flow produced
trigger   which one this was
now       when it finished

Omit body and the outputs go as they are.

A result a chat channel can read#

The chat draws an HTML table because it is a web page. Slack, Teams and Discord are not: what arrives there is mrkdwn, and <table> lands as literal characters. So a delivery says how a value becomes text:

"deliver": {
  "to": "webhook",
  "urlFrom": "SLACK_WEBHOOK_URL",
  "format": "table",
  "body": { "text": "Sales report:

{{outputs.query_result}} ```" } }


`json` is the default and is what every trigger written before this did.
`table` lays a result out in columns - the fences are what keeps them lined up
in the channel:

total sales per year year total_sales ---- ----------- 1996 208083.97 1997 617085.20


Two shapes are recognised, because they are the two a flow actually produces: a
list of records, and an object of named result sets (`{ "a question": { rows }}`).
Anything else is left exactly as it was rather than half-rendered. Long values
are shortened, a float nobody reads is rounded to two places, and a long result
stops at fifty rows and says how many more there were.

**So what is delivered has to be data.** "Left exactly as it was" is where this
bites: a flow whose answering prompt builds a Markdown table writes one string,
`table` has no rows to lay out and passes it through, and what reaches Slack is
the pipes and dashes themselves. The chat page is a web page and renders that as
a table; a chat channel has no table syntax at all. An output a trigger delivers
is therefore a `list` of records - an llm node with a `schema` for its fields -
and then both are right at once: the chat draws records as a table by itself
(0.17), and the delivery lays the same records out in columns.

### Three rules the checker holds

- **The address is never a template.** A webhook trigger's body may decide what
  the flow is *asked*; it must never decide where the answer is *sent*, or
  whoever can reach the endpoint can point your agent's output at themselves.
- **A credential is named, not written.** `urlFrom` names an environment
  variable in the deployed project. A Slack incoming webhook URL is a password
  and trigger files get committed. `url` is there for an address that is not a
  secret.
- **A body may only read what the flow produces.** `{{outputs.x}}` where the
  flow has no `x` is refused on save, the same way a missing input is.

### When it fails

A delivery's address is a variable this project needs, so a deploy puts it in
`.env.example` beside the model's key, and the agent says at startup when it is
not set:

This agent is missing SLACK_WEBHOOK_URL. Without SLACK_WEBHOOK_URL, a triggered run will produce its answer and post it nowhere. Put it in this project's .env, then start it again.


Only for triggers that will actually fire: one switched off delivers nothing,
and a check that cried wolf about it would teach everyone to ignore what the
agent says when it starts.

A delivery that fails does not fail the run: the answer was produced, and losing
it because a webhook was down would be worse than not posting it. The run is
reported `ok`, `delivered.ok` is false, both go to the trace, and the clock keeps
its next appointment. There is no retry - the next firing is the retry. A
message about a failed delivery names the endpoint's host and never its path,
because that path is the credential.

## In a deployed project

Writing the file arms nothing by itself — something has to be running. In a
deployed project that something is `run.js`, which every way of starting the
agent already goes through:

npm start chat in the browser triggers on node run.js --serve what LGStudio's Play starts triggers on node run.js '{"q":"…"}' run it once, here triggers off node run.js --no-triggers serve, with nothing armed triggers off


Deploy copies `<flow>.triggers.json` beside the flow into the project as
`triggers.json`, rewriting its `flow` to `./flow.json` — the name the copy
travelled under. It is source, like `flow.json` and `registry.json`: written
from the authored file every time, never guarded as something edited in the
project. What starts a flow is decided where the flow is authored, and a
schedule switched off in the deployed copy that came back on at the next deploy
would be worse than being told plainly.

A triggered run goes through the same journal as the chat, so a run nobody
watched can still be read back afterwards — which is the only way to find out
what happened at four in the morning. Two things are worth knowing:

- **A flow that stops to ask has nobody to ask.** An approval, an `ask` node or
  a form will sit in the checkpointer until someone comes back. The run is
  reported as failed rather than as finished, because it has not finished.
- **A run that failed is not a trigger that failed.** It is written down and
  said out loud, and the clock keeps its next appointment.

Webhooks get their own listener on their own port — the chat is a page for
people, a webhook is an endpoint for machines. `TRIGGER_PORT` fixes it; without
one it is chosen and printed.

When a project is served, it announces what it armed on one line, the way it
announces its port:

```json
{"lgflow":"triggers","armed":[{"id":"nightly","type":"schedule","next":"2026-10-01T03:00:00.000Z"}]}

LGStudio's Play reads that line and says what is armed, so pressing Play never quietly starts a clock.

In the chat, as it happens (v0.21)#

A deployed agent opens a WebSocket at /ws on the server already serving its chat. The chat page listens, so a run the agent started by itself appears in the conversation, marked with the trigger that caused it:

Agent · sales-summary
  { "query_result": { … } }

A schedule firing at three in the morning is still this agent answering, and a conversation showing only what was typed into it would be telling half the story.

Nothing is kept for a page that is closed. The socket is a live view, not a queue - a missed run is in the journal, where it belongs. That is deliberate: queueing would make this a message broker, and it is not one.

The same socket drives it#

It is the agent that listens, not a hub that collects, so the chat page needs no address and no hub to be running. A monitor connects to the same endpoint and speaks the same vocabulary:

the agent says    hello · run · node · triggers · ok · error · pong
the agent hears   hello · fire · stop · ping

hello carries the flow's id - not its name, and not its port. A name can be changed and a port is per run; the id is the only thing a monitor can key a history on, which is what it is for (0.21).

hello can be asked for as well as announced, because a client that attaches its listener after the open event - the obvious way to write one outside a browser - would otherwise miss the greeting.

A command either does something or says it did not. An agent with no triggers armed refuses fire rather than going quiet: a monitor told nothing assumes what it asked for happened, and "I stopped it" when nothing stopped is the worst answer available.

Not in this version#

No hub, and no agent dialling out to one - an agent listens, and whoever wants to watch connects to it. Adding a hub later changes nothing here: the same messages in the same shape, with the two ends swapped. No authentication beyond the loopback the chat is already on, and no durable history.

Tracing#

A triggered run explains itself. The runtime traces to the same tracer the run gets, so there is no separate trigger log to correlate against afterwards:

Schedule -> daily-report (ok, 95ms)
  sort
  ask_sales -> sales (ok, 11ms)
    look
  reply
Event "order.created" -> new-order (ok, 18ms)
  ...
Webhook POST /support-ticket -> support-ticket (ok, 17ms)
  ...

triggerTree(events) puts it back together — the v0.19 agent tree with one more thing on top: why the run exists. Each carries triggerId, triggerType, runId, triggeredAt, durationMs and whether it worked, plus eventName for an event and method/path for a webhook. A run nothing started reads as the run it is, with no trigger above it.

Security#

External data is untrusted, and the shape of the thing is what keeps it out:

a configured trigger        chosen when the file was read
      ↓
a known configured flow     decided before anything arrived
      ↓
the external payload
      ↓
a validated input mapping
      ↓
the flow's declared inputs

Nothing arriving from outside can name a flow, a file, a handler, a module or an agent. There is no step at which it could — the flow was resolved at load time, and a mapping reads only the named keys of a short, fixed context.

Webhook endpoints are loopback unless you say otherwise, and the way to say otherwise is TRIGGER_BIND (or host to serveTriggers). Reachable from the network is a decision about who may start your flows, and it should be one somebody makes rather than one they get — which is why it is its own variable and is never implied by the chat's BIND. The chat keeps a Host check whatever it binds; this endpoint has none, because it was never a page. Put something that authenticates in front of it.

With agents (v0.19)#

Triggers start flows; flows delegate to agents. Nothing special connects them:

Webhook  →  Support orchestrator  →  agent node  →  SQL · Documents · General

There are no "agent triggers". An agent is a flow with a contract, so to trigger an agent, trigger the flow that is that agent.

The example#

node examples/triggered-desk/agent.js              all three, one after another
node examples/triggered-desk/agent.js --schedule   fire the daily report now
node examples/triggered-desk/agent.js --event      emit order.created
node examples/triggered-desk/agent.js --webhook    bring the endpoint up and POST to it

One flow, three ways in, one trace that says which — and it delegates to a v0.19 agent, so the whole hierarchy is on show. No key needed.

Not in this version#

No Kafka, RabbitMQ, Redis Streams or cloud queues. No distributed scheduler, leader election or durable delivery. No catch-up for runs missed while the process was down, no retry or backoff policy, no event filtering or correlation, and no webhook authentication framework. Extension points where they fall out naturally; none of it implemented now.