AGBoss (agboss.dev) is an open-source TypeScript framework with static verification for complex multi-agent workflows on top of LangGraph. Agents, actions, routers, arbiters, tools, MCP servers, A2A services, and knowledge bases are strictly typed decorator components β assembled in an @Application container with constructor dependency injection, compile-time structural Path Scope verification (agboss check), declared action compensation ( compensateWith / committedBy) with explicit rollback side paths (.onError / .onJoinError to an undo @Action) and automatic LIFO compensation, two-phase human-in-the-loop s (@ChannelRole), pre-call FinOps budgets (@Budget), boundary PII token masking (@PiiPolicy), multi-instance SQLite WAL leases and child work-stealing (ChildPlacement.AnyInstance), signed Agent Mesh envelopes (@A2AExpose, @A2AAgent, agboss hub), and a 5-loop evaluation & testing pipeline (@EvalSuite, testWith + 10 unit runners).
Current release (v0.1.0): @agboss/core@0.1.0 (production runtime) and @agboss/cli@0.1.0 (CLI & static workflow compiler) are publicly published on registry.npmjs.org under the official agboss npm organization.
Documentation: https://agboss.dev Β· Quick Start: https://agboss.dev/getting-started/quick-start/ Β· CLI Reference: https://agboss.dev/cli/ Β· Examples: https://github.com/agbossjs/agboss-examples
npm exec --yes --package=@agboss/cli@0.1.0 -- agboss create my-app
cd my-app
npm install
npm exec --yes --package=@agboss/cli@0.1.0 -- agboss create my-app (also available via npx --yes --package=@agboss/cli@0.1.0 agboss create my-app) scaffolds a complete project where package.json already lists @agboss/core in dependencies and @agboss/cli in devDependencies, alongside AGENTS.md and CLAUDE.md (@AGENTS.md). Running npm install triggers "postinstall": "npm run types", which runs agboss check, generates .agboss/types.d.ts for TypeScript, and unpacks 13 version-matched reference guides into .claude/skills/agboss/ (SKILL.md + references/01..13) for your AI coding agent.
cp .env.example .env && npm run chat
npx agboss g tool refund-tool --workflow my-app-workflow --agent my-app-assistant-agent
npx agboss g openapi pets --url ./pets.yaml --workflow my-app-workflow --agent my-app-assistant-agent
npx agboss g workflow onboarding-workflow # a second workflow, listed in the @Application
npm install @agboss/core
npm install -D @agboss/cli
npx agboss check
Why two packages?
@agboss/core(dependencies) β the lightweight production runtime (decorators, execution graph, DI container, storage repository contracts, subpath entries@agboss/core/dto,@agboss/core/testing,@agboss/core/eval,@agboss/core/eslint, schemas, and bundled AI coding skills) that ships into your Docker / Kubernetes image.@agboss/cli(devDependencies) β the static graph compiler (agboss check), code generator (agboss generate), dev watcher (agboss dev), LangGraph Studio bridge (agboss studio), and local visual UI servers (agboss eval ui,agboss hub ui) that stay out of production container images.
// src/agents/scout.agent.ts β the prompt is a file next to the agent
import { Agent } from "@agboss/core";
@Agent({
name: "scout-agent",
description: "Finds jobs",
promptUrls: ["./scout.prompt.md"],
model: "moonshotai/kimi-k2.6",
tools: [GreenhouseJobs],
})
export class ScoutAgent {}
// src/job-scout.workflow.ts β the paths, defaults and providers; money is the application's budgets
import { Workflow, route, start } from "@agboss/core";
import { WorkflowStartTextDto } from "@agboss/core/dto";
@Workflow({
name: "job-scout-workflow",
version: "1.0.0",
flow: [
start(ChatWorkflowStart)
.returns(WorkflowStartTextDto)
.toRouter(MainRouter) // picks one of its @Router({ routes })
.chooseOne(
route(ProfilerAgent)
.toFinish(ChatWorkflowFinish),
route(ScoutAgent)
.toFinish(ChatWorkflowFinish),
),
],
defaults: {
models: { temperature: 0 },
router: { model: "typesafe/jev-1.13" },
tools: { maxToolCalls: 8 },
},
uncompactedTurns: 5, // the last 5 turns of the thread in full (older ones: a @Compaction's summaries)
providers: [JobFitJudge],
})
export class JobScout {}
The application is the one entry (#261): it lists the workflows, the infrastructure (a class that
extends CostLedgerRepository, ExecutionRecordRepository, dExecutionRepository,
LangGraph's BaseCheckpointSaver or one of the shared contracts of #265 β ExecutionLeaseRepository,
ExecutionTreeRepository, RateLimitRepository; none listed β the built-in in-memory ones, nothing
written to the home folder; the SQLite ones below β each with a fast ping() readiness calls, #300),
its model providers, every budget and its health checks (healthChecks).
createApp and testWith take it, never a workflow.
Model providers are infrastructure too (#266): @ModelProvider classes listed in
@Application({ providers }) β none listed β the built-in OpenRouterModelProvider (chat) and
DecisionsModelProvider (Jev and the other decision models); one listed β the list is complete,
the built-ins are not added next to it. A workflow and its components only name models; every model
of the application (agents, model routers, the default router, guardrails, compactions, judges) resolves
to exactly one listed provider by serves at assembly β none β [model.unserved], two β
[model.ambiguous-provider] (one model through two providers = two model strings, each in one
serves; the built-ins yield a model another listed provider claims, #454). agboss describe prints model: β¦ β <provider> for each, agboss check --models checks
every model of every workflow of the application against its provider:
A provider's policies are app-wide too: retryPolicy retries one call (attempts, exponential
backoff with jitter, Retry-After honoured β the only place backoff is set); rateLimitPolicy
(RateLimitPolicy.of({ maxConcurrentCalls, perMinute: { calls, tokens }, balancing })) is one limit
for the whole application β every execution, tenant, parallel branch and instance shares it through
RateLimitRepository β a call without room waits (its signal ends the wait, its timeout starts only
with the room), waiting tenants share free slots by QueueBalancingStrategy (TenantRoundRobin by
default, TenantLeastInFlight, TenantWeighted by tenant.weight, Fifo), and a 429 s the
provider's whole queue for Retry-After on every instance, then halves this instance's
concurrency and grows it back by one a minute; circuitBreakerPolicy stops calling a provider that
is down β its state in RateLimitRepository as well. Without rateLimitPolicy there is no limit:
set it per your provider plan. A provider's name ends with -model-provider; its key is a
Secret field of the application's environment (apiKey: "openRouterApiKey"):
// src/model-providers/openrouter.model-provider.ts β job-scout's chat models, with its own request fields
import {
ModelCost,
ModelProvider,
OpenRouterProvider,
OPENROUTER_BASE_URL,
PromptCaching,
QueueBalancingStrategy,
RateLimitPolicy,
Reasoning,
RetryPolicy,
TRANSIENT_FAILURES,
type OpenRouterFields,
} from "@agboss/core/models";
import { minutes, seconds } from "@agboss/core/units";
@ModelProvider({
name: "openrouter-model-provider",
description: "OpenRouter β every chat model job-scout uses",
serves: [/.*/], // its chat models; decision models go to DecisionsModelProvider (no chat there)
baseUrl: OPENROUTER_BASE_URL,
baseUrlField: "openRouterBaseUrl", // the environment may set another (a proxy, a stub)
apiKey: "openRouterApiKey", // a Secret field of the application's environment
timeout: minutes(2), // per attempt, from the moment it has room
cost: ModelCost.fromResponse(),
reasoning: Reasoning.modelDecides(),
promptCaching: PromptCaching.off(),
retryPolicy: RetryPolicy.exponential({
maxAttempts: 3,
initialDelay: seconds(1),
maxDelay: seconds(20),
jitter: true,
retryOn: TRANSIENT_FAILURES,
}),
rateLimitPolicy: RateLimitPolicy.of({
maxConcurrentCalls: 8, // the plan's limits, for every instance at once
perMinute: { calls: 120 },
balancing: QueueBalancingStrategy.TenantRoundRobin,
}),
circuitBreakerPolicy: { failureThreshold: 5, window: minutes(1), openFor: seconds(30) },
})
export class OpenRouterModelProvider extends OpenRouterProvider {
override readonly requestFields: OpenRouterFields = { provider: { ignore: ["Inceptron"] } };
}
// src/environments/app.environment.ts β the key both providers read; `test` leaves it out
import type { Secret } from "@agboss/core";
export class AppEnvironment {
openRouterApiKey?: Secret; // dev, prod: openRouterApiKey: secret("OPENROUTER_API_KEY")
openRouterBaseUrl?: string; // env.optional("OPENROUTER_BASE_URL"): unset β the provider's baseUrl
}
// src/job-scout.application.ts β what createApp(JobScoutApplication, { env }) and testWith run
import { Application, Budget, type BudgetHandler, BudgetPeriod, BudgetScope } from "@agboss/core";
import { DecisionsModelProvider } from "@agboss/core/models";
import { usd, type Usd } from "@agboss/core/units";
import { AppEnvironment } from "./environments/app.environment.js";
import { JobScout } from "./job-scout.workflow.js";
import { OpenRouterModelProvider } from "./model-providers/openrouter.model-provider.js";
// config/budget.config.ts in a project: the whole application, a tenant per day, every execution
@Budget({ name: "application-daily-budget", scope: BudgetScope.Application, period: BudgetPeriod.Day })
export class ApplicationDailyBudget implements BudgetHandler {
maxSpend(): Usd {
return usd(5);
}
}
@Budget({ name: "tenant-daily-budget", scope: BudgetScope.Tenant, period: BudgetPeriod.Day })
export class TenantDailyBudget implements BudgetHandler {
maxSpend(): Usd {
return usd(1);
}
}
@Budget({ name: "execution-budget", scope: BudgetScope.Execution }) // the default of every workflow
export class ExecutionBudget implements BudgetHandler {
maxSpend(): Usd {
return usd(0.1);
}
}
@Application({
name: "job-scout-application",
version: "1.0.0",
environment: AppEnvironment,
workflows: [JobScout], // app.execute(ChatWorkflowStart, β¦) runs the workflow of that start
providers: [OpenRouterModelProvider, DecisionsModelProvider], // every model resolves to one
budgets: [ApplicationDailyBudget, TenantDailyBudget, ExecutionBudget], // checked before each step
})
export class JobScoutApplication {}
Every storage contract has a ready SQLite implementation (#265), enabled only by listing it:
SqliteExecutionRecordRepository, SqliteCostLedgerRepository, SqlitedExecutionRepository,
SqliteCheckpointSaver, SqliteExecutionLeaseRepository, SqliteExecutionTreeRepository,
SqliteRateLimitRepository. They share one file per application instance β sqlitePath of the
environment contract, required only when one is listed (else [environment.missing-field]
names it); WAL, busy_timeout, every atomic operation in BEGIN IMMEDIATE, time from the
framework's clock. With them --thread, agboss eval score / replay / golden see what earlier
processes wrote, a survives a restart, and another instance on the file recovers a run
whose process died; tests run on sqlitePath: ":memory:". A project subclasses one through public
tokens only (extends + override, @Injectable({ deps }) as the class's own β
SqliteExecutionRecordRepository takes [SqliteConnection, Clock, ApplicationContext, IdGenerator],
#313) and lists the subclass instead. One file serves one machine (Wiki β Several-instances):
// environments/app.environment.ts β the contract declares the file
export class StoreEnvironment {
sqlitePath!: string; // required because SQLite implementations are listed
}
// environments/dev.environment.ts β a file of the project (git-ignored), or AGBOSS_SQLITE_PATH
export const environment: StoreEnvironment = {
sqlitePath: env("AGBOSS_SQLITE_PATH", { default: ".agboss/job-scout.sqlite" }),
};
// @Application({ β¦, environment: StoreEnvironment, providers: durableStores })
export const durableStores = [
SqliteExecutionRecordRepository,
SqlitedExecutionRepository,
SqliteCheckpointSaver,
SqliteExecutionLeaseRepository,
];
Time is the application's Clock (#262, infrastructure like the stores): every time the
framework reads (the ledger's day, records, leases) and every wait it makes (lease renewals,
recovery, the shutdown timeout, a tool's timeout, model call timeouts, retry backoff,
Retry-After s) goes through it β the system's clock unless @Application({ providers })
lists a class that extends Clock. A component that needs the time injects it; testWith runs
every test on a VirtualClock (@agboss/core/testing), moved by await clock.advance(seconds(30))
without real waiting. Direct time in the framework (Date.now(), new Date(), setTimeout, β¦)
fails lint (agboss/no-direct-time):
// services/reminder.service.ts β the time comes from the application's Clock, never Date.now()
import { Clock, Injectable } from "@agboss/core";
import { seconds } from "@agboss/core/units";
@Injectable({ deps: [Clock] })
export class ReminderService {
constructor(private readonly clock: Clock) {}
async remindLater(): Promise<Date> {
await this.clock.sleep(seconds(30)); // in a test: await clock.advance(seconds(30))
return this.clock.now();
}
}
Ids are the application's IdGenerator (#313, infrastructure like the Clock): every thread id
the framework mints (app.execute without a threadId) and every execution id β roots, children,
recursion levels β comes from it; a caller's own threadId is used as given, a recovered execution
keeps its id. The built-in RandomIdGenerator gives random UUIDs (the formats before #313) unless
@Application({ providers }) lists a class that extends IdGenerator; it is created before the
stores that inject it (deps: [IdGenerator]), so it may not depend on the contexts. testWith
makes them deterministic β thread-1, thread-2, β¦, execution-1, β¦ per test
(SequentialIdGenerator, @agboss/core/testing) β unless the application lists its own. A Id never
comes from it: it is an answer credential, a random 128-bit token.
// config/prefixed-id.generator.ts β listed in @Application({ providers: [PrefixedIdGenerator] })
import { randomUUID } from "node:crypto";
import { IdGenerator, Injectable } from "@agboss/core";
@Injectable()
export class PrefixedIdGenerator extends IdGenerator {
override newThreadId(): string {
return `thr_${randomUUID()}`;
}
override newExecutionId(): string {
return `exe_${randomUUID()}`;
}
}
A rule needs no model: @Router({ decidedBy: DecidedBy.Code, routes: [{ target: A }, { target: B }] })
on a class that implements RouterHandler<Self> decides in route(input), returning one of its
targets (Route<Self>) β no model call, cost 0, the same flow. Routes may carry the options of a
parallel launch (#273): spawn (stated on every route of a model router before a launch β
SpawnPolicy.Always or SpawnPolicy.OnDemand; OnDemand by default on a code router's) and
failure (FailurePolicy.FailFast by default)
(Wiki β Routers).
Several steps at once are a parallel launch (#273): .toParallelAgents(ResearcherAgent, CriticAgent, CriticAgent, { maxConcurrency: 3 }) or .toParallelActions(β¦), always followed by
.join() and the values the branches give β .join().returns(ResearchDto).returns(listOf(CritiqueDto)),
checked against the generated JoinOutputs; one agent after .returns(listOf(CompanyDto)) runs once
per item ({ maxItems } required; a longer list is truncated, never an error β result.launches
says { items, taken }). At most maxConcurrency branches run at once, a new one starting as
another ends; a failed branch cancels the others and the join takes .onJoinError(E, β¦)
(Wiki β Workflow).
Not every answer is always needed: a quorum (#279) counts them β .joinQuorum({ min: 2, max: 3, deadline: seconds(30) }) goes on with the counted answers, .joinQuorumGroups(group(LegalAgent, CriticAgent).priority(1).returns(β¦).toAgent(β¦), β¦) with the first complete group (by priority),
each group on its own path. @Arbiter classes decide which answers count, attached with
.acceptedBy(X) before the quorum (a chain is an AND, anyOf(A, B) stops at the first that
accepts): by model, a criterion per agent; by code, one @OnAnswer(Agent) method per agent. A single
answer a quorum may leave out is optional(XDto) in .returns(β¦) β the compiler and agboss check
say so on the quorum. At run time the quorum is decided as answers arrive: once max answers are
counted (or a group has won, or the quorum can no longer be reached) the other branches are
cancelled; the deadline runs on the application's Clock; not reached β QuorumNotReachedError
into .onJoinError(β¦); a failing arbiter is ArbiterError. result.launches and onQuorumDecided
show each answer as counted, rejected (and by which arbiter), surplus or cancelled, and the winning
group; agboss generate arbiter writes a code arbiter for a launch
(Wiki β Parallel).
A whole workflow is a step too (#263): .toWorkflow(CompanyCheckWorkflow) runs an ordinary
@Workflow of the application as a child β a component "start input β one of its finishes'
outputs", runnable and testable on its own. Its start is chosen by the class of what comes in (one
start, else the one taking the chain's current value; [graph.no-matching-start] /
[graph.ambiguous-start] otherwise), its scope starts with that input only, and its result is
branched by class β .returns(oneOf(CompanyScoreDto, CompanyRejectedDto)).switch(caseOf(β¦)β¦), one
case per finish output class. .toParallelWorkflows(CompanyCheckWorkflow, { maxConcurrency: 3, maxItems: 50 }) runs one child per item, joined as .join().returns(listOf(oneOf(β¦))); a child
reached from two places has its own path workflow(W).onError(β¦).returns(oneOf(β¦)).switch(β¦).
At run time each child is an execution of its own β its id, steps and maxSteps, no history β
inside its parent's checkpoint and budgets; an uncaught failure of its own fails the parent's step
with ChildExecutionFailedError, and result.children holds the nested records
(Wiki β Workflow-as-a-step).
A workflow may launch itself (#264): ThisWorkflow stands for the enclosing workflow in
.toWorkflow(ThisWorkflow), route(ThisWorkflow) or .toParallelWorkflows(ThisWorkflow, { maxItems }) β a topic too broad for one report researched subtopic by subtopic by the same workflow. A
recursive workflow sets @Workflow({ maxDepth: 3 }) ([graph.recursion-without-depth]); the root is
at depth 1 (executionContext.depth, and each child record's depth), so the last level is
depth === maxDepth. There the framework stops it, never the prompt: an agent's oneOf variant
whose caseOf(β¦) launches ThisWorkflow leaves its response schema, a model router does not see such
a route, a code router returning one fails with RouterDecisionError β so every such branch keeps a
way out ([graph.recursion-without-exit]); a launch decided by code past maxDepth does not start
and fails its step with ChildExecutionFailedError caused by DepthLimitExceededError
(.onError(LimitExceededError, β¦) takes it). Only direct recursion: a workflow launching one that
launches it is [graph.mutual-recursion]. The root's execution budget counts every level; each
level has its own maxSteps; agboss describe prints recursive: ThisWorkflow, maxDepth 3, on the last level: researcher-agent β TopicReportDto only. Each recursive workflow is typed by its own
entry: the .returns(β¦) after a ThisWorkflow launch is checked by @Workflow({ name, flow })
against the generated ThisWorkflowOutputs[name], so one project holds recursive workflows with
different finishes.
The whole tree shares one concurrency cap (#264): @Workflow({ maxConcurrentExecutions: 6 }) of
the root (default 10) β how many executions of the tree, the root and all its descendants, work on a
step at once. A step without a free slot waits in a queue β a throttle, never an error; an execution
waiting at a join, for its child or d holds no slot (an execution's parallel branches share its
one). The queue serves a parent whose children have all finished first, then the deepest execution,
every 10 s of waiting counting as one level deeper (the application's Clock). A launch's
maxConcurrency and the cap both hold β the stricter wins; a child workflow's own smaller
maxConcurrentExecutions limits its subtree (a larger one is [limits.child-concurrency-above-parent],
a warning). Time in the queue does not count toward a quorum's deadline. The slots are in-process
under the tree's lease: after a or a crash the next pass rebuilds the queue from the unfinished
tasks, and a FailFast failure empties it. A slot is an execution, not model load β provider
throughput is rateLimitPolicy (Wiki β FinOps β Concurrency of a tree).
The CLI works on the application whose path the project's package.json sets once β
"agboss": { "application": "src/job-scout.application.ts" } (or --application <path>). npx agboss check writes .agboss/types.d.ts β what types the paths, each node's input and app.execute; a
generated project runs it on install (npm run types).
The whole shape β DTOs, a service, a tool, an agent, a router, the workflow, its application and
its test β is the
worked example in CLAUDE.md β Architecture (compiled and run by make check).
npx agboss chat # the application's one start; with several: --start <name>
npx agboss run --start chat-workflow-start "my resume src/data/sample-resume.md"
npx agboss run --start <name> --input '<json>' # a start taking a DTO: checked field by field first
npx agboss describe [--json] # the application: workflows, starts, children, implementations β no API key
npx agboss check --env prod # types, assembly, the worst case per start, prod's process variables
npx agboss dev # watch mode: types regenerated, problems printed on change
npx agboss studio # LangGraph Studio on every workflow β no studio.ts, no langgraph.json
npx agboss --help # run, eval, replay, golden, compare, rag index
agboss dev reruns agboss check in a short-lived process on every change of the application's
src/ (debounced): .agboss/types.d.ts stays current and assembly problems show as you type.
agboss studio [--env <name>] [--port N] builds every workflow of the application β the children
too β writes the Studio config into the git-ignored .agboss/ (studio.ts, langgraph.json) and
starts LangGraph's dev server on it; a child workflow is drawn inside its parent (a subgraph,
toParallelWorkflows included), and a problem β assembly, a missing variable β stops it before the
server starts.
A command that runs one workflow (chat, run, eval, replay, golden add, compare) takes
it by its start's full name, --start chat-workflow-start β optional when the application has one
start; with several and none given, or a name no start has, it stops with the list of starts (and a
Did you mean "β¦"?), running nothing.
Needs OPENROUTER_API_KEY in .env (dev's openRouterApiKey: secret("OPENROUTER_API_KEY")). Docs: Wiki β Components Β·
Configuration Β· Knowledge bases Β·
Profiles and comparisons.
agboss ships its own guide for coding agents (#305), written for the installed version:
skills/agboss/ in the package β SKILL.md and 13 references, from composition and the flow DSL to
tests, the error catalog and migrating a LangGraph graph (#424), every snippet compiled by the gate. agboss create writes three things
for agents:
AGENTS.md(Cursor, Codex, β¦) β the project's instructions and a table routing each task to one reference; put the project's own rules under its## Project rules;CLAUDE.mdβ one line,@AGENTS.md(Claude Code readsCLAUDE.md);.claude/skills/agboss/β the skills and their manifest (.agboss-skills.json: the version and a sha256 per file), git-ignored.
AGENTS.md and CLAUDE.md are written once and never rewritten. The skills folder is the
framework's: npx agboss check (so the project's postinstall, npx agboss dev and
npx agboss generate β¦) makes it match the agboss the project resolves β skills: .claude/skills/agboss updated 0.1.0 β 0.2.0 (14 files) after an upgrade, installed on a fresh clone, repaired with
[skills.local-edit] when a file was edited by hand. A problem is a warning
([skills.sync-failed], [skills.no-framework]) and never changes the exit code; --json carries
result.skills. "agboss": { "skills": false } in package.json turns the sync off. More:
Wiki β CLI.
Standalone Verified Showcase (agbossjs/agboss-examples)
agbossjs/agboss-examples
7 self-contained, CI-verified applications proving AGBoss's compile-time and runtime guarantees (npx agboss check + npm test on every project, with canonical 1 class = 1 file component folders and symmetric Spring-style tests/ mirroring src/):
01-hello-agboss β First-run starter, real async HTTP service DI (PricingCatalogService withExecutionContext.signal +step.idempotencyKey ), production HTTP server (src/http/server.ts with multi-tenant headers,202 Accepted on s, and/health/live +/health/ready probes), andtestWith +mockOf .
2.
02-path-scope-proof β Side-by-side proof of 4 production graph bugs ([input.missing-on-path] ,[input.outdated-on-path] ,[launch.branch-outdates-sibling] ,[flow.uncompensated-action] ) that passtsc --noEmit in raw LangGraphAnnotation.Root (langgraph-broken/ ) and are caught before runtime byagboss check (agboss-broken/ vsagboss-fixed/ ).
3.
03-support-refund-desk β Support & refund desk proving reversible@PiiPolicy (replaceWithToken ,revealPii: true ,PiiRefusedError ),@ToolInterceptor (replyToModel ,block ),@InputGuardrail (DecidedBy.Code ),@OutputGuardrail , in-memory BM25/TF-IDF@Rag (InMemoryKnowledgeIndexService +RefundPolicyRetriever ),PolicyAwareCompaction , and a 5-loop@EvalSuite with compact git-trackedeval/baseline/main-baseline.json andagboss eval calibrate (Cohen's$\kappa$ ).
4.
04-travel-booking-saga β Transactional multi-step saga (compensateWith /committedBy ) acrosspayment-saga ,budget-saga ,quorum-saga (.toParallelAgents().acceptedBy().joinQuorum({ min: 2, max: 2 }) ), andhitl-hold-saga (Channel.withdraw() ,NotdError , SQLite WAL crash recovery, and HTTP202 Accepted adapter).
5.
05-cluster-work-stealing β Multi-instance horizontal scaling withChildPlacement.AnyInstance ,SqliteChildExecutionQueueRepository , recursiveThisWorkflow (maxDepth: 2 ), 30s SQLite leases & fencing (ExecutionLeaseLostError ),maxConcurrentSteps: 2 backpressure (HealthComponentKind.StepSlots ),@HealthCheck , andIncompatibleResumeError .
6.
06-enterprise-agent-mesh β 3-service mesh (apps/1-order-gateway ,apps/2-billing-a2a ,apps/3-catalog-mcp ) over@A2AAgent /@A2AExpose and@McpTool /@McpExpose , Ed25519agboss-mesh JWS envelope,MeshLoopDetectedError &HopsLimitExceededError , MCP_meta.callId deduplication viaSqliteMeshRequestRepository , andagboss check --federation Blast Radius.
7.
07-langgraph-migration β Side-by-side comparison of raw@langchain/langgraph (before-langgraph/support.graph.ts with 15-fieldAnnotation.Root God State, string-keyedaddConditionalEdges , unboundedSend() , dynamic prompt interpolation) vs AGBoss (after-agboss/src/ +after-agboss/tests/ with 10 narrow@Dto s,@Router , bounded.toParallelAgents() ,@Action ,@Channel , andagboss check --strict ).
examples/job-scout β resume β proposed search brief β Greenhouse jobs ranked
by Jev. It depends on agboss exactly like your project would:
npm install && npm run build # at the repo root
cd examples/job-scout && npm run chat # "my resume src/data/sample-resume.md"
examples/flow-patterns β the flow's patterns with scripted tests only
(no model key): src/offer/ a review loop (agent(ReviewerAgent).returns(oneOf(β¦)).switch(β¦),
caseOf(ChangesRequestedDto, { about: OfferDraftDto }), .onError(VisitsLimitExceededError, β¦)), src/credit/ a code
router (DecidedBy.Code, route(input) through deps) beside a model router, src/review/
parallel launches in waves (a reviewer per section, then a critic beside two fact checker copies),
src/topic/ a workflow that researches its subtopics itself (ThisWorkflow, maxDepth, #264).
Point the CLI at it:
cd examples/flow-patterns && npx agboss describe # every workflow of its application
More: Examples Showcase Β· Wiki β Example.
Runtime package @agboss/core + dev/CI companion @agboss/cli; the root @agboss/core is the authoring API, and subpath entries keep integrations focused:
@agboss/coreβ@Workflow,@Agent,@Router,@Arbiter,@Tool,@ToolInterceptor,@Injectable,@Action,@WorkflowStart/@WorkflowFinish,@Judge,@InputGuardrail/@OutputGuardrail,@PiiPolicy,@ChannelRole,@Compaction,InjectionToken,provide,env,secret,Secret, the context levels (ApplicationContext,TenantContext,ExecutionContext,ContextValues), the path builder (start,agent,router,action,route,caseOf,toAgent,toAction,toWorkflow,toParallelWorkflows,toFinish,ChildPlacement, β¦),WorkflowSettings,@Application,createApp,@Budget, the infrastructure contracts and SQLite / in-memory repositories (Clock,IdGenerator,ChildExecutionQueueRepository,ExecutionHistoryRepository,MeshRequestRepository, β¦),Decision, observers, errors and types@agboss/core/dtoβ field decorators (@Text,@ListOf, β¦), the DTO wrappers (listOf,oneOf,optional,variant) and the standard DTOs@agboss/core/unitsβusd(),seconds(),minutes(),hours(),kilobytes()@agboss/core/modelsβ model providers (@ModelProvider,OpenRouterModelProvider,DecisionsModelProvider, listed in@Application({ providers }))@agboss/core/testingβtestWith, the 10 unit runners (testTool,testAction,testRouter,testArbiter,testRag,testJudge,testGuardrail,testToolInterceptor,testPii,testCompaction), the scripts (replyWith,routeTo,when,waitFor, β¦),ToolFailure,mcpOf,VirtualClock,SequentialIdGenerator, VCR (+ @agboss/core/testing/setup: the matchers)@agboss/core/evalβ@EvalSuite,EvalCascade,evalCase,turn,pastTurn,toolCall,toolInvariant,checkTrajectory,judgedBy@agboss/core/mcp,/rag,/a2a,/memoryβ integrations (@McpServer,@McpTool,@McpExpose,@Rag,@A2AAgent,@A2AExpose,a2aHttpListener,A2ACallerResolver, memory)@agboss/core/eslintβ ESLint plugin (agboss/no-component-state,agboss/no-direct-time)
Importing @agboss/core has no side effects: tracing, the MCP SDK and SQLite load only when an app
uses them. The old entries @agboss/core/core, /graph, /router, /tool and /channels are
deprecated re-exports for one minor release (/concurrency went with the old flow DSL, #272); agboss migrate imports rewrites them
(and the renamed Router engine type β RouterEngine, root McpServer β McpExpose from
@agboss/core/mcp, testing Duration β ClockDuration, TestClock β VirtualClock). Every entry's surface is in
packages/agboss/api/*.api.md; npm run api:update after an intended API change.
packages/agboss/ the framework runtime (published as `@agboss/core`)
packages/agboss-cli/ the CLI, static compiler & UI tools (published as `@agboss/cli`)
examples/job-scout/ the example (uses only the public API)
examples/flow-patterns/ the flow's patterns: a review loop, a code router, parallel launches (scripted tests only)
make check β build, API reports, compiled docs, format, lint, types, tests with coverage for both packages. npm run dev β
rebuild the framework on change. The framework never imports the examples, and the examples use
only @agboss/core β both enforced by ESLint.
One set per application, in environments/ next to the application file (#261). The contract is a
plain class with declared fields only, declared once in the application:
import { AppEnvironment } from "./environments/app.environment.js";
@Application({
name: "audit-application",
version: "1.0.0",
environment: AppEnvironment, // the contract: environments/app.environment.ts
workflows: [AuditWorkflow],
budgets: [AuditApplicationDailyBudget, AuditTenantDailyBudget, AuditExecutionBudget],
})
export class AuditApplication {}
// environments/app.environment.ts β the contract: fields only, no values
import type { Secret } from "@agboss/core";
export class AppEnvironment {
auditUrl!: string;
databaseUrl!: Secret;
port!: number;
debug!: boolean;
}
Each environment is a plain object typed by the contract, in <name>.environment.ts β the file name
is the environment's name (--env prod β prod.environment.ts):
import { env, secret } from "@agboss/core";
import type { AppEnvironment } from "./app.environment.js";
export const environment: AppEnvironment = {
auditUrl: env("AUDIT_URL"), // a process variable, read when the app starts
databaseUrl: secret("DATABASE_URL"), // a Secret: only from a process variable, no default
port: env.integer("PORT", { default: 8080 }),
debug: env.flag("DEBUG", { default: false }),
};
python
import { secret } from "@agboss/core";
import type { AppEnvironment } from "./app.environment.js";
export const environment: AppEnvironment = {
auditUrl: "http://localhost:4000", // a value in the file
databaseUrl: secret("DATABASE_URL"), // `.env`: e.g. pglite://memory β dev without Docker
port: 8080,
debug: true,
};
- A field missing in an environment, a mistyped one or one the contract lacks does not compile; a
literal in a
Secretfield does not compile either, and aSecretis not astring. A value in the contract (auditUrl: string = "β¦"β the environments are object literals, it would be ignored) fails assembly:[environment.contract-value] AppEnvironment.auditUrl has a value β the contract declares fields only; put the value in each environment. A framework implementation listed inproviderswhose field the contract lacks fails with[environment.missing-field]. - There are no wrappers: no
declare module, noexport default, nodefineEnvironment/fromEnv.agboss checkwritesinterface Environment extends AppEnvironment {}into.agboss/types.d.ts, so components read the values asapplicationContext.environment(deps: [ApplicationContext], #261 β no separate token), typed by the contract β neverprocess.env; the chosen environment's name isapplicationContext.environmentName:
import { ApplicationContext, Injectable } from "@agboss/core";
@Injectable({ deps: [ApplicationContext] })
export class AuditStoreService {
constructor(private readonly applicationContext: ApplicationContext) {}
connectionString(): string {
return this.applicationContext.environment.databaseUrl.reveal(); // the only way to the value
}
}
- A
Secretprints as***everywhere it could be printed β logs, traces, JSON,agboss describe, error messages; only.reveal()gives the value. Secrets are process variables and only that: in Cloud Run a Secret Manager secret is bound to the variable at deploy (--set-secrets=DATABASE_URL=database-url:3); the framework calls no cloud SDK. agboss chat(the application ofpackage.json) loadsdev;--env stagingloadsstaging.environment.ts(every command that loads the application takes--env). An unknown name fails at once (exit 3) listing the available ones; a variable without a value or default fails the start naming it, the field and the environment, never the value:DATABASE_URL is not set (AppEnvironment.databaseUrl, prod).agboss checkvalidates the selected environment without running anything, creating every component the start creates, so it fails exactly when the start would;agboss check --env prodlists the process variables it needs β names only, eachvalueorsecret,optionalorhas a defaultwhen unset is fine, never a value (the list for--set-env-vars/--set-secretsat deploy, #268);agboss describeshows the environment with secrets masked.- From code:
createApp(Application, { env: "staging" }). Tests run ontest:testWith(Application)loadstest.environment.tsand fails without it (a test never reaches dev's addresses by accident);testWith(Application, { env: "dev" })names another. - Dev / prod parity (12-factor): the same
providersβ the same implementations and code β in every environment; only values differ. environmentis required: an application with no settings declares the framework's empty contract,@Application({ environment: EmptyEnvironment })(any contract without fields works the same), and needs no environment file β itsdevandtestare{}. (An untyped caller without one gets an empty environment.)- Model provider keys are part of it (#266): each
@ModelProvidernames theSecretfield that holds its key (apiKey: "openRouterApiKey"; the built-ins readopenRouterApiKey), set withopenRouterApiKey: secret("OPENROUTER_API_KEY")in each environment that calls models β a test environment may leave it out (tests script or replay models). A missing key fails the start of an app that calls the provider;agboss check(without--models) reads the contract's shape and writes the types without one. A base URL is read the same way:baseUrlField: "openRouterBaseUrl"withopenRouterBaseUrl: env.optional("OPENROUTER_BASE_URL")β no{ default }: unset, it isundefinedand the provider uses its decorator'sbaseUrl(#266 S87). env.optional("X")(env.integer.optional,env.flag.optional) reads a variable that may be unset βundefinedthen, for an optional field of the contract (x?: string); a set value is still checked, andagboss check --envmarks itoptional(#266 S90).
@Agent({ judges: [AnswerGroundedJudge], maxRetries: 1 }) runs every judge on the agent's reply. A
judge is a @Judge({ name, model, reply, deps }) class implementing JudgeHandler<TReply>; the
container creates it with its deps, and its own model is called through the model gateway, so
its spend is in the run's cost report (judge:<name>, category review). A judge sees the agent's
answer as its DTO (#262 S115): reply names what it judges, in every shape an agent's output
has β a DTO class, oneOf(A, B) (the reply is A | B), listOf(XDto) (a readonly XDto[]) or
PlainTextDto for a text agent ({ text }) β and judge({ variant, reply, task, attempt }) gets
the validated answer object, never a serialized string, and variant, the class it validated as
(#262 S125): the class of the answer tool the agent used, never guessed from the answer's shape. For
a oneOf(A, B) judge JudgeInput<A | B> is a discriminated union on variant, and
isVariant(input, A) narrows reply to A; a single DTO, listOf(XDto) or PlainTextDto judge
has variant as that one class; a listOf(oneOf(A, B)) judge reads
reply: readonly VariantOf<A | B>[] β each item { variant, value }, narrowed by
isVariant(item, A). An agent whose output its judge does not judge
fails assembly with [judge.wrong-reply]: a text agent with a DTO judge, a DTO agent with another
class, a oneOf variant the judge does not cover, a list agent with a judge of one value or the
other way round. A verdict is built with JudgeVerdict.passed({ metrics? }) or
JudgeVerdict.failed({ reason, metrics? }) β the only way; a failed verdict's reason goes back to
the agent for its retry, and once maxRetries retries are spent the run throws QualityGateError
(code: "step.agent.quality-gate", rejections: [{ judge, reason }]). A judge without a model
fails at app start ([judge.no-model]).
A judge's model comes through the constructor (deps: [JudgeModel]): a chat model
(judgeModel.invoke(...)) or a decision model ( judgeModel.decide({ state, questions })) β
every model of OpenRouter's Decisions API:
typesafe/jev-1.13, openai/gpt-6-luna-decisions, perplexity/pplx-decider-v1(.1)-27b,
cloudflare/clef(-flash|-omni), upstage/solar-decide, inception/mercury-decide
(DECISION_MODELS; DecisionsModelProvider serves them, JevModelProvider is its deprecated alias).
Questions come from Decision.noul (yes/no β noul = P(yes)), Decision.choice (β choice,
probabilities, confidence) and Decision.score (levels lowest first β score = the level's index);
the answers are typed by them. state is a text, a JSON object, or text and image parts
(Decision.image(dataUrl) β png/jpeg/webp data URLs; Luna, pplx-decider and Clef read images). At
most 200 questions per call; an answer that does not fit its question fails with
model.decision.invalid-response. decide on a chat model or invoke on a decision model throws
ModelKindError (model.wrong-kind).
import {
Decision,
Judge,
JudgeModel,
JudgeVerdict,
type JudgeHandler,
type JudgeInput,
} from "@agboss/core";
@Judge({
name: "answer-grounded-judge",
model: "openai/gpt-6-luna-decisions",
reply: JobReportDto, // what it judges: the agent's output, as its DTO
deps: [JudgeModel, JOB_SEARCH],
})
export class AnswerGroundedJudge implements JudgeHandler<JobReportDto> {
constructor(
private readonly judgeModel: JudgeModel,
private readonly jobSearch: JobSearch,
) {}
async judge({ reply, task }: JudgeInput<JobReportDto>): Promise<JudgeVerdict> {
const [first] = reply.jobs;
if (first === undefined || !(await this.jobSearch.exists(first.url))) {
return JudgeVerdict.failed({ reason: "the first job does not exist" });
}
const a = await this.judgeModel.decide({
state: { task, jobs: reply.jobs.map((job) => job.title), results: this.jobSearch.lastResults() },
questions: {
grounded: Decision.noul("Every job of the report appears in the results"),
tone: Decision.choice("Tone of the report", {
neutral: "Plain, factual",
pushy: "Sells or pressures",
}),
quality: Decision.score("Overall usefulness", ["useless", "partial", "complete"]),
},
});
// a.grounded.noul: number; a.tone.choice: "neutral" | "pushy"; a.quality.score: number
return a.grounded.noul > 0.7 && a.tone.choice === "neutral"
? JudgeVerdict.passed({ metrics: { quality: a.quality.score } })
: JudgeVerdict.failed({ reason: "Only report jobs from the search results, neutrally." });
}
}
In tests, script a chat judge like an agent β mockLlm(Judge).thenReturn(replyWith("PASS")) β and a
decision judge with its answers by question id (an option, P(yes), a level's index or text):
mockLlm(AnswerGroundedJudge).thenReturn(decideWith({ grounded: 0.9, tone: "neutral", quality: 2 }, { cost: usd(0.0002) }));
on its own, testJudge(AnswerGroundedJudge, β¦)({ variant, reply, task, attempt }) takes reply as a
plain object of its DTO, typed by the judge β another shape, or a variant with another class's
value, does not compile.
import { Judge, JudgeVerdict, type JudgeHandler, type JudgeInput } from "@agboss/core";
import { isVariant, oneOf } from "@agboss/core/dto";
@Judge({ name: "fair-decision-judge", model: "typesafe/jev-1.13", reply: oneOf(OfferDto, RefusalDto) })
export class FairDecisionJudge implements JudgeHandler<OfferDto | RefusalDto> {
judge(input: JudgeInput<OfferDto | RefusalDto>): Promise<JudgeVerdict> {
if (isVariant(input, OfferDto)) {
// input.variant is OfferDto, the class of the answer tool; input.reply is an OfferDto
return Promise.resolve(
input.reply.salary > 0
? JudgeVerdict.passed()
: JudgeVerdict.failed({ reason: "an offer pays something" }),
);
}
// input.reply is a RefusalDto
return Promise.resolve(
input.reply.why.trim() === ""
? JudgeVerdict.failed({ reason: "a refusal says why" })
: JudgeVerdict.passed(),
);
}
}
Routers and guardrails work on any decision model too (@Router({ model: "openai/gpt-6-luna-decisions" })):
one choice question over the routes, options sorted so declaration order never changes the request.
testWith(Application) (@agboss/core/testing) runs the whole application in each test β its real
container and infrastructure, models by script, MCP servers stubbed, the network blocked, a
VirtualClock and deterministic ids (thread-1, execution-1, β¦ per test, #313; an application
that lists its own IdGenerator keeps it). Scripts are typed by the generated .agboss/types.d.ts (agboss check, #262):
replyWith(XDto, fields) must be a class of the agent's output (a class outside its oneOf does
not compile), routeTo(β¦) a target of the router's routes β exactly one after .chooseOne(β¦), the
OnDemand targets it launches before .toParallelAgents(β¦) (routeTo() only with an Always
route). when(β¦) binds turns to the node's input values, at any depth: a deep partial of each
field's value (the producer is not compared), undefined for an absent field, a listOf field by
length and position, a oneOf element as variant(XDto, { β¦ }); a call no script matches fails the
test, naming its input. A script is an expectation: a thenReturn turn no call took, or a when(β¦)
nothing matched, fails a test whose body passed (thenReturnAlways never does).
waitFor(seconds(31)) before a turn makes the call take that long on the test's clock. A model
reply interpreter (#267) is scripted the same way, its input the 's { ask, reply } β
mockLlm(RefundReplyInterpreter).when({ reply: "ok, but only half" }).thenReturn(replyWith(ToolCallApprovalDecisionDto, { approved: true, β¦ })),
one on a decision model with decideWith({ approved: 0.9 }) (#267 S124);
a code one is created by the container and replaced with mockOf(Interpreter).interpret
(mockReturnValue(ReplyInterpretation.understood({ approved: true }))).
import { expect } from "vitest";
import { replyWith, testWith, ToolFailure, waitFor } from "@agboss/core/testing";
import { seconds, usd } from "@agboss/core/units";
const test = testWith(OfferApplication);
test("the reviewer asks for changes once, then approves", async ({ app, mockLlm, mockAction }) => {
mockLlm(ReviewerAgent)
.when({ draft: { text: "v1" } })
.thenReturn(replyWith(ChangesRequestedDto, { notes: "shorter" }));
mockLlm(ReviewerAgent)
.when({ draft: { text: "v2" } })
.thenReturn(waitFor(seconds(1)), replyWith(DraftApprovedDto, {}, { cost: usd(0.01) }));
mockLlm(EditorAgent).thenReturn(replyWith("v2"));
await app.execute(OfferWorkflowStart, shortlist);
expect(mockLlm(ReviewerAgent)).toHaveBeenCalledTimes(2);
expect(mockLlm(ReviewerAgent)).toHaveReturned(ChangesRequestedDto, { notes: "shorter" });
expect(mockAction(SendOfferAction)).toHaveBeenCalledWith({ draft: { text: "v2" } });
});
test("a cancel from inside a mock cancels the execution", async ({ app, mockOf, mockTool }) => {
mockTool(SearchJobsTool).thenFail(ToolFailure.Timeout); // the next call fails as a timeout would
mockOf(MailerService).send.mockImplementation(() => app.cancel("thread-1").then(() => undefined));
await expect(app.execute(OfferWorkflowStart, shortlist)).rejects.toFailWith(
ExecutionCancelledError,
);
});
Calls are checked with Vitest's names on mockLlm(X) and mockAction(A) (the action still runs):
.inputs, toHaveBeenCalledWith(β¦) (the matcher of when), toHaveBeenCalledTimes(n) (model
calls; an action's executions), toHaveReturned(XDto, fields?); on anything else β a vi.fn(), a
mockOf(β¦) method β they are Vitest's own. toHaveBeenAskedWith checks the prompt itself. Errors
are caused the real way β failWith(ModelFailure.β¦), a rejecting mockOf(β¦),
mockTool(Tool).thenFail(ToolFailure.Timeout), scripts that reach a limit, app.cancel β and
matched with rejects.toFailWith(ErrorClass, { β¦fields, cause: CauseClass }) through the cause
chain; the testing API has no way to create a framework error.
Parallel branches run one at a time in a test, in listed / item order (TestConcurrency.Sequential,
the default); TestConcurrency.Real runs them as production does, and
TestConcurrency.inOrder([step(A), step(B)]) makes them complete in that order β a race, a
FailFast or a quorum gives the same result on every run. One component is tested on its own with
a unit runner: testTool, testAction, testRouter, testArbiter, testRag, testJudge,
testGuardrail, testPii β its own container (overrides replace classes by classes), the
environment, the frame of its step ( executionContext.signal with a timeout, SpendRecorder),
its model by script; each call gives { output, spend }.
import { expect, it } from "vitest";
import {
decideWith,
override,
step,
TestConcurrency,
testJudge,
testTool,
testWith,
} from "@agboss/core/testing";
import { seconds } from "@agboss/core/units";
// the risks agent answers first, then the skills agent: the quorum counts them in that order
const test = testWith(QuickPanelApplication, {
concurrency: TestConcurrency.inOrder([step(RisksAgent), step(SkillsAgent)]),
});
test("the first two answers are counted", async ({ app }) => {
const result = await app.execute(PanelWorkflowStart, candidate);
expect(result.launches[0]?.isQuorumReached).toBe(true);
});
it("searches the board it is given", async () => {
const runSearch = testTool(SearchJobsTool, {
overrides: [override(JobBoardService, FakeJobBoardService)],
});
const { output, spend } = await runSearch({ query: "TS" }, { timeout: seconds(1) });
expect(output.jobs).toContain("TS");
expect(spend.totalUsd).toBe(0);
});
it("a grounded reply passes", async () => {
const runJudge = testJudge(GroundedJudge, { model: decideWith({ grounded: 0.9 }) });
const reply = { text: "Acme hires TS engineers [acme.example]" }; // a PlainTextDto, as an object
const { output } = await runJudge({ variant: PlainTextDto, reply, task: "Find TS jobs", attempt: 0 });
expect(output.passed).toBe(true);
});
Every component reads the execution it runs in through its constructor (#280):
deps: [ExecutionContext], a parameter named executionContext β tools, actions, judges and
services alike; there is no ctx parameter. It is data only and read-only, and every read gives the
current execution and step (one frame per node visit and per tool call), so it is safe in an
component (one instance for the app) and in parallel branches. One execution has one id: its observer events
(event.executionId), executionContext.executionId, its record, its trace and its result
(ExecutionOutput.executionId) carry the same one, also after a resume. What a node reads comes in its
input β for a model, its input message (each input field as ### field (from producer)), never
through {{input.β¦}} in its instructions ([prompt.input-in-instructions]); the context carries no
input and no metadata.
run(query: OrderQuery): Promise<OrderQuery> {
const { executionContext } = this;
executionContext.executionId; // unique per execution, the same after a resume (= ExecutionOutput.executionId)
executionContext.threadId; // the conversation (ExecutionOutput.threadId)
executionContext.ownerId; // from execute(β¦, { ownerId }): who the thread belongs to
executionContext.tenant.tenantId; // the organization, "default" when none is passed
executionContext.tenant.application.environmentName; // the application level, through the tenant
executionContext.signal; // aborted by app.cancel(threadId) or the caller's signal
executionContext.workflow; // { type, name, version, startedVersion, configHash }
executionContext.step.idempotencyKey; // `${executionId}:${nodeId}:${visitNumber}[:${itemIndex}][:${callId}]`
executionContext.step.callId; // the tool call's id (undefined outside a tool call)
return Promise.resolve(query);
}
Read outside an execution (in a constructor at start) or after its step ended (a forgotten promise),
the context throws ExecutionContextUnavailableError (context.unavailable) β never undefined.
await app.execute(
ChatWorkflowStart,
{ text: "Find me a backend job" },
{ threadId, ownerId: userId, signal, tenant: { tenantId: "acme" } },
);
const { cancelled } = await app.cancel(threadId, { tenantId: "acme", ownerId: userId });
A thread created with an ownerId belongs to it: execute on that thread, resume and cancel
with another owner (or none) throw ThreadOwnerError (run.thread-owner) before anything runs or
is read. A thread belongs to its tenant too (#261): its key is tenantId + threadId, so another
tenant's thread does not exist for the caller. execute with a threadId the tenant does not have
yet creates that thread β the caller's own, idempotent id (an order number, a ticket), bound to its
ownerId; the same id in another tenant is another thread (#262 Q4). An id that cannot be one
(empty, over 200 characters, a control character) is InvalidThreadIdError
(run.invalid-thread-id); cancel of a thread the tenant does not have is UnknownThreadError.
resume and cancel take the tenant by its id (omitted β "default"), and a resumed execution
runs on the context values it started with. With nothing declared tenant: { tenantId?, weight? } is
all there is (weight, a positive integer, default 1, is the tenant's share for #266's
TenantWeighted); an application that declares
@Application({ tenantContext: ShopTenantContext, executionContext: ShopExecutionContext }) β its
own subclasses with DTO field decorators, identity only, never secrets β gets their values typed
per start (tenant: ContextValues<ShopTenantContext>, execution: β¦), checked before the first
step (invalid β dto.invalid, an onExecutionRejected, the field named, never the value) and frozen. Tool arguments a channel shows (and ExecutionOutput.pending[i].ask.arguments) have the input DTO's
sensitive fields through their action (replacePart as written, a token or a kind as <field>,
#120); the tool itself still gets them whole.
s (#267). An execution may wait for several answers at once β one per tool call whose
channel replied ChannelOutcome.awaitReply(), in parallel branches too: a d branch does not
stop its siblings, the execution s only when nothing else can move, and result.pending
lists every waiting (Pending: answeredBy, agent, channel, itemIndex,
childExecutionId, the question ask). A is answered by its id, never by its thread β
app.resume({ Id, decision }, options) β found by (tenantId, Id) anywhere in the tree
(unknown, or another tenant's β UnknownError, .unknown); with other s left the
execution s again with the rest; the result carries threadId and executionId. Who may answer is
the 's channel role's answeredBy: a Answerer.ThreadOwner lists its Id in
result.pending and is resumed with the thread's ownerId (another β ThreadOwnerError); a
Answerer.Channel 's id is the right to answer it β only its channel got it
(ask.Id), result.pending leaves it out β and it is resumed with { answeredBy: "manager-17" }, recorded on the and in onExecutionResume; the other credential (or an empty
answeredBy) is AnswererError (.wrong-answerer). The decision is validated before
it is recorded β a ToolCallApprovalDecisionDto (approved, reason?, overrideArguments?
only with approved: true, checked by the tool's input), else DtoValidationError and the
keeps waiting:
// a webhook: the finance manager pressed "Approve" on the message whose reply key is the Id
const result = await app.resume(
{ Id: replyKey, decision: { approved: true } },
{ tenantId: "acme", answeredBy: "manager-17" },
);
Free-text replies (#267 PR 3). A webhook that only has the person's words passes them as
they are β app.resume({ Id, reply }, options), exactly one of decision or a non-empty
reply β and the 's role reads them with its @ReplyInterpreter (replyInterpreter on the
@ChannelRole, one interpreter per role: [channel.shared-interpreter]). The interpreter gets the
's original question and the reply ({ ask, reply }); what it gives is validated like a
decision. DecidedBy.Model β a chat model answers through one answer tool, its static prompt /
promptUrls its instructions, the question and the reply in the input message. For an approval role
(approves) the model may be a decision model (Jev, #267 S124): it reads approve / refuse only β one
Decision.noul("approved"), approved when P(yes) > 0.5, the answer { approved } with no reason
and no overrideArguments; a question role, whose answer needs more than a yes / no, cannot name
one ([reply-interpreter.decision-model]). Either model's call goes through the provider
resolved by serves, is checked against the tree's budgets first, counted in their ledger at once
and in the execution's spend when the step takes the answer, and traced (reply-interpreter:<name>).
DecidedBy.Code β the class's interpret(input: InputOf<Self>) (implements ReplyInterpreterHandler<Self>, deps like any component), created by the container, returns its
outcome (#267 S124): ReplyInterpretation.understood(decision) β validated like any decision β or
ReplyInterpretation.notUnderstood({ reason }) for a reply it cannot read (a plain object does not
compile); a throw is for an unexpected crash only. Either kind reads in the 's execution frame β its
ExecutionContext has the tenant, the owner and the waiting step, and the rate limits see the
tenant. A model interpreter reads the question and the reply through the application's and the
workflow's PII policies, like an agent: its answer is revealed before it is validated, a token no
map knows is a decline, a block rule throws PiiRefusedError. A reply nobody can read β not
understood, a model that gives no answer, an answer the role rejects, a crash β throws
UninterpretableReplyError (.uninterpretable-reply; reason says why, cause is the error
behind it, if any) and the stays pending, so the webhook can ask again or answer with a decision; a
reply for a role without an interpreter is ReplyInterpreterMissingError
(.no-reply-interpreter), the untouched.
@ReplyInterpreter({
name: "refund-reply-interpreter",
description: "Turns a manager's free-text reply into a refund decision",
decidedBy: DecidedBy.Model,
model: "moonshotai/kimi-k2.6",
prompt: "The manager answered a refund approval in free text. Decide approve / reject.",
})
export class RefundReplyInterpreter {}
@ChannelRole({
name: "refund-approval-channel",
description: "Approves card refunds",
approves: RefundCardInputDto,
answeredBy: Answerer.Channel,
replyInterpreter: RefundReplyInterpreter,
})
export abstract class RefundApprovalChannel extends ChannelOf<RefundApprovalChannel> {}
// a webhook passes the text as it came
await app.resume({ Id: replyKey, reply: "ok, refund it" }, { answeredBy: "manager-17" });
// approve / refuse on a decision model: one Decision.noul("approved") β no reason, no overrideArguments
@ReplyInterpreter({
name: "deposit-reply-interpreter",
description: "Reads whether a clerk's reply approves a deposit return",
decidedBy: DecidedBy.Model,
model: "typesafe/jev-1.13",
prompt: "The clerk answered a deposit return.",
})
export class DepositReplyInterpreter {}
@ChannelRole({
name: "deposit-approval-channel",
description: "A clerk approves deposit returns",
approves: DepositReturnDto, // an approval role: a decision model may read its replies
answeredBy: Answerer.Channel,
replyInterpreter: DepositReplyInterpreter,
})
export abstract class DepositApprovalChannel extends ChannelOf<DepositApprovalChannel> {}
A code interpreter returns its outcome: ReplyInterpretation.understood({ approved: true }) for a
button value it knows, ReplyInterpretation.notUnderstood({ reason: "not a button value" }) for
anything else (Wiki β s has the whole class).
Who answered each (#267 S107). app.resume(β¦, { record: true }) (or execute) returns
result.record.s: every resolved of the tree in the order asked β
AnswerRecord { Id (undefined for a Answerer.Channel ), executionId (the execution it waited in: the root's or a child's), step, role, answeredBy, responder, resolution, askedAt, resolvedAt, decision }, Resolution.Decided
(ChannelOutcome.decided, no ), Replied (awaitReply() + app.resume), Withdrawn (a
FailFast sibling failed) or Cancelled (app.cancel, or the tree ended); the decision masked by
the PII policies like the rest of the record, none when withdrawn or cancelled. Each child's record β
result.children and result.record.children β lists the s its own steps asked as
ChildExecutionRecord.s (#267 S124), a grandchild's in its own record; the root's
record.s keeps the whole tree's. It is written when
the is resolved into the execution records β ExecutionRecordRepository.saveAnswer, read
back with findAnswersByTenantIdAndRootExecutionId β the permanent history, so the retention
below never changes it; the records are operational state. A resolved
record (Taken, Cancelled) is kept @Application({ RecordRetention }) β default
hours(24) β after it was resolved, then removed by the recovery sweep
(dExecutionRepository.deleteResolvedBefore(cutoff), on the Clock): within it a late answer
is NotdError, after it UnknownError; a pending is never removed.
Before its first step an execution reads each of its budgets (README β FinOps): one exhausted β
the tenant's day spent, say β refuses it with BudgetExceededError (limit.budget, an
onExecutionRejected), before anything is written: no thread (a new one is created once the budgets pass), no record, no model call.
execute and resume forward signal, onStream (token and tool-call events) and
configurable (extra LangGraph keys; the framework's own win). There is no untyped metadata or
executionContext option (#261): what an execution carries is its typed context values β a
channel, a guardrail or any component reads them through its injected ExecutionContext
(this.executionContext.tenant.tenantId, the project's own fields), and an MCP session
(createMcpService(app, Mcp).connect(transport, { tenant, execution, ownerId })) or an A2A call
(a2aHttpListener(app, { expose, callerResolver }), whose resolver returns them per request, #387;
adapter.execute(Start, request, onEvent, { tenant, execution, ownerId })) passes them from its
authenticated caller. app.cancel(threadId) aborts a running
run β it stops before its next model or tool call and execute rejects with
ExecutionCancelledError β or drops a d one, whose s are withdrawn: their resume then
throws NotdError;
cancelled is false when the thread had neither (a thread the tenant does not have is
UnknownThreadError). From app.close() on, execute and resume
are refused with ApplicationStoppingError (app.stopping, an onExecutionRejected); running executions
go on and cancel still works. An execution tree is moved by one holder at a time (#265, Q58):
execute and resume hold its lease (seconds(30), renewed every 10 s and before every checkpoint
write, released when the pass ends). A resume first records its decision on its (Pending β Resolved, atomic) β of two resumes of one the second gets NotdError, as does an
answer to a its step already took (Taken) or one withdrawn ( Cancelled: the thread was
cancelled, or a sibling branch failed under FailFast) β then waits while another call or instance
holds the lease; the holder continues with every recorded answer of the tree in one pass and, before
the tree rests, takes the answers recorded meanwhile, so the waiting call returns the tree's current
output with its decision taken. An instance that lost the
lease (frozen past it) stops and its call rejects with ExecutionLeaseLostError
(execution.lease-lost) β no onExecutionError, no failed record: the new holder continues (Wiki β
Several-instances).
An execution's record is ExecutionRecordStatus.Running while its tree moves. Recovery (#265):
every instance looks for running roots at its start and every 30 s, takes the lease of each whose
holder died (its lease expired) or stopped (released it), and continues it from its last
checkpoint β a decision recorded meanwhile first β so only the interrupted step runs again; a root
started under another config is left to a compatible instance (logged once), unless
onIncompatibleResume says "resume". Observers get onExecutionResume with
reason: ResumeReason.Recovered (ResumeReason.Answered for app.resume). With the in-memory
defaults nothing survives a restart, so recovery needs persistent stores β the SQLite ones on one
machine. app.close() stops
the sweep, runs onStop, waits for the running executions up to the shutdown timeout β
@Application({ shutdownTimeout }), app.close({ timeout }), default seconds(25) β then
interrupts them: each call rejects with ApplicationStoppingError, without onExecutionError, releases its
lease at once and stays running, so another instance continues it without waiting for the lease
to expire. createApp calls app.close() on SIGTERM / SIGINT (a second signal interrupts at once)
until the close finishes; testWith never listens.
// one instance of a rolling deploy stops: what still runs after 10 s moves to another instance
await app.close({ timeout: seconds(10) });
Health checks (#300): app.checkLiveness() and app.checkReadiness() are the instance's probes
for an orchestrator (Kubernetes, Cloud Run) β the project's own server exposes them, the framework
opens no endpoint. Liveness does no I/O: the instance (Unhealthy, stopping, from app.close()
on) and its recovery loop (Unhealthy when its last sweep ended more than three sweep intervals ago
on the Clock). Readiness adds every store's ping() (each #265 contract has one β a project's own
implementation adds it; the SQLite ones run SELECT 1), every model provider's circuit breaker as
RateLimitRepository keeps it (open β Degraded; never a model call) and the project's
@HealthCheck s, listed in @Application({ healthChecks }) β all at once, each under its timeout
(stores and providers seconds(2)). A check that throws or passes its timeout is that component's
Unhealthy with its reason; neither call ever rejects. The report's status is its worst
component's (HealthStatus.Healthy | Degraded | Unhealthy), each component has its name, kind
(HealthComponentKind), status, reason and duration
(Wiki β Health-checks).
@HealthCheck({ name: "crm-health-check", timeout: seconds(2), deps: [CrmClient] })
export class CrmHealthCheck implements HealthCheckHandler {
constructor(private readonly crmClient: CrmClient) {}
async check(): Promise<HealthCheckResult> {
const isReachable: boolean = await this.crmClient.ping();
return isReachable
? HealthCheckResult.healthy()
: HealthCheckResult.unhealthy({ reason: "CRM is unreachable" });
}
}
// listed in the application: healthChecks: [CrmHealthCheck]
const liveness: HealthReport = await app.checkLiveness(); // GET /livez β 503 unless Healthy
const readiness: HealthReport = await app.checkReadiness(); // GET /readyz β 503 when Unhealthy
In unit tests, testExecutionContext({ β¦ }) from
@agboss/core/testing is a frozen ExecutionContext: new PayOrder(testExecutionContext()); its
tenant and tenant.application are set with { tenantContext, tenant, application: { environment } }.
Observers are registered only in @Application({ observers: [...] }) (#261) β one instance each,
created by the container at start with their deps, called for every execution of the
application (filter by event.workflow.name === nameOf(SupportWorkflow) β nameOf(C) gives the
name of any AGBoss-decorated class); none listed β none is
called. Each hook gets one event object, plain data, never the framework's internal state. Every
execution event (ExecutionEvent) carries executionId, parentExecutionId, rootExecutionId,
threadId, tenantId and workflow β a WorkflowRecord { name, version, startedVersion, configHash }, no class; a node event (agent, router, tool, model, knowledge base, guardrail, action,
channel, judge, onValuesOutdated, onLaunchItemsTruncated) adds step, a StepRecord
(component β { name, kind, promptHash, schemaHash }, the join keys of agboss describe --json
β, nodeId, visitNumber, copyNumber, itemIndex, idempotencyKey, callId,
toolLoopIteration, validationAttempts) β the serialized executionContext.step. onExecutionStart fires once per execution (at
execute); one that s because nothing else can move emits onExecution (event.pending:
every it waits for β again after a partial resume), app.resume and recovery emit
onExecutionResume (event.reason, event.decision, event.answeredBy), onExecutionEnd fires once
when it ends at a finish (event.finish: a ComponentRecord, event.spend) and
onExecutionError when it fails (event.error) β also after a resume; cancelling a d
execution is an onExecutionError with ExecutionCancelledError. A request refused before an
execution starts or is located β another owner's thread, a resume of an unknown or one no
longer waiting, an
execute on an unknown thread or with an invalid input, an exhausted budget, a request after
app.close() β emits onExecutionRejected (operation: ExecutionOperation.Execute | Resume | Cancel, threadId when given, tenantId, the error's stable code, a message without the input
values; never an executionId) and still throws. A resume refused as incompatible after a deploy
(IncompatibleResumeError) is an onExecutionError on the d execution; it stays d. A
write that renews a value of the path scope outdates what was built from the older one:
onValuesOutdated fires once per such cascade (the new value, each outdated value with its
OutdatedReason; never DTO values), and result.record.outdatedValues keeps the values themselves.
Assembly rejects an observer with no hook or with a misspelled one (did you mean onToolEnd?); an
observer that throws is a warning in the application's log (observer.failed) and never fails the
execution.
import {
Application,
EmptyEnvironment,
Injectable,
type AgentStartEvent,
type ExecutionEndEvent,
type ExecutionRejectedEvent,
type OnAgentStart,
type OnExecutionEnd,
type OnExecutionRejected,
} from "@agboss/core";
@Injectable()
export class MetricsObserver implements OnExecutionEnd, OnAgentStart, OnExecutionRejected {
onExecutionEnd(event: ExecutionEndEvent): void {
console.log(`${event.workflow.name} ${event.executionId} (tenant ${event.tenantId}) cost $${event.spend.totalUsd}`);
}
onAgentStart(event: AgentStartEvent): void {
console.log(`${event.step.component.name} visit ${event.step.visitNumber} of ${event.executionId}`);
}
onExecutionRejected(event: ExecutionRejectedEvent): void {
console.log(`${event.operation} refused for ${event.tenantId}: ${event.code}`);
}
}
@Application({
name: "metrics-application",
version: "1.0.0",
environment: EmptyEnvironment,
workflows: [],
observers: [MetricsObserver], // the only place observers are registered
})
export class MetricsApplication {}
The framework logs through the application's Logger (#261) β infrastructure like the Clock:
the built-in ConsoleLogger writes one line per entry to standard error; your own extends Logger
(write(entry: LogEntry)) goes in @Application({ providers }). At start, once, it logs only what
the decorators say β what started, and the worst case of one call of each start in counts β then
the startup warnings; no project code is called (no maxSpend()), nothing per tenant:
15:02:11 INFO Started job-scout-application 1.0.0 β environment prod, host scout-7f3a, process 41237
15:02:11 INFO Worst case of one chat-workflow-start call: 1 workflow execution, 1 at the same time, 12 agent and router steps
agboss check prints the same worst case, from the same decorators, with how each number is made
up (#268) β maxItems, maxConcurrency, maxSteps or the longest path, maxDepth; agboss describe prints it under each workflow's limits:
Worst case of one vendor-screening-workflow-start call
workflow executions 21 1 vendor-screening-workflow + 20 vendor-check-workflow (maxItems 20)
at the same time 4 1 vendor-screening-workflow + 3 vendor-check-workflow (maxConcurrency 3)
agent and router steps 22 2 in vendor-screening-workflow (longest path) + 20 Γ 1 in vendor-check-workflow (longest path)
nesting depth 2 vendor-screening-workflow β vendor-check-workflow
While it runs it logs only problems of the system β LogLevel.Warn at risk (a recovery left to a
compatible instance, an observer that threw), LogLevel.Error broken (a failed recovery, a failed
close) β each with its stable code and the executionId / tenantId it is about. One execution's
details are its trace, a tenant's spend is the cost ledger, a rejected execution is an observer
event. The startup warnings (app.warnings) include the assembly warnings: tenant or
application budgets on the built-in in-memory cost ledger ([application.in-memory-cost-ledger]:
each instance counts on its own, the counters reset on restart) and s possible with the
built-in in-memory d executions or checkpoints ([application.in-memory-s]), and PII
tokens made with a key of each execution because @Application({ pseudonymKey }) is not set
([application.pii-instance-key]: one value gets another token in each execution) β errors
under agboss check --strict, silent in tests ( testWith logs no start and no warning; an unlisted
logger is silent there).
Every model call is a usage record (caller, model, tokens, USD) in the run's spend
(ExecutionOutput.spend: total, per caller, per category) and in the counters of every budget of
the execution (the application's CostLedgerRepository, in memory unless one is listed).
Money limits are @Budget components (#261), all of them in @Application({ budgets })
(written in config/budget.config.ts), one template β @Budget({ name, scope, period?, workflow?, deps? }) on a class that implements BudgetHandler, one method maxSpend(): Usd | Promise<Usd>:
BudgetScope.Executionβ one full pass ofapp.execute: a workflow's own (workflow: X, at most one) or the one default (noworkflow) for every workflow without its own;BudgetScope.Tenantβ one organization (tenantId) perBudgetPeriod.Day | Week | Month(UTC);BudgetScope.Applicationβ the whole application per period.
Every workflow is covered ([budget.execution-budget-required]), there is at least one tenant and
one application budget ([budget.tenant-budget-required], [budget.application-budget-required]),
and no workflow has two ([budget.duplicate-execution-budget],
[budget.duplicate-default-execution-budget]) β checked at assembly. maxSpend() is read once per
execution, at its start, in its ExecutionContext (a budget injects it, or the project's tenant
context, or a service such as billing β a trusted source, never the request body) and kept for the
whole execution, a resume included; the ledger keeps the last one read. Before the first step every
budget is checked β one exhausted rejects the execution (onExecutionRejected); every model call's real
cost is then counted in all of them, and before the next step or model call the first exhausted
stops the execution with BudgetExceededError β budget ({ name }), scope, key (the
application's name, the tenantId or the execution's id), maxSpend, spent, and a sentence:
tenant acme spent $40.05 today; tenant-daily-budget allows $40. .onError(BudgetExceededError, β¦)
takes it. Nothing is reserved: a budget protects from draining money, so parallel executions of one
tenant may overrun it a little, and a budget is exceeded by at most the calls in flight when it was
exhausted (a cost is known after its call): within one tree every call counts the moment it is made,
and the call that exhausts a budget aborts the in-flight calls of the parallel branches beside it
(#338). A decision a
tool's service makes through ROUTER_FACTORY is the tool's spend (tool:<name>). A paid service
that is not a model is recorded with SpendRecorder (deps: [SpendRecorder], in a @Tool or
an @Action): the record joins the spend of the current tool call or step and counts in the same
budgets. Called outside a step or a tool call, record throws SpendOutsideStepError
([spend.outside-step]).
// config/budget.config.ts β the tenant's own amount, from its server-side data
import { Budget, BudgetPeriod, BudgetScope, type BudgetHandler } from "@agboss/core";
import { usd, type Usd } from "@agboss/core/units";
@Budget({
name: "tenant-daily-budget",
scope: BudgetScope.Tenant,
period: BudgetPeriod.Day,
deps: [ShopTenantContext],
})
export class TenantDailyBudget implements BudgetHandler {
constructor(private readonly shopTenantContext: ShopTenantContext) {}
maxSpend(): Usd {
return usd(this.shopTenantContext.dailyBudgetUsd);
}
}
@Budget({ name: "execution-budget", scope: BudgetScope.Execution, deps: [ExecutionContext] })
export class ExecutionBudget implements BudgetHandler {
constructor(private readonly executionContext: ExecutionContext) {}
maxSpend(): Usd {
return this.executionContext.tenant.weight > 1 ? usd(0.5) : usd(0.1); // a bigger tenant
}
}
A budget is unit-tested without the container β
new TenantDailyBudget(testExecutionContext({ tenantContext: ShopTenantContext, tenant: { dailyBudgetUsd: 40 } }).tenant).maxSpend()
is usd(40) β and an application test reaches an exhausted one the real way:
mockOf(TenantDailyBudget).maxSpend.mockReturnValue(usd(0.01)) plus a scripted cost
(replyWith(β¦, { cost: usd(0.02) })). Steps are not money: @Workflow({ maxSteps }) (default
(agents + routers) Γ 3; StepsLimitExceededError), checked at assembly too β
[limits.max-steps-too-low] when no finish fits in a hand-set maxSteps, or when the default
cannot hold the worst case of a per-item launch (maxItems runs: set maxSteps),
[limits.branch-exceeds-max-steps], [limits.max-steps-unused].
import { SpendRecorder, Tool, type ToolHandler } from "@agboss/core";
import { usd } from "@agboss/core/units";
@Tool({
name: "web-search-tool",
description: "Searches the web (a paid API)",
input: SearchQueryDto,
output: SearchResultsDto,
deps: [SpendRecorder],
})
export class WebSearchTool implements ToolHandler<SearchQueryDto, SearchResultsDto> {
constructor(private readonly spendRecorder: SpendRecorder) {}
run({ query }: SearchQueryDto): Promise<SearchResultsDto> {
this.spendRecorder.record({ service: "serp-api", cost: usd(0.002) }); // β tool:web-search-tool
return Promise.resolve({ text: `results for ${query}` });
}
}
A @PiiPolicy says which personal data the models never see as is (#120). Policies are listed in
piiPolicies of @Application, @Workflow, @Agent and @Router: the lists add up, and an agent
or a router removes an inherited one with excludePiiPolicies β for its own model and tools only;
logs, traces, observer events and result.record stay masked by the application's and the
workflow's lists. Masking happens only at the model boundary: what a model reads is masked, what
it gives back is revealed before any DTO is validated, so the scope, the checkpoint, every tool,
action and finish see real values. replaceWithToken() is the reversible one β a tool gets the
address the model only saw as <email:7f3a91> (an HMAC of the value with pseudonymKey; the
token map is kept in the execution's checkpoint). A blockWithRefusal rule fails the execution
with PiiRefusedError (kind, refusal) when the value comes in or is in an answer, and rejects
only the call when it is in tool arguments or a tool result.
@PiiPolicy({
name: "customer-pii-policy",
description: "Contact details hidden, cards refused, names found by a model",
rules: [
pii(PiiGroup.Contact, PiiAction.replaceWithKind()), // phones, addresses, postal codes
pii(PiiKind.Email, PiiAction.replaceWithToken()), // a kind rule beats its group's
pii(PiiKind.PersonName, PiiAction.replaceWithKind()),
pii(PiiKind.CardNumber, PiiAction.blockWithRefusal()), // the text from refusal(kind)
],
detectWithModel: "moonshotai/kimi-k2.6",
pseudonymKey: secret("PII_PSEUDONYM_KEY"),
})
export class CustomerPiiPolicy implements PiiRefusal {
refusal(kind: PiiKind): string {
return `Please do not send a ${kind} here.`;
}
}
@Injectable()
export class EmployeeDirectory {
readonly ids: readonly string[] = ["EMP-0042"];
}
// a project's own detector: the framework applies `action` to every range it finds
@PiiPolicy({
name: "employee-id-pii-policy",
action: PiiAction.replaceWithToken(),
deps: [EmployeeDirectory],
})
export class EmployeeIdPiiPolicy implements PiiPolicyHandler {
constructor(private readonly employeeDirectory: EmployeeDirectory) {}
findMatches(text: string): readonly PiiMatch[] {
return this.employeeDirectory.ids.flatMap((id) => {
const start = text.indexOf(id);
return start < 0 ? [] : [{ start, end: start + id.length }];
});
}
}
// a field without a format of its own: the same actions (masking ones only)
export class TicketDto {
@Text({ prompt: "the customer's account", sensitive: PiiAction.replacePart({ keepLast: 4 }) })
account!: string;
}
Assembly checks the lists: [pii.conflicting-rules] (two policies with different actions for one
kind, naming both), [pii.exclude-not-inherited], [pii.missing-refusal], [pii.unused-refusal],
[pii.kind-needs-model] (a name or an address without detectWithModel), [pii.not-a-policy].
Each pseudonymKey is read from its process variable when the app starts (a missing one fails the
start). The application's own key, @Application({ pseudonymKey: "piiPseudonymKey" }), names a
Secret field of its environment (piiPseudonymKey: secret("PII_PSEUDONYM_KEY"), like a model
provider's apiKey): the key of sensitive fields, of project detectors and of every policy without
its own β so one value is one token on every instance, and logs and traces from several machines
agree; a policy's own key wins, and a policy that needs the application's when it is not set fails
the start, naming the field. Not set and not needed, each instance makes a key of its own: the
start goes on with the warning [application.pii-instance-key] (reveal still works β the token map
is in the checkpoint β only logs and traces of two instances show one value as two tokens). Under
testWith an unset key gets a fixed test key, and mockLlm(CustomerPiiPolicy)
scripts its model detection (pii:<policy> in the spend). Wiki β Guards (PII).
Guardrails block (safety); judges correct (quality). An @InputGuardrail checks what an agent's or
a model router's model reads β its input message, masked by that element's PII policies, before the
model is called; an @OutputGuardrail checks what an agent answers. Each asks its model one yes / no
question β its prompt β a decision model through one Decision.noul, a chat model through one
answer tool; its model or defaults.guardrail.model (never defaults.router; none β
[guardrail.no-model]). At P(yes) β₯ threshold the execution fails with GuardrailBlockedError
(guardrail.blocked): refusal is the text to send instead, key the setting that decided
(guardrails.<name>.threshold). A model that does not answer fails closed ( GuardFailedError).
// a computed refusal: the class implements GuardrailRefusal, the execution through deps
@InputGuardrail({
name: "legal-advice-guardrail",
description: "Blocks requests for legal advice",
prompt: "Does the message ask for legal advice?",
model: "typesafe/jev-1.13",
threshold: 0.8,
deps: [ClinicExecutionContext, RefusalTextService],
})
export class LegalAdviceGuardrail implements GuardrailRefusal {
constructor(
private readonly clinicExecutionContext: ClinicExecutionContext,
private readonly refusalTextService: RefusalTextService,
) {}
refusal(): Promise<string> {
return this.refusalTextService.legalAdvice(this.clinicExecutionContext.locale);
}
}
// a fixed refusal, the model from defaults.guardrail.model
@OutputGuardrail({
name: "no-legal-advice-guardrail",
description: "Blocks answers that give legal advice",
prompt: "Does the answer give legal advice?",
threshold: 0.8,
refusal: "I can't give legal advice. Please contact a lawyer.",
})
export class NoLegalAdviceGuardrail {}
They are listed in guardrails of @Application, @Workflow, @Agent and @Router: the lists
add up, excludeGuardrails removes an inherited one for an agent or a router. A model router takes
the input guardrails; a code router calls no model and takes none. Assembly checks them all at once:
-guardrail names, one refusal (both β [guardrail.refusal-twice]; neither does not compile, and
is [guardrail.missing-refusal] at assembly), a question ([guardrail.no-prompt]), a threshold in
(0, 1] ([guardrail.threshold-invalid]), [guardrail.exclude-not-inherited],
[guardrail.not-a-guardrail], [guardrail.output-on-router], [guardrail.no-model-call].
agboss describe prints each one's side, threshold, model, refusal and the elements it guards. In
tests, mockLlm(LegalAdviceGuardrail).thenReturn(decideWith({ blocks: 0.9 })) blocks (an
unscripted guardrail passes), and testGuardrail(LegalAdviceGuardrail, { model: decideWith({ blocks: 0.9 }), executionContext: ClinicExecutionContext, execution: { locale: "he" } })("Can I sue?")
gives { isBlocked, probability, refusal }.
A thread's turns are its finished executions (start <name>: <input> / finish <name>: <output>).
Each agent and model router sees the last uncompactedTurns of them in full β set on
@Application, @Workflow, @Agent or @Router, the lowest set value wins (default 5,
TurnLimit.ALL = every turn). What is older is summarised by a @Compaction, named by
compaction on the same levels (again the lowest wins; none anywhere β the window only):
// a queue: every 5 turns older than the window become one summary, the latest 10 are kept
@Compaction({
name: "conversation-compaction",
description: "Summarises old turns of the conversation",
prompt: "Summarise the turns under `Turns to compact`: what was asked, decided and is still open.",
model: "moonshotai/kimi-k2.6",
strategy: CompactionStrategy.SummaryQueue,
turnsBetweenCompactions: 5,
summaryQueueLength: 10,
summaryMaxTokens: 400,
})
export class ConversationCompaction {}
// one running summary, rewritten from the previous one and the new turns (the built-in prompt)
@Compaction({
name: "code-context-compaction",
description: "Keeps one summary of the code touched so far",
model: "moonshotai/kimi-k2.6",
strategy: CompactionStrategy.SingleSummary,
turnsBetweenCompactions: 3,
summaryMaxTokens: TokenLimit.MODEL_MAX,
})
export class CodeContextCompaction {}
// no summaries at all: only the window (no model, no prompt, no parameters)
@Compaction({
name: "no-summaries-compaction",
description: "Only the window of recent turns",
strategy: CompactionStrategy.None,
})
export class NoSummariesCompaction {}
A compaction summarises everything older than the smallest uncompactedTurns among the agents
and routers that use it, so no one loses a turn: a router that sees 1 turn and an agent that sees 3
share one compaction that summarises what is older than 1 β the agent sees its 3 turns in full and
not the summary they already cover. summaryMaxTokens is the summary call's max_tokens (the
model's server counts it; thinking is off on that call): a summary cut there is asked once more,
shorter, and cut again fails the execution with CompactionError (compaction) β never a cut
summary. A compaction runs lazily, before a new execution first reads its thread β never after a
turn: a finished answer is always kept, a thread that ends pays for none, and a CompactionError
fails the new execution before any of its work (no model call, no step, no turn kept). The compaction's model reads the turns
through the application's and the workflow's PII policies, its summary is revealed before it is
stored, and each reader's own policies mask it on the way into its model. Spend is
compaction:<name>; result.compacted says what each compaction did before the execution. In tests,
mockLlm(ConversationCompaction).thenReturn(replyWith("β¦")) scripts it (replyWith("β¦", { truncated: true }) is a summary cut at the limit); unscripted, it writes a fixed summary.
scripts/langfuse.sh up # self-hosted Langfuse in Docker, keys written to .env
Then every run is traced at http://localhost:3000. Details: Observability & Tracing Guide Β· Wiki β Tracing.
Tickets move across the GitHub Project board of the repo:
| Stage | Skill | Result |
|---|---|---|
| Triage | triage |
business side clarified through quizzes; issue(s) created or updated, split into sub-issues if needed |
| Backlog | spec-session |
spec, implementation plan, automated + manual acceptance criteria β in the issue |
| In progress β Test | implement |
independent issues in parallel; branch + PR per issue, merged to main ; manual-test handoff |
| Done | a human | after manual acceptance |
Rules and the delivery pipeline: WORKFLOW.md Β· QUALITY.md Β· CLAUDE.md. Full documentation lives at https://agboss.dev and in the GitHub Wiki.
CONTRIBUTING.mdβ how a change goes from an issue to a merged pull request.SECURITY.mdβ report a vulnerability privately tosecurity@agboss.dev ; supported versions and what to expect.CHANGELOG.mdβ what each release changes; the per-pull-request log with every breaking change is theWiki's Changelog .