Triggers
A workflow may declare multiple start states, and one state may be the entry for several triggers. A firing can also resume a live session; see sessions and parked runs.
manual designates the CLI/Deep-Agent entry, and the user’s prompt travels
beside it as the turn’s message. Any other value names a tool or event whose
invocation starts the workflow there.
Declaring a start state
Section titled “Declaring a start state”A trigger is declared under a state’s triggers: mapping, and the key is the
trigger id. The state that mapping sits on is the trigger’s entry state.
A firing enters that state directly, as a normal turn: the agent’s model turn
for an agent state (the default), or the decision for a human one.
states: triage: triggers: manual: transitions: [{ to: answer }] on-email: triggers: email_received: transitions: [{ to: answer }] answer: {}An empty key (manual: above) declares just the id. A trigger is declared in
exactly one place, so reading one state tells you everything about what starts
it.
A workflow may declare as many start states as it likes. The trigger id is
recorded in checkpointed state, and lifecycle hooks and scripts read it as
args.trigger.
A state that declares a trigger can also be the target of another state’s transition. It runs its turn like any other state.
One state, several triggers
Section titled “One state, several triggers”Give the mapping several keys when the same state is where a session begins, however it was started:
states: triage: triggers: manual: email_received: session: conversation_id transitions: [{ to: answer }] answer: {}triage is the entry state for both ids, and each key carries its own
declaration. A shared entry state has one behavior: one set of
instructions, one tool allowlist, one set of transitions.
Which trigger fired stays readable per session, as ${{trigger}} in a guard and
args.trigger in a hook. Branch on it where the paths genuinely differ, and
declare separate states once they differ enough to be two states.
Two rules the loader enforces, each failing the workflow closed with the state and the id named:
- A trigger has at most one entry state. Two states declaring one id is an error naming both. A mapping with several keys is several triggers entering one state.
- At least one state declares a trigger, or the workflow has no start state and nothing can begin it.
A repeated id on one state needs no rule: a YAML mapping cannot repeat a key.
Hosts may decorate a declaration
Section titled “Hosts may decorate a declaration”A declaration says how a trigger’s firings map to a session (session:). A host
embedding the SDK often needs to record something else about a trigger: where a
firing’s words live, or which access connection guards the endpoint it is
exposed on. The declaration is where those go:
states: intake: triggers: chat: session: triggers.-1.threadId message: triggers.-1.text # host-resolved connection: acme-oidc # host-resolved piece: slack # the host's own, preserved and ignoredThe SDK preserves what it does not interpret, in three degrees:
message:andconnection:are shape-checked, so a malformedmessagepath gets the same diagnostic a malformedsessionpath gets. Both read back off the loaded machine, viamessagePathForTrigger(id)andconnectionForTrigger(id). The runtime itself leaves them alone.- A key the SDK defines nowhere is kept, reported as a warning, and otherwise ignored. A host can add its own without a coordinated release.
- A structural problem in the declaration fails workflow load.
Two keys are refused, because each would be wiring the SDK reads somewhere else:
entry:is settled by placement: the state the declaration sits on is the entry state.name:is settled by the key: the key the declaration sits under is the trigger id. To rename the trigger, rename the key.
Both fail workflow load, naming the key and the trigger. Kept as host decoration, either one would start the session somewhere the author did not say.
A trigger may declare its signature
Section titled “A trigger may declare its signature”Two keys say what a session started through the trigger needs and what it produces:
states: intake: triggers: chat: requires: [customer_id] # a firing must supply this to start returns: [ticket_ref] # the session guarantees this when it completesThe SDK acts on both, where the host keys above are inert.
requires is checked at the boundary. A firing that leaves one of its names
unsupplied is refused before the session starts, and the refusal names the
trigger and the missing variables. The old failure mode started the session and
died later at an unresolvable guard.
returns is checked once, where a session completes, and nothing is handed
back:
- A sub-workflow child that finishes with a
returnsname unset still completes. Its caller gets every declared return, the unset ones empty (null), plus anote:Not all return variables were set by the sub-workflow: 'description' was not set.The caller decides what to do about the rest; no second model call is spent. - A top-level session has no caller to read a note, so finishing with a name unset rejects it, naming the state and the names.
- A typed return holding another kind of value rejects the session either way, so a wrong-typed value never reaches a caller.
A session that parks is exempt, since it has not finished. note is reserved:
a trigger’s returns may not declare it. A workflow that wants the model to try
again checks its returns in a terminal after hook, which can correct.
A return is owed when the session finishes
Section titled “A return is owed when the session finishes”A return is the session’s result, so nothing asks for it earlier:
- Moving between states does not need it.
archmax_advancechecks only the state’s ownrequires:. A state that lists a return there holds the agent until it is set, as it would for any other name. - A later write replaces an earlier one. The value the session finishes with is the one its caller or host reads.
- A lock is the agent’s call. A return locks with
lock: truelike any other variable, and a locked one refuses every later write. So lock one only once its value is final. - A seed of a return lands unlocked. A name in both
requiresandreturnsis where the result starts, so the session can rewrite it. A later turn’s seed of it leaves a value the session already holds in place, as a seededtitledoes.
Both are lists of run-variable names. The names ride in the prompt’s current-state block, so the agent is told in every state what it must produce, and what finishing without them does, before it can be failed for omitting it. The same block tells it to set each return once its final value is known, and to lock one only when it is final.
What the agent should do to produce a variable is said by that state’s
instructions, which is where every other variable’s meaning already lives.
Typing a signature
Section titled “Typing a signature”An entry may be an object instead of a bare name, saying what kind of value the variable holds and what it is for:
states: intake: triggers: manual: description: Refund one order and report what was refunded. requires: - order_id - { name: quantity, type: integer } - { name: due, type: date, description: The day the refund is due. } returns: - { name: total, type: number, description: Refunded amount in EUR. } - approvedtype is one of string, integer, number, boolean, date, date-time,
object, array or file, with the conformance rules in the
reference. A bare
name stays untyped and takes any value, null included. A typed entry takes
neither null nor a value of another kind, and nothing is coerced, so the string
"4" is not an integer.
The types are held in three places:
- At the boundary.
quantity: "4"is refused before any model call. The refusal names the trigger,quantity,integerand that a string arrived. - At the write.
archmax_set_variables({ variables: { total: "12.50" } })is refused as a correctable tool refusal: nothing is written, the agent is told the variable, the type and the trigger, and it can write12.5instead. - At completion. A session that still holds a mistyped return is rejected, naming the terminal state, the variable and the type. The write check already covers the agent’s own writes, so this catches a script or hook that wrote one.
A file entry’s value is a path from the workspace root:
scratchpad/attachments/invoice.pdf for a file in the session, or
skills/billing/assets/template.pdf for an authored one. The shape check is the
same pure rule a host runs. In the same three places the runtime also checks that
a file is at the path and that the session may read it on runtime authority, the
rules a hook is bound by. Those rules include the workflow’s mount grants, so a
path under a governed mount no state of the workflow is granted is refused too. A
seed naming a file that is not there is refused before any model call, and archmax_set_variables({ variables: { report: "scratchpad/report.md" } }) is refused until the agent has written that file. A
host hands a session a file with TurnInput.files,
which places it and seeds the path for you.
The agent sees each typed entry with its type and description beside its name,
as total (number) — Refunded amount in EUR.. The declaration’s own
description it never sees: that sentence is for a caller (a delegating
workflow, an MCP client, the reader of a start form), and the session’s brief is
its instructions.
Type an entry when something outside the session reads the contract, for example a caller that must build the value or a host that publishes a schema. Leave it bare when any value will do.
A trigger’s requires: also guarantees the variable for the purposes of
${{…}} guards. A guard bound to one is backed by the boundary, so validate
stops warning about it.
Both reserved names are refused as a return, for opposite reasons:
trigger, because the runtime always sets it, which makes the contract vacuous.title, because a sub-session’s returns are copied up into its caller’s store. A title crossing upward would rename the caller’s task to whatever the sub-session called its own.
Both are fine in requires:. A supplied title is seeded unlocked, as
every route to that variable is.
The signature is what makes delegation checkable; see sub-workflows.
manual: one entry for every ingress
Section titled “manual: one entry for every ingress”manual is the one reserved trigger id. It names the entry at which a machine
is started, whichever ingress starts it:
archmax run <workflow>from the CLI;- an SDK invocation supplying no trigger id;
- a host firing that names
manual; - a delegation from another workflow calling
archmax_workflow_<slug>.
They all use the same trigger, and they differ in what a firing carries. A CLI or SDK invocation has a message, the prompt.
A delegation has no message, so the child works from its own state
instructions and the variables it was seeded with, whose short scalar values
its opening message lists. The declaration reads the same either way.
So one declaration makes a workflow both runnable and callable:
states: plan: triggers: manual: requires: [account_id] returns: [enrichment_file, risk_level] transitions: - to: write write: {}That signature is read at both boundaries. A caller sees requires: as the
delegation tool’s parameters, typed where the entries are typed, and returns:
as its result, checked offline by archmax validate.
A host firing that leaves out account_id is refused, and a session that
finishes with either return unset is rejected. So a workflow’s call signature is
declared in exactly one place.
It also means you can develop a delegated machine on its own, on the same terms a caller will hold it to:
archmax run enrich-order --variables '{"account_id":"acct-42"}'A machine cannot tell it was delegated: ${{trigger}} reads manual in a
sub-session exactly as in a CLI one. The same inputs produce the same session
either way, so developing a machine standalone reproduces how it behaves when a
caller delegates to it. A caller that needs the child to know passes an ordinary
variable.
session: and message: are ordinary host-facing keys on manual, and a
delegation ignores both. A delegated run is a separate session whose id derives
from the caller’s (<caller>~<n>, n the dispatch’s number in the caller), with its own scratchpad/,
and it carries no message.
An event that must not start a run
Section titled “An event that must not start a run”Every declared trigger is a start trigger: it sits on a state, and that state is where its firings open a session.
So an event that belongs to a live session (a ticket being closed, a payment settling) stays out of the spec. The host names the session it is delivered into:
await agent.workflow.send(sessionId, { delivery: { trigger: { id: "ticket_closed" }, variables } });archmax deliver <session> --trigger ticket_closed --variables '{"closed_by":"agent-7"}'A firing may also carry a sessionPath, the same dotted path grammar
session: uses. Use it when the host would rather hand over the path than
resolve the id itself:
await agent.workflow.resolveSession({ trigger: { id: "ticket_closed" }, sessionPath: "conversation_id", variables,});A firing that names a trigger no state declares, with no session to deliver
into, is refused with UnknownSessionTriggerError.
Every trigger firing is a manual invocation
Section titled “Every trigger firing is a manual invocation”A trigger is an identifier the caller supplies at invocation time. The SDK runs no event listeners, pollers, or webhook servers, so the external system that observed the event is the one that invokes the SDK with the id. Three places carry it in:
createAgent’striggeroption, the assembly-time default;agent.workflow.send’strigger, per turn;- the CLI’s
--triggerflag.
A trigger carries no payload. Whatever the event contained arrives as
session variables:
variables at assembly, or --variables on the CLI. A session’s facts
therefore have one home, which is also the one thing a tool guard can bind to.
One assembled agent serves every trigger
Section titled “One assembled agent serves every trigger”The turn boundary enters whichever declared start state the turn’s trigger selects. A single assembled agent therefore serves every trigger, with no reassembly per trigger.
The trigger is supplied per invoke as invoke input, falling back to the
assembly-time default (createAgent’s trigger option), then to manual:
const agent = await createAgent({ workflow: "support" });if (!agent.workflow) throw new Error("expected a workflow");
// Validate before invoking — fails closed on an unknown trigger id.const manual = agent.workflow.resolveTrigger({ id: "manual" });await agent.invoke( { messages: [{ role: "user", content: "…" }], trigger: { id: manual.id } }, { configurable: { thread_id: "t1" } },);
const email = agent.workflow.resolveTrigger({ id: "email_received" });await agent.invoke( { messages: [], trigger: { id: email.id } }, { configurable: { thread_id: "t2" } },);The event’s payload rides variables, mapped field by field. An explicit
mapping keeps the session’s variable set intentional. It also lets a guard bind
to a name you chose:
const agent = await createAgent({ workflow: "support", trigger: { id: "email_received" }, variables: { from_email: payload.from_email, account: { region: "eu", plan: "enterprise" }, // structured values are fine },});Every seed except title is locked: a host-supplied fact stays as the host
set it, which is what makes a guard bound to one trustworthy. A value the agent
should be able to change is left unseeded, and the agent sets it with
archmax_set_variables.
The id itself follows four rules:
- An omitted trigger uses the assembly-time default, or
manualwhen none was configured. - An unknown trigger id is refused before any model-driven work. Resolve it
via
resolveTrigger, which throwsUnknownTriggerErrorlisting the declared ids. - The trigger is written once at the turn boundary into checkpointed state.
Lifecycle
before/afterscripts read it asargs.trigger(the id string), and the session’s input asargs.variables. Both survive resume. - The id is also exposed as the built-in
triggervariable. A guard can bind to how the session started (${{trigger}}) without any seeding. It is one of two reserved names. The other,title, names the task and is written by the agent; see Two reserved names.
The CLI run command delivers the manual trigger by default. Pass
--trigger <id> to enter any declared start state, and --variables '<json>'
to seed the session’s input (see the CLI guide).
A case declares its own start on the case document: a top-level
trigger: { id } key, with a sibling variables: block for the input. See
start conditions.