Your first workflow
A workflow declares the order of the steps. Every step shares the same state and the same conversation history, so what one agent says, the next one sees.
A step is one of exactly three things:
import { Workflow, parallel, loop, untilAnswered } from "@thenajs/core";
@Workflow({
steps: [
PlannerAgent, // 1. an agent, in sequence
parallel([ExplorerAgent, ReviewerAgent]), // 2. concurrent
loop({
// 3. repetition
steps: [ExecutorAgent],
until: untilAnswered,
maxIterations: 8,
}),
],
})
export class MyWorkflow {}They nest freely — a loop can hold a parallel, which can hold another loop.
Sequence
The simplest form. Each agent runs after the previous one and sees what it said, because the history is shared.
@Workflow({ steps: [ResearcherAgent, WriterAgent] })
export class ArticleWorkflow {}One step's output becomes the next one's own words
An agent's turn enters the history as assistant. The next agent receives that as if it had said it — which is right for a continuing conversation, and wrong when the output should have been context instead.
Parallel
For independent agents looking at the same input:
parallel([SecurityAnalystAgent, PerformanceAnalystAgent]);They all read the same history and run at the same time, and their turns land in the order you declared them. ctx.output ends up being the last declared branch's — read the results from the history, or have each agent write into a field of its own on ctx.
Loop
Where the agent works in several turns — investigate, act, look, repeat:
loop({
steps: [ExecutorAgent],
until: untilAnswered,
maxIterations: 8,
onExhausted: (ctx, n) => console.warn(`hit the ceiling after ${n} turns`),
});until returns true to stop. untilAnswered is the built-in one that means "stop when the agent answered without calling a tool" — the usual end of an investigate-and-act loop.
maxIterations defaults to 10, and it is not optional in spirit: without a ceiling, a model that never converges runs forever.
A complete example
A reviewer that reads, judges and repeats until it approves. The state is what ties it together:
// src/workflows/review.state.ts
export class ReviewState {
approved = false;
rounds = 0;
}// src/workflows/review.workflow.ts
@Workflow({
state: ReviewState,
steps: [
PlannerAgent, // decides what to look at
loop({
steps: [ReaderAgent, ReviewerAgent],
until: (_ctx, s: ReviewState) => s.approved,
maxIterations: 5,
onExhausted: (_ctx, rounds) =>
console.warn(`did not approve in ${rounds} rounds`),
}),
],
})
export class ReviewWorkflow {}// the reviewer writes the decision that `until` reads
import { state } from "@thenajs/core";
export class ReviewerAgent {
constructor(@state() private readonly s: ReviewState) {}
async afterResponse(response: string) {
this.s.rounds++;
this.s.approved = response.includes("APPROVED");
}
}Note the second parameter of until: it is the ReviewState instance for this run. Without something writing approved, the condition would never become true and the loop would run to the ceiling every time — the most common mistake when building a loop with your own stopping criterion.
Running it
// src/main.ts
import { Thena } from "@thenajs/core";
import { ReviewWorkflow } from "./workflows/review.workflow";
import { config } from "./config";
const app = Thena.create(ReviewWorkflow, config);
const verdict = await app.run({
prompt: "Review the src/ directory",
state: { memory: ["userId: 123"] }, // durable context, becomes a `system` message
});
await app.dispose();run returns the output and propagates the error — printing is your application's job, not the framework's. Concurrent calls are safe: each one opens its own run context.
Next: configuration.
