stiggy

Guide

Everything a fleet file can say, what it compiles to, and how to run it in production. Built on phero v2.2.0 and packtrail v1.0.3.

Overview

stiggy deploys multi-agent architectures on NATS by gluing two libraries: phero (agents, LLMs, tools, memory, the NATS Agent Protocol) and packtrail (a durable, event-sourced workflow engine on JetStream). It adds crews, supervisors, swarms, human review and replay on a substrate that survives crashes and scales out.

A fleet is defined once, as YAML (the stiggy binary) or in Go (the library), and both build the same model.

  • models, tools, knowledge: any LLM provider, tool, memory backend or vector store phero offers, plus MCP servers;
  • agents: a model, a role/goal/backstory, tools, memory, knowledge and delegates;
  • flows: packtrail flows whose steps run agents, with every packtrail feature: choices, fan-out/join, maps, subflows, waits, retries, budgets, fork, rerun and time travel;
  • crews and patterns: task crews and the supervisor, swarm, evaluator-optimizer, debate and plan-execute patterns, expanded into flows.

How it works

How stiggy works fleet.yaml and Go options both build a spec.Fleet, which compile validates into a plan. The plan runs on a packtrail engine and on workers, one per agent, activity or remote, each running a fresh phero agent per job. Everything communicates over NATS JetStream. fleet.yamlGo options spec.Fleetcompile one modelvalidate → Plan packtrail engine event log · timers · budgetsflows, choices, fan-out, waits workers agent-<name> · act-<name>remote-<name> fresh phero agent per job jobs ⇄ results NATS · JetStream — the only transport; stiggy never touches it directly
  • A flow step that runs an agent is a packtrail task whose meta holds the step (prompt, writes, routes, ...). It is versioned with the flow, so a running execution keeps the configuration it started with.
  • Each agent is a packtrail worker kind. Every job builds a fresh phero agent, runs it, and returns its output, channel writes and usage.
  • Agent-chosen routing, ask_human, structured output and execution memory live in packtrail state, so they are durable and correct under fork, rerun and time travel.
  • stiggy never touches NATS directly. See Architecture rules.

Install & build

Requires Go 1.26.4 (the toolchain is fetched automatically) and a NATS server with JetStream enabled.

go install github.com/henomis/stiggy/cmd/stiggy@latest

# from a checkout
make build        # bin/stiggy (static, honors GOOS/GOARCH)
make test         # all tests with -race
make check        # test + lint + vet

The module depends on phero/v2 v2.2.0 and packtrail v1.0.3.

Quick start

nats-server -js &

Write a fleet:

# fleet.yaml
namespace: newsroom
models:
  fast: {provider: ollama, model: llama3.2-eco}
agents:
  researcher: {model: fast, role: Researcher, goal: "Find accurate, recent facts."}
  writer: {model: fast, role: Writer, goal: "Write short, clear articles."}
crews:
  article:
    tasks:
      - {id: research, agent: researcher, description: "Research {{.input.topic}}."}
      - {id: write, agent: writer, description: Write a 200-word article from the research.}

Check it, look at what it compiles to, serve it, and start an execution:

stiggy validate fleet.yaml        # every problem, with its line
stiggy compile fleet.yaml         # the packtrail flows it becomes
stiggy run fleet.yaml &           # serve it
stiggy start -ns newsroom article -input '{"topic": "NATS"}' -wait

start prints the execution id; with -wait it then prints the final state. The same fleet in Go is shown under Go library.

How a job runs

From the file you write to an agent answering, a fleet goes through five stages. Knowing them tells you where an error comes from and what survives a crash.

  1. Load. The YAML loader (or the Go options) builds one spec.Fleet. Decoding is strict, ${ENV} is expanded outside flows, crews and patterns, and every value remembers its file line for error messages.
  2. Compile. compile.Compile is pure, with no I/O. It expands crews and patterns into plain packtrail flows, then validates everything and reports every problem at once. The result is a plan: the flows, the worker kinds and the schedules. stiggy compile prints it.
  3. Store the step with the flow. A node with agent, activity or remote becomes a packtrail task whose meta holds the step: prompt, writes, routes. The step is versioned with the flow, so an execution keeps the configuration it started with even after you redeploy.
  4. Run. App.Run (or stiggy run) builds models, tools, knowledge and memories once, starts the packtrail engine and one worker per kind for its role, and exposes agents and flows on the NATS Agent Protocol. On shutdown, workers drain before the engine stops.
  5. Answer one job. For each job a worker builds a fresh phero agent, renders the prompt over the job context, adds the steering tools (route_to_*, ask_human), delegates and structured output, and returns the output, channel writes and usage. Routing choices, human questions and execution memory are written to packtrail state, so they stay correct under fork, rerun and time travel.
What a crash costs. Delivery is at least once. If a worker dies mid-job, the job is delivered again and the agent runs again from the start of that step; finished steps are never repeated. Make tool side effects idempotent (see Tools and the job).

Fleet structure

A fleet is one YAML document, or the equivalent Go options. Decoding is strict: an unknown key is an error, reported with its line. stiggy validate reports every problem at once, with one exception: a crew or a pattern reports only its first problem, so fix it and validate again to see the next.

