Triggers

Triggers

Triggers define how and when your agents are activated. Beyond simple API calls, agents can respond to schedules, emails, file changes, and more.


Available Triggers

Trigger Description Use Case
ApiTrigger HTTP endpoint Chat interfaces, webhooks
SlackTrigger Slack Events API Mentions, DMs, channel messages
Rate Schedule-based Cron jobs, periodic tasks
EmailTrigger Incoming emails Email automation
FileTrigger File system changes Document processing

API Trigger

The default trigger. Exposes your agent as an HTTP endpoint. ApiTrigger binds StreamingAPIResponse so the host writes the model stream as SSE. Return a buffered HTTP body instead by binding a different outcome, or by returning BufferedHttpResponse from a custom trigger.

import { Agent, ApiTrigger } from "@shuttl-io/core";

export const chatAgent = new Agent({
    name: "ChatBot",
    systemPrompt: "You're a helpful assistant.",
    model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
    triggers: [new ApiTrigger()],  // This is the default
});

Endpoints

When you run shuttl serve, each HTTP trigger is mounted at /<agent>/<trigger>. Any method on that path or a subpath is forwarded to the trigger. The trigger decides whether to handle it.

The default ApiTrigger only invokes the agent on POST at the mount (POST /my-agent/api). GET /my-agent/api/abc and POST /my-agent/api/abc return 404.

Method Path Description
* /<agent>/<trigger> and /<agent>/<trigger>/** Forwarded to that trigger
POST /<agent>/api Default ApiTrigger invoke
GET /health Health check
GET / List trigger mounts

!!! tip "Development vs Production" Use shuttl dev for interactive development in the web UI. Use shuttl serve to expose HTTP endpoints for production or integration testing.

Request Format

curl -X POST http://localhost:8080/chat \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Hello!",
    "agentName": "ChatBot"
  }'

With attachments:

curl -X POST http://localhost:8080/chat \
  -H "Content-Type: multipart/form-data" \
  -F "message=Analyze this image" \
  -F "file=@photo.jpg"

Rate Trigger

Schedule agents to run at specific intervals or times.

Simple Intervals

import { Rate, StreamingOutcome } from "@shuttl-io/core";

// Every 5 minutes
triggers: [Rate.minutes(5).bindOutcome(new StreamingOutcome())]

// Every hour
triggers: [Rate.hours(1).bindOutcome(new SlackOutcome("#channel"))]

// Every day
triggers: [Rate.days(1).bindOutcome(new WebhookOutcome("https://..."))]

Available Interval Methods

Rate.milliseconds(500)  // Every 500ms
Rate.seconds(30)        // Every 30 seconds
Rate.minutes(15)        // Every 15 minutes
Rate.hours(6)           // Every 6 hours
Rate.days(1)            // Every day
Rate.weeks(1)           // Every week
Rate.months(1)          // Every month (30 days)

Cron Expressions

For precise scheduling, use cron syntax:

// 9 AM every weekday
Rate.cron("0 9 * * MON-FRI", "America/New_York")

// Every hour on the hour
Rate.cron("0 * * * *")

// First day of every month at midnight
Rate.cron("0 0 1 * *")

// Every 15 minutes during business hours
Rate.cron("*/15 9-17 * * MON-FRI", "America/New_York")

Cron Syntax Reference

┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, SUN-SAT)
│ │ │ │ │
* * * * *

Custom Input on Trigger

Customize what the agent receives when triggered:

Rate.hours(1)
    .withOnTrigger({
        onTrigger: async () => {
            const stats = await fetchDailyStats();
            return [{
                typeName: "text",
                text: `Generate a report for: ${JSON.stringify(stats)}`,
            }];
        },
    })
    .bindOutcome(new SlackOutcome("#reports"))

Email Trigger

React to incoming emails.

import { EmailTrigger, EmailOutcome } from "@shuttl-io/core";

