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
- A flow step that runs an agent is a packtrail task whose
metaholds 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 + vetThe 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"}' -waitstart 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.
- Load. The YAML loader (or the Go options) builds one
spec.Fleet. Decoding is strict,${ENV}is expanded outsideflows, crews and patterns, and every value remembers its file line for error messages. - Compile.
compile.Compileis 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 compileprints it. - Store the step with the flow. A node with
agent,activityorremotebecomes a packtrail task whosemetaholds 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. - Run.
App.Run(orstiggy 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. - 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.
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.
${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 processollama 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.
| Type | Options |
|---|---|
bash | allowlist, blocklist, safe_mode, working_dir, timeout, max_timeout, max_output_chars |
file_read, file_edit, glob, grep | working_dir, max_file_size (bytes) |
file_write | working_dir, max_file_size, no_overwrite |
skill | root |
kv | bucket |
mcp | command, 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 andask_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 withstiggy 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
| Type | Kept in | Notes |
|---|---|---|
none (default) | nowhere | Every step starts fresh |
execution | the execution's state (hidden channel _mem_<agent>) | Correct under fork, rerun and time travel |
simple | the worker process | max_items (default 100) |
nats | NATS KV | bucket |
psql | PostgreSQL | dsn, table, ensure_schema |
rag | a knowledge index | knowledge |
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, sosession: "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 withuser_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 | recursiveAn agent lists knowledge: [handbook] and gets a search_handbook tool. The engine role ingests the sources at startup when the store is empty.
| Type | Options | distance |
|---|---|---|
qdrant | host, port (default 6334), api_key, use_tls, collection, vector_size, distance | cosine, euclid, dot, manhattan |
psql | dsn, collection, table, vector_size, distance, ensure_extension | cosine, euclid, dot |
weaviate | host, scheme (default http), api_key, class, vector_size, distance | cosine, 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:
| Field | Meaning |
|---|---|
agent | Run a fleet agent |
activity | Run a Go activity (library mode) |
remote | Prompt a remote agent |
prompt | Go template over the job context; default: the input or map item, plus the context outputs |
expected_output | Appended to the prompt; a template like prompt |
context | Nodes whose outputs the prompt includes; default the previous node |
writes | {channel: output} or {channel: output.field} |
routes | Nodes the agent may choose next, and human for ask_human (task nodes only, not in fan-outs or maps) |
human_input | A 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}| Kind | Resume 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
asynctasks run as a fan-out. - hierarchical: a
manageragent assigns the tasks.asyncandguardrailapply to sequential crews only. - guardrail: a checker agent reviews the answer; on failure the task is redone with the feedback, up to
max_retriestimes (default 2). Anasynctask 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}| Type | Behavior |
|---|---|
supervisor | The supervisor routes to one worker at a time; every worker reports back until the supervisor answers without routing. Node ids are the agents' names. |
swarm | Every agent may hand off to any other. The first starts; the flow ends with the first agent that answers without handing off. |
evaluator_optimizer | The evaluator judges with structured output {pass, feedback}; the generator regenerates with the feedback until it passes or max_rounds generations ran (default 3). |
debate | Debaters argue in parallel for rounds rounds (default 2), each seeing the transcript so far; then the judge decides. |
plan_execute | The 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 outputExposed 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.
| Block | What it does | In the fleet |
|---|---|---|
| Start, Output | The workflow's inputs, and what it returns | the flow's start and output |
| AI Agent | A model with instructions does one task, optionally answering with named fields | a model, an agent and a task step |
| AI Router | An agent picks the next step from named routes | a step with routes |
| Loop over items | Runs an agent once per item of a list | a map step |
| Condition | Goes to true or false by comparing values | a choice node |
| Parallel, Merge | Runs branches side by side and waits for all, the first, or all with some allowed to fail | fanout and join |
| Human approval | Waits for a person: approved, rejected or timed out | an await on a signal named after the block, then a choice |
| Run workflow | Runs another workflow of the project | a subflow |
| Agent team | Several agents on one task: crew, supervisor, swarm, write & review, debate, plan & execute | a crew or a pattern, run as a subflow |
| Tools, Knowledge base | Dropped onto an AI block: files, shell, key-value store, MCP server, skills; searchable documents | tools; 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| Role | Runs |
|---|---|
all (default) | Engine and every worker: one process, typical in development |
engine | The 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 |
workers | The 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.
| Level | Lines |
|---|---|
| Info | stiggy: 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. |
| Debug | Each 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.
| Field | Meaning |
|---|---|
stop_reason | Why 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_iterations | The agent loop's iterations and its limit |
ttft | Time to the first token, for an agent with stream: true |
llm_calls, llm_time | Model calls and their summed duration, delegates included |
tool_calls, tool_errors, tool_time, tools | Tool calls; tools lists name:calls/errors/duration per tool |
routes, route_by, route_reason | For 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_wait | For 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_code | What 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_id | The 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}
| Exporter | Output |
|---|---|
otlp | OpenTelemetry spans over OTLP/HTTP, following the GenAI semantic conventions (gen_ai.*). endpoint, insecure, headers and service_name are for it only |
jsonfile | One JSON line per span, with its full payload, to file (required) |
text | An 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
| Command | Purpose |
|---|---|
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.
| Command | Purpose |
|---|---|
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
| Command | Purpose |
|---|---|
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 JSON | Write 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
| Command | Purpose |
|---|---|
version | Print 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
| Option | Purpose |
|---|---|
WithFleet(*spec.Fleet) | Merge a whole fleet (for example one loaded from YAML) |
WithNamespace | Packtrail namespace |
WithModel / WithModelSpec | A phero LLM instance, or a spec resolved by the registry |
WithTool / WithToolSpec | Ready *llm.Tool values, or a spec |
WithAgent | An agent (spec.Agent) |
WithEmbedder, WithVectorStore, WithKnowledge | RAG building blocks |
WithCrew, WithPattern | Crews 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, WithSchedule | Remote agents, exposure and cron schedules |
WithRegistry | Register custom LLM providers, tool types, memory backends, embedders, vector stores |
WithRole, WithOnly | RoleAll, RoleEngine, RoleWorkers; restrict the workers |
WithLogger | The *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, WithWorkerOptions | Pass 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))
| Field | Holds |
|---|---|
ExecID, Flow, Node | Where the step is |
Agent | The step's agent |
StepID | This 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 |
Attempt | The attempt, from 1 |
Index | The map item's index; nil outside a map |
Input, Item | The flow input and the map item, as JSON; decode them with DecodeInput and DecodeItem |
Channels | The flow's channels as the step sees them, as JSON (DecodeChannel). Read only, and without the hidden _mem_<agent> channels |
Store | packtrail'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.
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.
| Example | Shows |
|---|---|
examples/hello | The smallest YAML fleet |
examples/router | Agent-chosen routing, built with the Go API |
examples/translator | An agent served on its own, without a workflow (translator/), used by two separate workflow programs as a remote step (support/, newsletter/) |
examples/mapreduce | Structured output, a parallel map, an append channel |
examples/hitl | ask_human and human review, resumed with Client.Resume |
examples/crew | A crew with a guardrail, also served as a protocol agent |
examples/tracing | OpenTelemetry spans of a whole execution in Jaeger (docker compose), from Go or from the fleet's tracing section |
examples/remote | Another team's phero agent as a step and as a delegate |
examples/socialsim | A 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/natsconnand passes the connection to phero and packtrail constructors; - it never imports
nats.go/jetstream,nats.go/microornats-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
| Package | Role |
|---|---|
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.