Harnesses
A Harness is a deterministic workflow of steps: agents, loops, branches, and your own IStep classes. The graph is the default path. An optional manager agent only runs on control events (errors, in-flight questions, pause).
This is different from a SubAgent tool, where a parent model decides to call a child. A harness declares the order.
Create a harness
import { Agent, App, Graph, Harness, Model, Secret, ApiTrigger } from "@shuttl-io/core";
const triage = new Agent({
name: "email-triage",
systemPrompt: `Classify the email. Set your entire reply to one of: bug_report, feature_request, other.`,
model: Model.openAI("gpt-4o", Secret.fromEnv("OPENAI_KEY")),
});
const writer = new Agent({
name: "writer",
systemPrompt: "Draft a reply from the bag's prompt and any feedback.",
model: Model.openAI("gpt-4o", Secret.fromEnv("OPENAI_KEY")),
});
const inbox = new Harness("inbox", {
triggers: [new ApiTrigger()],
// managerAgent: supervisor, // optional
});
inbox.choice(triage)
.when("bug_report", writer)
.when("feature_request", writer)
.otherwise(writer);
const app = new App("support");
app.addHarness(inbox);
Register harnesses with app.addHarness. Chat (shuttl dev) and invokeAgent use the same name as agents. Triggers on the harness call Harness.invoke through next.invoke(prompt).
HarnessProps
| Property | Type | Description |
|---|---|---|
managerAgent |
Agent? |
Optional supervisor. Runs only on errors, pause, or an invoke while a run is in flight. |
triggers |
ITrigger[]? |
How the harness is activated. Unlike Agent, there is no default ApiTrigger. |
outcomes |
IOutcome[]? |
Default outcomes when a trigger does not bind its own. |
Harness.invoke(prompt, threadId?, streamer?, attachments?) matches Agent.invoke.
Composition: sequence vs parallel
next returns an IStep. Chaining .next on that return value sequences the branch. Calling .next again on the same graph adds a sibling that runs in parallel.
harness.next(a).next(b); // a then b
harness.next(a);
harness.next(b); // a and b at the same time
Graph is the same fluent IStep without triggers. Use it to name subgraphs:
const research = new Graph("research").next(search).next(summarize);
// Join two subgraphs, then continue:
harness.parallel(research, draft).next(editor);
parallel(left, right) is two-arg sugar (JSII has no variadic IStep[]). For N-way parallel, call next N times on the same parent.
Agent.next(other) returns a new chain and does not mutate the agent.
Context bag
Every run has one HarnessContext. Steps read and write keys. Missing keys are null.
| Key | Meaning |
|---|---|
prompt |
Seeded from invoke / the trigger prompt |
output |
Last step's primary text |
<stepName> |
That step's payload (use this in parallel so writes do not clobber output only) |
choice |
Branch label (bug_report, again, break, …) |
feedback |
Convention for loop terminals |
ctx.get("prompt");
ctx.set("choice", "bug_report");
ctx.keys();
ctx.snapshot(); // redacted clone for logs / the manager
An agent used as a step is invoked with a prompt built from prompt, plus output and feedback when present. Its final output_text is written to ctx.output and ctx.<agentName>.
Triggers still call next.invoke(prompt) with a string (or prompt contents). Put structured fields in that prompt, or parse them in a custom IStep.
Choice
graph.choice(evaluator) // run evaluator, then branch on ctx.choice
.when("bug_report", bugFix)
.when("feature_request", feature)
.otherwise(drop) // not `.else()` — reserved in Python
.next(research); // after the chosen arm
graph.choiceKey("route") // no extra run; branch on an existing bag key
.when("b", armB)
.otherwise(drop);
The evaluator must set("choice", label) (or a short output_text is used as the label). Unknown labels run otherwise, or fail the step if otherwise is missing.
Loops
new Graph("code")
.loop(new Graph("implement").next(coder))
.until(reviewer)
.maxIterations(5);
- Run
body. - Run
terminal. It must setctx.choicetoagainorbreak. againrepeats the body (bag as the terminal left it) untilmaxIterations.breakends the loop; the parent continues.until()andmaxIterations(n)(n >= 1) are required.
The reviewer can set("feedback", "…") for the next iteration.
Custom ISteps
Prefer extending Step so .next() does not mutate your instance:
import { Step, HarnessContext, IStep } from "@shuttl-io/core";
export class ParseEmail extends Step {
public constructor() {
super("parse-email");
}
public async run(ctx: HarnessContext): Promise<void> {
const raw = String(ctx.get("prompt") ?? "");
ctx.set("subject", raw.split("\n")[0]);
ctx.set("output", raw);
}
}
harness.next(new ParseEmail()).next(triage);
You can also implement IStep directly (name, run, next). In Python, implement the interface with @jsii.implements(IStep) rather than subclassing the interface (same rule as ITool). Subclassing Step is the usual path.
run writes the bag and returns void. Do not use JS lambdas as steps — they are not JSII-safe.
Manager (optional)
The manager is not a step. On a step error, max-iterations, or an invoke while a run is in flight, Shuttl invokes managerAgent with a redacted bag snapshot and these tools:
| Tool | Effect |
|---|---|
ask_step |
invoke a nested agent. Does not move the cursor. |
inject |
ctx.set(key, value) |
retry |
Re-run the failed step |
skip |
Mark the step done and continue |
answer |
Reply to the user only |
Without a manager, in-flight chat and step errors fail the run.
Idle invoke always starts a new run (one bag per run, not per thread).
shuttl dev
Harnesses appear in the graph as kind: "harness" (id: harness:<slug>). Agent steps use agent:<path> so they light up like other agents. Loops reuse the same node ids; iteration is on the span payload.
Next steps
- 🧠Agents — models, prompts,
invoke - ⚡ Triggers — how a harness is activated
- 📤 Outcomes — where tokens go