export const emailAgent = new Agent({
    name: "EmailResponder",
    systemPrompt: `You handle customer support emails.
    
    For each email:
    1. Understand the customer's issue
    2. Search the knowledge base for solutions
    3. Draft a helpful response`,
    model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
    tools: [searchKnowledgeTool, draftEmailTool],
    triggers: [
        new EmailTrigger({
            address: "support@company.com",
            filters: {
                excludeSpam: true,
                subjectContains: ["help", "support", "issue"],
            },
        }),
    ],
    outcomes: [new EmailOutcome()],
});

Email Input Format

When triggered by an email, the agent receives:

{
    from: "customer@example.com",
    to: "support@company.com",
    subject: "Help with my order",
    body: "I placed an order yesterday but...",
    attachments: [/* any attached files */],
    receivedAt: "2025-01-03T10:30:00Z",
}

File Trigger

Watch for file system changes.

import { FileTrigger } from "@shuttl-io/core";

export const documentProcessor = new Agent({
    name: "DocProcessor",
    systemPrompt: "Process incoming documents and extract key information.",
    model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
    tools: [extractTextTool, classifyDocTool, saveToDbTool],
    triggers: [
        new FileTrigger({
            path: "/incoming/documents",
            patterns: ["*.pdf", "*.docx"],
            events: ["created"],
        }),
    ],
});

Configuration Options

new FileTrigger({
    path: "/path/to/watch",      // Directory to monitor
    patterns: ["*.pdf"],          // Glob patterns to match
    events: ["created", "modified", "deleted"],  // Events to trigger on
    recursive: true,              // Watch subdirectories
    debounceMs: 1000,            // Wait for file to stabilize
})

Multiple Triggers

Agents can have multiple triggers:

export const flexibleAgent = new Agent({
    name: "FlexBot",
    systemPrompt: "You respond to various inputs.",
    model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
    tools: [searchTool],
    triggers: [
        new ApiTrigger(),                    // HTTP endpoint
        Rate.hours(1),                       // Hourly check-in
        new EmailTrigger("bot@company.com"), // Email responses
    ],
});

Each trigger can have its own outcome:

triggers: [
    new ApiTrigger().bindOutcome(new StreamingOutcome()),
    Rate.hours(1).bindOutcome(new SlackOutcome("#updates")),
]

Binding Outcomes

Connect triggers to specific outcomes:

// Rate trigger → Slack
Rate.hours(1).bindOutcome(new SlackOutcome("#channel"))

// Rate trigger → Webhook
Rate.minutes(30).bindOutcome(new WebhookOutcome("https://api.example.com/hook"))

// Rate trigger → Multiple outcomes
Rate.days(1).bindOutcome(
    new CombinationOutcome([
        new SlackOutcome("#reports"),
        new WebhookOutcome("https://backup.example.com"),
    ])
)

Trigger Patterns

The Poller

Check for updates periodically:

const pollerAgent = new Agent({
    name: "DataPoller",
    systemPrompt: "Check for new data and process it.",
    model: Model.openAI("gpt-4o-mini", Secret.fromEnv("KEY")),
    tools: [checkForUpdatesTool, processUpdateTool],
    triggers: [
        Rate.minutes(5).withOnTrigger({
            onTrigger: async () => [{
                typeName: "text",
                text: "Check for new data and process any updates.",
            }],
        }),
    ],
});

The Reporter

Generate scheduled reports:

const reporterAgent = new Agent({
    name: "DailyReporter",
    systemPrompt: "Generate comprehensive daily reports.",
    model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
    tools: [fetchMetricsTool, generateChartTool],
    triggers: [
        Rate.cron("0 9 * * MON-FRI", "America/New_York")
            .withOnTrigger({
                onTrigger: async () => {
                    const date = new Date().toISOString().split("T")[0];
                    return [{
                        typeName: "text",
                        text: `Generate the daily report for ${date}.`,
                    }];
                },
            })
            .bindOutcome(new SlackOutcome("#reports")),
    ],
});

The Hybrid

Respond to both API calls and schedules:

