Outcomes
Outcomes define where agent responses go. Stream to users in real-time, post to Slack, call webhooks, or build custom integrations.
Available Outcomes
| Outcome | Description | Use Case |
|---|---|---|
StreamingAPIResponse |
SSE back to the HTTP caller | API triggers, chat interfaces |
StreamingOutcome |
Stream events to stdout | CLI / local runs |
SlackOutput / SlackOutcome |
Post to Slack | Notifications, reports |
CombinationOutcome |
Multiple destinations | Broadcast to several channels |
Streaming Outcome
ApiTrigger binds StreamingAPIResponse by default. That outcome invokes the agent and returns a StreamingHttpResponse; the host writes the model stream as Server-Sent Events.
StreamingOutcome consumes the stream and writes events to stdout (CLI / local runs). Bind it on rate, file, or email triggers when you want those events on the process output instead of an HTTP response.
import { Agent, ApiTrigger, StreamingAPIResponse, StreamingOutcome } 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().bindOutcome(new StreamingAPIResponse())],
});
Stream Events
When streaming, clients receive events like:
{"type": "response.requested", "data": {...}}
{"type": "output_text_delta", "data": {"text": "Hello"}}
{"type": "output_text_delta", "data": {"text": "! How"}}
{"type": "output_text_delta", "data": {"text": " can I help?"}}
{"type": "tool_call", "data": {"name": "search", "arguments": {...}}}
{"type": "tool_calls_completed", "data": {...}}
{"type": "output_text", "data": {"text": "Based on the search results..."}}
{"type": "response.completed", "data": {...}}
{"type": "overall.completed", "data": {...}}
Consuming Streams (JavaScript)
When using shuttl serve to expose HTTP endpoints, you can consume streams like this:
const response = await fetch("http://localhost:8080/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "Hello!" }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).split("\n");
for (const line of lines) {
if (line.trim()) {
const event = JSON.parse(line);
if (event.type === "output_text_delta") {
process.stdout.write(event.data.text);
}
}
}
}
Slack Outcome
Post agent responses to Slack channels.
import { SlackOutcome } from "@shuttl-io/core";
export const reportAgent = new Agent({
name: "Reporter",
systemPrompt: "Generate concise reports.",
model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
triggers: [Rate.days(1)],
outcomes: [new SlackOutcome("#reports")],
});
Configuration
new SlackOutcome(
"#channel-name", // Channel name or ID
{
token: Secret.fromEnv("SLACK_BOT_TOKEN"), // Bot token
username: "Shuttl Bot", // Display name
iconEmoji: ":robot_face:", // Custom icon
}
)
Message Formatting
The agent's text response is posted directly. For rich formatting, include markdown in the response:
systemPrompt: `Generate reports with this format:
*Daily Report - ${date}*
📊 *Key Metrics*
• Users: {count}
• Revenue: ${amount}
📈 *Trends*
{analysis}
✅ *Recommendations*
{actions}`
Combination Outcome
Send responses to multiple destinations simultaneously.
import { CombinationOutcome, StreamingOutcome, SlackOutcome } from "@shuttl-io/core";
export const broadcastAgent = new Agent({
name: "Broadcaster",
systemPrompt: "Announce important updates.",
model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
outcomes: [
new CombinationOutcome([
new StreamingOutcome(), // Real-time to API caller
new SlackOutcome("#announcements"), // Also post to Slack
]),
],
});
Use Cases
// Log + Notify
new CombinationOutcome([
new StreamingOutcome(),
new WebhookOutcome("https://logging.example.com/ingest"),
])
// Multi-channel notification
new CombinationOutcome([
new SlackOutcome("#team-a"),
new SlackOutcome("#team-b"),
new EmailOutcome("stakeholders@company.com"),
])
Binding Outcomes to Triggers
Different triggers can have different outcomes:
export const flexAgent = new Agent({
name: "FlexBot",
systemPrompt: "Handle various requests.",
model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
triggers: [
// API calls stream back to the caller
new ApiTrigger().bindOutcome(new StreamingAPIResponse()),
// Scheduled runs post to Slack
Rate.hours(1).bindOutcome(new SlackOutcome("#hourly-updates")),
// Email triggers reply via email
new EmailTrigger("bot@company.com").bindOutcome(new EmailOutcome()),
],
});
Custom Outcomes
Outcomes share the same activate(args, next) contract as triggers. The host wires next.invoke to the agent. Extend BaseOutcome and override deliver unless you need to skip or wrap invoke.
import { IOutcome, BaseOutcome, ActivationArgs, ITriggerNext, IActivationResponse } from "@shuttl-io/core";
interface IOutcome {
activate(args: ActivationArgs, next: ITriggerNext): Promise<IActivationResponse>;
deliver(stream: IModelResponseStream, args: ActivationArgs): Promise<IActivationResponse>;
}
BaseOutcome.activate is const stream = await next.invoke(args.prompt) then return this.deliver(stream, args). Delivery context (channel, token, request ids) is on args.parsed.
StreamingAPIResponse overrides activate: it invokes the agent and returns StreamingHttpResponse without consuming the stream so the host can write SSE. A StreamingAPIResponse child inside CombinationOutcome is unsupported; combination always consumes the stream and returns buffered HTTP.
The IModelResponseStream is an async iterator over model responses:
interface IModelResponseStream {
next(): Promise<ModelResponseStreamValue>;
}
Webhook Outcome Example
import { BaseOutcome, IModelResponseStream, ActivationArgs, BufferedHttpResponse } from "@shuttl-io/core";
class WebhookOutcome extends BaseOutcome {
constructor(private webhookUrl: string) {
super();
}
async deliver(messageStream: IModelResponseStream, _args: ActivationArgs) {
const chunks: string[] = [];
while (true) {
const { value, done } = await messageStream.next();
if (done) break;
if (value?.data?.typeName === "output_text_delta") {
chunks.push(value.data.text);
}
}
await fetch(this.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
response: chunks.join(""),
timestamp: new Date().toISOString(),
}),
});
return new BufferedHttpResponse(200, "");
}
}
// Usage
new ApiTrigger().bindOutcome(new WebhookOutcome("https://api.example.com/webhook"))
Database Logging Outcome
class DatabaseLogOutcome extends BaseOutcome {
constructor(private db: Database) {
super();
}
async deliver(messageStream: IModelResponseStream, _args: ActivationArgs) {
const events: ModelResponse[] = [];
while (true) {
const { value, done } = await messageStream.next();
if (done) break;
if (value) events.push(value);
}
await this.db.agentLogs.insert({
events,
createdAt: new Date(),
});
return new BufferedHttpResponse(200, "");
}
}
Real-time Forwarding Outcome
Forward events as they stream (don't wait for completion):
class RealtimeForwardOutcome extends BaseOutcome {
constructor(private websocket: WebSocket) {
super();
}
async deliver(messageStream: IModelResponseStream, _args: ActivationArgs) {
while (true) {
const { value, done } = await messageStream.next();
if (done) break;
if (value) {
this.websocket.send(JSON.stringify(value));
}
}
return new BufferedHttpResponse(200, "");
}
}
Outcome Patterns
The Audit Trail
Log all responses while still streaming to stdout or another child. Combination invokes the agent once, then calls deliver on each child:
outcomes: [
new CombinationOutcome([
new StreamingOutcome(),
new DatabaseLogOutcome(db),
]),
]
The Notification Hub
Route based on content:
class SmartRouterOutcome extends BaseOutcome {
async deliver(messageStream: IModelResponseStream, _args: ActivationArgs) {
const chunks: string[] = [];
while (true) {
const { value, done } = await messageStream.next();
if (done) break;
if (value?.data?.typeName === "output_text_delta") {
chunks.push(value.data.text);
}
}
const response = chunks.join("");
const isUrgent = response.includes("URGENT") || response.includes("ERROR");
const isReport = response.includes("Report") || response.includes("Summary");
if (isUrgent) {
await slackClient.post("#alerts", response);
await pagerDuty.alert(response);
} else if (isReport) {
await slackClient.post("#reports", response);
} else {
await slackClient.post("#general", response);
}
return new BufferedHttpResponse(200, "");
}
}
The Transformer
Modify output before sending:
class FormattedSlackOutcome extends BaseOutcome {
constructor(private channel: string) {
super();
}
async deliver(messageStream: IModelResponseStream, _args: ActivationArgs) {
const chunks: string[] = [];
while (true) {
const { value, done } = await messageStream.next();
if (done) break;
if (value?.data?.typeName === "output_text_delta") {
chunks.push(value.data.text);
}
}
const response = chunks.join("");
const formatted = `
🤖 *Agent Response*
───────────────
${response}
───────────────
_Generated at ${new Date().toLocaleTimeString()}_
`.trim();
await slackClient.post(this.channel, formatted);
return new BufferedHttpResponse(200, "");
}
}
Best Practices
1. Match Outcome to Use Case
| Scenario | Recommended Outcome |
|---|---|
| Interactive chat | StreamingAPIResponse |
| Scheduled reports | SlackOutcome |
| Background tasks | WebhookOutcome or DatabaseLogOutcome |
| Critical alerts | CombinationOutcome with multiple channels |
2. Handle Failures Gracefully
class ResilientWebhookOutcome extends BaseOutcome {
async deliver(messageStream: IModelResponseStream, _args: ActivationArgs) {
const chunks: string[] = [];
while (true) {
const { value, done } = await messageStream.next();
if (done) break;
if (value?.data?.typeName === "output_text_delta") {
chunks.push(value.data.text);
}
}
const response = chunks.join("");
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await this.postToWebhook(response);
return new BufferedHttpResponse(200, "");
} catch (error) {
if (attempt === maxRetries) {
console.error("Webhook failed after retries:", error);
await this.fallbackToDatabase(response);
}
await this.sleep(1000 * attempt);
}
}
return new BufferedHttpResponse(200, "");
}
}
3. Include Context in Outputs
class ContextualSlackOutcome extends BaseOutcome {
constructor(private channel: string) {
super();
}
async deliver(messageStream: IModelResponseStream, _args: ActivationArgs) {
const chunks: string[] = [];
while (true) {
const { value, done } = await messageStream.next();
if (done) break;
if (value?.data?.typeName === "output_text_delta") {
chunks.push(value.data.text);
}
}
const response = chunks.join("");
await slackClient.post(this.channel, {
blocks: [
{
type: "header",
text: { type: "plain_text", text: "Agent Report" },
},
{
type: "section",
text: { type: "mrkdwn", text: response },
},
{
type: "context",
elements: [
{ type: "mrkdwn", text: `Generated: ${new Date().toISOString()}` },
],
},
],
});
return new BufferedHttpResponse(200, "");
}
}
Next Steps
- :robot_face: Learn about Models - Configure LLM providers
- ⚡ Review Triggers - Connect triggers to outcomes
- 🔍 See Examples - Real-world outcome usage