Building Workflows
Seshat workflows are not a separate graph engine bolted on top of the runtime. They are runtime-native executions built from the same SDK session model, then wrapped with scheduling, retries, sinks, and execution state when you need automation discipline.
What “workflow” means here
In Seshat today, a workflow is best understood as one controlled mission executed inside one fresh runtime session. That makes it ideal for recurring research, maintenance, compliance, reporting, alert triage, or other automation jobs where each run should be isolated and observable.
This layer is not a DAG designer or a node-graph DSL. If you need a clean per-run execution contract over the runtime, use pkg/automation. If you need a durable multi-turn or multi-agent product loop, stay closer to pkg/sdk or the mailbox runtime.
Workflow Architecture Map
The package split matters. Workflow carries business intent, Executor carries operational control, and Runner is the bridge back into the normal mono-run runtime.
A cron job, queue worker, operator tool, or internal backend decides that a workflow should run now.
Business parameters live in the workflow struct. Cross-cutting execution controls live in automation.Options.
The same executor can be fired manually or behind Every, Once, and Cron schedules.
Executor is the production shell around the actual workflow call. It adds retries, timeout policy, logging, result fan-out, and persistent run counters without changing the workflow code itself.
Runner builds a new SDK client for each execution so model overrides and prompt replacement stay isolated.
The runner still enters the normal Seshat runtime: provider client, prompt stack, tool surface, and response streaming.
A new session is created for the workflow run, then your workflow owns the turn by calling session methods directly.
This is the business logic boundary. The workflow crafts the prompt, submits the message, and decides what success means.
Stdout, file, webhook, or custom sinks run after every execution. They receive accumulated output, timing, and error information.
StateStore persists last run status, counters, and next-run metadata. It is not the same thing as chat memory, RAG, or knowledge graphs.
Execution Contract
These are the public primitives exposed by pkg/automation. They are small on purpose, but they already give you a workable host-side automation surface.
| Name | Type / Shape | Behavior | Notes |
|---|---|---|---|
| automation.Workflow | Interface | Your unit of work. | Implements Name, Description, and Run(ctx, session). The workflow receives an SDK session and decides what prompt or session operations to perform. |
| automation.Runner | Execution primitive | Creates a fresh SDK client and session for each run. | This is why model overrides and system-prompt replacement stay isolated per execution. |
| automation.Executor | Orchestrator | Wraps Runner with middleware, sinks, and execution state. | This is the main production entry point for scheduled jobs, internal workers, and background automation. |
| automation.Options | Per-run config | Model override, timeout, retries, dry-run, and workflow-specific extra flags. | Cross-cutting execution controls live here. Workflow-specific business parameters belong in the workflow struct itself. |
| automation.Schedule | Trigger contract | Computes the next run time. | Built-in helpers cover interval, one-shot, and cron-style schedules. |
| automation.StateStore | Persistence hook | Stores execution history across runs. | This is execution metadata persistence, not session memory or RAG storage. |
| automation.Sink | Output hook | Receives the final Result after each run. | Use sinks for stdout, file snapshots, webhooks, or custom delivery paths. |
Per-run options
| Name | Type / Shape | Behavior | Notes |
|---|---|---|---|
| Model | string | — | Per-run model override in provider:model format. Empty means use RunnerConfig.Model. |
| DryRun | bool | — | Skips the actual LLM execution and returns a dry-run result. Useful to validate schedules or plumbing without cost. |
| Timeout | time.Duration | — | Caps total wall-clock time when WithTimeout is active or when Options itself overrides timeout behavior. |
| MaxRetries | int | — | Additional attempts after a failure. Used by WithRetry. |
| RetryBackoff | time.Duration | — | Initial backoff used by WithRetry. It doubles after each failure. |
| Extra | map[string]any | — | Escape hatch for workflow-specific flags. The automation package stores it, but your workflow must read it explicitly. |
If a workflow needs to fully replace the stock system prompt, implement automation.SystemPrompter. That replacement is total, not additive, so use it only when the workflow truly needs a dedicated contract.
type StrictReviewer struct{}
func (w *StrictReviewer) Name() string { return "strict-reviewer" }
func (w *StrictReviewer) Description() string { return "Review a patch with a hard review contract" }
func (w *StrictReviewer) SystemPrompt() string {
return `You are a strict software reviewer.
Only report concrete defects, risks, or missing tests.
Do not praise code. Do not speculate without evidence.`
}
func (w *StrictReviewer) Run(ctx context.Context, session *sdk.Session) error {
_, err := session.SubmitMessage(ctx, "Review the current repository diff.")
return err
}Scenario 1: one-shot isolated workflow
Start here for internal automation. Put business parameters in the workflow struct, keep the run body inside Run, and let the executor handle output capture.
type DailyDigest struct {
Topic string
}
func (w *DailyDigest) Name() string { return "daily-digest" }
func (w *DailyDigest) Description() string { return "Produce a concise digest for one topic" }
func (w *DailyDigest) Run(ctx context.Context, session *sdk.Session) error {
prompt := fmt.Sprintf(
"Produce a markdown digest about %s with the 5 most relevant updates and why they matter.",
w.Topic,
)
_, err := session.SubmitMessage(ctx, prompt)
return err
}
func main() {
ctx := context.Background()
runnerCfg, err := automation.RunnerConfigFromEnv("anthropic:claude-sonnet-4-6")
if err != nil {
log.Fatal(err)
}
executor, err := automation.NewExecutor(automation.ExecutorConfig{
RunnerConfig: runnerCfg,
Sinks: []automation.Sink{
&automation.StdoutSink{},
},
})
if err != nil {
log.Fatal(err)
}
defer executor.Close()
opts := automation.DefaultOptions().WithTimeout(15 * time.Minute)
result, err := executor.Run(ctx, &DailyDigest{Topic: "open-source Go agents"}, opts)
if err != nil {
log.Fatal(err)
}
fmt.Println(result.Output)
}The workflow stays business-oriented. It knows about prompts and session calls, not about retries, persistence, or webhook delivery.
A fresh runtime session, streamed output accumulation into Result.Output, duration tracking, and a consistent error boundary.
Scenario 2: add operational control with Executor
This is the real production layer. Middleware adds retry, timeout, logging, recovery, and metrics. Sinks define where results go. StateStore makes the workflow observable across restarts.
state, err := automation.NewFileStateStore("./var/automation-state")
if err != nil {
log.Fatal(err)
}
executor, err := automation.NewExecutor(automation.ExecutorConfig{
RunnerConfig: runnerCfg,
Middleware: []automation.Middleware{
automation.WithLogging(nil),
automation.WithTimeout(20 * time.Minute),
automation.WithRetry(2, 10 * time.Second),
automation.WithMetrics(func(result automation.Result) {
fmt.Printf("workflow=%s success=%v duration=%s\n",
result.WorkflowName,
result.Success(),
result.Duration.Round(time.Second),
)
}),
},
Sinks: []automation.Sink{
&automation.StdoutSink{},
&automation.FileSink{Dir: "./var/workflow-runs", Format: "json"},
automation.NewWebhookSink("https://ops.example.com/hooks/seshat"),
},
State: state,
})
if err != nil {
log.Fatal(err)
}WithRecovery() is always prepended by NewExecutor, and sinks still run even when the workflow fails. That makes the output path reliable for ops reporting.
Scenario 3: scheduling and persistent run state
When you want recurring automation, put the executor behind a scheduler. The built-in scheduler supports interval, once, and cron patterns, and it exposes the next upcoming runs for inspection.
executor, err := automation.NewExecutor(automation.ExecutorConfig{
RunnerConfig: runnerCfg,
Middleware: []automation.Middleware{
automation.WithLogging(nil),
automation.WithRetry(3, 15 * time.Second),
},
Sinks: []automation.Sink{
&automation.StdoutSink{},
&automation.FileSink{Dir: "./var/digests"},
},
State: state,
})
if err != nil {
log.Fatal(err)
}
defer executor.Close()
scheduler := automation.NewScheduler(executor).
Add(
&DailyDigest{Topic: "AI agents and orchestration"},
automation.MustCron("0 */6 * * *"),
automation.DefaultOptions().WithTimeout(20*time.Minute),
).
Add(
&DailyDigest{Topic: "Go runtime engineering"},
automation.Every(12*time.Hour),
automation.DefaultOptions().WithRetry(2, 20*time.Second),
)
for _, item := range scheduler.Next() {
fmt.Printf("%s -> %s (%s)\n", item.WorkflowName, item.NextRun.Format(time.RFC3339), item.Schedule)
}
log.Fatal(scheduler.Run(ctx))Use automation.Every for simple recurring jobs such as “every 6 hours” or “every day”.
Use automation.Once for delayed one-time runs such as approval follow-ups or migration windows.
Use automation.Cron or automation.MustCron for structured calendars and daily operating rhythms.
Use FileStateStore when the process may restart and you still want run counters and last-status visibility.
Choose the right runtime layer
The most common mistake is forcing everything into the automation package. Use it when it fits. Drop lower or move sideways when your product shape demands it.
| Name | Type / Shape | Behavior | Notes |
|---|---|---|---|
| pkg/automation | — | Best for one-shot or scheduled background runs. | Choose this when each execution can start from a clean session and you want retries, sinks, scheduling, or execution-state tracking. |
| pkg/sdk directly | — | Best for multi-turn product flows or interactive operator tools. | Choose this when you need persistent sessions, interactive permissions, session resume, prompt appends, or richer per-turn orchestration. |
| Mailbox / agent teams | — | Best for durable multi-agent identity and delegation. | Choose this when work must move between named agents instead of staying inside one fresh session per run. |
| gRPC API | — | Best for other languages or process boundaries. | Choose this when the runtime should stay in a separate process and your host system only needs transport-level access. |
Scheduled research, recurring compliance checks, repo audits, daily digests, or background maintenance jobs fit the current automation surface well.
- Fresh session per execution
- Retry and timeout policy
- File or webhook delivery
- Simple cron-style scheduling
If users need approvals, long-lived context, prompt appends, session resume, or memory-backed interactions, keep control at the SDK layer instead of wrapping it too early.
- Persisted sessions
- Interactive permission flows
- Prompt appends and session shaping
- Richer runtime event handling
client, err := sdk.NewClient(&sdk.ClientConfig{
Model: sdk.ModelIdentifier{
Provider: sdk.APIProviderAnthropic,
Model: "claude-sonnet-4-20250514",
},
APIKey: os.Getenv("ANTHROPIC_API_KEY"),
PermissionMode: sdk.PermissionModeOnRequest,
PersistSessions: true,
EnableMemory: true,
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
session, err := client.CreateSession(ctx)
if err != nil {
log.Fatal(err)
}
defer session.Close()
session.SetAppendSystemPrompt("You are the billing operations agent for this company.")
session.SetPermissionMode(sdk.PermissionModeOnRequest)
_, err = session.SubmitMessage(ctx, "Investigate the invoice mismatch and ask for approval before changing files.")
if err != nil {
log.Fatal(err)
}Current shape and next likely enrichments
The current automation layer is already useful, but it is intentionally narrow. Knowing the real shape helps you document and extend it correctly instead of assuming features that are not wired yet.
| Name | Type / Shape | Behavior | Notes |
|---|---|---|---|
| Fresh client per execution | — | — | Runner creates a brand-new SDK client every time. That keeps runs isolated, but it also means workflow executions do not automatically share session state. |
| PermissionModeBypass | — | — | The stock automation runner forces bypass permissions. That is correct for trusted background automation, not for end-user interactive approval flows. |
| Memory disabled | — | — | Runner disables memory, hooks, monitoring, session persistence, and title generation. The current automation layer is intentionally lean and stateless by default. |
| Scheduler is single-threaded | — | — | One Scheduler runs due jobs synchronously, one at a time. Use multiple schedulers or your own outer concurrency if you need parallel pipelines. |
| StateStore is execution history only | — | — | FileStateStore and MemoryStateStore track last status, counters, and last error. They do not persist the runtime conversation transcript. |
| CLI surface is early | — | — | Today cmd/automation ships a small seshat-auto wrapper with a tech-watch example. The runtime primitives are more general than the built-in CLI workflow list. |