Harnesses

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);
  1. Run body.
  2. Run terminal. It must set ctx.choice to again or break.
  3. again repeats the body (bag as the terminal left it) until maxIterations.
  4. break ends the loop; the parent continues.
  5. until() and maxIterations(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