version: "1"          # optional
namespace: newsroom   # packtrail namespace; default "stiggy"
models: {}
embedders: {}
vectorstores: {}
knowledge: {}
tools: {}
agents: {}
remotes: {}
flows: {}
crews: {}
patterns: {}
expose: {}
schedules: {}
tracing: {exporter: otlp}   # optional: span export, see Tracing

Names (keys of every section, node ids) must match [A-Za-z0-9_-]. Agent, owner and exposed names are NATS subject tokens of at most 64 characters.

Environment variables in the file

String values outside flows, crews and patterns may use ${VAR}, ${VAR:-default} and $$ (a literal $). A missing variable without a default is an error. Quote a reference inside a {...} mapping (api_key: "${OPENAI_API_KEY}"), where YAML would otherwise read its braces. stiggy validate -no-env skips expansion.

Flows are never expanded. They are versioned definitions stored by packtrail, so they must not depend on the environment or hold secrets. Crews and patterns compile into flows, so a ${VAR} in them is a load error; pass runtime values as inputs instead.

Models

models:
  fast:
    provider: openai          # openai | anthropic | google | ollama (or a registered one)
    model: gpt-4o-mini        # provider default when empty; required for ollama
    api_key: ${OPENAI_API_KEY}
    base_url: https://...     # OpenAI-compatible endpoints
    temperature: 0.2
    max_tokens: 1024
    retries: 2                # retry a failed call (exponential back-off)
    retry_backoff: 5s         # first wait, doubling up to 30s (default 500ms); 4xx errors but 408/429 are not retried
    rate_limit: {requests_per_minute: 60, max_concurrent: 4}   # per process

ollama uses Ollama's OpenAI-compatible endpoint (default http://localhost:11434/v1), for example {provider: ollama, model: llama3. google uses the Gemini API natively, e.g. {provider: google, model: gemini-3.5-flash, api_key: "${GEMINI_API_KEY}"}; prefer it to Gemini's OpenAI-compatible endpoint for agents with tools, since Gemini 3 needs each tool call's thought signature sent back and only the native provider keeps it.2-eco}. rate_limit takes requests_per_minute, max_concurrent or both; an empty one is an error. In Go, WithModel(name, llm) registers a ready phero LLM and WithModelSpec the same struct as YAML.

Tools

