Webhook + schedule + HITL (complete)
Customer-style automation loop using the expanded public Aiden surface:
- Create a webhook and a schedule against an existing agent
- Trigger the schedule (
/trigger) and the webhook (one-time token) - Resolve pending HITL approvals
- Wait on
subscribeExecution(SSE) and list recent tool calls - Delete the demo webhook and schedule
This is the same shape appcd-e2e uses for unattended webhook/schedule suites,
but expressed through stackgen-sdk so customer code and CI share one client.
How to run
export STACKGEN_URL=https://app.stackgen.com
export STACKGEN_TOKEN=stackgen_…
export STACKGEN_WORKSPACE_ID=<workspace-uuid>
pip install stackgen-sdk==0.2.0
python examples/automation/webhook-schedule-hitl.py --agent my-oncall-agentexport STACKGEN_URL=https://app.stackgen.com
export STACKGEN_TOKEN=stackgen_…
export STACKGEN_WORKSPACE_ID=<workspace-uuid>
npm install stackgen-sdk@0.2.0 tsx
npx tsx examples/automation/webhook-schedule-hitl.ts --agent my-oncall-agentexport STACKGEN_URL=https://app.stackgen.com
export STACKGEN_TOKEN=stackgen_…
export STACKGEN_WORKSPACE_ID=<workspace-uuid>
# From a clone of this repo (go.work includes clients/go)
go run ./examples/go/automation/webhook-schedule-hitl.go --agent my-oncall-agentMinimal
from stackgen import StackgenClient, StackgenConfig
client = StackgenClient(
StackgenConfig(
base_url="https://app.stackgen.com",
api_token="stackgen_…",
workspace_id="<workspace-uuid>",
)
)
wh = client.aiden.create_webhook({
"name": "ops-pager",
"target_type": "agent",
"target_name": "my-oncall-agent",
"action": "Investigate the alert",
"enabled": True,
})
sched = client.aiden.create_schedule({
"name": "ops-hourly",
"target_type": "agent",
"target_name": "my-oncall-agent",
"expression": "0 * * * *",
"action": "Hourly health check",
"enabled": True,
})
client.aiden.trigger_schedule(sched["id"])
# After a run starts:
# client.aiden.wait_for_subscribe_complete(trace_id)
# client.aiden.resolve_approval(approval_id, {"decision": "approve"})import { StackgenClient, StackgenConfig } from "stackgen-sdk";
const client = new StackgenClient(
new StackgenConfig({
baseUrl: "https://app.stackgen.com",
apiToken: "stackgen_…",
workspaceId: "<workspace-uuid>",
}),
);
const wh = await client.aiden.createWebhook({
name: "ops-pager",
target_type: "agent",
target_name: "my-oncall-agent",
action: "Investigate the alert",
enabled: true,
});
const sched = await client.aiden.createSchedule({
name: "ops-hourly",
target_type: "agent",
target_name: "my-oncall-agent",
expression: "0 * * * *",
action: "Hourly health check",
enabled: true,
});
await client.aiden.triggerSchedule(String(sched.id));
// After a run starts:
// await client.aiden.waitForSubscribeComplete(traceId);
// await client.aiden.resolveApproval(approvalId, { decision: "approve" });wh, err := client.Aiden.CreateWebhook(ctx, map[string]any{
"name": "ops-pager",
"target_type": "agent",
"target_name": "my-oncall-agent",
"action": "Investigate the alert",
"enabled": true,
})
if err != nil { return err }
sched, err := client.Aiden.CreateSchedule(ctx, map[string]any{
"name": "ops-hourly",
"target_type": "agent",
"target_name": "my-oncall-agent",
"expression": "0 * * * *",
"action": "Hourly health check",
"enabled": true,
})
if err != nil { return err }
_, err = client.Aiden.TriggerSchedule(ctx, fmt.Sprint(sched["id"]))
_ = wh
// After a run starts:
// client.Aiden.SubscribeExecution(ctx, traceID, func(aiden.ExecutionEvent) error { return nil })
// client.Aiden.ResolveApproval(ctx, approvalID, true)
return errComplete scripts
- Python:
examples/automation/webhook-schedule-hitl.py - TypeScript:
examples/automation/webhook-schedule-hitl.ts - Go:
examples/go/automation/webhook-schedule-hitl.go
#!/usr/bin/env python3
"""Real-world Aiden automation: webhook + schedule + HITL resolve + SSE wait.
Creates a short-lived webhook and a never-ticking schedule against an existing
agent, triggers both paths, resolves any pending approvals, and waits on the
execution SSE stream until complete.
Requires STACKGEN_URL, STACKGEN_TOKEN, STACKGEN_WORKSPACE_ID, and an agent name.
Usage:
export STACKGEN_URL=https://app.stackgen.com
export STACKGEN_TOKEN=stackgen_…
export STACKGEN_WORKSPACE_ID=<workspace-uuid>
python examples/automation/webhook-schedule-hitl.py --agent my-oncall-agent
# Empty CI workspaces: prefer a published workflow (agents need an integration).
python examples/automation/webhook-schedule-hitl.py --workflow my-note-workflow
"""
from __future__ import annotations
import argparse
import os
import sys
import uuid
from stackgen import StackgenClient, StackgenConfig
def _approval_for_run(item: dict, session_id: str, trace_id: str) -> bool:
if not isinstance(item, dict):
return False
keys = (
"session_id",
"sessionId",
"trace_id",
"traceId",
"execution_id",
"executionId",
)
values: set[str] = set()
def collect(obj: dict) -> None:
for key in keys:
value = str(obj.get(key) or "").strip()
if value:
values.add(value)
collect(item)
for nested_key in ("execution", "run", "session"):
nested = item.get(nested_key)
if isinstance(nested, dict):
collect(nested)
if session_id and session_id in values:
return True
return bool(trace_id and trace_id in values)
def env(name: str, *fallbacks: str) -> str:
for key in (name, *fallbacks):
value = (os.environ.get(key) or "").strip()
if value:
return value
raise SystemExit(f"missing env {name}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
target = parser.add_mutually_exclusive_group(required=True)
target.add_argument("--agent", help="Existing agent name to target")
target.add_argument("--workflow", help="Existing workflow name to target")
parser.add_argument(
"--message",
default="e2e: investigate checkout latency spike",
help="Payload / schedule action text",
)
parser.add_argument("--timeout-seconds", type=int, default=600)
args = parser.parse_args()
target_type = "agent" if args.agent else "workflow"
target_name = args.agent or args.workflow
client = StackgenClient(
StackgenConfig(
base_url=env("STACKGEN_URL"),
api_token=env("STACKGEN_TOKEN"),
workspace_id=env("STACKGEN_WORKSPACE_ID", "STACKGEN_PROJECT"),
timeout_seconds=args.timeout_seconds,
)
)
suffix = uuid.uuid4().hex[:8]
webhook_id = ""
schedule_id = ""
trace_id = ""
session_id = ""
try:
# 1) Create webhook (token only returned on create)
wh = client.aiden.create_webhook(
{
"name": f"sdk-demo-wh-{suffix}",
"target_type": target_type,
"target_name": target_name,
"action": args.message,
"enabled": True,
}
)
webhook_id = str(wh.get("id") or "")
token = str(wh.get("token") or "")
print(f"created webhook id={webhook_id}")
# 2) Create a schedule that never ticks on its own; we /trigger it
sched = client.aiden.create_schedule(
{
"name": f"sdk-demo-sched-{suffix}",
"target_type": target_type,
"target_name": target_name,
"expression": "0 0 1 1 *",
"action": args.message,
"enabled": True,
}
)
schedule_id = str(sched.get("id") or "")
print(f"created schedule id={schedule_id}")
# 3) Trigger schedule (same InternalTask path as cron)
trig = client.aiden.trigger_schedule(schedule_id)
print(f"schedule trigger: {trig}")
# 4) Trigger webhook with the one-time token
if token:
client_wh = StackgenClient(
StackgenConfig(
base_url=env("STACKGEN_URL"),
api_token=env("STACKGEN_TOKEN"),
workspace_id=env("STACKGEN_WORKSPACE_ID", "STACKGEN_PROJECT"),
webhook_token=token,
timeout_seconds=args.timeout_seconds,
)
)
started = client_wh.aiden.trigger_webhook(args.message)
print(f"webhook trigger: {started}")
webhook_id = str(started.get("webhook_id") or webhook_id)
invocation = str(started.get("invocation_id") or "")
if webhook_id and invocation:
session_id = client_wh.aiden.wait_for_webhook_session(
webhook_id, invocation
)
print(f"webhook session_id={session_id}")
runs = client.aiden.list_executions(session_id=session_id, limit=1)
if runs:
trace_id = str(runs[0].get("trace_id") or runs[0].get("id") or "")
if not trace_id and schedule_id:
runs = client.aiden.list_schedule_runs(schedule_id)
for run in runs:
tid = str(run.get("trace_id") or "")
if tid:
trace_id = tid
break
# Resolve only approvals that belong to this run.
pending = client.aiden.list_approvals(status="pending")
for item in pending:
if not _approval_for_run(item, session_id, trace_id):
continue
approval_id = str(item.get("id") or "")
if not approval_id:
continue
resolved = client.aiden.resolve_approval(
approval_id, {"decision": "approve", "comment": "sdk demo auto-approve"}
)
print(f"resolved approval {approval_id}: {resolved.get('status')}")
if not trace_id:
print("no trace_id yet; check the Aiden UI for the run", file=sys.stderr)
return 1
subscribe_ok = False
event_count = 0
def _fallback_execution(reason: str) -> None:
nonlocal subscribe_ok
print(
f"subscribe incomplete ({reason}); waiting on execution status",
file=sys.stderr,
)
trace = client.aiden.wait_for_execution(trace_id)
execution = trace.get("execution") if isinstance(trace, dict) else {}
if not isinstance(execution, dict):
execution = {}
status = str(execution.get("status") or "")
subscribe_ok = status in {"completed", "success", "succeeded"}
print(f"subscribe complete ok={subscribe_ok} events={event_count}")
print(f"execution status={status}")
try:
result = client.aiden.wait_for_subscribe_complete(
trace_id, timeout_ms=args.timeout_seconds * 1000
)
subscribe_ok = bool(result.ok)
event_count = int(result.event_count)
print(f"subscribe complete ok={result.ok} events={result.event_count}")
if not subscribe_ok:
_fallback_execution(str(getattr(result, "raw_snippet", "") or "ok=False"))
except Exception as err: # noqa: BLE001
message = str(err)
if not any(
token in message.lower()
for token in ("timed out", "subscribeexecution", "aborted", "eof")
):
raise
_fallback_execution(message)
tools = client.aiden.list_execution_tool_calls(trace_id, limit=5)
print(f"recent tool calls: {len(tools)}")
if not subscribe_ok:
return 1
return 0
finally:
# Cleanup demo resources
if schedule_id:
try:
client.aiden.delete_schedule(schedule_id)
print(f"deleted schedule {schedule_id}")
except Exception as err: # noqa: BLE001
print(f"schedule cleanup: {err}", file=sys.stderr)
if webhook_id:
try:
client.aiden.delete_webhook(webhook_id)
print(f"deleted webhook {webhook_id}")
except Exception as err: # noqa: BLE001
print(f"webhook cleanup: {err}", file=sys.stderr)
if __name__ == "__main__":
raise SystemExit(main())/**
* Real-world Aiden automation: webhook + schedule + HITL resolve + SSE wait.
*
* Creates a short-lived webhook and a never-ticking schedule against an existing
* agent, triggers both paths, resolves pending approvals, waits on SSE.
*
* Usage:
* export STACKGEN_URL=https://app.stackgen.com
* export STACKGEN_TOKEN=stackgen_…
* export STACKGEN_WORKSPACE_ID=<workspace-uuid>
* npx tsx examples/automation/webhook-schedule-hitl.ts --agent my-oncall-agent
* # Empty CI workspaces: prefer a published workflow (agents need an integration).
* npx tsx examples/automation/webhook-schedule-hitl.ts --workflow my-note-workflow
*/
import { parseArgs } from "node:util";
import { randomUUID } from "node:crypto";
import { StackgenClient, StackgenConfig, StackgenError } from "stackgen-sdk";
function approvalForRun(
item: Record<string, unknown>,
sessionId: string,
traceId: string,
): boolean {
const keys = [
"session_id",
"sessionId",
"trace_id",
"traceId",
"execution_id",
"executionId",
];
const values = new Set<string>();
const collect = (obj: Record<string, unknown>) => {
for (const key of keys) {
const value = String(obj[key] ?? "").trim();
if (value) values.add(value);
}
};
collect(item);
for (const nestedKey of ["execution", "run", "session"]) {
const nested = item[nestedKey];
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
collect(nested as Record<string, unknown>);
}
}
return Boolean((sessionId && values.has(sessionId)) || (traceId && values.has(traceId)));
}
function requireEnv(name: string, ...fallbacks: string[]): string {
for (const key of [name, ...fallbacks]) {
const value = (process.env[key] || "").trim();
if (value) {
return value;
}
}
throw new Error(`missing env ${name}`);
}
async function main(): Promise<void> {
const { values } = parseArgs({
options: {
agent: { type: "string" },
workflow: { type: "string" },
message: {
type: "string",
default: "e2e: investigate checkout latency spike",
},
"timeout-seconds": { type: "string", default: "600" },
},
});
if (Boolean(values.agent) === Boolean(values.workflow)) {
throw new Error("require exactly one of --agent or --workflow");
}
const targetType = values.agent ? "agent" : "workflow";
const targetName = values.agent || values.workflow!;
const timeoutSeconds = Number(values["timeout-seconds"]);
const base = {
baseUrl: requireEnv("STACKGEN_URL"),
apiToken: requireEnv("STACKGEN_TOKEN"),
workspaceId: requireEnv("STACKGEN_WORKSPACE_ID", "STACKGEN_PROJECT"),
timeoutSeconds,
};
const client = new StackgenClient(new StackgenConfig(base));
const suffix = randomUUID().slice(0, 8);
let webhookId = "";
let scheduleId = "";
let traceId = "";
let sessionId = "";
try {
const wh = await client.aiden.createWebhook({
name: `sdk-demo-wh-${suffix}`,
target_type: targetType,
target_name: targetName,
action: values.message,
enabled: true,
});
webhookId = String(wh.id ?? "");
const token = String(wh.token ?? "");
console.log(`created webhook id=${webhookId}`);
const sched = await client.aiden.createSchedule({
name: `sdk-demo-sched-${suffix}`,
target_type: targetType,
target_name: targetName,
expression: "0 0 1 1 *",
action: values.message,
enabled: true,
});
scheduleId = String(sched.id ?? "");
console.log(`created schedule id=${scheduleId}`);
const trig = await client.aiden.triggerSchedule(scheduleId);
console.log("schedule trigger:", JSON.stringify(trig));
if (token) {
const whClient = new StackgenClient(
new StackgenConfig({ ...base, webhookToken: token }),
);
const started = await whClient.aiden.triggerWebhook(values.message!);
console.log("webhook trigger:", JSON.stringify(started));
webhookId = String(started.webhook_id ?? webhookId);
const invocation = String(started.invocation_id ?? "");
if (webhookId && invocation) {
sessionId = await whClient.aiden.waitForWebhookSession(
webhookId,
invocation,
);
console.log(`webhook session_id=${sessionId}`);
const runs = await client.aiden.listExecutions({
session_id: sessionId,
limit: 1,
});
if (runs[0]) {
traceId = String(runs[0].trace_id ?? runs[0].id ?? "");
}
}
}
if (!traceId && scheduleId) {
const runs = await client.aiden.listScheduleRuns(scheduleId);
for (const run of runs) {
const tid = String(run.trace_id ?? "");
if (tid) {
traceId = tid;
break;
}
}
}
const pending = await client.aiden.listApprovals({ status: "pending" });
for (const item of pending) {
if (!approvalForRun(item, sessionId, traceId)) continue;
const approvalId = String(item.id ?? "");
if (!approvalId) continue;
const resolved = await client.aiden.resolveApproval(approvalId, {
decision: "approve",
comment: "sdk demo auto-approve",
});
console.log(`resolved approval ${approvalId}: ${resolved.status}`);
}
if (!traceId) {
throw new Error("no trace_id yet; check the Aiden UI for the run");
}
let subscribeOk = false;
let eventCount = 0;
const fallbackExecution = async (reason: string) => {
console.error(`subscribe incomplete (${reason}); waiting on execution status`);
const trace = await client.aiden.waitForExecution(traceId);
const execution = (trace.execution ?? {}) as Record<string, unknown>;
const status = String(execution.status ?? "");
subscribeOk = ["completed", "success", "succeeded"].includes(status);
console.log(`subscribe complete ok=${subscribeOk} events=${eventCount}`);
console.log(`execution status=${status}`);
};
try {
const result = await client.aiden.waitForSubscribeComplete(traceId, {
timeoutMs: timeoutSeconds * 1000,
});
subscribeOk = result.ok;
eventCount = result.eventCount;
console.log(
`subscribe complete ok=${result.ok} events=${result.eventCount}`,
);
if (!subscribeOk) {
await fallbackExecution(String(result.rawSnippet || "ok=false"));
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!/timed out|subscribeExecution|aborted|eof/i.test(message)) {
throw err;
}
await fallbackExecution(message);
}
const tools = await client.aiden.listExecutionToolCalls(traceId, {
limit: 5,
});
console.log(`recent tool calls: ${tools.length}`);
if (!subscribeOk) {
throw new Error(`subscribe complete ok=${subscribeOk}`);
}
} finally {
if (scheduleId) {
try {
await client.aiden.deleteSchedule(scheduleId);
console.log(`deleted schedule ${scheduleId}`);
} catch (err) {
console.error("schedule cleanup:", err);
}
}
if (webhookId) {
try {
await client.aiden.deleteWebhook(webhookId);
console.log(`deleted webhook ${webhookId}`);
} catch (err) {
console.error("webhook cleanup:", err);
}
}
}
}
main().catch((err) => {
console.error(err instanceof StackgenError ? err.message : err);
process.exit(1);
});// Create a webhook and an hourly schedule, trigger the schedule once, then delete both.
//
// Documented at https://appcd-dev.github.io/stackgen-sdk/code-samples/webhook-schedule-hitl/
//
// go run ./examples/go/automation/webhook-schedule-hitl.go --agent my-oncall-agent
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
stackgen "github.com/appcd-dev/stackgen-sdk/clients/go"
)
// envOr returns the first non-empty environment variable among keys.
func envOr(keys ...string) string {
for _, key := range keys {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
}
return ""
}
func main() {
agent := flag.String("agent", "", "Existing agent name")
workflow := flag.String("workflow", "", "Existing workflow name")
flag.Parse()
if (*agent == "") == (*workflow == "") {
fmt.Fprintln(os.Stderr, "pass exactly one of --agent or --workflow")
os.Exit(2)
}
base, token, org := envOr("STACKGEN_URL"), envOr("STACKGEN_TOKEN"), envOr("STACKGEN_WORKSPACE_ID", "STACKGEN_PROJECT")
if base == "" || token == "" || org == "" {
fmt.Fprintln(os.Stderr, "set STACKGEN_URL, STACKGEN_TOKEN, and STACKGEN_WORKSPACE_ID")
os.Exit(2)
}
targetType, targetName := "agent", *agent
if *workflow != "" {
targetType, targetName = "workflow", *workflow
}
client, err := stackgen.NewClient(stackgen.Config{BaseURL: base, APIToken: token, WorkspaceID: org})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
ctx := context.Background()
wh, err := client.Aiden.CreateWebhook(ctx, map[string]any{
"name": "ops-pager",
"target_type": targetType,
"target_name": targetName,
"action": "Investigate the alert",
"enabled": true,
})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
webhookID, _ := wh["id"].(string)
defer func() {
if webhookID != "" {
_, _ = client.Aiden.DeleteWebhook(context.Background(), webhookID)
}
}()
sched, err := client.Aiden.CreateSchedule(ctx, map[string]any{
"name": "ops-hourly",
"target_type": targetType,
"target_name": targetName,
"expression": "0 * * * *",
"action": "Hourly health check",
"enabled": true,
})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
scheduleID, _ := sched["id"].(string)
defer func() {
if scheduleID != "" {
_, _ = client.Aiden.DeleteSchedule(context.Background(), scheduleID)
}
}()
if _, err := client.Aiden.TriggerSchedule(ctx, scheduleID); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Printf("webhook=%s schedule=%s triggered\n", webhookID, scheduleID)
}