const hybridAgent = new Agent({
    name: "HybridBot",
    systemPrompt: `You perform analysis tasks.
    
    If invoked via API: Respond to the user's specific request.
    If invoked on schedule: Generate the standard daily summary.`,
    model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
    tools: [analyzeTool, summarizeTool],
    triggers: [
        new ApiTrigger(),
        Rate.cron("0 18 * * *").bindOutcome(new SlackOutcome("#eod")),
    ],
});

Custom Triggers

Build your own triggers by implementing ITrigger or extending BaseTrigger. The host calls activate(args, next). You either return an HTTP/activation response or continue the chain with next.activate.

The host always starts with empty parsed ({}) and prompt (Prompt.empty()). The trigger fills those before calling next.

The ITrigger Interface

import { ITrigger, IOutcome, ActivationArgs, ITriggerNext, IActivationResponse } from "@shuttl-io/core";

interface ITrigger {
    name: string;
    triggerType: string;
    triggerConfig: Record<string, unknown>;
    outcome?: IOutcome;
    activate(args: ActivationArgs, next: ITriggerNext): Promise<IActivationResponse>;
    bindOutcome(outcome: IOutcome): ITrigger;
    withName(name: string): ITrigger;
}

interface ActivationArgs {
    readonly request: any;
    readonly parsed: any;
    readonly prompt: Prompt;
}

interface ITriggerNext {
    activate(args: ActivationArgs): Promise<IActivationResponse>;
    invoke(prompt: Prompt): Promise<IModelResponseStream>;
    enqueue(args: ActivationArgs): Promise<void>;
}

request is the raw inbound payload. Extra delivery context (Slack channel/token, etc.) lives on parsed, not as sibling fields on args. prompt.contents is the model input.

Extending BaseTrigger (Recommended)

BaseTrigger.activate parses the request and continues the chain:

  1. { parsed, prompt } = await this.parseArgs(args.request)
  2. return next.activate({ request: args.request, parsed, prompt })
import { BaseTrigger, ParseArgsResult, Prompt, BufferedHttpResponse } from "@shuttl-io/core";

class WebhookTrigger extends BaseTrigger {
    constructor(private webhookSecret: string) {
        super("webhook", { secret: webhookSecret });
    }

    async parseArgs(request: any): Promise<ParseArgsResult> {
        const { event, data } = request;
        return {
            parsed: { event, data },
            prompt: new Prompt([{
                typeName: "text",
                text: `Webhook event: ${event}\nData: ${JSON.stringify(data)}`,
            }]),
        };
    }

    async activate(args: ActivationArgs, next: ITriggerNext) {
        const signature = args.request.headers?.["x-webhook-signature"];
        if (!this.verifySignature(signature, args.request.body)) {
            return new BufferedHttpResponse(401, "");
        }
        return super.activate(args, next);
    }

    private verifySignature(signature: string, body: any): boolean {
        return true;
    }
}

Handshake, auth, and skip paths return BufferedHttpResponse (400/401/200). Do not throw for those, and do not call next. Config errors (invalid patterns, missing required options) throw in the constructor.

parseArgs

interface ParseArgsResult {
    parsed: any;
    prompt: Prompt;
}

class Prompt {
    readonly contents: InputContent[];
    constructor(contents: InputContent[]);
    static empty(): Prompt;
}

InputContent is text, files, or images for the model.

Background work with fork

To ACK a webhook immediately (Slack Events API) and run the agent later, await this.fork(next, args) then return a buffered 200. fork waits until the queue accepts the job, not until the agent finishes. A worker then runs next.activate (agent or harness + the bound outcome). If enqueue fails, activate throws and the webhook can retry.

See the work queue and adapters.

class SlackishTrigger extends BaseTrigger {
    async activate(args: ActivationArgs, next: ITriggerNext) {
        const { parsed, prompt } = await this.parseArgs(args.request);
        await this.fork(next, { request: args.request, parsed, prompt });
        return new BufferedHttpResponse(200, "");
    }

    async parseArgs(request: any): Promise<ParseArgsResult> {
        return {
            parsed: { slack: { channel: request.channel } },
            prompt: new Prompt([{ typeName: "text", text: request.text }]),
        };
    }
}