tools:
  shell: {type: bash, allowlist: [ls, cat], timeout: 30s, working_dir: /work}
  files: {type: file_read, working_dir: /work}    # also file_write, file_edit, glob, grep
  skills: {type: skill, root: ./skills}
  scratch: {type: kv, bucket: scratchpad}         # NATS KV read/write tools
  browser: {type: mcp, command: npx, args: [-y, "@playwright/mcp"], allow: [browser_navigate]}
  remote: {type: mcp, url: https://mcp.example.com/mcp, deny: [delete_everything]}

Built-in types: bash, file_read, file_write, file_edit, glob, grep, skill, kv and mcp. Options are checked strictly per type. An mcp entry is either a stdio command (with args and env; only a minimal environment is passed on) or a streamable-HTTP url, filtered with allow and deny.

TypeOptions
bashallowlist, blocklist, safe_mode, working_dir, timeout, max_timeout, max_output_chars
file_read, file_edit, glob, grepworking_dir, max_file_size (bytes)
file_writeworking_dir, max_file_size, no_overwrite
skillroot
kvbucket
mcpcommand, args, env, or url; allow, deny

Agents

agents:
  writer:
    model: fast
    system: You write for a newsroom.   # system prompt (a template, see below)
    role: Writer                        # appended to system
    goal: Short, accurate articles.
    backstory: Ten years at a daily paper.
    description: Writes articles.       # for agents that delegate to it
    tools: [shell, files]
    knowledge: [handbook]               # one search_<name> tool each
    delegates: [researcher, legal]      # agents or remotes, as ask_<name> tools
    max_iterations: 10                  # default 25
    stream: true                        # progress events while it runs
    memory: execution                   # see below

Agents run in-process inside packtrail workers of kind agent-<name>. Each job builds a fresh phero agent.

  • Steering tools are added from the step: route_to_<node> for each route and ask_human (see Routing & human in the loop).
  • Delegates appear as ask_<name> tools; their usage is charged to the calling step.
  • Streaming (stream: true) publishes text and tool calls as progress, readable with stiggy progress.

system, role, goal and backstory are templates over the job context, like a step prompt: role: "{{.item.name}}" gives each item of a map its own persona, and delegates render over the job of the step that delegates. Exposed agents and their delegates answer outside any job, so a template in theirs is rejected.

memory

TypeKept inNotes
none (default)nowhereEvery step starts fresh
executionthe execution's state (hidden channel _mem_<agent>)Correct under fork, rerun and time travel
simplethe worker processmax_items (default 100)
natsNATS KVbucket
psqlPostgreSQLdsn, table, ensure_schema
raga knowledge indexknowledge

Long-term types (all but none and execution) take summarize. All of them but rag also take session and per_execution; a rag memory is one collection shared by every job, so either is rejected on it.

  • session: default the agent's name, shared by every execution. It is a template over the job context, so session: "user-{{.input.user_id}}" keeps one memory per user. A missing or empty value fails the step instead of falling back to a shared session: a missing key, a null value, or an action that renders nothing, as with user_id: "". An action that only declares a variable ({{$u := .input.user_id}}) is exempt. The result must be letters, digits, - _ = / and inner dots. A templated session is rejected on an exposed agent;
  • per_execution: true: append the execution id to the session;
  • summarize: {model, threshold, size}.

memory may be a bare type (memory: execution) or a mapping (memory: {type: nats, bucket: crew-memory, per_execution: true}).

Embedders, vector stores & knowledge

embedders:
  small: {provider: openai, model: text-embedding-3-small, api_key: "${OPENAI_API_KEY}"}  # or ollama
vectorstores:
  docs: {type: qdrant, host: localhost, port: 6334, collection: handbook, vector_size: 1536, distance: cosine}
  pg: {type: psql, dsn: "${DATABASE_URL}", collection: handbook, vector_size: 1536}
  wv: {type: weaviate, host: localhost:8080, class: Handbook}
knowledge:
  handbook:
    embedder: small
    vectorstore: docs
    description: The editorial handbook.
    top_k: 4
    sources:
      - {path: docs/*.md, chunk_size: 1000, chunk_overlap: 100}   # splitter: markdown | recursive

An agent lists knowledge: [handbook] and gets a search_handbook tool. The engine role ingests the sources at startup when the store is empty.

TypeOptionsdistance
qdranthost, port (default 6334), api_key, use_tls, collection, vector_size, distancecosine, euclid, dot, manhattan
psqldsn, collection, table, vector_size, distance, ensure_extensioncosine, euclid, dot
weaviatehost, scheme (default http), api_key, class, vector_size, distancecosine, dot, l2-squared

Remotes

Agents served by others on the NATS Agent Protocol, found by discovery:

remotes:
  legal: {owner: legal-team, name: reviewer, description: Reviews legal risks.}

Steps run them with remote: legal (worker kind remote-legal); agents list them in delegates. The protocol is final-answer only, so remote agents do not stream.

Flows & steps

A flow is a packtrail flow: start, channels (with reducers replace, append, merge, sum), output (an expression; default all channels, or the last node's result), budget, max_steps, retention, on_expire, archive_retention, triggers, and nodes of types task, choice (to: $end ends the flow), fanout, join, await, map and subflow. Refer to packtrail's documentation for everything on those nodes.

retention is how long a finished execution is kept (default: forever). Then on_expire decides: archive (default) moves it to the archive, where it stays readable, and delete removes it for good. on_expire needs a retention. archive_retention deletes an archived execution that long after it was archived; together with on_expire: delete it is rejected. packtrail never deletes an execution sooner than ten minutes after it finished.

A task or map node runs a stiggy step when it sets one of these fields:

FieldMeaning
agentRun a fleet agent
activityRun a Go activity (library mode)
remotePrompt a remote agent
promptGo template over the job context; default: the input or map item, plus the context outputs
expected_outputAppended to the prompt; a template like prompt
contextNodes whose outputs the prompt includes; default the previous node
writes{channel: output} or {channel: output.field}
routesNodes the agent may choose next, and human for ask_human (task nodes only, not in fan-outs or maps)
human_inputA human reviews the answer: {"approve": true} or feedback

A step runs exactly one of agent, activity and remote. The node's own output_schema, retry, timeout, cache and concurrency still apply. With output_schema the agent answers structured JSON.

A hand-written flow

flows:
  greet:
    nodes:
      - {id: hello, type: task, agent: greeter, prompt: "Greet {{.input.name}}."}

Prompt templates

Templates see .input, .channels, .results, .item, .index, .last_node, .visits, .signals, .errors, .counters and .resume, plus the json function: {{json .channels.notes}}. A missing key renders as the literal <no value>. The same holds for an agent's system, role, goal and backstory; only memory.session fails the step on a missing or empty value.

Without a prompt, the agent receives the Item (in a map) or the Input, followed by the output of each context node, or of the previous node when context is empty. A step with nothing to show gets Begin..

Outputs

An agent step's output is {"text": "<answer>"}, or the parsed object when it has an output_schema. writes copies all of it (output) or a field (output.verdict) into a channel, applying the channel's reducer. A field needs an output_schema that can hold it: without one, only output and output.text compile. With an output_schema that sets additionalProperties: false, the field must be one of its properties. Activity steps are not checked.

An empty answer, from an agent or a remote, is a retryable failure: the node's retry asks again, and without one the step fails. An agent that chose a route may answer empty.

Parallel agents that may fail

A fanout runs agent steps side by side and its join decides when the flow moves on. all (default), any and quorum:N settle as soon as the outcome is known and cancel the agents still running. min:N waits for every agent, then continues if at least N succeeded (min:0 always continues): no answer is lost to cancellation. The join's result holds each successful answer by branch, .branches tells which branches completed or failed, and .errors why. When fewer than N succeed, the join goes to its on_failure, or fails the execution without one.

      - {id: fan, type: fanout, branches: [a, b, c], next: gather}
      - {id: a, type: task, agent: researcher_a, retry: {max_attempts: 2}}
      - {id: b, type: task, agent: researcher_b}
      - {id: c, type: task, agent: researcher_c}
      - {id: gather, type: join, policy: "min:1", next: write, on_failure: give_up}
      - {id: write, type: task, agent: writer, prompt: "Use these answers: {{json .results.gather}}"}
      - {id: give_up, type: task, agent: writer, prompt: "No researcher answered: {{json .errors}}"}

Usage counters and budgets

Budgets in budget (flow, crew or pattern) can use the counters every agent step reports: agent_steps (the step's agent and each delegate run), tokens_in, tokens_out, tokens_cache_read, tokens_cache_write, tokens_reasoning (part of tokens_out), cost_usd, llm_calls, tool_calls and tool_errors. tokens_in, tokens_out and cost_usd are also broken down as <counter>__agent__<agent> and <counter>__model__<model>, with characters outside [A-Za-z0-9_-] replaced by _, for example cost_usd__model__gpt-4_1. Counters that would be zero are left out. Delegates count in the step that calls them. They are charged for every run, including failed and paused ones, and checked on completion, failure and pause. max_steps bounds the number of node visits.

A larger example

agents:
  planner: {model: m, knowledge: [handbook], delegates: [critic, legal]}
  researcher: {model: m, memory: {type: execution}}
  critic: {model: m, stream: true}
flows:
  crew:
    start: plan
    channels:
      notes: {reducer: append}
      verdict: {}
    budget: {tokens_out: 50000, agent_steps: 40}
    max_steps: 200
    nodes:
      - id: plan
        type: task
        agent: planner
        output_schema:
          type: object
          properties: {topics: {type: array, items: {type: string}}}
          required: [topics]
        next: research
      - id: research
        type: map
        over: results.plan.topics
        max_parallel: 4
        agent: researcher
        prompt: "Research {{.item}}. Context: {{json .channels.notes}}"
        writes: {notes: output}
        next: review
      - id: review
        type: task
        agent: critic
        context: [plan, research]
        expected_output: A verdict and the reason for it.
        human_input: true
        output_schema: {type: object, properties: {verdict: {type: string}}, required: [verdict]}
        writes: {verdict: output.verdict}
        routes: [plan, human]

Routing & human in the loop

Agent-chosen routing

routes: [a, b] gives the agent a route_to_a and a route_to_b tool and makes both nodes dynamic edges of the step. The choice is stored in packtrail state, so it is durable and replayable. Routes work on task nodes only, not inside fan-outs or maps. See examples/router.

Asking a human

Add human to routes to give the agent an ask_human tool, or set human_input: true to have a human review every answer. Either way the step pauses. Its interrupt payload is in the execution state, at State.Tasks[...].Interrupt:

{kind: "question" | "review", agent, node, question | answer}
KindResume with
question"text" or {"answer": "..."}
review{"approve": true}, or "text" / {"feedback": "..."} to have the agent revise its answer
stiggy resume -ns newsroom <exec> <node> -value '"answer"'
stiggy resume -ns newsroom <exec> <node> -value '{"approve": true}'

In Go, use Client().Resume. Exchanges with the human are kept in the payload (the event log), so forks and reruns see exactly their own history. await nodes with stiggy signal cover waits that are not agent questions. See examples/hitl.

Crews

crews:
  launch:
    process: sequential            # default; or hierarchical, with manager: <agent>
    max_steps: 40
    budget: {tokens_out: 50000}
    retry: {max_attempts: 3, delay: 2s}   # every agent step of the crew
    tasks:
      - id: research
        agent: researcher
        description: "Research {{.input.product}}."
        expected_output: Three facts.
        async: true                # runs in parallel with neighbouring async tasks
      - id: draft
        agent: writer
        description: Write the post.
        context: [research]
        output_schema: {...}
        human_input: true
        guardrail: {agent: editor, max_retries: 2}
        retry: {max_attempts: 2}   # this task's step only, instead of the crew's
  • sequential (default): tasks run in order; consecutive async tasks run as a fan-out.
  • hierarchical: a manager agent assigns the tasks. async and guardrail apply to sequential crews only.
  • guardrail: a checker agent reviews the answer; on failure the task is redone with the feedback, up to max_retries times (default 2). An async task cannot have a guardrail.
  • retry: a node retry policy, as on flow nodes, given to every agent step the crew runs: tasks, guardrail checks and the manager. Without it each step has one attempt, so an empty answer or one that misses its schema fails the execution.

A crew runs as a flow named after it. stiggy compile shows it. See examples/crew.

Patterns

patterns:
  team: {type: supervisor, supervisor: boss, workers: [researcher, writer]}
  swarm: {type: swarm, agents: [triage, billing, tech]}
  polish: {type: evaluator_optimizer, generator: writer, evaluator: editor, max_rounds: 3}
  argue: {type: debate, debaters: [optimist, skeptic], judge: editor, rounds: 2}
  project: {type: plan_execute, planner: boss, executor: researcher, synthesizer: writer, max_parallel: 4}
TypeBehavior
supervisorThe supervisor routes to one worker at a time; every worker reports back until the supervisor answers without routing. Node ids are the agents' names.
swarmEvery agent may hand off to any other. The first starts; the flow ends with the first agent that answers without handing off.
evaluator_optimizerThe evaluator judges with structured output {pass, feedback}; the generator regenerates with the feedback until it passes or max_rounds generations ran (default 3).
debateDebaters argue in parallel for rounds rounds (default 2), each seeing the transcript so far; then the judge decides.
plan_executeThe planner lists steps (structured output {steps}), the executor runs each (max_parallel at a time, default 4), and the optional synthesizer combines the results.

All patterns take prompt, max_steps, budget and retry. retry works as for crews: every agent step of the pattern gets it, for example retry: {max_attempts: 3, delay: 2s}. Each runs as a flow named after it; stiggy compile shows the graph, and validation errors are mapped back to the pattern.

The choices patterns generate have named rules, recorded on the execution's ChoiceEvaluated events (stiggy history), so the history says why a loop ended: passed (the evaluator or a crew guardrail accepted the output), next_round, max_rounds (the rounds ran out: the evaluator-optimizer returns the last output, the debate goes to its judge), retry and max_retries (a crew guardrail's retries ran out and the crew goes on).

Expose

expose:
  owner: newsroom          # default: the namespace
  agents: [writer]         # each prompt runs a fresh agent
  flows: [launch]          # each prompt starts an execution, answered with its output

Exposed agents and flows are served on the NATS Agent Protocol (phero's nats.Server), so any protocol client can call them. Discover them with stiggy agents. Readiness waits until they are discoverable.

Exposed flows take the prompt as input: a JSON object as is, otherwise {"prompt": text}. Without a Stiggy-Execution-Id request header, the execution is cancelled when the caller goes away. With one it keeps running, and a retry with the same id joins it. An id that belongs to another flow's execution is refused with a 409 execution_conflict.

Schedules

schedules:
  morning: {flow: launch, cron: "0 0 7 * * *", input: {product: stiggy}}

The cron expression has a leading seconds field; @-descriptors such as @hourly work too. input must be an object. Schedules are registered by the engine role.

Designer

stiggy design builds workflows in the browser from blocks, and compiles them to a fleet. It never connects to NATS and never runs anything: it writes files you run with stiggy run.

make ui                                        # build the page (needs Node)
stiggy design -ui ui/dist news.stiggy.json     # http://127.0.0.1:7357
stiggy run news.yaml                           # the generated fleet

A project holds one or more workflows, one canvas each, and the model providers they share. Drag blocks from the palette, connect a block's output port to the next block, and click a block to set it up. Prompts take values from earlier blocks through + value: workflow inputs, an agent's answer or one field of its structured answer, the current item of a loop.

BlockWhat it doesIn the fleet
Start, OutputThe workflow's inputs, and what it returnsthe flow's start and output
AI AgentA model with instructions does one task, optionally answering with named fieldsa model, an agent and a task step
AI RouterAn agent picks the next step from named routesa step with routes
Loop over itemsRuns an agent once per item of a lista map step
ConditionGoes to true or false by comparing valuesa choice node
Parallel, MergeRuns branches side by side and waits for all, the first, or all with some allowed to failfanout and join
Human approvalWaits for a person: approved, rejected or timed outan await on a signal named after the block, then a choice
Run workflowRuns another workflow of the projecta subflow
Agent teamSeveral agents on one task: crew, supervisor, swarm, write & review, debate, plan & executea crew or a pattern, run as a subflow
Tools, Knowledge baseDropped onto an AI block: files, shell, key-value store, MCP server, skills; searchable documentstools; embedder, vector store and knowledge

Every change is compiled and checked as you work; anything to fix is shown on its block and listed under To fix. Saving writes the project and the fleet next to it (-o picks another path). Model providers (Providers in the top bar) name a service such as OpenAI or Ollama and the environment variable that holds its API key: the key is never stored, the fleet reads it from the environment when it runs. To answer a Human approval, send its signal: stiggy signal <run> <signal> -payload '{"approved": true}'.

Deployment & roles

Every process of a deployment runs the same fleet file and picks its part:

stiggy run -role engine  -http :8080 fleet.yaml                # one or more engines
stiggy run -role workers -http :8080 fleet.yaml                # scale out
stiggy run -role workers -only researcher,writer fleet.yaml    # dedicated hosts
RoleRuns
all (default)Engine and every worker: one process, typical in development
engineThe packtrail engine: provisions the namespace, registers flows and schedules, ingests knowledge, drives executions. A fleet without flows (agents only) starts no engine and provisions nothing
workersThe agent, activity and remote workers. Scale it out freely

-only restricts a process to the named agents, activities and remotes. On shutdown, workers drain before the engine stops.

deploy/ has a Dockerfile (build from the repo root) and a docker-compose setup with NATS, one engine, scalable workers and a CLI profile:

docker build -f deploy/Dockerfile -t stiggy .
OPENAI_API_KEY=... docker compose -f deploy/docker-compose.yaml up --scale workers=3
docker compose -f deploy/docker-compose.yaml run --rm cli \
  start -ns newsroom article -input '{"prompt": "NATS"}' -wait

Health endpoints

-http ADDR serves /healthz (200 while the process runs) and /readyz (503 until the app is ready, then 200).

Logs

stiggy, packtrail's engine and its workers log through one *slog.Logger (WithLogger; stiggy run builds it from -log-level and -log-format). Lines from every process share their keys, so filtering on exec follows one execution: exec, flow, node, key, attempt, and agent, remote or activity for what runs the step.

LevelLines
Infostiggy: step started and stiggy: step ended for every attempt of a step, the end with outcome (completed, interrupted, retryable_failed, failed), duration, tokens_in, tokens_out, cost_usd and route, then what the attempt learnt (see the table below). A failed attempt ends at Warn with err. An exposed agent logs stiggy: request started and stiggy: request ended for every request it answers, on agent and trace_id, and on caller_exec, caller_flow, caller_node and caller_attempt when a stiggy remote step sent it, with outcome (completed or failed), duration, stop_reason, the same usage (delegates included), llm_calls and tool_calls; a failed request ends at Warn with err.
DebugEach model call (stiggy: llm request, stiggy: llm response with model, tokens, duration and the start of the text), each tool call (stiggy: tool call, stiggy: tool result with arguments, duration and result or err), memory reads and writes, and the end of every agent run; a delegate's lines name the delegate in agent. Text, arguments and results are cut at 512 bytes. packtrail adds one line per decision, dispatched job and attempt.

Debug lines carry model output and tool arguments: keep them out of shared log stores when prompts hold private data. Each carries the span_id of its span (see Tracing).

The end line of an agent step adds these fields when they apply; a remote step adds status_code, error_code and retry_after.

FieldMeaning
stop_reasonWhy the agent's run ended: final, max_iterations (the step fails, without retry, with max_iterations: reached N), stopped (the agent called ask_human; not a failure), cancelled, guardrail (a guardrail blocked a model call), handoff or error
iterations, max_iterationsThe agent loop's iterations and its limit
ttftTime to the first token, for an agent with stream: true
llm_calls, llm_timeModel calls and their summed duration, delegates included
tool_calls, tool_errors, tool_time, toolsTool calls; tools lists name:calls/errors/duration per tool
routes, route_by, route_reasonFor a step with routes: the routes it could take, agent when the agent chose route (with the reason it gave) or default
review_kind, review_outcome, reviewed_by, review_waitFor a step resumed by a human: question or review; answered, approved or feedback; who answered (stiggy resume -by); how long the step had waited by this attempt
retry_after, status_codeWhat a provider said about an error; a remote agent's error_code is also in the step's error text. The node's retry policy still decides the next attempt; a model's own retries honour retry_after
trace_idThe step's trace, when its execution was started without a traceparent (with one, the job's logger already carries it)

Tracing

Every agent step and exposed-agent request records spans: stiggy.step <node> (or stiggy.request <agent>), the agent's run under it (invoke_agent <agent>), its model calls (chat <model>), tool calls (execute_tool <tool>), memory and retrieval, and each delegate's run under the tool call that asked it. A step whose execution was started with a traceparent (packtrail's WithTraceparent) joins that trace. Every span carries the step as stiggy.exec, stiggy.flow, stiggy.node, stiggy.key, stiggy.generation, stiggy.attempt, stiggy.agent (and stiggy.index, stiggy.resumed_by), and the execution id as its session. The step's span also gets the end-line fields above as stiggy.* attributes, durations in milliseconds (stiggy.ttft_ms).

tracing:
  exporter: otlp                 # otlp, jsonfile or text
  endpoint: otel-collector:4318  # host:port or base URL (+ /v1/traces); default OTEL_EXPORTER_OTLP_*, else localhost:4318
  insecure: true                 # plain HTTP
  headers: {authorization: "Bearer ${OTEL_TOKEN}"}
  service_name: newsroom        # default OTEL_SERVICE_NAME, else the namespace
  content: {capture: false, max_bytes: 65536}
ExporterOutput
otlpOpenTelemetry spans over OTLP/HTTP, following the GenAI semantic conventions (gen_ai.*). endpoint, insecure, headers and service_name are for it only
jsonfileOne JSON line per span, with its full payload, to file (required)
textAn indented span tree on stderr

content decides whether prompts, answers, tool arguments and results are exported. Unset, otlp exports none, as the semantic conventions advise, and jsonfile and text export all. capture: true turns it on for otlp, each value cut at max_bytes (default 64 KiB); capture: false turns it off for the others. Without a tracing section, spans are only used for the step's counters and debug lines. In Go, WithTracing takes any phero trace provider options instead. See examples/tracing.

Worker kinds

agent-<name>, act-<name> and remote-<name> belong to stiggy. A plain task node with any other kind is left to external packtrail workers, in any language.

CLI reference

Flags may appear before, between or after positional arguments. Online commands take -server URL (default $NATS_URL, else nats://127.0.0.1:4222) and -creds FILE. Execution commands also take -ns NAMESPACE (default stiggy).

Offline

CommandPurpose
validate [-activities a,b] [-no-env] <fleet.yaml>...Check fleet files; every problem is reported with its line
compile [-activities a,b] [-no-env] [-format yaml|json] <fleet.yaml>Print the packtrail flows and the workers the fleet compiles to
design [-addr ADDR] [-ui DIR] [-o fleet.yaml] [-activities a,b] [project.stiggy.json]Build workflows from blocks in the browser and generate the fleet (see Designer); never touches NATS

-activities names the Go activities a library program registers, so a fleet written for the library can be checked by the binary.

Online

Online and execution commands take -server, -creds, -log-level debug|info|warn|error (default $STIGGY_LOG_LEVEL or info) and -log-format text|json (default $STIGGY_LOG_FORMAT or text). agents and ask check them but write no log lines. Logs go to stderr; see Logs.

CommandPurpose
run [-role all|engine|workers] [-only a,b] [-http ADDR] <fleet.yaml>Serve the fleet until interrupted
start [-ns NS] [-input JSON] [-id ID] [-wait] <flow>Start an execution and print its id; -id makes the start idempotent; -wait prints the final state
agents [-owner OWNER] [-agent FRAMEWORK]List NATS Agent Protocol agents on the bus: exposed stiggy agents and flows, phero agents, any v0.3 agent
ask [-owner OWNER] <agent> <prompt>Prompt an agent on the bus and print its answer; nothing is stored in NATS. -owner picks one when several owners serve the name

Executions

CommandPurpose
get <exec> [-seq N]State now, or right after event N (time travel)
history <exec>The event log, as JSON lines
progress <exec>Streamed agent progress, as JSON lines
signal <exec> <name> [-payload JSON]Signal an await node
resume <exec> <node> [-value JSON] [-by WHO]Answer an agent waiting for a human; -by records who answered, on the execution and in the step's end line and spans
update <exec> -writes JSONWrite channels, applied with their reducers (edit state while paused)
cancel <exec> [-reason TEXT]Cancel an execution
fork <exec> <seq>Branch an execution from a point in its history
rerun <exec> <node>Run again from a node

Other

CommandPurpose
versionPrint the stiggy, phero and packtrail versions and the Go toolchain

Executions can also be inspected, signalled, resumed, forked and cancelled with packtrail's own CLI and packtrail-ui on the fleet's namespace.

Go library

github.com/henomis/stiggy builds the same fleet from options. New validates and compiles without I/O; models and tools are built, and NATS used, only by Run.

nc, _ := nats.Connect(nats.DefaultURL)

app, err := stiggy.New(nc,
    stiggy.WithNamespace("newsroom"),
    stiggy.WithModel("fast", openai.New("ollama",
        openai.WithBaseURL(openai.OllamaBaseURL), openai.WithModel("llama3.2-eco"))),
    stiggy.WithAgent("researcher", spec.Agent{Model: "fast", Role: "Researcher"}),
    stiggy.WithAgent("writer", spec.Agent{Model: "fast", Role: "Writer"}),
    stiggy.WithCrew("article", spec.Crew{Tasks: []spec.Task{
        {ID: "research", Agent: "researcher", Description: "Research {{.input.topic}}."},
        {ID: "write", Agent: "writer", Description: "Write a 200-word article from the research."},
    }}),
)

go app.Run(ctx)
<-app.Ready()

id, _ := app.Client().Start(ctx, "article", map[string]any{"topic": "NATS"})
st, _ := app.Client().Wait(ctx, id)

app.Client() is a packtrail client: signal, resume, update, fork, rerun, watch and stream progress from it. A YAML fleet loads with config and passes to WithFleet.

Options

OptionPurpose
WithFleet(*spec.Fleet)Merge a whole fleet (for example one loaded from YAML)
WithNamespacePacktrail namespace
WithModel / WithModelSpecA phero LLM instance, or a spec resolved by the registry
WithTool / WithToolSpecReady *llm.Tool values, or a spec
WithAgentAn agent (spec.Agent)
WithEmbedder, WithVectorStore, WithKnowledgeRAG building blocks
WithCrew, WithPatternCrews and patterns, expanded into flows
WithFlow(*flow.Flow)A packtrail flow; build agent nodes with AgentTask
WithActivity(name, handler)A Go function run as a step (worker kind act-<name>)
WithRemote, WithExpose, WithScheduleRemote agents, exposure and cron schedules
WithRegistryRegister custom LLM providers, tool types, memory backends, embedders, vector stores
WithRole, WithOnlyRoleAll, RoleEngine, RoleWorkers; restrict the workers
WithLoggerThe *slog.Logger of the app, packtrail's engine, workers and client, and exposed agents (default slog.Default(); see Logs)
WithTracing(...trace.Option)Export spans through a phero trace provider built from the options, such as trace.WithIDSource(exp.IDSource()), trace.WithProcessor(exp) for exp := pherootel.New(tp). Replaces the fleet's tracing; the app shuts the provider down when Run returns, the OTel TracerProvider is yours (see Tracing)
WithEngineOptions, WithWorkerOptionsPass packtrail options through

Defining a name twice is an error (ErrOption). AgentTask(id, agent, step) and ActivityTask(id, activity, step) build task nodes for flows written in Go. App.Plan() returns the compiled plan.

Tools and the job

A Go tool (WithTool) asks which step calls it with stiggy.JobFromContext(ctx). Trust it, not the model's arguments, to know who is calling. It reports false outside a flow step, as for an exposed agent. A delegate's tools see the job of the step that delegates. job.Logger is the step's logger, with the execution's keys and the agent bound: a tool that logs through it joins the step's lines.

post, err := llm.NewTool("post", "Publish a post.",
    func(ctx context.Context, in *struct{ Text string }) (string, error) {
        job, ok := stiggy.JobFromContext(ctx)
        if !ok {
            return "", errNoJob // not a flow step
        }

        var who struct{ Name string }
        if err := job.DecodeItem(&who); err != nil {
            return "", err
        }

        // A retried step calls the tool again and replaces its own post.
        // StepID is not a store key as it is, so hash it.
        sum := sha256.Sum256([]byte(job.StepID))
        key := hex.EncodeToString(sum[:12])

        err := job.Store.Put(ctx, "feed-"+job.ExecID, key,
            map[string]string{"author": who.Name, "text": in.Text})

        return "posted", err
    })

app, err := stiggy.New(nc, stiggy.WithTool("network", post))
FieldHolds
ExecID, Flow, NodeWhere the step is
AgentThe step's agent
StepIDThis run of the step: retries and redeliveries keep it; the next visit of the node and a resume after an interrupt get a new one. Map items get one each
AttemptThe attempt, from 1
IndexThe map item's index; nil outside a map
Input, ItemThe flow input and the map item, as JSON; decode them with DecodeInput and DecodeItem
ChannelsThe flow's channels as the step sees them, as JSON (DecodeChannel). Read only, and without the hidden _mem_<agent> channels
Storepacktrail's long-term store of the fleet's namespace, shared by every worker. Its writes are not in the execution log, so a retried step writes again. Namespaces and keys are tokens ([A-Za-z0-9_-], at most 128 characters)

The decoders return ErrNoJobValue for an absent value. A step writes channels through writes, never through Channels.

Side effects must be idempotent. Delivery is at least once: a retried step (a provider error, a timeout, a worker crash) runs the agent again, and with it every tool call. A tool that posts, writes or sends should key its effect on Job.StepID. A StepID looks like exec/key/gen (map items add #i), which is not a valid Store key: derive the key from it, for example with a hash. See examples/socialsim.

Testing

stiggytest.Start(t, opts...) boots an embedded NATS server, runs the app and returns a harness with the client; Harness.Run(t, flow, input) starts a flow and waits for the execution to end. Fake models (Reply, Replies, Echo) script agent answers deterministically, so end-to-end tests need no API key. Model(fn func(Request) Response) is a fake that can call tools: return Calls, then Text once ToolResults shows they ran.

Examples

Each example is a plain program, written the way you would write your own: it connects to the NATS server at $NATS_URL (default nats://127.0.0.1:4222, with JetStream) and uses gemma4:cloud through a local Ollama.

ExampleShows
examples/helloThe smallest YAML fleet
examples/routerAgent-chosen routing, built with the Go API
examples/translatorAn agent served on its own, without a workflow (translator/), used by two separate workflow programs as a remote step (support/, newsletter/)
examples/mapreduceStructured output, a parallel map, an append channel
examples/hitlask_human and human review, resumed with Client.Resume
examples/crewA crew with a guardrail, also served as a protocol agent
examples/tracingOpenTelemetry spans of a whole execution in Jaeger (docker compose), from Go or from the fleet's tracing section
examples/remoteAnother team's phero agent as a step and as a delegate
examples/socialsimA small social simulation: one persona agent per map item through templated system prompts and memory sessions, Go tools that know their caller (JobFromContext), rounds in a loop, and a what-if fork with breaking news
nats-server -js &
go run ./examples/router
make examples        # the examples' tests, with scripted models
make examples-live   # the same tests, against gemma4:cloud

The tests supply scripted models (stiggytest) through each example's run function, so make examples checks the output exactly. Both targets use the server at $NATS_URL and skip without one.

Architecture rules

No direct NATS access

NATS is the only transport, and stiggy's own state lives in NATS (through packtrail). Agent storage you configure may be any backend phero offers. stiggy reaches NATS only through the public Go APIs of phero and packtrail:

  • it dials NATS only in internal/natsconn and passes the connection to phero and packtrail constructors;
  • it never imports nats.go/jetstream, nats.go/micro or nats-server, never publishes or subscribes on a subject, and never reads or writes phero's or packtrail's subjects, buckets or wire formats.

internal/archguard enforces this with a type-based check over the whole module, tests included (make guard).

phero and packtrail are read-only

When a feature needs something they don't expose, stiggy leaves it out or makes the compiler reject it with a clear error.

Layout

PackageRole
spec/The fleet model and ApplyStep
config/YAML loader: strict decoding, ${ENV}, error lines
compile/Pure Compile(fleet, catalog) reporting every problem
registry/Providers, tool types, memory backends, embedders, vector stores, activities
agentrun/The worker handler that runs a phero agent per job
patterns/Crews and patterns as macros that expand into flows
stiggytest/Embedded NATS, the app and fake LLMs
cmd/stiggy/The CLI

Licensed under Apache 2.0.