Docs/SDK and API/Go SDK/Building Workflows

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.

Important distinction

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.

Trigger
Host app or worker

A cron job, queue worker, operator tool, or internal backend decides that a workflow should run now.

Definition
Workflow + Options

Business parameters live in the workflow struct. Cross-cutting execution controls live in automation.Options.

Scheduling
Optional scheduler

The same executor can be fired manually or behind Every, Once, and Cron schedules.

Control Layer
Executor = runner + middleware + sinks + execution state

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
Fresh client per run

Runner builds a new SDK client for each execution so model overrides and prompt replacement stay isolated.

SDK layer
Mono-run runtime

The runner still enters the normal Seshat runtime: provider client, prompt stack, tool surface, and response streaming.

Session
Fresh session

A new session is created for the workflow run, then your workflow owns the turn by calling session methods directly.

Run
Workflow.Run(ctx, session)

This is the business logic boundary. The workflow crafts the prompt, submits the message, and decides what success means.

Outputs
Sinks capture the final result

Stdout, file, webhook, or custom sinks run after every execution. They receive accumulated output, timing, and error information.

State
Execution state is separate from memory

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.

NameType / ShapeBehaviorNotes
automation.WorkflowInterfaceYour 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.RunnerExecution primitiveCreates a fresh SDK client and session for each run.This is why model overrides and system-prompt replacement stay isolated per execution.
automation.ExecutorOrchestratorWraps Runner with middleware, sinks, and execution state.This is the main production entry point for scheduled jobs, internal workers, and background automation.
automation.OptionsPer-run configModel 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.ScheduleTrigger contractComputes the next run time.Built-in helpers cover interval, one-shot, and cron-style schedules.
automation.StateStorePersistence hookStores execution history across runs.This is execution metadata persistence, not session memory or RAG storage.
automation.SinkOutput hookReceives the final Result after each run.Use sinks for stdout, file snapshots, webhooks, or custom delivery paths.

Per-run options

NameType / ShapeBehaviorNotes
ModelstringPer-run model override in provider:model format. Empty means use RunnerConfig.Model.
DryRunboolSkips the actual LLM execution and returns a dry-run result. Useful to validate schedules or plumbing without cost.
Timeouttime.DurationCaps total wall-clock time when WithTimeout is active or when Options itself overrides timeout behavior.
MaxRetriesintAdditional attempts after a failure. Used by WithRetry.
RetryBackofftime.DurationInitial backoff used by WithRetry. It doubles after each failure.
Extramap[string]anyEscape 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.

Custom system prompt workflow
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.

Minimal automation workflow
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)
}
Why this shape works

The workflow stays business-oriented. It knows about prompts and session calls, not about retries, persistence, or webhook delivery.

What you get automatically

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.

Executor with middleware, sinks, and state
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)
}
Current behavior to know

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.

Scheduled automation
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))
Interval schedule

Use automation.Every for simple recurring jobs such as “every 6 hours” or “every day”.

One-shot schedule

Use automation.Once for delayed one-time runs such as approval follow-ups or migration windows.

Cron schedule

Use automation.Cron or automation.MustCron for structured calendars and daily operating rhythms.

State store

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.

NameType / ShapeBehaviorNotes
pkg/automationBest 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 directlyBest 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 teamsBest 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 APIBest 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.
Choose automationWhen each run should start clean

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
Choose raw SDKWhen the workflow is really a product conversation

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
When to stay on pkg/sdk directly
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.

NameType / ShapeBehaviorNotes
Fresh client per executionRunner creates a brand-new SDK client every time. That keeps runs isolated, but it also means workflow executions do not automatically share session state.
PermissionModeBypassThe stock automation runner forces bypass permissions. That is correct for trusted background automation, not for end-user interactive approval flows.
Memory disabledRunner disables memory, hooks, monitoring, session persistence, and title generation. The current automation layer is intentionally lean and stateless by default.
Scheduler is single-threadedOne 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 onlyFileStateStore and MemoryStateStore track last status, counters, and last error. They do not persist the runtime conversation transcript.
CLI surface is earlyToday 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.