Example: Queue Trigger

class QueueTrigger extends BaseTrigger {
    constructor(private queueUrl: string) {
        super("queue", { queueUrl });
    }

    async parseArgs(request: any): Promise<ParseArgsResult> {
        const message = request.message;
        const metadata = request.metadata || {};
        return {
            parsed: { message, metadata },
            prompt: new Prompt([{
                typeName: "text",
                text: `Process this queue message:

Message ID: ${metadata.messageId}
Received: ${metadata.timestamp}
Content: ${JSON.stringify(message)}`,
            }]),
        };
    }
}

export const queueAgent = new Agent({
    name: "QueueProcessor",
    systemPrompt: "Process incoming queue messages.",
    model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
    triggers: [
        new QueueTrigger("https://sqs.us-east-1.amazonaws.com/queue")
            .withName("order-queue")
            .bindOutcome(new SlackOutcome("#orders")),
    ],
});

Example: GitHub Webhook Trigger

class GitHubWebhookTrigger extends BaseTrigger {
    constructor(private webhookSecret: string) {
        super("github-webhook", { secret: webhookSecret });
    }

    async parseArgs(request: any): Promise<ParseArgsResult> {
        const event = request.headers["x-github-event"];
        const payload = request.body;
        let text: string;
        switch (event) {
            case "pull_request":
                text = `Review this PR:
                    Title: ${payload.pull_request.title}
                    Author: ${payload.pull_request.user.login}
                    Description: ${payload.pull_request.body}`;
                break;
            case "issues":
                text = `Triage this issue:
                    Title: ${payload.issue.title}
                    Body: ${payload.issue.body}`;
                break;
            default:
                text = `GitHub ${event} event received: ${JSON.stringify(payload)}`;
        }
        return {
            parsed: { event, payload },
            prompt: new Prompt([{ typeName: "text", text }]),
        };
    }

    async activate(args: ActivationArgs, next: ITriggerNext) {
        const signature = args.request.headers["x-hub-signature-256"];
        if (!this.verifyGitHubSignature(signature, args.request.bodyRaw)) {
            return new BufferedHttpResponse(401, "");
        }
        return super.activate(args, next);
    }
}

Overriding activate

next.activate runs the bound outcome (or the host default). next.invoke runs the agent and returns a live model stream — outcomes call this, not triggers, unless you are skipping the outcome layer.

class CustomTrigger extends BaseTrigger {
    async parseArgs(request: any): Promise<ParseArgsResult> {
        return {
            parsed: request,
            prompt: new Prompt([{ typeName: "text", text: request.message }]),
        };
    }

    async activate(args: ActivationArgs, next: ITriggerNext) {
        console.log("Trigger activated:", this.name);
        const response = await next.activate({
            request: args.request,
            ...(await this.parseArgs(args.request)),
        });
        console.log("Trigger completed:", this.name);
        return response;
    }
}

Best Practices

1. Use Appropriate Intervals

Don't poll more often than necessary:

// ❌ Too aggressive
Rate.seconds(1)

// ✅ Reasonable for most use cases
Rate.minutes(5)

2. Handle Trigger Context

Use onTrigger to provide context:

Rate.hours(1).withOnTrigger({
    onTrigger: async () => {
        const context = await gatherContext();
        return [{
            typeName: "text",
            text: `Current context: ${JSON.stringify(context)}. 
                   Analyze and report any anomalies.`,
        }];
    },
})

3. Match Outcomes to Triggers

API triggers usually stream; scheduled triggers usually notify:

// API → Stream to user
new ApiTrigger().bindOutcome(new StreamingAPIResponse())

// Schedule → Post to Slack
Rate.days(1).bindOutcome(new SlackOutcome("#channel"))

4. Consider Timezones

Always specify timezone for cron expressions:

// ❌ Ambiguous
Rate.cron("0 9 * * *")

// ✅ Explicit
Rate.cron("0 9 * * *", "America/New_York")

Next Steps