Repository navigation
Components
A workflow is built from annotated classes, one per file, in folders by kind — Angular style.
Decorators carry metadata; behaviour is a contract the class implements (like implements OnInit);
dependencies come through the constructor. The compiler checks every link. Decision:
ADR-0009. Every block on this page
compiles against the current package.
examples/job-scout/src/
job-scout.workflow.ts @Workflow — the module (the paths, defaults, providers, maxSteps)
workflow-starts/ routers/ agents/ actions/ workflow-finishes/
tools/ services/ mcp/ rag/ helpers/ environments/
The example examples/job-scout uses every kind below — see Example.
Every component reads the same way, top to bottom:
-
The decorator holds metadata (name, description, input / output DTOs,
deps). A flow node'snameis kebab-case and ends with its kind —support-agent,main-router,charge-action,chat-workflow-start,chat-workflow-finish,shop-workflow([name.kind-suffix]) — explicit, never derived from the class, and unique in the application: it keys records, checkpoints, traces, golden sets and the generated types. -
The constructor holds the dependencies —
private readonlyparameters, each named after its type (ordersService: OrdersService); the execution comes the same way (deps: [ExecutionContext], below). -
The methods implement a contract:
- your own implementation →
implementsthe contract (ToolHandler<In, Out>for@Tool/@McpTool,ActionHandler<Self>for@Action,RouterHandler<Self>for a code@Router(decidedBy: DecidedBy.Code, Routers),FinishHandler<Self>for a@WorkflowFinishwithinput,RagConnectorfor@Rag,JudgeHandler<TReply>for@Judge); - extending a standard implementation →
extendsit (SqliteFtsConnector,McpServerClient) and mark overridden methodsoverride(noImplicitOverrideis on).
- your own implementation →
-
Data is a DTO: a class with one field decorator per field (
agboss/dto), so the class is both the type and the runtime check. See DTOs. -
Input/output lives in services (
@Injectable,*.service.ts: HTTP, files, model calls); helpers are pure (*.helper.ts: parsing, filters, scores).
One component per file, the kind in the name; agboss create / agboss generate follow them:
| File | Holds |
|---|---|
<name>.workflow.ts |
the @Workflow module |
workflow-starts/<name>.workflow-start.ts |
a @WorkflowStart — where a run starts (Workflow) |
routers/<name>.router.ts |
a @Router with its routes (Routers) |
actions/<name>.action.ts |
an @Action — code without a model |
arbiters/<name>.arbiter.ts |
an @Arbiter — whether an answer counts for a quorum (Parallel) |
agents/<name>-agent.options.ts |
an options const two agents share (satisfies Omit<AgentOptions, …>) |
workflow-finishes/<name>.workflow-finish.ts |
a @WorkflowFinish — where a run finishes |
agents/<name>.agent.ts + <name>.prompt.md
|
an @Agent; its prompt file in promptUrls: ["./<name>.prompt.md"]
|
tools/<name>.tool.ts + <name>.tool.test.ts
|
a @Tool and its test |
tools/<name>.dto.ts, mcp/<name>.dto.ts
|
the DTOs of the tools / server next to it (plain data classes) |
mcp/<name>.server.ts |
an @McpServer and the server tools it uses (extends McpServerClient) |
mcp/<name>.mcp.ts |
an @McpTool — a tool with its server injected |
rag/<name>.rag.ts |
a @Rag knowledge base |
services/<name>.service.ts |
an @Injectable (like an Angular service) |
helpers/<name>.helper.ts |
plain functions |
environments/<name>.environment.ts |
an environment's values (Environments) |
Prompts are prompt (inline) or promptUrls (files next to the component, read relative to the
declaring module), plus promptVariables; {{variables}} are checked at assembly
([prompt.unknown-variable]), a missing file fails with [prompt.missing-file]. Prompts are
instructions only: what a node reads comes in its input message (Path scope), never
as {{input.…}} ([prompt.input-in-instructions]).
import { OneOf, Text } from "agboss/dto";
export class OrderQuery {
@Text({ prompt: "the order id, e.g. A-1042" })
orderId!: string;
}
export class OrderStatus {
@OneOf({ values: ["placed", "shipped", "delivered"] })
status!: "placed" | "shipped" | "delivered";
@Text({ prompt: "expected delivery day, YYYY-MM-DD" })
eta!: string;
}import { Injectable } from "agboss";
import type { OrderStatus } from "../tools/search-orders.dto.js";
@Injectable()
export class OrdersService {
find(orderId: string): Promise<OrderStatus> {
return Promise.resolve({ status: orderId.startsWith("A") ? "shipped" : "placed", eta: "2026-11-02" });
}
}import { ExecutionContext, Tool, type ToolHandler } from "agboss";
import { OrdersService } from "../services/orders.service.js";
import { OrderQuery, OrderStatus } from "./search-orders.dto.js";
@Tool({
name: "search_orders",
description: "Find an order's status by its id",
input: OrderQuery,
output: OrderStatus,
deps: [ExecutionContext, OrdersService],
})
export class SearchOrders implements ToolHandler<OrderQuery, OrderStatus> {
constructor(
private readonly executionContext: ExecutionContext,
private readonly ordersService: OrdersService,
) {}
run(query: OrderQuery): Promise<OrderStatus> {
this.executionContext.signal.throwIfAborted();
return this.ordersService.find(query.orderId);
}
}-
runis checked against the decorator's DTOs (a mismatch is a compile error); the core validates input and output, appliestimeoutMsand returns errors to the model (invalid input: …). -
depslists the constructor's dependencies in order — a missing, extra or reordered entry is a compile error (standard decorators have no parameter-type metadata, so they are listed once). -
rungets only its input — there is noctxparameter (#280); the execution, its step and the call's idempotency key come from the injectedExecutionContext. - Test it on its own with
testTool(SearchOrders, { overrides: [override(OrderBook, FakeOrderBook)] })— its own container, the frame of a tool call, nonew(Tools, Testing).
Data shapes — tool inputs and outputs, server tools, the inputs and outputs of flow nodes — are
classes with one field decorator per field, imported from agboss/dto. A DTO on a path (a
node's input / output, .returns(…), caseOf(…)) ends with Dto ([dto.name]) and its class
name is unique in the application ([dto.duplicate-name]); on paths they are wrapped as
listOf(Dto), oneOf(A, B), optional(Dto) (input only), historyOf(Dto, { limit }) (input
only: the current value and its earlier versions, Path scope) and
variant(Dto, value) (an action returning one class of its oneOf). zod stays inside the framework.
Decision: ADR-0013; ready-made ones: Standard DTOs.
import { Integer, ListOf, OneOf, Text } from "agboss/dto";
export class JobQuery {
@Text({ prompt: "what the user wants", minLength: 20 })
profile!: string;
@ListOf(Text, { prompt: "places or cities", minItems: 1 })
locations!: string[];
@Integer({ prompt: "how many jobs", min: 1, max: 50, default: 20 })
count!: number;
@OneOf({ values: ["remote", "office"], optional: true })
mode?: "remote" | "office";
}| Decorator | Field type | Checked at runtime |
|---|---|---|
@Text({ minLength?, maxLength?, pattern? }) |
string |
length; pattern — your own format (say it in prompt) |
@Integer({ min?, max? }) / @Decimal({ min?, max? })
|
number |
whole / finite number, range |
@Flag() |
boolean |
— |
@Email(), @Url(), @Uuid()
|
string |
format |
@DateTime() / @Duration()
|
string |
ISO 8601; a date-time needs an offset or Z
|
@DecimalString({ scale? }) |
string |
"19.99", at most scale digits after the point |
@CurrencyCode() / @CountryCode()
|
string |
ISO 4217 (USD) / ISO 3166-1 alpha-2 (IL), from lists |
@PhoneNumber() / @MediaType() / @TimeZone()
|
string |
E.164 / type/subtype / IANA zone (or UTC) |
@OneOf({ values }) |
their union | one of the values |
@ListOf(Dto or Text…, { minItems?, maxItems? }) / @Nested(Dto)
|
T[] / T
|
each element / the nested DTO |
Every decorator takes prompt (what the model reads — the field's JSON Schema description),
optional: true or a default, sensitive: PiiAction.… (#120: a masking action applied to the
field on its way into a model and wherever the arguments are shown — Guards → PII
policies) and example.
-
The compiler checks: the decorator fits the field's type (
@Texton anumber),optional: true↔name?:both ways,@OneOfvalues ≡ the field's union, the element type of@ListOf/@Nested. -
At runtime (a
DtoValidationErrorwith the field path and the reason): integer vs decimal, formats, lengths, ranges, list sizes, code lists, cross-field rules of the standard DTOs. -
At startup: a field without a decorator →
dto.undecorated-field; methods, accessors or#privatefields →dto.not-plain-data. A DTO is plain data, so object literals fit it. - Field order is the declaration order — the order the model reads and fills the fields in.
-
Names: a tool's input and output are a pair from one noun —
FileRead→FileContent,JobQuery→JobMatches. A tool that takes nothing:input: NoInputDto. A flow DTO ends withDto(OrderRequestDto,OfferDraftDto).
import { Agent } from "agboss";
import { Reasoning } from "agboss/models";
@Agent({
name: "support-agent",
description: "Answers questions about orders", // what routers read
promptUrls: ["./support.prompt.md"], // next to this file; or prompt: "…"
model: "moonshotai/kimi-k2.6",
reasoning: Reasoning.off(),
maxTokens: 2000,
tools: [SearchOrders],
})
export class SupportAgent {}Without input / output it is a text agent: it reads the message (WorkflowStartTextDto)
and answers text. With them it reads its fields from the path's scope and answers through answer
tools (Agents):
@Agent({
name: "editor-agent",
description: "Rewrites the draft by the review notes",
promptUrls: ["./editor.prompt.md"],
model: "moonshotai/kimi-k2.6",
input: { draft: OfferDraftDto, changes: ChangesRequestedDto }, // read by type from the path
output: OfferDraftDto, // or oneOf(ADto, BDto), listOf(XDto); what it is about: in the flow
maxVisits: 2, // the third edit is refused before its model call
})
export class EditorAgent {}Two stages of the same work are two classes sharing an options const — one class is one node:
// agents/reviewer-agent.options.ts
export const reviewerAgentOptions = {
promptUrls: ["./reviewer.prompt.md"],
model: "moonshotai/kimi-k2.6",
input: { article: ArticleDto },
output: ReviewDto,
} satisfies Omit<AgentOptions, "name" | "description">;
// agents/final-reviewer.agent.ts
@Agent({ ...reviewerAgentOptions, name: "final-reviewer-agent", description: "Reviews before publishing" })
export class FinalReviewerAgent {}The options must be a static object (a call or a conditional spread → [agent.dynamic-options]).
The other options — temperature, cache, maxToolCalls, historyLimit, historySummaries,
judges + maxRetries, rag (RagMode.Tool / RagMode.Context), memoryStrategy, guardrails
and PII policies — are on Configuration and the pages of each feature. How the
agent runs: Agents.
Code without a model, a node of the flow (.toAction(ChargeAction)):
@Action({
name: "charge-action",
description: "Charges the card",
input: { order: OrderDto },
output: ReceiptDto,
deps: [ExecutionContext, PaymentsService],
})
export class ChargeAction implements ActionHandler<ChargeAction> {
constructor(
private readonly executionContext: ExecutionContext,
private readonly paymentsService: PaymentsService,
) {}
async execute(input: InputOf<ChargeAction>): Promise<OutputOf<ChargeAction>> {
// each field is { value, producer }; the step's key is the charge's idempotency key
return this.paymentsService.charge(
input.order.value,
this.executionContext.step.idempotencyKey,
);
}
}An action gets only its input; the execution comes through deps: [ExecutionContext]. What it
throws is the step's failure (ActionFailedError, the original in cause), taken by an
.onError(…) right after it (Rollback).
An @Arbiter (#279) decides whether one answer of a parallel launch counts for its quorum; it is
attached in the flow with .acceptedBy(X) and never chooses where to go. decidedBy: DecidedBy.Model — model, prompt, one criteria entry per agent of the launch;
DecidedBy.Code — one @OnAnswer(Agent) from<Agent>(answer: XDto): boolean method per agent class
(the plain answer; the copy or item through deps: [ExecutionContext]). No …Handler interface:
@OnAnswer(Agent) types the method itself, so a wrong answer type, return type or an extra
parameter does not compile; coverage is [arbiter.unhandled-agent] at assembly. The whole contract
is on Parallel.
ExecutionContext (#280) is the one context of an execution — the type and the DI token. Any
component takes it through its constructor, like every other dependency, and names the parameter
executionContext:
import { ExecutionContext, Injectable } from "agboss";
/** A shared service: called from an action or a tool, it reads the step that called it. */
@Injectable({ deps: [ExecutionContext] })
export class AuditService {
readonly records: AuditRecordDto[] = [];
constructor(private readonly executionContext: ExecutionContext) {}
record(): AuditRecordDto {
const { executionId, step } = this.executionContext;
const entry = { executionId, key: step.idempotencyKey };
this.records.push(entry);
return entry;
}
}-
Data only, read-only:
executionId,threadId,ownerId,signal,workflow(WorkflowDescriptor:type,name,version,startedVersion),step(StepDescriptor:component,nodeId,visitNumber,idempotencyKey,callId,itemIndex,copyNumber) and the tree (parent,root,depth,itemIndex— a per-item child's, #263 —executionOf(W)). No container, no I/O, no input, no metadata: domain data come throughinput, anything that calls out is a service. -
Live, not a snapshot: every read gives the current execution and step — one frame per node
visit and per tool call — so a service (one instance for the app, like the one above) is safe in parallel
branches. A read outside an execution (a constructor at start) or after its step ended (a
forgotten promise) throws
ExecutionContextUnavailableError(context.unavailable). -
Parent and root are objects (
parent?.executionId,root.executionId);executionOf(W)finds the nearest execution of@WorkflowWin the chain. All three return the project's own subclass type (this). Above it,executionContext.tenant(TenantContext) andexecutionContext.tenant.application(ApplicationContext) — the levels, and the project's subclasses declared in@Application({ tenantContext, executionContext })(#261).
Field reference: Context and Authorization.
What agboss generate, the examples and these pages follow — a style guide, not checked:
- an explicit return type on every method;
-
implements …Handlerwhere the kind has one (ToolHandler<In, Out>,ActionHandler<Self>,RouterHandler<Self>,FinishHandler<Self>,JudgeHandler<TReply>,ChannelHandler); -
@Injectableclasses named by role:…Repository(database access),…Service(calls to an external service),…Provider(system things the app runs on); - injected fields named after their type (
ordersService: OrdersService); the execution context's parameter isexecutionContext; - no decision by matching text (
text.includes("refund")): a model turns text into a typed DTO, code decides by types and enums.
A server declares the server tools the workflow uses; an MCP tool is a tool with the server injected (more: Mcp):
import { ListOf, Text } from "agboss/dto";
export class FileRead {
@Text({ prompt: "path inside the shortlist folder" }) path!: string;
}
export class FileContent {
@Text({ prompt: "the file's text" }) content!: string;
}
export class FileWrite {
@Text() path!: string;
@Text() content!: string;
}
export class FileWritten {
@Text() content!: string;
}
export class ChosenJobs {
@ListOf(Text, { prompt: "links of the jobs the user chose", minItems: 1 }) jobs!: string[];
}
export class SavedJobs {
@ListOf(Text) added!: string[];
}import { McpServer, McpServerClient } from "agboss/mcp";
import { FileContent, FileRead, FileWrite, FileWritten } from "./shortlist.dto.js";
const tools = {
read_text_file: { input: FileRead, output: FileContent },
write_file: { input: FileWrite, output: FileWritten },
};
@McpServer({
name: "shortlist",
transport: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "./shortlist"],
tools,
})
export class ShortlistServer extends McpServerClient<typeof tools> {}import { InjectionToken, TerminalUserChannel, type ToolHandler } from "agboss";
import { McpTool } from "agboss/mcp";
import { ChosenJobs, SavedJobs } from "./shortlist.dto.js";
import { ShortlistServer } from "./shortlist.server.js";
export const SHORTLIST = new InjectionToken<string>("SHORTLIST");
@McpTool({
server: ShortlistServer,
name: "save_shortlist",
description: "Save the jobs the user chose to their shortlist",
channel: TerminalUserChannel, // a write the user approves first
input: ChosenJobs,
output: SavedJobs,
deps: [ShortlistServer, SHORTLIST],
})
export class SaveShortlist implements ToolHandler<ChosenJobs, SavedJobs> {
constructor(
private readonly server: ShortlistServer,
private readonly file: string,
) {}
async run({ jobs }: ChosenJobs): Promise<SavedJobs> {
const { content } = await this.server.call("read_text_file", { path: this.file }); // typed by `tools`
const added = jobs.filter((job) => !content.includes(job));
await this.server.call("write_file", { path: this.file, content: [content, ...added].join("\n") });
return { added };
}
}-
server.call(name, args)— the name and arguments are checked by the compiler (by the DTOs' fields), validated and parsed by the declared DTOs. - At startup every declared server tool must exist on the server with a compatible input schema, or the app stops naming it.
- Test without a process:
mcpServerStub(ShortlistServer, { write_file: … })in a unit test, ormcpOf(ShortlistServer)in a workflow test (Testing).
@Rag classes in rag/, bound by agents with a required mode — see Knowledge bases.
import { TerminalUserChannel, Workflow, provide, route, start } from "agboss";
import { WorkflowStartTextDto } from "agboss/dto";
import { ShortlistServer } from "./mcp/shortlist.server.js";
import { OrdersService } from "./services/orders.service.js";
@Workflow({
name: "shop-workflow",
version: "2.0.0",
flow: [
start(ChatWorkflowStart)
.returns(WorkflowStartTextDto)
.toRouter(MainRouter)
.chooseOne(
route(SupportAgent)
.toFinish(ChatWorkflowFinish),
route(ShortlistAgent)
.toFinish(ChatWorkflowFinish),
),
],
defaults: {
models: { temperature: 0 },
router: { model: "typesafe/jev-1.13" },
tools: { maxToolCalls: 8 },
history: { limit: 5 },
},
maxSteps: 6,
mcp: [ShortlistServer],
providers: [OrdersService, provide(SHORTLIST, "./shortlist/shortlist.md")],
})
export class Shop {} // money: the application's @Budgets (FinOps → Budgets)-
providersregisters what components inject: classes (@Injectable({ deps }), created by the container) and values (provide(TOKEN, value)for anInjectionToken<T>; a raw{ provide, useValue }does not compile). The core providesExecutionContext(the current execution, above),JudgeModel(a judge's own model, Judges),ROUTER_FACTORY(router engines for cheap decisions inside components — their spend during a tool call is the tool's) and the context levels —ApplicationContext(with the environment, typed by@Application({ environment })),TenantContextand the declared subclasses (Context and Authorization). -
One container per application instance, no scopes (#261): it holds the providers of the
@Applicationand of every workflow it lists, one instance per class (OrdersServicelisted by two workflows is one instance), every listed class created at start. There is noscopeoption and no per-execution container. -
Infrastructure —
CostLedgerRepository,ExecutionRecordRepository,PausedExecutionRepository,BaseCheckpointSaver— is listed only in@Application({ providers }); a class that extends one in a workflow'sprovidersfails with[di.infrastructure-in-workflow](App → Infrastructure). -
Implementation by inheritance: a dependency on a contract (an abstract class) gets the one listed
class that is it or
extendsit — the same instance as asking for that class; none listed → the framework's default implementation of the contract, if it has one; two →[di.ambiguous-implementation] AuditService depends on AuditRepository, and 2 listed classes implement it: …. -
No
new: the container creates every instance.providerstake classes; values go only with anInjectionToken—provide(AuditRepository, new MemoryAuditRepository())fails with[di.instance-provider]. One token with two different values (in two workflows, or twice in one) fails with[di.conflicting-values]— use different tokens or an environment value. -
Assembly (
workflowOf/createApp, before the first request): the dependency graph is checked and every instance is created once, dependencies first. A cycle, an unregistered provider (Consumer: "TOKEN" is not registered …), an undecorated class inproviders([di.undecorated-provider]), a channel role with no or two implementations in@Application({ providers })([di.missing-implementation],[di.ambiguous-implementation], #267), a missing prompt file or prompt variable fail with the component's name. -
Lifecycle hooks:
implements OnInit, OnStart, OnStop, OnDestroy, each optional (App → Lifecycle hooks).
One execution is not tied to one application instance: after a pause another instance may continue
it. So a component holds no state of an execution — neither a field set in a method nor a
Map<executionId, …>. It reads the execution from the live ExecutionContext; data that must outlive
a step live in the graph state (a node's output on the path); caches and deduplication are your own
service over external storage:
@Injectable({ deps: [RedisClientProvider] })
export class SeenJobsRepository {
constructor(private readonly redisClientProvider: RedisClientProvider) {}
async markSeen(executionId: string, jobId: string): Promise<boolean> {
const added: number = await this.redisClientProvider.client.sAdd(`seen:${executionId}`, jobId);
return added === 1;
}
}The lint rule agboss/no-component-state flags an assignment to this.x (=, +=, ++) outside
the constructor of any component (@Injectable, @Tool, @McpTool, @Rag, @Action, a code
@Router, @Judge, @InputGuardrail / @OutputGuardrail, @PiiPolicy, @Channel, @A2AAgent).
-
agboss describeshows each tool's dependencies.
-
a tool —
tools/<name>.dto.ts+tools/<name>.tool.ts, listed in an agent'stools; a service it needs goes inproviders. -
an agent —
agents/<name>.agent.ts(+<name>.prompt.md), one route in its router'sroutes, and its path in the router's choice (route(NewAgent).toFinish(…)). -
an action —
actions/<name>.action.ts, a step of a path (.toAction(NewAction)). -
an MCP server —
@McpServer+@McpToolclasses inmcp/, the server in@Workflow({ mcp }).
agboss generate <kind> <name> --workflow <path> (alias agb g) writes and wires each of them,
then regenerates .agboss/types.d.ts (CLI).
AGBoss
- Home
- Delivery Pipeline
- Architecture
- Configuration
- Environments
- Workflow — the paths
- Path scope
- Workflow as a step
- Parallel — quorums and arbiters
- Routing patterns
- Rollback
- Routers
- Guards
- CLI — agboss
- App — @Application and createApp
- Several instances
- Context and Authorization
- Testing
- The test split
- Suites and Comparisons
- Components
- Standard DTOs
- Examples: job-scout, flow-patterns
- Agents — the loop
- Model providers
- Tools
- Knowledge bases (RAG)
- FinOps
- Tracing
- Observability
- Terns and evaluation
- Profiles and comparisons
- Pauses — pending, resume by id
- Pause seam
- Decisions (ADR)