> ## Documentation Index
> Fetch the complete documentation index at: https://docs.os.meetluno.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Advanced triggers

> Filter and transform payloads before they run.

Every trigger source ([schedule](https://docs.os.meetluno.ai/learn/triggers/schedules.md), [webhook](https://docs.os.meetluno.ai/learn/triggers/webhooks.md), or [poll](https://docs.os.meetluno.ai/learn/triggers/polling.md)) attaches to a target the same way. This page covers what happens between the source firing and the run starting: filtering, transforming, attaching to agents, and skipping.

## Match on the source, transform on the attachment

Two separate concerns, in two separate places:

| Concern | Question | Where it lives |
| - | - | - |
| **Match** | Should this run at all? | The **source**: webhook `payload`, poll `filter` |
| **Transform** | What input does the run receive? | The **attachment**: `transform` on a workflow target |

Keeping them separate means "should this fire?" sits next to the source definition, and "what does the run get?" sits next to the binding.

## Transform a workflow input

A workflow target accepts an optional `transform` that maps the (already validated) source payload into the workflow's `input`. Use it to reshape a third-party payload into the clean shape your workflow expects.

```ts src/triggers/incoming-message.ts theme={null}
import { defineWebhookSource } from "@meetluno/luno/trigger";
import { z } from "zod";
import workflow from "../workflows/incoming-message";

const IncomingMessageSchema = z.object({
  text: z.string().trim().min(1),
  from: z.string().optional(),
});

export default defineWebhookSource({
  slug: "incoming-message",
  name: "Incoming message",
  description: "Fires when an inbound message webhook arrives.",
  endpoint: "incoming-message",
  payload: IncomingMessageSchema,
}).attach({
  workflow,
  transform: (payload) => ({
    message: payload.text,
    sender: payload.from ?? "anonymous",
  }),
});
```

The transform's return value is parsed against the workflow's `input` schema, so the workflow still gets a validated payload. If you omit `transform`, the matched payload passes straight through to `input` (extra fields are ignored). The matched `payload` type flows into `transform` and agent `prompt` callbacks.

For [polls](https://docs.os.meetluno.ai/learn/triggers/polling.md#how-a-poll-tick-works), `transform` runs once on the whole `run()` return value — not once per array element. Arrays do not auto-fan-out into multiple workflow runs.

## Attach to an agent

Instead of a workflow, a source can attach to an [agent](https://docs.os.meetluno.ai/learn/agents/overview.md). An agent target takes a `prompt` instead of a `transform`; the payload becomes a prompt rather than a typed input.

```ts src/triggers/signup.ts theme={null}
import { defineWebhookSource } from "@meetluno/luno/trigger";
import { z } from "zod";
import signupResearcher from "../agents/signup-researcher";

export default defineWebhookSource({
  slug: "signup",
  name: "Signup",
  description: "Fires when a new signup is posted.",
  endpoint: "signup",
  payload: z.object({ name: z.string(), email: z.string().email() }),
}).attach({
  agent: signupResearcher,
  prompt: (payload) => `Research the new signup ${payload.name} <${payload.email}>.`,
});
```

The `prompt` can be a static string or a function of the payload. A `transform` is not used for agent targets; the agent receives the payload directly through its prompt.

### Prompt interpolation

For a static prompt string, you can interpolate payload fields with `{{payload.path}}` placeholders instead of writing a function:

```ts theme={null}
.attach({
  agent: support,
  prompt: "You have {{payload.count}} unread emails. Triage them.",
})
```

### Deliver into an existing session

An event that belongs to a conversation already in progress (a customer replying to a ticket, a CI run the agent started) can go into that session instead of starting a new run. Return the session id from `session`; the event arrives as a [signal](https://docs.os.meetluno.ai/learn/agents/build-agents.md#signals-messages-into-a-running-conversation) shown as `[<trigger slug>] <message>`:

```ts src/triggers/ticket-reply.ts theme={null}
export default defineWebhookSource({
  slug: "ticket-reply",
  name: "Ticket reply",
  description: "A customer replied to a ticket.",
  endpoint: "ticket-reply",
  payload: z.object({ sessionId: z.string().optional(), text: z.string() }),
}).attach({
  agent: support,
  session: (payload) => payload.sessionId,
  // The ticket links to a conversation a person started (dashboard, API): accept those too.
  allowSession: (session) => session.sourceKind === "api",
  message: (payload) => `Customer replied: ${payload.text}`,
  mode: "queue", // default; "inject" reaches a running prompt before its next step
  prompt: (payload) => `New ticket: ${payload.text}`, // optional fallback
});
```

* The session must belong to the attached agent (or have been [handed off](https://docs.os.meetluno.ai/learn/agents/build-agents.md#handoffs) to it). An idle session wakes up and answers right away.
* Because the payload picks the session, events only go into sessions this trigger started (its own new runs, e.g. from `prompt`). To deliver into other sessions, accept them with `allowSession(session)` — it gets `{ id, sourceKind, ranByUserId }` (`sourceKind` is `trigger`, `api` for conversations started from the web app, CLI or API, `workflow`, …). Only accept what the source's callers may write into: anyone who can fire the source can name any session id.
* When `session` returns nothing, a session of another agent, or a session that is not accepted, the event starts a new run with `prompt` if you set one; otherwise it is skipped.
* Delivery needs queued execution (the default in deployed projects). A delivered signal does not appear as a trigger run.

## Fan out to multiple targets

One source can drive several workflows and/or agents. Chain `.attach()` once per target — each call binds the same source to another workflow or agent:

```ts src/triggers/inbox-check.ts theme={null}
import { defineCronSource } from "@meetluno/luno/trigger";
import assistant from "../agents/assistant";
import assistantVm from "../agents/assistant-vm";

export default defineCronSource({
  slug: "inbox-check",
  name: "Inbox check",
  description: "Checks email every five minutes for both assistant agents.",
  schedule: "*/5 * * * *",
})
  .attach({ agent: assistant, prompt: "Check my email and list new emails." })
  .attach({ agent: assistantVm, prompt: "Check my email and list new emails." });
```

You can mix workflow and agent targets, and you can default-export an array instead of chaining:

```ts theme={null}
const inbox = defineCronSource({
  slug: "inbox-check",
  name: "Inbox check",
  description: "Checks email every five minutes.",
  schedule: "*/5 * * * *",
});

export default [
  inbox.attach({ workflow: triage }),
  inbox.attach({ agent: assistant, prompt: "Summarize new mail." }),
];
```

This is still **one trigger**. Each fire — a cron tick, a poll cycle, or a matching webhook request — evaluates the source **once** (a poll's `run()` and filters execute a single time) and then fans out to every attached target. The fire is recorded once and links to all the runs and sessions it started. Every target keeps its own [attachment id](https://docs.os.meetluno.ai/learn/triggers/overview.md#attachment-id) (`{sourceSlug}:{targetSlug}`), so the two agents above are `inbox-check:assistant` and `inbox-check:assistant-vm`.

This works for all three source kinds (schedule, webhook, and poll). Each `{source}:{target}` id must be unique, so a given workflow or agent can only attach to a source once.

## Skipping a run

A source can decide a particular event isn't worth a run:

* **Poll:** if a [filter](https://docs.os.meetluno.ai/learn/triggers/polling.md#filtering) returns `false`, the tick is skipped and no run is created. Skipped poll ticks don't count toward execution limits.
* **Webhook:** a payload that matches no trigger on the endpoint (including an invalid payload on a single-trigger endpoint) returns `{ ok: true, skipped: true }`. See [shared endpoints](https://docs.os.meetluno.ai/learn/triggers/webhooks.md#shared-endpoints).

## Inspect what ran

Every trigger-driven run is recorded under the [attachment id](https://docs.os.meetluno.ai/learn/triggers/overview.md#attachment-id). Audit them from the CLI:

```bash theme={null}
luno triggers runs list signup --workflow signup-researcher
luno triggers runs get signup <run-id> --workflow signup-researcher --include workflows,trace
```

See the [CLI reference](https://docs.os.meetluno.ai/cli.md#triggers) and [run history](https://docs.os.meetluno.ai/learn/logs/overview.md).

## Next steps

> **[App events](https://docs.os.meetluno.ai/learn/triggers/app-events.md)**
>
> React to events from connected third-party apps.

> **[Webhooks](https://docs.os.meetluno.ai/learn/triggers/webhooks.md)**
>
> Endpoints, validation, filters, and shared routes.

> **[Run agents](https://docs.os.meetluno.ai/learn/agents/run-agents.md)**
>
> How agents run and how to inspect their sessions.

> **[Run history](https://docs.os.meetluno.ai/learn/logs/overview.md)**
>
> Review trigger-driven runs in the web app.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.