Ask → subscribe → session artifacts
You ask Aiden to do a job in plain language. When the run finishes, downloadable files show up on the session. This sample uses the public SDK step by step:
startAsk/start_askwaitForSubscribeComplete/wait_for_subscribe_completedownloadSessionArtifacts/download_session_artifactsinto a directory
Use this when you need all notes from a session, not one hard-coded
filename. The helper lists metadata, sanitizes filenames, and downloads every
named file. Prefer the single-file helpers only when you already know the name.
The one-call helper
Ask → artifact
is enough when you only want session-report.md.
Ask journeys use the API token only (no webhook token).
When you need this
- Operator follow-through. You ask Aiden to review recent org changes, leave
a downloadable note, and optionally publish a Grafana dashboard when your
integrations allow it. The note is a session artifact; a live dashboard may
show up as a
console_urlin that note or in the run write-up. - Product changelog. You ask for an end-user note in Keep a Changelog form (Added, Changed, Deprecated, Removed, Fixed, Security). Download the markdown, keep it in CI artifacts, optionally ask for a Slack summary or a draft docs PR in the same message. Those side effects are part of the Ask; the SDK only starts, waits, and downloads.
- Any multi-file session. Triage that ships both a markdown note and a JSON bundle. List first, then download what you care about.
Phrase Asks in ordinary language. Do not name internal tool ids in the message.
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.4.0rc11
python examples/ask/ask-session-artifacts.py \
--message 'Look at recent org changes and leave me a markdown note I can download' \
--out-dir ./out/session-artifactsexport STACKGEN_URL=https://app.stackgen.com
export STACKGEN_TOKEN=stackgen_…
export STACKGEN_WORKSPACE_ID=<workspace-uuid>
npm install stackgen-sdk@0.4.0-rc.11 tsx
npx tsx examples/ask/ask-session-artifacts.ts \
--message 'Look at recent org changes and leave me a markdown note I can download' \
--out-dir ./out/session-artifactsexport STACKGEN_URL=https://app.stackgen.com
export STACKGEN_TOKEN=stackgen_…
export STACKGEN_WORKSPACE_ID=<workspace-uuid>
go run ./examples/go/ask/ask-session-artifacts.go \
--message 'Look at recent org changes and leave me a markdown note I can download' \
--output-dir ./out/session-artifactsOptional pins: --agent my-ops-agent or --workflow-name incident-triage.
Minimal
from pathlib import Path
from stackgen import StackgenClient, StackgenConfig
client = StackgenClient(
StackgenConfig(
base_url="https://app.stackgen.com",
api_token="stackgen_…",
workspace_id="<workspace-uuid>",
)
)
start = client.aiden.start_ask(
"Look at recent changes in our GitHub org. "
"Leave me a short markdown note I can download.",
entity_refs=[{"name": "my-ops-agent", "type": "agent"}],
)
session_id = start["session_id"]
trace_id = start["trace_id"]
done = client.aiden.wait_for_subscribe_complete(trace_id)
assert done.ok
downloaded = client.aiden.download_session_artifacts(
session_id,
"session-artifacts",
)
# Each entry is {"name", "path", "meta"}.
print([row["path"] for row in downloaded])import { StackgenClient, StackgenConfig } from "stackgen-sdk";
const client = new StackgenClient(
new StackgenConfig({
baseUrl: "https://app.stackgen.com",
apiToken: "stackgen_…",
workspaceId: "<workspace-uuid>",
}),
);
const start = await client.aiden.startAsk(
"Look at recent changes in our GitHub org. "
+ "Leave me a short markdown note I can download.",
{ entityRefs: [{ name: "my-ops-agent", type: "agent" }] },
);
const sessionId = String(start.session_id);
const traceId = String(start.trace_id);
const done = await client.aiden.waitForSubscribeComplete(traceId);
if (!done.ok) {
throw new Error("subscribe did not complete");
}
const downloaded = await client.aiden.downloadSessionArtifacts(
sessionId,
"session-artifacts",
);
// Each entry is { name, path, meta }.
console.log(downloaded.map((row) => row.path));start, err := client.Aiden.StartAsk(ctx,
"Look at recent changes in our GitHub org. "+
"Leave me a short markdown note I can download.",
aiden.StartAskOptions{
EntityRefs: []aiden.EntityRef{{Name: "my-ops-agent", Type: "agent"}},
},
)
if err != nil { return err }
if err = client.Aiden.SubscribeExecution(ctx, start.TraceID, func(aiden.ExecutionEvent) error {
return nil
}); err != nil { return err }
downloaded, err := client.Aiden.DownloadSessionArtifacts(ctx, start.SessionID, "session-artifacts")
if err != nil { return err }
for _, row := range downloaded {
fmt.Println(row.Path)
}
return nilKeep a Changelog notes
Ask for sections from Keep a Changelog. Example message shape:
Write an end-user product changelog for last week's changes.
Use Keep a Changelog sections: Added, Changed, Deprecated, Removed, Fixed, and Security.
Skip empty sections. Credit authors when you can, and include PR links.
Leave that changelog as a markdown note I can download.
If Slack is available, post a short summary to #engineering.
If public docs would help, open a draft PR under docs/release-notes (do not merge).
After download, treat the file as ordinary markdown. The runnable scripts print which of those section headings appear. A minimal check:
import re
SECTIONS = ("Added", "Changed", "Deprecated", "Removed", "Fixed", "Security")
def keep_a_changelog_hits(text: str) -> list[str]:
return [
s for s in SECTIONS
if re.search(r"^#{1,3}\s*" + re.escape(s) + r"\b", text, flags=re.M | re.I)
]const SECTIONS = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"];
function keepAChangelogHits(text: string): string[] {
return SECTIONS.filter((section) =>
new RegExp(`^#{1,3}\\s*${section}\\b`, "im").test(text),
);
}var sections = []string{"Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"}
func keepAChangelogHits(text string) []string {
var hits []string
for _, section := range sections {
matched, _ := regexp.MatchString("(?im)^#{1,3}\\s*"+regexp.QuoteMeta(section)+`\b`, text)
if matched {
hits = append(hits, section)
}
}
return hits
}Empty weeks are fine. The note should say the window was quiet instead of inventing entries.
Operator follow-through and dashboards
Same SDK path. Change the Ask, not the client calls:
Look at what changed in our GitHub org since yesterday. You have Grafana too.
If those changes already have metrics, make a dashboard I can open, or update
one we already have if it fits. If something important shipped and there is
nothing to graph, and a small draft PR to add metrics would help, open one.
Leave me a short note I can download.
When a dashboard is published under your Grafana integration, published boards
often land in an aiden-managed folder and the note (or run text) can include a
console_url. The example scripts surface matching URLs from downloaded text.
If no dashboard is appropriate, the downloadable note still completes the
journey.
Complete scripts
- Python:
examples/ask/ask-session-artifacts.py - TypeScript:
examples/ask/ask-session-artifacts.ts - Go:
examples/go/ask/ask-session-artifacts.go
#!/usr/bin/env python3
"""Ask → wait on subscribe → list and download session artifacts.
Use when you want every downloadable note from a finished Ask (operator
follow-through, product changelog, triage reports), not a single named file.
Docs: https://appcd-dev.github.io/stackgen-sdk/code-samples/ask-session-artifacts/
Usage:
export STACKGEN_URL=https://app.stackgen.com
export STACKGEN_TOKEN=stackgen_…
export STACKGEN_WORKSPACE_ID=<workspace-uuid>
python examples/ask/ask-session-artifacts.py \\
--message 'Look at recent org changes and leave me a markdown note I can download'
# optional pin:
# --agent my-ops-agent
# --out-dir ./out/session-artifacts
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
from stackgen import StackgenClient, StackgenConfig, TimeoutError as SdkTimeoutError
# Keep a Changelog section titles (https://keepachangelog.com/).
KEEP_A_CHANGELOG_SECTIONS = (
"Added",
"Changed",
"Deprecated",
"Removed",
"Fixed",
"Security",
)
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 looks_like_text(name: str, data: bytes) -> bool:
lower = name.lower()
if re.search(r"\.(md|txt|markdown|json)$", lower):
return True
sample = data[:512].decode("utf-8", errors="replace")
return bool(sample) and "\0" not in sample
def keep_a_changelog_hits(text: str) -> list[str]:
hits: list[str] = []
for section in KEEP_A_CHANGELOG_SECTIONS:
if re.search(rf"^#{{1,3}}\s*{re.escape(section)}\b", text, flags=re.M | re.I):
hits.append(section)
return hits
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--message",
default=(
"Look at recent changes in our GitHub org. Leave me a short "
"markdown note I can download covering what you found."
),
help="Natural-language Ask message",
)
parser.add_argument("--agent", help="Pin an agent via entity_refs")
parser.add_argument("--workflow-name", help="Pin a workflow via entity_refs")
parser.add_argument(
"--out-dir",
default="session-artifacts",
help="Directory for downloaded artifact files",
)
parser.add_argument(
"--timeout-ms",
type=int,
default=30 * 60 * 1000,
help="Subscribe wait budget in milliseconds",
)
args = parser.parse_args()
client = StackgenClient(
StackgenConfig(
base_url=env("STACKGEN_URL"),
api_token=env("STACKGEN_TOKEN"),
workspace_id=env("STACKGEN_WORKSPACE_ID", "STACKGEN_PROJECT"),
timeout_seconds=max(30, args.timeout_ms // 1000),
)
)
entity_refs: list[dict[str, str]] = []
if args.agent:
entity_refs.append({"name": args.agent, "type": "agent"})
if args.workflow_name:
entity_refs.append({"name": args.workflow_name, "type": "workflow"})
session_id = ""
try:
start = client.aiden.start_ask(
args.message,
entity_refs=entity_refs or None,
)
session_id = str(start.get("session_id") or "")
trace_id = str(start.get("trace_id") or "")
if not session_id or not trace_id:
raise SystemExit(f"start_ask missing session_id/trace_id: {start}")
print(f"started session_id={session_id} trace_id={trace_id}", file=sys.stderr)
try:
done = client.aiden.wait_for_subscribe_complete(
trace_id,
timeout_ms=args.timeout_ms,
)
if not done.ok:
print(
f"subscribe incomplete ({done}); waiting on execution status",
file=sys.stderr,
)
trace = client.aiden.wait_for_execution(trace_id)
status = str((trace.get("execution") or {}).get("status") or "")
print(f"execution status={status}", file=sys.stderr)
else:
print(f"subscribe complete ok={done.ok}", file=sys.stderr)
except SdkTimeoutError as exc:
print(
f"subscribe timed out ({exc}); waiting on execution status",
file=sys.stderr,
)
trace = client.aiden.wait_for_execution(trace_id)
status = str((trace.get("execution") or {}).get("status") or "")
print(f"execution status={status}", file=sys.stderr)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
downloaded = client.aiden.download_session_artifacts(session_id, str(out_dir))
for file in downloaded:
print(
f"downloaded {file.get('name')} -> {file.get('path')}",
file=sys.stderr,
)
if not downloaded:
export = client.aiden.export_session(session_id, parts="report")
parts = export.get("parts") or []
report = next(
(
str(part.get("content") or "")
for part in parts
if isinstance(part, dict) and part.get("kind") == "report"
),
"",
)
if not report.strip():
raise SystemExit(
f"no session artifacts and no export report for session_id={session_id}"
)
dest = out_dir / "session-report.md"
dest.write_text(report, encoding="utf-8")
downloaded.append(
{"name": "session-report.md", "path": str(dest), "meta": {}}
)
print(f"wrote export report -> {dest}", file=sys.stderr)
text_notes: list[str] = []
for file in downloaded:
data = Path(file["path"]).read_bytes()
if not looks_like_text(file["name"], data):
continue
text = data.decode("utf-8", errors="replace").strip()
if not text:
continue
text_notes.append(text)
sections = keep_a_changelog_hits(text)
if sections:
print(
f"{file['name']}: Keep a Changelog sections found: "
+ ", ".join(sections),
file=sys.stderr,
)
if text_notes:
combined = out_dir / "combined-note.md"
combined.write_text("\n\n---\n\n".join(text_notes), encoding="utf-8")
print(f"wrote {combined}", file=sys.stderr)
blob = "\n".join(text_notes)
urls = re.findall(r"https?://[^\s)\]>]+", blob)
console_urls = [
u for u in urls if re.search(r"grafana|console_url|/d/", u, flags=re.I)
][:5]
print(
json.dumps(
{
"session_id": session_id,
"trace_id": trace_id,
"artifacts": downloaded,
"console_urls": console_urls,
},
indent=2,
)
)
return 0
finally:
if session_id:
try:
client.aiden.terminate_session(session_id, reason="sdk example finished")
except Exception as exc: # noqa: BLE001
print(f"terminate session {session_id}: {exc}", file=sys.stderr)
if __name__ == "__main__":
raise SystemExit(main())/**
* Ask → wait on subscribe → list and download session artifacts.
*
* Use when you want every downloadable note from a finished Ask (operator
* follow-through, product changelog, triage reports), not a single named file.
*
* Docs: https://appcd-dev.github.io/stackgen-sdk/code-samples/ask-session-artifacts/
*
* Usage:
* export STACKGEN_URL=https://app.stackgen.com
* export STACKGEN_TOKEN=stackgen_…
* export STACKGEN_WORKSPACE_ID=<workspace-uuid>
* npx tsx examples/ask/ask-session-artifacts.ts \
* --message 'Look at recent org changes and leave me a markdown note I can download'
* # optional pin:
* # --agent my-ops-agent
* # --out-dir ./out/session-artifacts
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { parseArgs } from "node:util";
import { StackgenClient, StackgenConfig } from "stackgen-sdk";
/** Keep a Changelog section titles (https://keepachangelog.com/). */
const KEEP_A_CHANGELOG_SECTIONS = [
"Added",
"Changed",
"Deprecated",
"Removed",
"Fixed",
"Security",
] as const;
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}`);
}
function looksLikeText(name: string, bytes: Buffer): boolean {
const lower = name.toLowerCase();
if (/\.(md|txt|markdown|json)$/i.test(lower)) {
return true;
}
const sample = bytes.subarray(0, Math.min(bytes.length, 512)).toString("utf8");
return sample.length > 0 && !sample.includes("\0");
}
function keepAChangelogHits(text: string): string[] {
return KEEP_A_CHANGELOG_SECTIONS.filter((section) => {
const re = new RegExp(`^#{1,3}\\s*${section}\\b`, "im");
return re.test(text);
});
}
async function main(): Promise<void> {
const { values } = parseArgs({
options: {
message: {
type: "string",
default:
"Look at recent changes in our GitHub org. Leave me a short markdown note I can download covering what you found.",
},
agent: { type: "string" },
"workflow-name": { type: "string" },
"out-dir": { type: "string", default: "session-artifacts" },
"timeout-ms": { type: "string", default: String(30 * 60 * 1000) },
},
});
const timeoutMs = Number(values["timeout-ms"]);
const client = new StackgenClient(
new StackgenConfig({
baseUrl: requireEnv("STACKGEN_URL"),
apiToken: requireEnv("STACKGEN_TOKEN"),
workspaceId: requireEnv("STACKGEN_WORKSPACE_ID", "STACKGEN_PROJECT"),
timeoutSeconds: Math.max(30, Math.floor(timeoutMs / 1000)),
}),
);
const entityRefs: Array<{ name: string; type: string }> = [];
if (values.agent) {
entityRefs.push({ name: values.agent, type: "agent" });
}
if (values["workflow-name"]) {
entityRefs.push({ name: values["workflow-name"]!, type: "workflow" });
}
let sessionId = "";
try {
const start = await client.aiden.startAsk(values.message!, {
entityRefs: entityRefs.length ? entityRefs : undefined,
});
sessionId = String(start.session_id ?? "");
const traceId = String(start.trace_id ?? "");
if (!sessionId || !traceId) {
throw new Error(`startAsk missing session_id/trace_id: ${JSON.stringify(start)}`);
}
console.error(`started session_id=${sessionId} trace_id=${traceId}`);
try {
const done = await client.aiden.waitForSubscribeComplete(traceId, {
timeoutMs,
});
if (!done.ok) {
console.error(
`subscribe incomplete (${JSON.stringify(done)}); waiting on execution status`,
);
const trace = await client.aiden.waitForExecution(traceId);
const execution = (trace.execution ?? {}) as Record<string, unknown>;
console.error(`execution status=${execution.status ?? ""}`);
} else {
console.error(`subscribe complete ok=${done.ok}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!/timed out|subscribeExecution|aborted|eof/i.test(message)) {
throw err;
}
console.error(`subscribe timed out (${message}); waiting on execution status`);
const trace = await client.aiden.waitForExecution(traceId);
const execution = (trace.execution ?? {}) as Record<string, unknown>;
console.error(`execution status=${execution.status ?? ""}`);
}
const outDir = values["out-dir"]!;
mkdirSync(outDir, { recursive: true });
const downloaded = await client.aiden.downloadSessionArtifacts(sessionId, outDir);
for (const file of downloaded) {
console.error(`downloaded ${file.name} → ${file.path}`);
}
if (!downloaded.length) {
const exported = await client.aiden.exportSession(sessionId, { parts: "report" });
const parts = Array.isArray(exported.parts) ? exported.parts : [];
const report = parts.find(
(part) =>
part &&
typeof part === "object" &&
(part as Record<string, unknown>).kind === "report",
) as Record<string, unknown> | undefined;
const content = String(report?.content ?? "").trim();
if (!content) {
throw new Error(
`no session artifacts and no export report for session_id=${sessionId}`,
);
}
const dest = join(outDir, "session-report.md");
writeFileSync(dest, content, "utf8");
downloaded.push({ name: "session-report.md", path: dest, meta: {} });
console.error(`wrote export report → ${dest}`);
}
const textNotes: string[] = [];
for (const file of downloaded) {
const bytes = readFileSync(file.path);
if (!looksLikeText(file.name, bytes)) {
continue;
}
const text = bytes.toString("utf8").trim();
if (!text) {
continue;
}
textNotes.push(text);
const sections = keepAChangelogHits(text);
if (sections.length) {
console.error(
`${file.name}: Keep a Changelog sections found: ${sections.join(", ")}`,
);
}
}
if (textNotes.length) {
const combined = join(outDir, "combined-note.md");
writeFileSync(combined, textNotes.join("\n\n---\n\n"), "utf8");
console.error(`wrote ${combined}`);
}
console.log(
JSON.stringify(
{
sessionId,
traceId,
artifacts: downloaded,
consoleUrls: textNotes
.join("\n")
.match(/https?:\/\/[^\s)\]>]+/g)
?.filter((u) => /grafana|console_url|d\//i.test(u))
.slice(0, 5),
},
null,
2,
),
);
} finally {
if (sessionId) {
try {
await client.aiden.terminateSession(sessionId, { reason: "sdk example finished" });
} catch (err) {
console.error(`terminate session ${sessionId}:`, err);
}
}
}
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});// Start Ask, follow the execution stream, then download every session artifact.
//
// Documented at https://appcd-dev.github.io/stackgen-sdk/code-samples/ask-session-artifacts/
//
// go run ./examples/go/ask/ask-session-artifacts.go --agent my-ops-agent
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"time"
stackgen "github.com/appcd-dev/stackgen-sdk/clients/go"
"github.com/appcd-dev/stackgen-sdk/clients/go/aiden"
)
// 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() {
message := flag.String("message", "Look at recent changes in our GitHub org. Leave me a short markdown note I can download.", "Ask message")
agent := flag.String("agent", "", "Pin an agent via entity_refs")
workflowName := flag.String("workflow-name", "", "Pin a workflow via entity_refs")
outputDir := flag.String("output-dir", "session-artifacts", "Directory for downloaded artifacts")
timeoutSeconds := flag.Int("timeout-seconds", 600, "Subscribe timeout")
flag.Parse()
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)
}
var refs []aiden.EntityRef
if strings.TrimSpace(*agent) != "" {
refs = append(refs, aiden.EntityRef{Name: *agent, Type: "agent"})
}
if strings.TrimSpace(*workflowName) != "" {
refs = append(refs, aiden.EntityRef{Name: *workflowName, Type: "workflow"})
}
client, err := stackgen.NewClient(stackgen.Config{BaseURL: base, APIToken: token, WorkspaceID: org, TimeoutSeconds: *timeoutSeconds})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSeconds)*time.Second)
defer cancel()
started, err := client.Aiden.StartAsk(ctx, *message, aiden.StartAskOptions{EntityRefs: refs})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
if err := client.Aiden.SubscribeExecution(ctx, started.TraceID, func(aiden.ExecutionEvent) error { return nil }); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
downloaded, err := client.Aiden.DownloadSessionArtifacts(ctx, started.SessionID, *outputDir)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
for _, row := range downloaded {
fmt.Println(row.Path)
}
}Related
- Ask → artifact — one helper call for a single named file
- Ask → execution — wait only, no download
- Ask mid-turn steer — follow-ups on the same session
- Async Ask worker — non-blocking start + later poll