Outcomes

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