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:
{ parsed, prompt } = await this.parseArgs(args.request)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
- 📤 Configure Outcomes - Route trigger responses
- :robot_face: Learn about Models - LLM configuration
- 🔍 See Examples - Real scheduled agents