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).
The check is on the decorator line;
implementsis documentation (#120 PR 4). Every contract is a function-typed property (readonly run: (input) => …), not a method signature, so its parameters are checked with strict variance: a method that takes less than the contract gives it (retrieve(query: "faq")on a@Rag,findMatches(text: "x")on a@PiiPolicy,refusal(kind: PiiKind.Iban)on a policy with two block rules,capabilities(model: "x/one")on a@ModelProvider) does not compile on the decorator — with or withoutimplements. The decorators whose method's types are known on that line check it there:@Tool/@McpTool,@Rag,@Judge,@PiiPolicy(its detector'sfindMatches, a declarative policy'srefusal(kind)),@InputGuardrail/@OutputGuardrailwithoutrefusal,@Budget,@HealthCheck,@ModelProvider. The generated ones (ActionHandler<Self>,RouterHandler<Self>,FinishHandler<Self>,ReplyInterpreterHandler<Self>, a channel'ssend) are checked byimplementswith the same strict variance. Every relation by class — the kinds inguardrails/piiPolicies/exclude…, a router'sroutesagainst the flow,@Tool({ channel }),routeTo(X)andtoFollowPath([…])in tests — is checked at assembly or in the test, never by the compiler (empty component classes are alike to it). - 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
normalised at load and the normalised text is what is sent and fingerprinted (#120 PR 4): the
byte order mark, \r\n line breaks and blank lines at the edges are removed, the text is Unicode
NFC; indentation, spaces and tabs inside a line and blank lines between paragraphs are kept as
written. The inline prompt and each promptUrls file are normalised on their own and joined with
one blank line (a blank file adds none) — for agents, routers and their routes, guardrails,
compactions, reply interpreters, an arbiter's prompt and criteria, and a profile's prompt
override alike. 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-tool",
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, appliestimeoutand 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, uncompactedTurns, compaction,
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,configHash),step(StepDescriptor:component— its class,name,kind,promptHash,schemaHash—,nodeId,visitNumber,idempotencyKey,callId,itemIndex,copyNumber,toolLoopIteration,validationAttempts) 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-tool",
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 },
},
uncompactedTurns: 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, theClock, theIdGenerator(#313: every thread and execution id the framework mints) and the rest of App → Infrastructure — 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> <full name> --workflow <workflow's full name> (alias agb g; agboss g agent billing-agent --workflow desk-workflow) 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 — declared compensation
- Routers
- Guards
- CLI — agboss
- App — @Application and createApp
- Several instances
- Health checks
- Context and Authorization
- Testing
- The test split
- Suites and Comparisons
- Components
- Naming
- 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)