{"slug": "show-hn-agboss-dependency-injection-and-static-validation-for-ai-agents", "title": "Show HN: AGBoss Dependency injection and static validation for AI agents", "summary": "AGBoss released version 0.1.0 of its open-source TypeScript framework, publishing @agboss/core@0.1.0 as the production runtime and @agboss/cli@0.1.0 as the CLI and static workflow compiler on registry.npmjs.org under the official agboss npm organization. The framework layers static verification on LangGraph for multi-agent workflows, with typed decorator components, constructor dependency injection, compile-time Path Scope verification via `agboss check`, declared action compensation with automatic LIFO rollback, pre-call FinOps budgets, boundary PII token masking, and signed Agent Mesh envelopes for A2A services. Running `npm exec --yes --package=@agboss/cli@0.1.0 -- agboss create my-app` scaffolds a project whose postinstall step runs `agboss check`, generates `.agboss/types.d.ts`, and unpacks 13 version-matched reference guides into `.claude/skills/agboss/` for AI coding agents.", "body_md": "**AGBoss** ([agboss.dev](https://agboss.dev)) is an open-source TypeScript framework with static verification for complex multi-agent workflows on top of [LangGraph](https://www.langchain.com/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 pauses (`@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).\n\n**Current release (`v0.1.0`):** [`@agboss/core@0.1.0`](https://www.npmjs.com/package/@agboss/core) (production runtime) and [`@agboss/cli@0.1.0`](https://www.npmjs.com/package/@agboss/cli) (CLI & static workflow compiler) are publicly published on `registry.npmjs.org` under the official `agboss` npm organization.\n\n**Documentation:** [https://agboss.dev](https://agboss.dev) · **Quick Start:** [https://agboss.dev/getting-started/quick-start/](https://agboss.dev/getting-started/quick-start/) · **CLI Reference:** [https://agboss.dev/cli/](https://agboss.dev/cli/) · **Examples:** [https://github.com/agbossjs/agboss-examples](https://github.com/agbossjs/agboss-examples)\n\n```\nnpm exec --yes --package=@agboss/cli@0.1.0 -- agboss create my-app\ncd my-app\nnpm install\n```\n\n`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`](https://www.npmjs.com/package/@agboss/core) in `dependencies` and [`@agboss/cli`](https://www.npmjs.com/package/@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.\n\n```\ncp .env.example .env && npm run chat\n# every name is the full one, as in `name` (#268): the CLI never adds a suffix for you\nnpx agboss g tool refund-tool --workflow my-app-workflow --agent my-app-assistant-agent\n# one tool per OpenAPI 3 operation (DTOs, a client in services/, tests), wired into the agent\nnpx agboss g openapi pets --url ./pets.yaml --workflow my-app-workflow --agent my-app-assistant-agent\nnpx agboss g workflow onboarding-workflow   # a second workflow, listed in the @Application\n# any layout: targets come from the workflow module, tests go where vitest looks, files are\n# prettier-formatted; a rerun refuses — --force regenerates the files and keeps the wiring in them\n# (tools, rag, MCP, judges, routes, …); --force --reset starts them over without it\nnpm install @agboss/core\nnpm install -D @agboss/cli\nnpx agboss check\n```\n\nWhy two packages?\n\n- **`@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.\n- **`@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.\n\n``` js\n// src/agents/scout.agent.ts — the prompt is a file next to the agent\nimport { Agent } from \"@agboss/core\";\n\n@Agent({\n  name: \"scout-agent\",\n  description: \"Finds jobs\",\n  promptUrls: [\"./scout.prompt.md\"],\n  model: \"moonshotai/kimi-k2.6\",\n  tools: [GreenhouseJobs],\n})\nexport class ScoutAgent {}\n// src/job-scout.workflow.ts — the paths, defaults and providers; money is the application's budgets\nimport { Workflow, route, start } from \"@agboss/core\";\nimport { WorkflowStartTextDto } from \"@agboss/core/dto\";\n\n@Workflow({\n  name: \"job-scout-workflow\",\n  version: \"1.0.0\",\n  flow: [\n    start(ChatWorkflowStart)\n      .returns(WorkflowStartTextDto)\n      .toRouter(MainRouter) // picks one of its @Router({ routes })\n      .chooseOne(\n        route(ProfilerAgent)\n          .toFinish(ChatWorkflowFinish),\n        route(ScoutAgent)\n          .toFinish(ChatWorkflowFinish),\n      ),\n  ],\n  defaults: {\n    models: { temperature: 0 },\n    router: { model: \"typesafe/jev-1.13\" },\n    tools: { maxToolCalls: 8 },\n  },\n  uncompactedTurns: 5, // the last 5 turns of the thread in full (older ones: a @Compaction's summaries)\n  providers: [JobFitJudge],\n})\nexport class JobScout {}\n```\n\nThe application is the one entry (#261): it lists the workflows, the infrastructure (a class that\n`extends CostLedgerRepository`, `ExecutionRecordRepository`, `PausedExecutionRepository`,\nLangGraph's `BaseCheckpointSaver` or one of the shared contracts of #265 — `ExecutionLeaseRepository`,\n`ExecutionTreeRepository`, `RateLimitRepository`; none listed → the built-in in-memory ones, nothing\nwritten to the home folder; the SQLite ones below — each with a fast `ping()` readiness calls, #300),\nits **model providers**, every budget and its health checks (`healthChecks`).\n`createApp` and `testWith` take it, never a workflow.\n\nModel providers are infrastructure too (#266): `@ModelProvider` classes listed in\n`@Application({ providers })` — none listed → the built-in `OpenRouterModelProvider` (chat) and\n`DecisionsModelProvider` (Jev and the other decision models); one listed → the list is complete,\nthe built-ins are not added next to it. A workflow and its components only name models; every model\nof the application (agents, model routers, the default router, guardrails, compactions, judges) resolves\nto exactly one listed provider by `serves` at assembly — none → `[model.unserved]`, two →\n`[model.ambiguous-provider]` (one model through two providers = two model strings, each in one\n`serves`; the built-ins yield a model another listed provider claims, #454). `agboss describe` prints `model: … → <provider>` for each, `agboss check --models` checks\nevery model of every workflow of the application against its provider:\n\nA provider's **policies** are app-wide too: `retryPolicy` retries one call (attempts, exponential\nbackoff with jitter, `Retry-After` honoured — the only place backoff is set); `rateLimitPolicy`\n(`RateLimitPolicy.of({ maxConcurrentCalls, perMinute: { calls, tokens }, balancing })`) is one limit\nfor the whole application — every execution, tenant, parallel branch and instance shares it through\n`RateLimitRepository` — a call without room waits (its signal ends the wait, its timeout starts only\nwith the room), waiting tenants share free slots by `QueueBalancingStrategy` (`TenantRoundRobin` by\ndefault, `TenantLeastInFlight`, `TenantWeighted` by `tenant.weight`, `Fifo`), and a 429 pauses the\nprovider's whole queue for `Retry-After` on every instance, then halves this instance's\nconcurrency and grows it back by one a minute; `circuitBreakerPolicy` stops calling a provider that\nis down — its state in `RateLimitRepository` as well. Without `rateLimitPolicy` there is no limit:\nset it per your provider plan. A provider's `name` ends with `-model-provider`; its key is a\n`Secret` field of the application's environment (`apiKey: \"openRouterApiKey\"`):\n\n```\n// src/model-providers/openrouter.model-provider.ts — job-scout's chat models, with its own request fields\nimport {\n  ModelCost,\n  ModelProvider,\n  OpenRouterProvider,\n  OPENROUTER_BASE_URL,\n  PromptCaching,\n  QueueBalancingStrategy,\n  RateLimitPolicy,\n  Reasoning,\n  RetryPolicy,\n  TRANSIENT_FAILURES,\n  type OpenRouterFields,\n} from \"@agboss/core/models\";\nimport { minutes, seconds } from \"@agboss/core/units\";\n\n@ModelProvider({\n  name: \"openrouter-model-provider\",\n  description: \"OpenRouter — every chat model job-scout uses\",\n  serves: [/.*/], // its chat models; decision models go to DecisionsModelProvider (no chat there)\n  baseUrl: OPENROUTER_BASE_URL,\n  baseUrlField: \"openRouterBaseUrl\", // the environment may set another (a proxy, a stub)\n  apiKey: \"openRouterApiKey\", // a Secret field of the application's environment\n  timeout: minutes(2), // per attempt, from the moment it has room\n  cost: ModelCost.fromResponse(),\n  reasoning: Reasoning.modelDecides(),\n  promptCaching: PromptCaching.off(),\n  retryPolicy: RetryPolicy.exponential({\n    maxAttempts: 3,\n    initialDelay: seconds(1),\n    maxDelay: seconds(20),\n    jitter: true,\n    retryOn: TRANSIENT_FAILURES,\n  }),\n  rateLimitPolicy: RateLimitPolicy.of({\n    maxConcurrentCalls: 8, // the plan's limits, for every instance at once\n    perMinute: { calls: 120 },\n    balancing: QueueBalancingStrategy.TenantRoundRobin,\n  }),\n  circuitBreakerPolicy: { failureThreshold: 5, window: minutes(1), openFor: seconds(30) },\n})\nexport class OpenRouterModelProvider extends OpenRouterProvider {\n  override readonly requestFields: OpenRouterFields = { provider: { ignore: [\"Inceptron\"] } };\n}\n// src/environments/app.environment.ts — the key both providers read; `test` leaves it out\nimport type { Secret } from \"@agboss/core\";\n\nexport class AppEnvironment {\n  openRouterApiKey?: Secret; // dev, prod: openRouterApiKey: secret(\"OPENROUTER_API_KEY\")\n  openRouterBaseUrl?: string; // env.optional(\"OPENROUTER_BASE_URL\"): unset → the provider's baseUrl\n}\n// src/job-scout.application.ts — what createApp(JobScoutApplication, { env }) and testWith run\nimport { Application, Budget, type BudgetHandler, BudgetPeriod, BudgetScope } from \"@agboss/core\";\nimport { DecisionsModelProvider } from \"@agboss/core/models\";\nimport { usd, type Usd } from \"@agboss/core/units\";\nimport { AppEnvironment } from \"./environments/app.environment.js\";\nimport { JobScout } from \"./job-scout.workflow.js\";\nimport { OpenRouterModelProvider } from \"./model-providers/openrouter.model-provider.js\";\n\n// config/budget.config.ts in a project: the whole application, a tenant per day, every execution\n@Budget({ name: \"application-daily-budget\", scope: BudgetScope.Application, period: BudgetPeriod.Day })\nexport class ApplicationDailyBudget implements BudgetHandler {\n  maxSpend(): Usd {\n    return usd(5);\n  }\n}\n\n@Budget({ name: \"tenant-daily-budget\", scope: BudgetScope.Tenant, period: BudgetPeriod.Day })\nexport class TenantDailyBudget implements BudgetHandler {\n  maxSpend(): Usd {\n    return usd(1);\n  }\n}\n\n@Budget({ name: \"execution-budget\", scope: BudgetScope.Execution }) // the default of every workflow\nexport class ExecutionBudget implements BudgetHandler {\n  maxSpend(): Usd {\n    return usd(0.1);\n  }\n}\n\n@Application({\n  name: \"job-scout-application\",\n  version: \"1.0.0\",\n  environment: AppEnvironment,\n  workflows: [JobScout], // app.execute(ChatWorkflowStart, …) runs the workflow of that start\n  providers: [OpenRouterModelProvider, DecisionsModelProvider], // every model resolves to one\n  budgets: [ApplicationDailyBudget, TenantDailyBudget, ExecutionBudget], // checked before each step\n})\nexport class JobScoutApplication {}\n```\n\nEvery storage contract has a ready **SQLite** implementation (#265), enabled only by listing it:\n`SqliteExecutionRecordRepository`, `SqliteCostLedgerRepository`, `SqlitePausedExecutionRepository`,\n`SqliteCheckpointSaver`, `SqliteExecutionLeaseRepository`, `SqliteExecutionTreeRepository`,\n`SqliteRateLimitRepository`. They share one file per application instance — `sqlitePath` of the\nenvironment contract, required only when one is listed (else `[environment.missing-field]`\nnames it); WAL, `busy_timeout`, every atomic operation in `BEGIN IMMEDIATE`, time from the\nframework's clock. With them `--thread`, `agboss eval score` / `replay` / `golden` see what earlier\nprocesses wrote, a pause survives a restart, and another instance on the file recovers a run\nwhose process died; tests run on `sqlitePath: \":memory:\"`. A project subclasses one through public\ntokens only (`extends` + `override`, `@Injectable({ deps })` as the class's own —\n`SqliteExecutionRecordRepository` takes `[SqliteConnection, Clock, ApplicationContext, IdGenerator]`,\n#313) and lists the subclass instead. One file serves one machine (Wiki → Several-instances):\n\n```\n// environments/app.environment.ts — the contract declares the file\nexport class StoreEnvironment {\n  sqlitePath!: string; // required because SQLite implementations are listed\n}\n\n// environments/dev.environment.ts — a file of the project (git-ignored), or AGBOSS_SQLITE_PATH\nexport const environment: StoreEnvironment = {\n  sqlitePath: env(\"AGBOSS_SQLITE_PATH\", { default: \".agboss/job-scout.sqlite\" }),\n};\n\n// @Application({ …, environment: StoreEnvironment, providers: durableStores })\nexport const durableStores = [\n  SqliteExecutionRecordRepository,\n  SqlitePausedExecutionRepository,\n  SqliteCheckpointSaver,\n  SqliteExecutionLeaseRepository,\n];\n```\n\n**Time is the application's `Clock`** (#262, infrastructure like the stores): every time the\nframework reads (the ledger's day, records, leases) and every wait it makes (lease renewals,\nrecovery, the shutdown timeout, a tool's `timeout`, model call timeouts, retry backoff,\n`Retry-After` pauses) goes through it — the system's clock unless `@Application({ providers })`\nlists a class that `extends Clock`. A component that needs the time injects it; `testWith` runs\nevery test on a `VirtualClock` (`@agboss/core/testing`), moved by `await clock.advance(seconds(30))`\nwithout real waiting. Direct time in the framework (`Date.now()`, `new Date()`, `setTimeout`, …)\nfails lint (`agboss/no-direct-time`):\n\n```\n// services/reminder.service.ts — the time comes from the application's Clock, never Date.now()\nimport { Clock, Injectable } from \"@agboss/core\";\nimport { seconds } from \"@agboss/core/units\";\n\n@Injectable({ deps: [Clock] })\nexport class ReminderService {\n  constructor(private readonly clock: Clock) {}\n\n  async remindLater(): Promise<Date> {\n    await this.clock.sleep(seconds(30)); // in a test: await clock.advance(seconds(30))\n    return this.clock.now();\n  }\n}\n```\n\n**Ids are the application's `IdGenerator`** (#313, infrastructure like the `Clock`): every thread id\nthe framework mints (`app.execute` without a `threadId`) and every execution id — roots, children,\nrecursion levels — comes from it; a caller's own `threadId` is used as given, a recovered execution\nkeeps its id. The built-in `RandomIdGenerator` gives random UUIDs (the formats before #313) unless\n`@Application({ providers })` lists a class that `extends IdGenerator`; it is created before the\nstores that inject it (`deps: [IdGenerator]`), so it may not depend on the contexts. `testWith`\nmakes them deterministic — `thread-1`, `thread-2`, …, `execution-1`, … per test\n(`SequentialIdGenerator`, `@agboss/core/testing`) — unless the application lists its own. A `pauseId` never\ncomes from it: it is an answer credential, a random 128-bit token.\n\n```\n// config/prefixed-id.generator.ts — listed in @Application({ providers: [PrefixedIdGenerator] })\nimport { randomUUID } from \"node:crypto\";\nimport { IdGenerator, Injectable } from \"@agboss/core\";\n\n@Injectable()\nexport class PrefixedIdGenerator extends IdGenerator {\n  override newThreadId(): string {\n    return `thr_${randomUUID()}`;\n  }\n\n  override newExecutionId(): string {\n    return `exe_${randomUUID()}`;\n  }\n}\n```\n\nA rule needs no model: `@Router({ decidedBy: DecidedBy.Code, routes: [{ target: A }, { target: B }] })`\non a class that `implements RouterHandler<Self>` decides in `route(input)`, returning one of its\ntargets (`Route<Self>`) — no model call, cost 0, the same flow. Routes may carry the options of a\nparallel launch (#273): `spawn` (stated on every route of a model router before a launch —\n`SpawnPolicy.Always` or `SpawnPolicy.OnDemand`; `OnDemand` by default on a code router's) and\n`failure` (`FailurePolicy.FailFast` by default)\n([Wiki → Routers](https://github.com/agbossjs/agboss/wiki/Routers)).\n\nSeveral steps at once are a **parallel launch** (#273): `.toParallelAgents(ResearcherAgent, CriticAgent, CriticAgent, { maxConcurrency: 3 })` or `.toParallelActions(…)`, always followed by\n`.join()` and the values the branches give — `.join().returns(ResearchDto).returns(listOf(CritiqueDto))`,\nchecked against the generated `JoinOutputs`; one agent after `.returns(listOf(CompanyDto))` runs once\nper item (`{ maxItems }` required; a longer list is truncated, never an error — `result.launches`\nsays `{ items, taken }`). At most `maxConcurrency` branches run at once, a new one starting as\nanother ends; a failed branch cancels the others and the join takes `.onJoinError(E, …)`\n([Wiki → Workflow](https://github.com/agbossjs/agboss/wiki/Workflow)).\n\nNot 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),\neach group on its own path. `@Arbiter` classes decide which answers count, attached with\n`.acceptedBy(X)` before the quorum (a chain is an AND, `anyOf(A, B)` stops at the first that\naccepts): by model, a criterion per agent; by code, one `@OnAnswer(Agent)` method per agent. A single\nanswer a quorum may leave out is `optional(XDto)` in `.returns(…)` — the compiler and `agboss check`\nsay so on the quorum. At run time the quorum is decided as answers arrive: once `max` answers are\ncounted (or a group has won, or the quorum can no longer be reached) the other branches are\ncancelled; the deadline runs on the application's `Clock`; not reached → `QuorumNotReachedError`\ninto `.onJoinError(…)`; a failing arbiter is `ArbiterError`. `result.launches` and `onQuorumDecided`\nshow each answer as counted, rejected (and by which arbiter), surplus or cancelled, and the winning\ngroup; `agboss generate arbiter` writes a code arbiter for a launch\n([Wiki → Parallel](https://github.com/agbossjs/agboss/wiki/Parallel)).\n\nA whole **workflow is a step** too (#263): `.toWorkflow(CompanyCheckWorkflow)` runs an ordinary\n`@Workflow` of the application as a child — a component \"start input → one of its finishes'\noutputs\", runnable and testable on its own. Its start is chosen by the class of what comes in (one\nstart, else the one taking the chain's current value; `[graph.no-matching-start]` /\n`[graph.ambiguous-start]` otherwise), its scope starts with that input only, and its result is\nbranched by class — `.returns(oneOf(CompanyScoreDto, CompanyRejectedDto)).switch(caseOf(…)…)`, one\ncase per finish output class. `.toParallelWorkflows(CompanyCheckWorkflow, { maxConcurrency: 3, maxItems: 50 })` runs one child per item, joined as `.join().returns(listOf(oneOf(…)))`; a child\nreached from two places has its own path `workflow(W).onError(…).returns(oneOf(…)).switch(…)`.\nAt run time each child is an execution of its own — its id, steps and `maxSteps`, no history —\ninside its parent's checkpoint and budgets; an uncaught failure of its own fails the parent's step\nwith `ChildExecutionFailedError`, and `result.children` holds the nested records\n([Wiki → Workflow-as-a-step](https://github.com/agbossjs/agboss/wiki/Workflow-as-a-step)).\n\nA workflow may **launch itself** (#264): `ThisWorkflow` stands for the enclosing workflow in\n`.toWorkflow(ThisWorkflow)`, `route(ThisWorkflow)` or `.toParallelWorkflows(ThisWorkflow, { maxItems })` — a topic too broad for one report researched subtopic by subtopic by the same workflow. A\nrecursive workflow sets `@Workflow({ maxDepth: 3 })` (`[graph.recursion-without-depth]`); the root is\nat depth 1 (`executionContext.depth`, and each child record's `depth`), so the last level is\n`depth === maxDepth`. There the framework stops it, never the prompt: an agent's `oneOf` variant\nwhose `caseOf(…)` launches `ThisWorkflow` leaves its response schema, a model router does not see such\na route, a code router returning one fails with `RouterDecisionError` — so every such branch keeps a\nway out (`[graph.recursion-without-exit]`); a launch decided by code past `maxDepth` does not start\nand fails its step with `ChildExecutionFailedError` caused by `DepthLimitExceededError`\n(`.onError(LimitExceededError, …)` takes it). Only direct recursion: a workflow launching one that\nlaunches it is `[graph.mutual-recursion]`. The root's execution budget counts every level; each\nlevel 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\nentry: the `.returns(…)` after a `ThisWorkflow` launch is checked by `@Workflow({ name, flow })`\nagainst the generated `ThisWorkflowOutputs[name]`, so one project holds recursive workflows with\ndifferent finishes.\n\nThe whole tree shares one **concurrency cap** (#264): `@Workflow({ maxConcurrentExecutions: 6 })` of\nthe root (default 10) — how many executions of the tree, the root and all its descendants, work on a\nstep at once. A step without a free slot waits in a queue — a throttle, never an error; an execution\nwaiting at a join, for its child or paused holds no slot (an execution's parallel branches share its\none). The queue serves a parent whose children have all finished first, then the deepest execution,\nevery 10 s of waiting counting as one level deeper (the application's `Clock`). A launch's\n`maxConcurrency` and the cap both hold — the stricter wins; a child workflow's own smaller\n`maxConcurrentExecutions` limits its subtree (a larger one is `[limits.child-concurrency-above-parent]`,\na warning). Time in the queue does not count toward a quorum's `deadline`. The slots are in-process\nunder the tree's lease: after a pause or a crash the next pass rebuilds the queue from the unfinished\ntasks, and a `FailFast` failure empties it. A slot is an execution, not model load — provider\nthroughput is `rateLimitPolicy` ([Wiki → FinOps → Concurrency of a tree](https://github.com/agbossjs/agboss/wiki/FinOps#concurrency-of-a-tree-264)).\n\nThe CLI works on the application whose path the project's `package.json` sets once —\n`\"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\ngenerated project runs it on install (`npm run types`).\n\nThe whole shape — DTOs, a service, a tool, an agent, a router, the workflow, its application and\nits test — is the\nworked example in [`CLAUDE.md`](https://github.com/agbossjs/agboss/blob/main/CLAUDE.md) → Architecture (compiled and run by `make check`).\n\n```\nnpx agboss chat                                 # the application's one start; with several: --start <name>\nnpx agboss run --start chat-workflow-start \"my resume src/data/sample-resume.md\"\nnpx agboss run --start <name> --input '<json>'  # a start taking a DTO: checked field by field first\nnpx agboss describe [--json]                    # the application: workflows, starts, children, implementations — no API key\nnpx agboss check --env prod                     # types, assembly, the worst case per start, prod's process variables\nnpx agboss dev                                  # watch mode: types regenerated, problems printed on change\nnpx agboss studio                               # LangGraph Studio on every workflow — no studio.ts, no langgraph.json\nnpx agboss --help                               # run, eval, replay, golden, compare, rag index\n```\n\n`agboss dev` reruns `agboss check` in a short-lived process on every change of the application's\n`src/` (debounced): `.agboss/types.d.ts` stays current and assembly problems show as you type.\n`agboss studio [--env <name>] [--port N]` builds every workflow of the application — the children\ntoo — writes the Studio config into the git-ignored `.agboss/` (`studio.ts`, `langgraph.json`) and\nstarts LangGraph's dev server on it; a child workflow is drawn inside its parent (a subgraph,\n`toParallelWorkflows` included), and a problem — assembly, a missing variable — stops it before the\nserver starts.\n\nA command that runs one workflow (`chat`, `run`, `eval`, `replay`, `golden add`, `compare`) takes\nit by its start's full name, `--start chat-workflow-start` — optional when the application has one\nstart; with several and none given, or a name no start has, it stops with the list of starts (and a\n`Did you mean \"…\"?`), running nothing.\n\nNeeds `OPENROUTER_API_KEY` in `.env` (dev's `openRouterApiKey: secret(\"OPENROUTER_API_KEY\")`). Docs: [Wiki → Components](https://github.com/agbossjs/agboss/wiki/Components) ·\n[Configuration](https://github.com/agbossjs/agboss/wiki/Configuration) · [Knowledge bases](https://github.com/agbossjs/agboss/wiki/Knowledge-bases) ·\n[Profiles and comparisons](https://github.com/agbossjs/agboss/wiki/Profiles-and-comparisons).\n\n`agboss` ships its own guide for coding agents (#305), written for the installed version:\n`skills/agboss/` in the package — `SKILL.md` and 13 references, from composition and the flow DSL to\ntests, the error catalog and migrating a LangGraph graph (#424), every snippet compiled by the gate. `agboss create` writes three things\nfor agents:\n\n- `AGENTS.md` (Cursor, Codex, …) — the project's instructions and a table routing each task to one\nreference; put the project's own rules under its`## Project rules` ;\n- `CLAUDE.md` — one line,`@AGENTS.md` (Claude Code reads`CLAUDE.md` );\n- `.claude/skills/agboss/` — the skills and their manifest (`.agboss-skills.json` : the version and a\nsha256 per file), git-ignored.\n\n`AGENTS.md` and `CLAUDE.md` are written once and never rewritten. The skills folder is the\nframework's: `npx agboss check` (so the project's `postinstall`, `npx agboss dev` and\n`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\n`[skills.local-edit]` when a file was edited by hand. A problem is a warning\n(`[skills.sync-failed]`, `[skills.no-framework]`) and never changes the exit code; `--json` carries\n`result.skills`. `\"agboss\": { \"skills\": false }` in `package.json` turns the sync off. More:\n[Wiki → CLI](https://github.com/agbossjs/agboss/wiki/CLI).\n\n### Standalone Verified Showcase ([`agbossjs/agboss-examples`](https://github.com/agbossjs/agboss-examples))\n\n`agbossjs/agboss-examples`\n7 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/`):\n\n1. \n**[`01-hello-agboss`](https://github.com/agbossjs/agboss-examples/tree/main/01-hello-agboss)** — First-run starter, real async HTTP service DI (`PricingCatalogService` with`ExecutionContext.signal` +`step.idempotencyKey` ), production HTTP server (`src/http/server.ts` with multi-tenant headers,`202 Accepted` on pauses, and`/health/live` +`/health/ready` probes), and`testWith` +`mockOf` .\n2. \n**[`02-path-scope-proof`](https://github.com/agbossjs/agboss-examples/tree/main/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 pass`tsc --noEmit` in raw LangGraph`Annotation.Root` (`langgraph-broken/` ) and are caught before runtime by`agboss check` (`agboss-broken/` vs`agboss-fixed/` ).\n3. \n**[`03-support-refund-desk`](https://github.com/agbossjs/agboss-examples/tree/main/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-tracked`eval/baseline/main-baseline.json` and`agboss eval calibrate` (Cohen's$\\kappa$ ).\n4. \n**[`04-travel-booking-saga`](https://github.com/agbossjs/agboss-examples/tree/main/04-travel-booking-saga)** — Transactional multi-step saga (`compensateWith` /`committedBy` ) across`payment-saga` ,`budget-saga` ,`quorum-saga` (`.toParallelAgents().acceptedBy().joinQuorum({ min: 2, max: 2 })` ), and`hitl-hold-saga` (`Channel.withdraw()` ,`NotPausedError` , SQLite WAL crash recovery, and HTTP`202 Accepted` pause adapter).\n5. \n**[`05-cluster-work-stealing`](https://github.com/agbossjs/agboss-examples/tree/main/05-cluster-work-stealing)** — Multi-instance horizontal scaling with`ChildPlacement.AnyInstance` ,`SqliteChildExecutionQueueRepository` , recursive`ThisWorkflow` (`maxDepth: 2` ), 30s SQLite leases & fencing (`ExecutionLeaseLostError` ),`maxConcurrentSteps: 2` backpressure (`HealthComponentKind.StepSlots` ),`@HealthCheck` , and`IncompatibleResumeError` .\n6. \n**[`06-enterprise-agent-mesh`](https://github.com/agbossjs/agboss-examples/tree/main/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` , Ed25519`agboss-mesh` JWS envelope,`MeshLoopDetectedError` &`HopsLimitExceededError` , MCP`_meta.callId` deduplication via`SqliteMeshRequestRepository` , and`agboss check --federation` Blast Radius.\n7. \n**[`07-langgraph-migration`](https://github.com/agbossjs/agboss-examples/tree/main/07-langgraph-migration)** — Side-by-side comparison of raw`@langchain/langgraph` (`before-langgraph/support.graph.ts` with 15-field`Annotation.Root` God State, string-keyed`addConditionalEdges` , unbounded`Send()` , dynamic prompt interpolation) vs AGBoss (`after-agboss/src/` +`after-agboss/tests/` with 10 narrow`@Dto` s,`@Router` , bounded`.toParallelAgents()` ,`@Action` ,`@Channel` , and`agboss check --strict` ).\n\n[`examples/job-scout`](https://github.com/agbossjs/agboss/blob/main/examples/job-scout) — resume → proposed search brief → Greenhouse jobs ranked\nby Jev. It depends on `agboss` exactly like your project would:\n\n```\nnpm install && npm run build          # at the repo root\ncd examples/job-scout && npm run chat # \"my resume src/data/sample-resume.md\"\n```\n\n[`examples/flow-patterns`](https://github.com/agbossjs/agboss/blob/main/examples/flow-patterns) — the flow's patterns with scripted tests only\n(no model key): `src/offer/` a review loop (`agent(ReviewerAgent).returns(oneOf(…)).switch(…)`,\n`caseOf(ChangesRequestedDto, { about: OfferDraftDto })`, `.onError(VisitsLimitExceededError, …)`), `src/credit/` a code\nrouter (`DecidedBy.Code`, `route(input)` through `deps`) beside a model router, `src/review/`\nparallel launches in waves (a reviewer per section, then a critic beside two fact checker copies),\n`src/topic/` a workflow that researches its subtopics itself (`ThisWorkflow`, `maxDepth`, #264).\nPoint the CLI at it:\n\n```\ncd examples/flow-patterns && npx agboss describe   # every workflow of its application\n```\n\nMore: [Examples Showcase](https://agboss.dev/examples/job-scout/) · [Wiki → Example](https://github.com/agbossjs/agboss/wiki/Example).\n\nRuntime package `@agboss/core` + dev/CI companion `@agboss/cli`; the root `@agboss/core` is the authoring API, and subpath entries keep integrations focused:\n\n- `@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\n- `@agboss/core/dto` — field decorators (`@Text` ,`@ListOf` , …), the DTO wrappers (`listOf` ,`oneOf` ,`optional` ,`variant` ) and the standard DTOs\n- `@agboss/core/units` —`usd()` ,`seconds()` ,`minutes()` ,`hours()` ,`kilobytes()`\n- `@agboss/core/models` — model providers (`@ModelProvider` ,`OpenRouterModelProvider` ,`DecisionsModelProvider` , listed in`@Application({ providers })` )\n- `@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)\n- `@agboss/core/eval` —`@EvalSuite` ,`EvalCascade` ,`evalCase` ,`turn` ,`pastTurn` ,`toolCall` ,`toolInvariant` ,`checkTrajectory` ,`judgedBy`\n- `@agboss/core/mcp` ,`/rag` ,`/a2a` ,`/memory` — integrations (`@McpServer` ,`@McpTool` ,`@McpExpose` ,`@Rag` ,`@A2AAgent` ,`@A2AExpose` ,`a2aHttpListener` ,`A2ACallerResolver` , memory)\n- `@agboss/core/eslint` — ESLint plugin (`agboss/no-component-state` ,`agboss/no-direct-time` )\n\nImporting `@agboss/core` has no side effects: tracing, the MCP SDK and SQLite load only when an app\nuses them. The old entries `@agboss/core/core`, `/graph`, `/router`, `/tool` and `/channels` are\ndeprecated re-exports for one minor release (`/concurrency` went with the old flow DSL, #272); `agboss migrate imports` rewrites them\n(and the renamed `Router` engine type → `RouterEngine`, root `McpServer` → `McpExpose` from\n`@agboss/core/mcp`, testing `Duration` → `ClockDuration`, `TestClock` → `VirtualClock`). Every entry's surface is in\n`packages/agboss/api/*.api.md`; `npm run api:update` after an intended API change.\n\n```\npackages/agboss/      the framework runtime (published as `@agboss/core`)\npackages/agboss-cli/  the CLI, static compiler & UI tools (published as `@agboss/cli`)\nexamples/job-scout/   the example (uses only the public API)\nexamples/flow-patterns/ the flow's patterns: a review loop, a code router, parallel launches (scripted tests only)\n```\n\n`make check` — build, API reports, compiled docs, format, lint, types, tests with coverage for both packages. `npm run dev` —\nrebuild the framework on change. The framework never imports the examples, and the examples use\nonly `@agboss/core` — both enforced by ESLint.\n\nOne set per application, in `environments/` next to the application file (#261). The contract is a\nplain class with declared fields only, declared once in the application:\n\n``` js\nimport { AppEnvironment } from \"./environments/app.environment.js\";\n\n@Application({\n  name: \"audit-application\",\n  version: \"1.0.0\",\n  environment: AppEnvironment, // the contract: environments/app.environment.ts\n  workflows: [AuditWorkflow],\n  budgets: [AuditApplicationDailyBudget, AuditTenantDailyBudget, AuditExecutionBudget],\n})\nexport class AuditApplication {}\n// environments/app.environment.ts — the contract: fields only, no values\nimport type { Secret } from \"@agboss/core\";\n\nexport class AppEnvironment {\n  auditUrl!: string;\n  databaseUrl!: Secret;\n  port!: number;\n  debug!: boolean;\n}\n```\n\nEach environment is a plain object typed by the contract, in `<name>.environment.ts` — the file name\nis the environment's name (`--env prod` → `prod.environment.ts`):\n\n``` python\nimport { env, secret } from \"@agboss/core\";\nimport type { AppEnvironment } from \"./app.environment.js\";\n\nexport const environment: AppEnvironment = {\n  auditUrl: env(\"AUDIT_URL\"), // a process variable, read when the app starts\n  databaseUrl: secret(\"DATABASE_URL\"), // a Secret: only from a process variable, no default\n  port: env.integer(\"PORT\", { default: 8080 }),\n  debug: env.flag(\"DEBUG\", { default: false }),\n};\npython\nimport { secret } from \"@agboss/core\";\nimport type { AppEnvironment } from \"./app.environment.js\";\n\nexport const environment: AppEnvironment = {\n  auditUrl: \"http://localhost:4000\", // a value in the file\n  databaseUrl: secret(\"DATABASE_URL\"), // `.env`: e.g. pglite://memory — dev without Docker\n  port: 8080,\n  debug: true,\n};\n```\n\n- A field missing in an environment, a mistyped one or one the contract lacks does not compile; a\nliteral in a `Secret` field does not compile either, and a`Secret` is not a`string` . A value in\nthe contract (`auditUrl: string = \"…\"` — the environments are object literals, it would be\nignored) fails assembly:`[environment.contract-value] AppEnvironment.auditUrl has a value — the contract declares fields only; put the value in each environment` . A framework implementation\nlisted in`providers` whose field the contract lacks fails with`[environment.missing-field]` .\n- There are no wrappers: no `declare module` , no`export default` , no`defineEnvironment` /`fromEnv` .`agboss check` writes`interface Environment extends AppEnvironment {}` into`.agboss/types.d.ts` , so components read the values as`applicationContext.environment` (`deps: [ApplicationContext]` , #261 — no separate token), typed by the contract — never`process.env` ; the chosen environment's name is`applicationContext.environmentName` :\n\n``` js\nimport { ApplicationContext, Injectable } from \"@agboss/core\";\n\n@Injectable({ deps: [ApplicationContext] })\nexport class AuditStoreService {\n  constructor(private readonly applicationContext: ApplicationContext) {}\n\n  connectionString(): string {\n    return this.applicationContext.environment.databaseUrl.reveal(); // the only way to the value\n  }\n}\n```\n\n- A `Secret` prints as`***` everywhere it could be printed — logs, traces, JSON,`agboss describe` ,\nerror messages; only`.reveal()` gives the value. Secrets are process variables and only that: in\nCloud Run a Secret Manager secret is bound to the variable at deploy\n(`--set-secrets=DATABASE_URL=database-url:3` ); the framework calls no cloud SDK.\n- `agboss chat` (the application of`package.json` ) loads`dev` ;`--env staging` loads`staging.environment.ts` (every command that loads the application takes`--env` ). An unknown\nname fails at once (exit 3) listing the available ones; a variable without a value or default fails\nthe start naming it, the field and the environment, never the value:`DATABASE_URL is not set (AppEnvironment.databaseUrl, prod)` .`agboss check` validates the selected\nenvironment without running anything, creating every component the start creates, so it fails\nexactly when the start would;`agboss check --env prod` lists the process variables it needs —\nnames only, each`value` or`secret` ,`optional` or`has a default` when unset is fine, never a\nvalue (the list for`--set-env-vars` /`--set-secrets` at deploy, #268);`agboss describe` shows the\nenvironment with secrets masked.\n- From code: `createApp(Application, { env: \"staging\" })` . Tests run on`test` :`testWith(Application)` loads`test.environment.ts` and fails without it (a test never reaches\ndev's addresses by accident);`testWith(Application, { env: \"dev\" })` names another.\n- Dev / prod parity (12-factor): the same `providers` — the same implementations and code — in\nevery environment; only values differ.\n- `environment` is required: an application with no settings declares the framework's empty\ncontract,`@Application({ environment: EmptyEnvironment })` (any contract without fields works the\nsame), and needs no environment file — its`dev` and`test` are`{}` . (An untyped caller without one gets an empty environment.)\n- Model provider keys are part of it (#266): each `@ModelProvider` names the`Secret` field that\nholds its key (`apiKey: \"openRouterApiKey\"` ; the built-ins read`openRouterApiKey` ), set with`openRouterApiKey: secret(\"OPENROUTER_API_KEY\")` in each environment that calls models — a test\nenvironment may leave it out (tests script or replay models). A missing key fails the start of an\napp that calls the provider;`agboss check` (without`--models` ) reads the contract's shape and\nwrites the types without one. A base URL is read the same way:`baseUrlField: \"openRouterBaseUrl\"` with`openRouterBaseUrl: env.optional(\"OPENROUTER_BASE_URL\")` — no`{ default }` :\nunset, it is`undefined` and the provider uses its decorator's`baseUrl` (#266 S87).\n- `env.optional(\"X\")` (`env.integer.optional` ,`env.flag.optional` ) reads a variable that may be\nunset —`undefined` then, for an optional field of the contract (`x?: string` ); a set value is\nstill checked, and`agboss check --env` marks it`optional` (#266 S90).\n\n`@Agent({ judges: [AnswerGroundedJudge], maxRetries: 1 })` runs every judge on the agent's reply. A\njudge is a `@Judge({ name, model, reply, deps })` class implementing `JudgeHandler<TReply>`; the\ncontainer creates it with its `deps`, and its **own** model is called through the model gateway, so\nits spend is in the run's cost report (`judge:<name>`, category `review`). A judge sees the agent's\nanswer **as its DTO** (#262 S115): `reply` names what it judges, in every shape an agent's `output`\nhas — a DTO class, `oneOf(A, B)` (the reply is `A | B`), `listOf(XDto)` (a `readonly XDto[]`) or\n`PlainTextDto` for a text agent (`{ text }`) — and `judge({ variant, reply, task, attempt })` gets\nthe validated answer object, never a serialized string, and `variant`, the class it validated as\n(#262 S125): the class of the answer tool the agent used, never guessed from the answer's shape. For\na `oneOf(A, B)` judge `JudgeInput<A | B>` is a discriminated union on `variant`, and\n`isVariant(input, A)` narrows `reply` to `A`; a single DTO, `listOf(XDto)` or `PlainTextDto` judge\nhas `variant` as that one class; a `listOf(oneOf(A, B))` judge reads\n`reply: readonly VariantOf<A | B>[]` — each item `{ variant, value }`, narrowed by\n`isVariant(item, A)`. An agent whose `output` its judge does not judge\nfails assembly with `[judge.wrong-reply]`: a text agent with a DTO judge, a DTO agent with another\nclass, a `oneOf` variant the judge does not cover, a list agent with a judge of one value or the\nother way round. A verdict is built with `JudgeVerdict.passed({ metrics? })` or\n`JudgeVerdict.failed({ reason, metrics? })` — the only way; a failed verdict's `reason` goes back to\nthe agent for its retry, and once `maxRetries` retries are spent the run throws `QualityGateError`\n(`code: \"step.agent.quality-gate\"`, `rejections: [{ judge, reason }]`). A judge without a model\nfails at app start (`[judge.no-model]`).\n\nA judge's model comes through the constructor (`deps: [JudgeModel]`): a chat model\n(`judgeModel.invoke(...)`) or a **decision model** (` judgeModel.decide({ state, questions })`) —\nevery model of OpenRouter's Decisions API:\n`typesafe/jev-1.13`, `openai/gpt-6-luna-decisions`, `perplexity/pplx-decider-v1(.1)-27b`,\n`cloudflare/clef(-flash|-omni)`, `upstage/solar-decide`, `inception/mercury-decide`\n(`DECISION_MODELS`; `DecisionsModelProvider` serves them, `JevModelProvider` is its deprecated alias).\nQuestions come from `Decision.noul` (yes/no → `noul` = P(yes)), `Decision.choice` (→ `choice`,\n`probabilities`, `confidence`) and `Decision.score` (levels lowest first → `score` = the level's index);\nthe answers are typed by them. `state` is a text, a JSON object, or text and image parts\n(`Decision.image(dataUrl)` — png/jpeg/webp data URLs; Luna, pplx-decider and Clef read images). At\nmost 200 questions per call; an answer that does not fit its question fails with\n`model.decision.invalid-response`. `decide` on a chat model or `invoke` on a decision model throws\n`ModelKindError` (`model.wrong-kind`).\n\n``` js\nimport {\n  Decision,\n  Judge,\n  JudgeModel,\n  JudgeVerdict,\n  type JudgeHandler,\n  type JudgeInput,\n} from \"@agboss/core\";\n\n@Judge({\n  name: \"answer-grounded-judge\",\n  model: \"openai/gpt-6-luna-decisions\",\n  reply: JobReportDto, // what it judges: the agent's output, as its DTO\n  deps: [JudgeModel, JOB_SEARCH],\n})\nexport class AnswerGroundedJudge implements JudgeHandler<JobReportDto> {\n  constructor(\n    private readonly judgeModel: JudgeModel,\n    private readonly jobSearch: JobSearch,\n  ) {}\n\n  async judge({ reply, task }: JudgeInput<JobReportDto>): Promise<JudgeVerdict> {\n    const [first] = reply.jobs;\n    if (first === undefined || !(await this.jobSearch.exists(first.url))) {\n      return JudgeVerdict.failed({ reason: \"the first job does not exist\" });\n    }\n    const a = await this.judgeModel.decide({\n      state: { task, jobs: reply.jobs.map((job) => job.title), results: this.jobSearch.lastResults() },\n      questions: {\n        grounded: Decision.noul(\"Every job of the report appears in the results\"),\n        tone: Decision.choice(\"Tone of the report\", {\n          neutral: \"Plain, factual\",\n          pushy: \"Sells or pressures\",\n        }),\n        quality: Decision.score(\"Overall usefulness\", [\"useless\", \"partial\", \"complete\"]),\n      },\n    });\n    // a.grounded.noul: number; a.tone.choice: \"neutral\" | \"pushy\"; a.quality.score: number\n    return a.grounded.noul > 0.7 && a.tone.choice === \"neutral\"\n      ? JudgeVerdict.passed({ metrics: { quality: a.quality.score } })\n      : JudgeVerdict.failed({ reason: \"Only report jobs from the search results, neutrally.\" });\n  }\n}\n```\n\nIn tests, script a chat judge like an agent — `mockLlm(Judge).thenReturn(replyWith(\"PASS\"))` — and a\ndecision judge with its answers by question id (an option, P(yes), a level's index or text):\n`mockLlm(AnswerGroundedJudge).thenReturn(decideWith({ grounded: 0.9, tone: \"neutral\", quality: 2 }, { cost: usd(0.0002) }))`;\non its own, `testJudge(AnswerGroundedJudge, …)({ variant, reply, task, attempt })` takes `reply` as a\nplain object of its DTO, typed by the judge — another shape, or a `variant` with another class's\nvalue, does not compile.\n\n``` js\nimport { Judge, JudgeVerdict, type JudgeHandler, type JudgeInput } from \"@agboss/core\";\nimport { isVariant, oneOf } from \"@agboss/core/dto\";\n\n@Judge({ name: \"fair-decision-judge\", model: \"typesafe/jev-1.13\", reply: oneOf(OfferDto, RefusalDto) })\nexport class FairDecisionJudge implements JudgeHandler<OfferDto | RefusalDto> {\n  judge(input: JudgeInput<OfferDto | RefusalDto>): Promise<JudgeVerdict> {\n    if (isVariant(input, OfferDto)) {\n      // input.variant is OfferDto, the class of the answer tool; input.reply is an OfferDto\n      return Promise.resolve(\n        input.reply.salary > 0\n          ? JudgeVerdict.passed()\n          : JudgeVerdict.failed({ reason: \"an offer pays something\" }),\n      );\n    }\n    // input.reply is a RefusalDto\n    return Promise.resolve(\n      input.reply.why.trim() === \"\"\n        ? JudgeVerdict.failed({ reason: \"a refusal says why\" })\n        : JudgeVerdict.passed(),\n    );\n  }\n}\n```\n\nRouters and guardrails work on any decision model too (`@Router({ model: \"openai/gpt-6-luna-decisions\" })`):\none choice question over the routes, options sorted so declaration order never changes the request.\n\n`testWith(Application)` (`@agboss/core/testing`) runs the whole application in each test — its real\ncontainer and infrastructure, models by script, MCP servers stubbed, the network blocked, a\n`VirtualClock` and deterministic ids (`thread-1`, `execution-1`, … per test, #313; an application\nthat lists its own `IdGenerator` keeps it). Scripts are typed by the generated `.agboss/types.d.ts` (`agboss check`, #262):\n`replyWith(XDto, fields)` must be a class of the agent's output (a class outside its `oneOf` does\nnot compile), `routeTo(…)` a target of the router's routes — exactly one after `.chooseOne(…)`, the\n`OnDemand` targets it launches before `.toParallelAgents(…)` (`routeTo()` only with an `Always`\nroute). `when(…)` binds turns to the node's input values, at any depth: a deep partial of each\nfield's `value` (the producer is not compared), `undefined` for an absent field, a `listOf` field by\nlength and position, a `oneOf` element as `variant(XDto, { … })`; a call no script matches fails the\ntest, naming its input. A script is an expectation: a `thenReturn` turn no call took, or a `when(…)`\nnothing matched, fails a test whose body passed (`thenReturnAlways` never does).\n`waitFor(seconds(31))` before a turn makes the call take that long on the test's clock. A model\nreply interpreter (#267) is scripted the same way, its input the pause's `{ ask, reply }` —\n`mockLlm(RefundReplyInterpreter).when({ reply: \"ok, but only half\" }).thenReturn(replyWith(ToolCallApprovalDecisionDto, { approved: true, … }))`,\none on a decision model with `decideWith({ approved: 0.9 })` (#267 S124);\na code one is created by the container and replaced with `mockOf(Interpreter).interpret`\n(`mockReturnValue(ReplyInterpretation.understood({ approved: true }))`).\n\n``` js\nimport { expect } from \"vitest\";\nimport { replyWith, testWith, ToolFailure, waitFor } from \"@agboss/core/testing\";\nimport { seconds, usd } from \"@agboss/core/units\";\n\nconst test = testWith(OfferApplication);\n\ntest(\"the reviewer asks for changes once, then approves\", async ({ app, mockLlm, mockAction }) => {\n  mockLlm(ReviewerAgent)\n    .when({ draft: { text: \"v1\" } })\n    .thenReturn(replyWith(ChangesRequestedDto, { notes: \"shorter\" }));\n  mockLlm(ReviewerAgent)\n    .when({ draft: { text: \"v2\" } })\n    .thenReturn(waitFor(seconds(1)), replyWith(DraftApprovedDto, {}, { cost: usd(0.01) }));\n  mockLlm(EditorAgent).thenReturn(replyWith(\"v2\"));\n\n  await app.execute(OfferWorkflowStart, shortlist);\n\n  expect(mockLlm(ReviewerAgent)).toHaveBeenCalledTimes(2);\n  expect(mockLlm(ReviewerAgent)).toHaveReturned(ChangesRequestedDto, { notes: \"shorter\" });\n  expect(mockAction(SendOfferAction)).toHaveBeenCalledWith({ draft: { text: \"v2\" } });\n});\n\ntest(\"a cancel from inside a mock cancels the execution\", async ({ app, mockOf, mockTool }) => {\n  mockTool(SearchJobsTool).thenFail(ToolFailure.Timeout); // the next call fails as a timeout would\n  mockOf(MailerService).send.mockImplementation(() => app.cancel(\"thread-1\").then(() => undefined));\n\n  await expect(app.execute(OfferWorkflowStart, shortlist)).rejects.toFailWith(\n    ExecutionCancelledError,\n  );\n});\n```\n\nCalls are checked with Vitest's names on `mockLlm(X)` and `mockAction(A)` (the action still runs):\n`.inputs`, `toHaveBeenCalledWith(…)` (the matcher of `when`), `toHaveBeenCalledTimes(n)` (model\ncalls; an action's executions), `toHaveReturned(XDto, fields?)`; on anything else — a `vi.fn()`, a\n`mockOf(…)` method — they are Vitest's own. `toHaveBeenAskedWith` checks the prompt itself. Errors\nare caused the real way — `failWith(ModelFailure.…)`, a rejecting `mockOf(…)`,\n`mockTool(Tool).thenFail(ToolFailure.Timeout)`, scripts that reach a limit, `app.cancel` — and\nmatched with `rejects.toFailWith(ErrorClass, { …fields, cause: CauseClass })` through the cause\nchain; the testing API has no way to create a framework error.\n\nParallel branches run one at a time in a test, in listed / item order (`TestConcurrency.Sequential`,\nthe default); `TestConcurrency.Real` runs them as production does, and\n`TestConcurrency.inOrder([step(A), step(B)])` makes them complete in that order — a race, a\n`FailFast` or a quorum gives the same result on every run. One component is tested on its own with\na unit runner: `testTool`, `testAction`, `testRouter`, `testArbiter`, `testRag`, `testJudge`,\n`testGuardrail`, `testPii` — its own container (`overrides` replace classes by classes), the\n`environment`, the frame of its step (` executionContext.signal` with a `timeout`, `SpendRecorder`),\nits model by script; each call gives `{ output, spend }`.\n\n``` js\nimport { expect, it } from \"vitest\";\nimport {\n  decideWith,\n  override,\n  step,\n  TestConcurrency,\n  testJudge,\n  testTool,\n  testWith,\n} from \"@agboss/core/testing\";\nimport { seconds } from \"@agboss/core/units\";\n\n// the risks agent answers first, then the skills agent: the quorum counts them in that order\nconst test = testWith(QuickPanelApplication, {\n  concurrency: TestConcurrency.inOrder([step(RisksAgent), step(SkillsAgent)]),\n});\n\ntest(\"the first two answers are counted\", async ({ app }) => {\n  const result = await app.execute(PanelWorkflowStart, candidate);\n  expect(result.launches[0]?.isQuorumReached).toBe(true);\n});\n\nit(\"searches the board it is given\", async () => {\n  const runSearch = testTool(SearchJobsTool, {\n    overrides: [override(JobBoardService, FakeJobBoardService)],\n  });\n\n  const { output, spend } = await runSearch({ query: \"TS\" }, { timeout: seconds(1) });\n\n  expect(output.jobs).toContain(\"TS\");\n  expect(spend.totalUsd).toBe(0);\n});\n\nit(\"a grounded reply passes\", async () => {\n  const runJudge = testJudge(GroundedJudge, { model: decideWith({ grounded: 0.9 }) });\n\n  const reply = { text: \"Acme hires TS engineers [acme.example]\" }; // a PlainTextDto, as an object\n  const { output } = await runJudge({ variant: PlainTextDto, reply, task: \"Find TS jobs\", attempt: 0 });\n\n  expect(output.passed).toBe(true);\n});\n```\n\nEvery component reads the execution it runs in through its constructor (#280):\n`deps: [ExecutionContext]`, a parameter named `executionContext` — tools, actions, judges and\nservices alike; there is no `ctx` parameter. It is data only and read-only, and every read gives the\ncurrent execution and step (one frame per node visit and per tool call), so it is safe in an\ncomponent (one instance for the app) and in parallel branches. One execution has one id: its observer events\n(`event.executionId`), `executionContext.executionId`, its record, its trace and its result\n(`ExecutionOutput.executionId`) carry the same one, also after a resume. What a node reads comes in its\ninput — for a model, its input message (each `input` field as `### field (from producer)`), never\nthrough `{{input.…}}` in its instructions (`[prompt.input-in-instructions]`); the context carries no\ninput and no metadata.\n\n```\nrun(query: OrderQuery): Promise<OrderQuery> {\n  const { executionContext } = this;\n  executionContext.executionId; // unique per execution, the same after a resume (= ExecutionOutput.executionId)\n  executionContext.threadId; // the conversation (ExecutionOutput.threadId)\n  executionContext.ownerId; // from execute(…, { ownerId }): who the thread belongs to\n  executionContext.tenant.tenantId; // the organization, \"default\" when none is passed\n  executionContext.tenant.application.environmentName; // the application level, through the tenant\n  executionContext.signal; // aborted by app.cancel(threadId) or the caller's signal\n  executionContext.workflow; // { type, name, version, startedVersion, configHash }\n  executionContext.step.idempotencyKey; // `${executionId}:${nodeId}:${visitNumber}[:${itemIndex}][:${callId}]`\n  executionContext.step.callId; // the tool call's id (undefined outside a tool call)\n  return Promise.resolve(query);\n}\n```\n\nRead outside an execution (in a constructor at start) or after its step ended (a forgotten promise),\nthe context throws `ExecutionContextUnavailableError` (`context.unavailable`) — never `undefined`.\n\n```\nawait app.execute(\n  ChatWorkflowStart,\n  { text: \"Find me a backend job\" },\n  { threadId, ownerId: userId, signal, tenant: { tenantId: \"acme\" } },\n);\nconst { cancelled } = await app.cancel(threadId, { tenantId: \"acme\", ownerId: userId });\n```\n\nA thread created with an `ownerId` belongs to it: `execute` on that thread, `resume` and `cancel`\nwith another owner (or none) throw `ThreadOwnerError` (`run.thread-owner`) before anything runs or\nis read. A thread belongs to its tenant too (#261): its key is `tenantId` + `threadId`, so another\ntenant's thread does not exist for the caller. `execute` with a `threadId` the tenant does not have\nyet creates that thread — the caller's own, idempotent id (an order number, a ticket), bound to its\n`ownerId`; the same id in another tenant is another thread (#262 Q4). An id that cannot be one\n(empty, over 200 characters, a control character) is `InvalidThreadIdError`\n(`run.invalid-thread-id`); `cancel` of a thread the tenant does not have is `UnknownThreadError`.\n`resume` and `cancel` take the tenant by its id (omitted → `\"default\"`), and a resumed execution\nruns on the context values it started with. With nothing declared `tenant: { tenantId?, weight? }` is\nall there is (`weight`, a positive integer, default 1, is the tenant's share for #266's\n`TenantWeighted`); an application that declares\n`@Application({ tenantContext: ShopTenantContext, executionContext: ShopExecutionContext })` — its\nown subclasses with DTO field decorators, identity only, never secrets — gets their values typed\nper start (`tenant: ContextValues<ShopTenantContext>`, `execution: …`), checked before the first\nstep (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\n`sensitive` fields through their action (`replacePart` as written, a token or a kind as `<field>`,\n#120); the tool itself still gets them whole.\n\n**Pauses (#267).** An execution may wait for several answers at once — one per tool call whose\nchannel replied `ChannelOutcome.awaitReply()`, in parallel branches too: a paused branch does not\nstop its siblings, the execution pauses only when nothing else can move, and `result.pending`\nlists every waiting pause (`PendingPause`: `answeredBy`, `agent`, `channel`, `itemIndex`,\n`childExecutionId`, the question `ask`). A pause is answered by its id, never by its thread —\n`app.resume({ pauseId, decision }, options)` — found by `(tenantId, pauseId)` anywhere in the tree\n(unknown, or another tenant's → `UnknownPauseError`, `pause.unknown`); with other pauses left the\nexecution pauses again with the rest; the result carries `threadId` and `executionId`. Who may answer is\nthe pause's channel role's `answeredBy`: a `PauseAnswerer.ThreadOwner` pause lists its `pauseId` in\n`result.pending` and is resumed with the thread's `ownerId` (another → `ThreadOwnerError`); a\n`PauseAnswerer.Channel` pause's id is the right to answer it — only its channel got it\n(`ask.pauseId`), `result.pending` leaves it out — and it is resumed with `{ answeredBy: \"manager-17\" }`, recorded on the pause and in `onExecutionResume`; the other credential (or an empty\n`answeredBy`) is `PauseAnswererError` (`pause.wrong-answerer`). The decision is validated before\nit is recorded — a `ToolCallApprovalDecisionDto` (`approved`, `reason?`, `overrideArguments?`\nonly with `approved: true`, checked by the tool's input), else `DtoValidationError` and the pause\nkeeps waiting:\n\n```\n// a webhook: the finance manager pressed \"Approve\" on the message whose reply key is the pauseId\nconst result = await app.resume(\n  { pauseId: replyKey, decision: { approved: true } },\n  { tenantId: \"acme\", answeredBy: \"manager-17\" },\n);\n```\n\n**Free-text replies (#267 PR 3).** A webhook that only has the person's words passes them as\nthey are — `app.resume({ pauseId, reply }, options)`, exactly one of `decision` or a non-empty\n`reply` — and the pause's role reads them with its `@ReplyInterpreter` (`replyInterpreter` on the\n`@ChannelRole`, one interpreter per role: `[channel.shared-interpreter]`). The interpreter gets the\npause's original question and the reply (`{ ask, reply }`); what it gives is validated like a\ndecision. `DecidedBy.Model` — a chat model answers through one answer tool, its static `prompt` /\n`promptUrls` its instructions, the question and the reply in the input message. For an approval role\n(`approves`) the model may be a decision model (Jev, #267 S124): it reads approve / refuse only — one\n`Decision.noul(\"approved\")`, approved when P(yes) > 0.5, the answer `{ approved }` with no `reason`\nand no `overrideArguments`; a question role, whose answer needs more than a yes / no, cannot name\none (`[reply-interpreter.decision-model]`). Either model's call goes through the provider\nresolved by `serves`, is checked against the tree's budgets first, counted in their ledger at once\nand in the execution's spend when the step takes the answer, and traced (`reply-interpreter:<name>`).\n`DecidedBy.Code` — the class's `interpret(input: InputOf<Self>)` (`implements ReplyInterpreterHandler<Self>`, `deps` like any component), created by the container, returns its\noutcome (#267 S124): `ReplyInterpretation.understood(decision)` — validated like any decision — or\n`ReplyInterpretation.notUnderstood({ reason })` for a reply it cannot read (a plain object does not\ncompile); a `throw` is for an unexpected crash only. Either kind reads in the pause's execution frame — its\n`ExecutionContext` has the tenant, the owner and the waiting step, and the rate limits see the\ntenant. A model interpreter reads the question and the reply through the application's and the\nworkflow's PII policies, like an agent: its answer is revealed before it is validated, a token no\nmap knows is a decline, a block rule throws `PiiRefusedError`. A reply nobody can read — not\nunderstood, a model that gives no answer, an answer the role rejects, a crash — throws\n`UninterpretableReplyError` (`pause.uninterpretable-reply`; `reason` says why, `cause` is the error\nbehind it, if any) and the pause stays pending, so the webhook can ask again or answer with a `decision`; a\n`reply` for a role without an interpreter is `ReplyInterpreterMissingError`\n(`pause.no-reply-interpreter`), the pause untouched.\n\n```\n@ReplyInterpreter({\n  name: \"refund-reply-interpreter\",\n  description: \"Turns a manager's free-text reply into a refund decision\",\n  decidedBy: DecidedBy.Model,\n  model: \"moonshotai/kimi-k2.6\",\n  prompt: \"The manager answered a refund approval in free text. Decide approve / reject.\",\n})\nexport class RefundReplyInterpreter {}\n\n@ChannelRole({\n  name: \"refund-approval-channel\",\n  description: \"Approves card refunds\",\n  approves: RefundCardInputDto,\n  answeredBy: PauseAnswerer.Channel,\n  replyInterpreter: RefundReplyInterpreter,\n})\nexport abstract class RefundApprovalChannel extends ChannelOf<RefundApprovalChannel> {}\n\n// a webhook passes the text as it came\nawait app.resume({ pauseId: replyKey, reply: \"ok, refund it\" }, { answeredBy: \"manager-17\" });\n// approve / refuse on a decision model: one Decision.noul(\"approved\") — no reason, no overrideArguments\n@ReplyInterpreter({\n  name: \"deposit-reply-interpreter\",\n  description: \"Reads whether a clerk's reply approves a deposit return\",\n  decidedBy: DecidedBy.Model,\n  model: \"typesafe/jev-1.13\",\n  prompt: \"The clerk answered a deposit return.\",\n})\nexport class DepositReplyInterpreter {}\n\n@ChannelRole({\n  name: \"deposit-approval-channel\",\n  description: \"A clerk approves deposit returns\",\n  approves: DepositReturnDto, // an approval role: a decision model may read its replies\n  answeredBy: PauseAnswerer.Channel,\n  replyInterpreter: DepositReplyInterpreter,\n})\nexport abstract class DepositApprovalChannel extends ChannelOf<DepositApprovalChannel> {}\n```\n\nA code interpreter returns its outcome: `ReplyInterpretation.understood({ approved: true })` for a\nbutton value it knows, `ReplyInterpretation.notUnderstood({ reason: \"not a button value\" })` for\nanything else (Wiki → Pauses has the whole class).\n\n**Who answered each pause (#267 S107).** `app.resume(…, { record: true })` (or `execute`) returns\n`result.record.pauses`: every resolved pause of the tree in the order asked —\n`PauseAnswerRecord { pauseId (undefined for a PauseAnswerer.Channel pause), executionId (the execution it waited in: the root's or a child's), step, role, answeredBy, responder, resolution, askedAt, resolvedAt, decision }`, `PauseResolution.Decided`\n(`ChannelOutcome.decided`, no pause), `Replied` (`awaitReply()` + `app.resume`), `Withdrawn` (a\n`FailFast` sibling failed) or `Cancelled` (`app.cancel`, or the tree ended); the decision masked by\nthe PII policies like the rest of the record, none when withdrawn or cancelled. Each child's record —\n`result.children` and `result.record.children` — lists the pauses its own steps asked as\n`ChildExecutionRecord.pauses` (#267 S124), a grandchild's in its own record; the root's\n`record.pauses` keeps the whole tree's. It is written when\nthe pause is resolved into the execution records — `ExecutionRecordRepository.savePauseAnswer`, read\nback with `findPauseAnswersByTenantIdAndRootExecutionId` — the permanent history, so the retention\nbelow never changes it; the pause records are operational state. A resolved pause\nrecord (`Taken`, `Cancelled`) is kept `@Application({ pauseRecordRetention })` — default\n`hours(24)` — after it was resolved, then removed by the recovery sweep\n(`PausedExecutionRepository.deleteResolvedBefore(cutoff)`, on the `Clock`): within it a late answer\nis `NotPausedError`, after it `UnknownPauseError`; a pending pause is never removed.\n\nBefore its first step an execution reads each of its budgets (README → FinOps): one exhausted —\nthe tenant's day spent, say — refuses it with `BudgetExceededError` (`limit.budget`, an\n`onExecutionRejected`), before anything is written: no thread (a new one is created once the budgets pass), no record, no model call.\n\n`execute` and `resume` forward `signal`, `onStream` (token and tool-call events) and\n`configurable` (extra LangGraph keys; the framework's own win). There is no untyped `metadata` or\n`executionContext` option (#261): what an execution carries is its typed context values — a\nchannel, a guardrail or any component reads them through its injected `ExecutionContext`\n(`this.executionContext.tenant.tenantId`, the project's own fields), and an MCP session\n(`createMcpService(app, Mcp).connect(transport, { tenant, execution, ownerId })`) or an A2A call\n(`a2aHttpListener(app, { expose, callerResolver })`, whose resolver returns them per request, #387;\n`adapter.execute(Start, request, onEvent, { tenant, execution, ownerId })`) passes them from its\nauthenticated caller. `app.cancel(threadId)` aborts a running\nrun — it stops before its next model or tool call and `execute` rejects with\n`ExecutionCancelledError` — or drops a paused one, whose pauses are withdrawn: their `resume` then\nthrows `NotPausedError`;\n`cancelled` is `false` when the thread had neither (a thread the tenant does not have is\n`UnknownThreadError`). From `app.close()` on, `execute` and `resume`\nare refused with `ApplicationStoppingError` (`app.stopping`, an `onExecutionRejected`); running executions\ngo on and `cancel` still works. An execution tree is moved by one holder at a time (#265, Q58):\n`execute` and `resume` hold its lease (`seconds(30)`, renewed every 10 s and before every checkpoint\nwrite, released when the pass ends). A `resume` first records its decision on its pause (`Pending → Resolved`, atomic) — of two resumes of one pause the second gets `NotPausedError`, as does an\nanswer to a pause its step already took (`Taken`) or one withdrawn (` Cancelled`: the thread was\ncancelled, or a sibling branch failed under `FailFast`) — then waits while another call or instance\nholds the lease; the holder continues with every recorded answer of the tree in one pass and, before\nthe tree rests, takes the answers recorded meanwhile, so the waiting call returns the tree's current\noutput with its decision taken. An instance that lost the\nlease (frozen past it) stops and its call rejects with `ExecutionLeaseLostError`\n(`execution.lease-lost`) — no `onExecutionError`, no failed record: the new holder continues (Wiki →\nSeveral-instances).\n\nAn execution's record is `ExecutionRecordStatus.Running` while its tree moves. **Recovery** (#265):\nevery instance looks for `running` roots at its start and every 30 s, takes the lease of each whose\nholder died (its lease expired) or stopped (released it), and continues it from its last\ncheckpoint — a decision recorded meanwhile first — so only the interrupted step runs again; a root\nstarted under another config is left to a compatible instance (logged once), unless\n`onIncompatibleResume` says `\"resume\"`. Observers get `onExecutionResume` with\n`reason: ResumeReason.Recovered` (`ResumeReason.Answered` for `app.resume`). With the in-memory\ndefaults nothing survives a restart, so recovery needs persistent stores — the SQLite ones on one\nmachine. **`app.close()`** stops\nthe sweep, runs `onStop`, waits for the running executions up to the shutdown timeout —\n`@Application({ shutdownTimeout })`, `app.close({ timeout })`, default `seconds(25)` — then\ninterrupts them: each call rejects with `ApplicationStoppingError`, without `onExecutionError`, releases its\nlease at once and stays `running`, so another instance continues it without waiting for the lease\nto expire. `createApp` calls `app.close()` on SIGTERM / SIGINT (a second signal interrupts at once)\nuntil the close finishes; `testWith` never listens.\n\n```\n// one instance of a rolling deploy stops: what still runs after 10 s moves to another instance\nawait app.close({ timeout: seconds(10) });\n```\n\n**Health checks** (#300): `app.checkLiveness()` and `app.checkReadiness()` are the instance's probes\nfor an orchestrator (Kubernetes, Cloud Run) — the project's own server exposes them, the framework\nopens no endpoint. Liveness does no I/O: the instance (`Unhealthy`, `stopping`, from `app.close()`\non) and its recovery loop (`Unhealthy` when its last sweep ended more than three sweep intervals ago\non the `Clock`). Readiness adds every store's `ping()` (each #265 contract has one — a project's own\nimplementation adds it; the SQLite ones run `SELECT 1`), every model provider's circuit breaker as\n`RateLimitRepository` keeps it (open → `Degraded`; never a model call) and the project's\n`@HealthCheck` s, listed in `@Application({ healthChecks })` — all at once, each under its timeout\n(stores and providers `seconds(2)`). A check that throws or passes its timeout is that component's\n`Unhealthy` with its `reason`; neither call ever rejects. The report's `status` is its worst\ncomponent's (`HealthStatus.Healthy | Degraded | Unhealthy`), each component has its `name`, `kind`\n(`HealthComponentKind`), `status`, `reason` and `duration`\n([Wiki → Health-checks](https://github.com/agbossjs/agboss/wiki/Health-checks)).\n\n```\n@HealthCheck({ name: \"crm-health-check\", timeout: seconds(2), deps: [CrmClient] })\nexport class CrmHealthCheck implements HealthCheckHandler {\n  constructor(private readonly crmClient: CrmClient) {}\n\n  async check(): Promise<HealthCheckResult> {\n    const isReachable: boolean = await this.crmClient.ping();\n    return isReachable\n      ? HealthCheckResult.healthy()\n      : HealthCheckResult.unhealthy({ reason: \"CRM is unreachable\" });\n  }\n}\n\n// listed in the application: healthChecks: [CrmHealthCheck]\nconst liveness: HealthReport = await app.checkLiveness(); // GET /livez → 503 unless Healthy\nconst readiness: HealthReport = await app.checkReadiness(); // GET /readyz → 503 when Unhealthy\n```\n\nIn unit tests, `testExecutionContext({ … })` from\n`@agboss/core/testing` is a frozen `ExecutionContext`: `new PayOrder(testExecutionContext())`; its\n`tenant` and `tenant.application` are set with `{ tenantContext, tenant, application: { environment } }`.\n\nObservers are registered only in `@Application({ observers: [...] })` (#261) — one instance each,\ncreated by the container at start with their `deps`, called for every execution of the\napplication (filter by `event.workflow.name === nameOf(SupportWorkflow)` — `nameOf(C)` gives the\n`name` of any AGBoss-decorated class); none listed → none is\ncalled. Each hook gets one event object, plain data, never the framework's internal state. Every\nexecution event (`ExecutionEvent`) carries `executionId`, `parentExecutionId`, `rootExecutionId`,\n`threadId`, `tenantId` and `workflow` — a `WorkflowRecord` `{ name, version, startedVersion, configHash }`, no class; a node event (agent, router, tool, model, knowledge base, guardrail, action,\nchannel, judge, `onValuesOutdated`, `onLaunchItemsTruncated`) adds `step`, a `StepRecord`\n(`component` — `{ name, kind, promptHash, schemaHash }`, the join keys of `agboss describe --json`\n—, `nodeId`, `visitNumber`, `copyNumber`, `itemIndex`, `idempotencyKey`, `callId`,\n`toolLoopIteration`, `validationAttempts`) — the serialized `executionContext.step`. `onExecutionStart` fires once per execution (at\n`execute`); one that pauses because nothing else can move emits `onExecutionPause` (`event.pending`:\nevery pause it waits for — again after a partial resume), `app.resume` and recovery emit\n`onExecutionResume` (`event.reason`, `event.decision`, `event.answeredBy`), `onExecutionEnd` fires once\nwhen it ends at a finish (`event.finish`: a `ComponentRecord`, `event.spend`) and\n`onExecutionError` when it fails (`event.error`) — also after a resume; cancelling a paused\nexecution is an `onExecutionError` with `ExecutionCancelledError`. A request refused before an\nexecution starts or is located — another owner's thread, a `resume` of an unknown pause or one no\nlonger waiting, an\n`execute` on an unknown thread or with an invalid input, an exhausted budget, a request after\n`app.close()` — emits `onExecutionRejected` (`operation`: `ExecutionOperation.Execute | Resume | Cancel`, `threadId` when given, `tenantId`, the error's stable `code`, a `message` without the input\nvalues; never an `executionId`) and still throws. A resume refused as incompatible after a deploy\n(`IncompatibleResumeError`) is an `onExecutionError` on the paused execution; it stays paused. A\nwrite that renews a value of the path scope outdates what was built from the older one:\n`onValuesOutdated` fires once per such cascade (the new value, each outdated value with its\n`OutdatedReason`; never DTO values), and `result.record.outdatedValues` keeps the values themselves.\nAssembly rejects an observer with no hook or with a misspelled one (`did you mean onToolEnd?`); an\nobserver that throws is a warning in the application's log (`observer.failed`) and never fails the\nexecution.\n\n``` js\nimport {\n  Application,\n  EmptyEnvironment,\n  Injectable,\n  type AgentStartEvent,\n  type ExecutionEndEvent,\n  type ExecutionRejectedEvent,\n  type OnAgentStart,\n  type OnExecutionEnd,\n  type OnExecutionRejected,\n} from \"@agboss/core\";\n\n@Injectable()\nexport class MetricsObserver implements OnExecutionEnd, OnAgentStart, OnExecutionRejected {\n  onExecutionEnd(event: ExecutionEndEvent): void {\n    console.log(`${event.workflow.name} ${event.executionId} (tenant ${event.tenantId}) cost $${event.spend.totalUsd}`);\n  }\n  onAgentStart(event: AgentStartEvent): void {\n    console.log(`${event.step.component.name} visit ${event.step.visitNumber} of ${event.executionId}`);\n  }\n  onExecutionRejected(event: ExecutionRejectedEvent): void {\n    console.log(`${event.operation} refused for ${event.tenantId}: ${event.code}`);\n  }\n}\n\n@Application({\n  name: \"metrics-application\",\n  version: \"1.0.0\",\n  environment: EmptyEnvironment,\n  workflows: [],\n  observers: [MetricsObserver], // the only place observers are registered\n})\nexport class MetricsApplication {}\n```\n\nThe framework logs through the application's `Logger` (#261) — infrastructure like the `Clock`:\nthe built-in `ConsoleLogger` writes one line per entry to standard error; your own `extends Logger`\n(`write(entry: LogEntry)`) goes in `@Application({ providers })`. At start, once, it logs only what\nthe decorators say — what started, and the worst case of one call of each start in counts — then\nthe startup warnings; no project code is called (no `maxSpend()`), nothing per tenant:\n\n```\n15:02:11 INFO  Started job-scout-application 1.0.0 — environment prod, host scout-7f3a, process 41237\n15:02:11 INFO  Worst case of one chat-workflow-start call: 1 workflow execution, 1 at the same time, 12 agent and router steps\n```\n\n`agboss check` prints the same worst case, from the same decorators, with how each number is made\nup (#268) — `maxItems`, `maxConcurrency`, `maxSteps` or the longest path, `maxDepth`; `agboss describe` prints it under each workflow's limits:\n\n```\nWorst case of one vendor-screening-workflow-start call\n  workflow executions     21  1 vendor-screening-workflow + 20 vendor-check-workflow (maxItems 20)\n  at the same time        4   1 vendor-screening-workflow + 3 vendor-check-workflow (maxConcurrency 3)\n  agent and router steps  22  2 in vendor-screening-workflow (longest path) + 20 × 1 in vendor-check-workflow (longest path)\n  nesting depth           2   vendor-screening-workflow → vendor-check-workflow\n```\n\nWhile it runs it logs only problems of the system — `LogLevel.Warn` at risk (a recovery left to a\ncompatible instance, an observer that threw), `LogLevel.Error` broken (a failed recovery, a failed\nclose) — each with its stable `code` and the `executionId` / `tenantId` it is about. One execution's\ndetails are its trace, a tenant's spend is the cost ledger, a rejected execution is an observer\nevent. The startup warnings (`app.warnings`) include the **assembly warnings**: tenant or\napplication budgets on the built-in in-memory cost ledger (`[application.in-memory-cost-ledger]`:\neach instance counts on its own, the counters reset on restart) and pauses possible with the\nbuilt-in in-memory paused executions or checkpoints (`[application.in-memory-pauses]`), and PII\ntokens made with a key of each execution because `@Application({ pseudonymKey })` is not set\n(`[application.pii-instance-key]`: one value gets another token in each execution) — errors\nunder `agboss check --strict`, silent in tests (` testWith` logs no start and no warning; an unlisted\nlogger is silent there).\n\nEvery model call is a usage record (caller, model, tokens, USD) in the run's `spend`\n(`ExecutionOutput.spend`: total, per caller, per category) and in the counters of every budget of\nthe execution (the application's `CostLedgerRepository`, in memory unless one is listed).\n\n**Money limits are `@Budget` components** (#261), all of them in `@Application({ budgets })`\n(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>`:\n\n- `BudgetScope.Execution` — one full pass of`app.execute` : a workflow's own (`workflow: X` , at\nmost one) or the one default (no`workflow` ) for every workflow without its own;\n- `BudgetScope.Tenant` — one organization (`tenantId` ) per`BudgetPeriod.Day | Week | Month` (UTC);\n- `BudgetScope.Application` — the whole application per period.\n\nEvery workflow is covered (`[budget.execution-budget-required]`), there is at least one tenant and\none application budget (`[budget.tenant-budget-required]`, `[budget.application-budget-required]`),\nand no workflow has two (`[budget.duplicate-execution-budget]`,\n`[budget.duplicate-default-execution-budget]`) — checked at assembly. `maxSpend()` is read once per\nexecution, at its start, in its `ExecutionContext` (a budget injects it, or the project's tenant\ncontext, or a service such as billing — a trusted source, never the request body) and kept for the\nwhole execution, a resume included; the ledger keeps the last one read. Before the first step every\nbudget is checked — one exhausted rejects the execution (`onExecutionRejected`); every model call's real\ncost is then counted in all of them, and before the next step or model call the first exhausted\nstops the execution with `BudgetExceededError` — `budget` (`{ name }`), `scope`, `key` (the\napplication's name, the `tenantId` or the execution's id), `maxSpend`, `spent`, and a sentence:\n`tenant acme spent $40.05 today; tenant-daily-budget allows $40`. `.onError(BudgetExceededError, …)`\ntakes it. Nothing is reserved: a budget protects from draining money, so parallel executions of one\ntenant may overrun it a little, and a budget is exceeded by at most the calls in flight when it was\nexhausted (a cost is known after its call): within one tree every call counts the moment it is made,\nand the call that exhausts a budget aborts the in-flight calls of the parallel branches beside it\n(#338). A decision a\ntool's service makes through `ROUTER_FACTORY` is the tool's spend (`tool:<name>`). A paid service\nthat is **not** a model is recorded with `SpendRecorder` (`deps: [SpendRecorder]`, in a `@Tool` or\nan `@Action`): the record joins the spend of the current tool call or step and counts in the same\nbudgets. Called outside a step or a tool call, `record` throws `SpendOutsideStepError`\n(`[spend.outside-step]`).\n\n```\n// config/budget.config.ts — the tenant's own amount, from its server-side data\nimport { Budget, BudgetPeriod, BudgetScope, type BudgetHandler } from \"@agboss/core\";\nimport { usd, type Usd } from \"@agboss/core/units\";\n\n@Budget({\n  name: \"tenant-daily-budget\",\n  scope: BudgetScope.Tenant,\n  period: BudgetPeriod.Day,\n  deps: [ShopTenantContext],\n})\nexport class TenantDailyBudget implements BudgetHandler {\n  constructor(private readonly shopTenantContext: ShopTenantContext) {}\n\n  maxSpend(): Usd {\n    return usd(this.shopTenantContext.dailyBudgetUsd);\n  }\n}\n\n@Budget({ name: \"execution-budget\", scope: BudgetScope.Execution, deps: [ExecutionContext] })\nexport class ExecutionBudget implements BudgetHandler {\n  constructor(private readonly executionContext: ExecutionContext) {}\n\n  maxSpend(): Usd {\n    return this.executionContext.tenant.weight > 1 ? usd(0.5) : usd(0.1); // a bigger tenant\n  }\n}\n```\n\nA budget is unit-tested without the container —\n`new TenantDailyBudget(testExecutionContext({ tenantContext: ShopTenantContext, tenant: { dailyBudgetUsd: 40 } }).tenant).maxSpend()`\nis `usd(40)` — and an application test reaches an exhausted one the real way:\n`mockOf(TenantDailyBudget).maxSpend.mockReturnValue(usd(0.01))` plus a scripted cost\n(`replyWith(…, { cost: usd(0.02) })`). Steps are not money: `@Workflow({ maxSteps })` (default\n(agents + routers) × 3; `StepsLimitExceededError`), checked at assembly too —\n`[limits.max-steps-too-low]` when no finish fits in a hand-set `maxSteps`, or when the default\ncannot hold the worst case of a per-item launch (`maxItems` runs: set `maxSteps`),\n`[limits.branch-exceeds-max-steps]`, `[limits.max-steps-unused]`.\n\n``` js\nimport { SpendRecorder, Tool, type ToolHandler } from \"@agboss/core\";\nimport { usd } from \"@agboss/core/units\";\n\n@Tool({\n  name: \"web-search-tool\",\n  description: \"Searches the web (a paid API)\",\n  input: SearchQueryDto,\n  output: SearchResultsDto,\n  deps: [SpendRecorder],\n})\nexport class WebSearchTool implements ToolHandler<SearchQueryDto, SearchResultsDto> {\n  constructor(private readonly spendRecorder: SpendRecorder) {}\n\n  run({ query }: SearchQueryDto): Promise<SearchResultsDto> {\n    this.spendRecorder.record({ service: \"serp-api\", cost: usd(0.002) }); // → tool:web-search-tool\n    return Promise.resolve({ text: `results for ${query}` });\n  }\n}\n```\n\nA `@PiiPolicy` says which personal data the models never see as is (#120). Policies are listed in\n`piiPolicies` of `@Application`, `@Workflow`, `@Agent` and `@Router`: the lists add up, and an agent\nor a router removes an inherited one with `excludePiiPolicies` — for its own model and tools only;\nlogs, traces, observer events and `result.record` stay masked by the application's and the\nworkflow's lists. Masking happens only at the model boundary: what a model reads is masked, what\nit gives back is revealed before any DTO is validated, so the scope, the checkpoint, every tool,\naction and finish see real values. `replaceWithToken()` is the reversible one — a tool gets the\naddress the model only saw as `<email:7f3a91>` (an HMAC of the value with `pseudonymKey`; the\ntoken map is kept in the execution's checkpoint). A `blockWithRefusal` rule fails the execution\nwith `PiiRefusedError` (`kind`, `refusal`) when the value comes in or is in an answer, and rejects\nonly the call when it is in tool arguments or a tool result.\n\n```\n@PiiPolicy({\n  name: \"customer-pii-policy\",\n  description: \"Contact details hidden, cards refused, names found by a model\",\n  rules: [\n    pii(PiiGroup.Contact, PiiAction.replaceWithKind()), // phones, addresses, postal codes\n    pii(PiiKind.Email, PiiAction.replaceWithToken()), // a kind rule beats its group's\n    pii(PiiKind.PersonName, PiiAction.replaceWithKind()),\n    pii(PiiKind.CardNumber, PiiAction.blockWithRefusal()), // the text from refusal(kind)\n  ],\n  detectWithModel: \"moonshotai/kimi-k2.6\",\n  pseudonymKey: secret(\"PII_PSEUDONYM_KEY\"),\n})\nexport class CustomerPiiPolicy implements PiiRefusal {\n  refusal(kind: PiiKind): string {\n    return `Please do not send a ${kind} here.`;\n  }\n}\n\n@Injectable()\nexport class EmployeeDirectory {\n  readonly ids: readonly string[] = [\"EMP-0042\"];\n}\n\n// a project's own detector: the framework applies `action` to every range it finds\n@PiiPolicy({\n  name: \"employee-id-pii-policy\",\n  action: PiiAction.replaceWithToken(),\n  deps: [EmployeeDirectory],\n})\nexport class EmployeeIdPiiPolicy implements PiiPolicyHandler {\n  constructor(private readonly employeeDirectory: EmployeeDirectory) {}\n\n  findMatches(text: string): readonly PiiMatch[] {\n    return this.employeeDirectory.ids.flatMap((id) => {\n      const start = text.indexOf(id);\n      return start < 0 ? [] : [{ start, end: start + id.length }];\n    });\n  }\n}\n\n// a field without a format of its own: the same actions (masking ones only)\nexport class TicketDto {\n  @Text({ prompt: \"the customer's account\", sensitive: PiiAction.replacePart({ keepLast: 4 }) })\n  account!: string;\n}\n```\n\nAssembly checks the lists: `[pii.conflicting-rules]` (two policies with different actions for one\nkind, naming both), `[pii.exclude-not-inherited]`, `[pii.missing-refusal]`, `[pii.unused-refusal]`,\n`[pii.kind-needs-model]` (a name or an address without `detectWithModel`), `[pii.not-a-policy]`.\nEach `pseudonymKey` is read from its process variable when the app starts (a missing one fails the\nstart). The application's own key, `@Application({ pseudonymKey: \"piiPseudonymKey\" })`, names a\n`Secret` field of its environment (`piiPseudonymKey: secret(\"PII_PSEUDONYM_KEY\")`, like a model\nprovider's `apiKey`): the key of sensitive fields, of project detectors and of every policy without\nits own — so one value is one token on every instance, and logs and traces from several machines\nagree; a policy's own key wins, and a policy that needs the application's when it is not set fails\nthe start, naming the field. Not set and not needed, each instance makes a key of its own: the\nstart goes on with the warning `[application.pii-instance-key]` (reveal still works — the token map\nis in the checkpoint — only logs and traces of two instances show one value as two tokens). Under\n`testWith` an unset key gets a fixed test key, and `mockLlm(CustomerPiiPolicy)`\nscripts its model detection (`pii:<policy>` in the spend). Wiki → Guards (PII).\n\nGuardrails block (safety); judges correct (quality). An `@InputGuardrail` checks what an agent's or\na model router's model reads — its input message, masked by that element's PII policies, before the\nmodel is called; an `@OutputGuardrail` checks what an agent answers. Each asks its model one yes / no\nquestion — its `prompt` — a decision model through one `Decision.noul`, a chat model through one\nanswer tool; its `model` or `defaults.guardrail.model` (never `defaults.router`; none →\n`[guardrail.no-model]`). At P(yes) ≥ `threshold` the execution fails with `GuardrailBlockedError`\n(`guardrail.blocked`): `refusal` is the text to send instead, `key` the setting that decided\n(`guardrails.<name>.threshold`). A model that does not answer fails closed (` GuardFailedError`).\n\n```\n// a computed refusal: the class implements GuardrailRefusal, the execution through deps\n@InputGuardrail({\n  name: \"legal-advice-guardrail\",\n  description: \"Blocks requests for legal advice\",\n  prompt: \"Does the message ask for legal advice?\",\n  model: \"typesafe/jev-1.13\",\n  threshold: 0.8,\n  deps: [ClinicExecutionContext, RefusalTextService],\n})\nexport class LegalAdviceGuardrail implements GuardrailRefusal {\n  constructor(\n    private readonly clinicExecutionContext: ClinicExecutionContext,\n    private readonly refusalTextService: RefusalTextService,\n  ) {}\n\n  refusal(): Promise<string> {\n    return this.refusalTextService.legalAdvice(this.clinicExecutionContext.locale);\n  }\n}\n\n// a fixed refusal, the model from defaults.guardrail.model\n@OutputGuardrail({\n  name: \"no-legal-advice-guardrail\",\n  description: \"Blocks answers that give legal advice\",\n  prompt: \"Does the answer give legal advice?\",\n  threshold: 0.8,\n  refusal: \"I can't give legal advice. Please contact a lawyer.\",\n})\nexport class NoLegalAdviceGuardrail {}\n```\n\nThey are listed in `guardrails` of `@Application`, `@Workflow`, `@Agent` and `@Router`: the lists\nadd up, `excludeGuardrails` removes an inherited one for an agent or a router. A model router takes\nthe input guardrails; a code router calls no model and takes none. Assembly checks them all at once:\n`-guardrail` names, one refusal (both → `[guardrail.refusal-twice]`; neither does not compile, and\nis `[guardrail.missing-refusal]` at assembly), a question (`[guardrail.no-prompt]`), a threshold in\n(0, 1] (`[guardrail.threshold-invalid]`), `[guardrail.exclude-not-inherited]`,\n`[guardrail.not-a-guardrail]`, `[guardrail.output-on-router]`, `[guardrail.no-model-call]`.\n`agboss describe` prints each one's side, threshold, model, refusal and the elements it guards. In\ntests, `mockLlm(LegalAdviceGuardrail).thenReturn(decideWith({ blocks: 0.9 }))` blocks (an\nunscripted guardrail passes), and `testGuardrail(LegalAdviceGuardrail, { model: decideWith({ blocks: 0.9 }), executionContext: ClinicExecutionContext, execution: { locale: \"he\" } })(\"Can I sue?\")`\ngives `{ isBlocked, probability, refusal }`.\n\nA thread's turns are its finished executions (`start <name>: <input>` / `finish <name>: <output>`).\nEach agent and model router sees the last `uncompactedTurns` of them in full — set on\n`@Application`, `@Workflow`, `@Agent` or `@Router`, the lowest set value wins (default 5,\n`TurnLimit.ALL` = every turn). What is older is summarised by a `@Compaction`, named by\n`compaction` on the same levels (again the lowest wins; none anywhere → the window only):\n\n```\n// a queue: every 5 turns older than the window become one summary, the latest 10 are kept\n@Compaction({\n  name: \"conversation-compaction\",\n  description: \"Summarises old turns of the conversation\",\n  prompt: \"Summarise the turns under `Turns to compact`: what was asked, decided and is still open.\",\n  model: \"moonshotai/kimi-k2.6\",\n  strategy: CompactionStrategy.SummaryQueue,\n  turnsBetweenCompactions: 5,\n  summaryQueueLength: 10,\n  summaryMaxTokens: 400,\n})\nexport class ConversationCompaction {}\n\n// one running summary, rewritten from the previous one and the new turns (the built-in prompt)\n@Compaction({\n  name: \"code-context-compaction\",\n  description: \"Keeps one summary of the code touched so far\",\n  model: \"moonshotai/kimi-k2.6\",\n  strategy: CompactionStrategy.SingleSummary,\n  turnsBetweenCompactions: 3,\n  summaryMaxTokens: TokenLimit.MODEL_MAX,\n})\nexport class CodeContextCompaction {}\n\n// no summaries at all: only the window (no model, no prompt, no parameters)\n@Compaction({\n  name: \"no-summaries-compaction\",\n  description: \"Only the window of recent turns\",\n  strategy: CompactionStrategy.None,\n})\nexport class NoSummariesCompaction {}\n```\n\nA compaction summarises everything older than the **smallest** `uncompactedTurns` among the agents\nand routers that use it, so no one loses a turn: a router that sees 1 turn and an agent that sees 3\nshare one compaction that summarises what is older than 1 — the agent sees its 3 turns in full and\nnot the summary they already cover. `summaryMaxTokens` is the summary call's `max_tokens` (the\nmodel's server counts it; thinking is off on that call): a summary cut there is asked once more,\nshorter, and cut again fails the execution with `CompactionError` (`compaction`) — never a cut\nsummary. A compaction runs lazily, before a new execution first reads its thread — never after a\nturn: a finished answer is always kept, a thread that ends pays for none, and a `CompactionError`\nfails the new execution before any of its work (no model call, no step, no turn kept). The compaction's model reads the turns\nthrough the application's and the workflow's PII policies, its summary is revealed before it is\nstored, and each reader's own policies mask it on the way into its model. Spend is\n`compaction:<name>`; `result.compacted` says what each compaction did before the execution. In tests,\n`mockLlm(ConversationCompaction).thenReturn(replyWith(\"…\"))` scripts it (`replyWith(\"…\", { truncated: true })` is a summary cut at the limit); unscripted, it writes a fixed summary.\n\n```\nscripts/langfuse.sh up   # self-hosted Langfuse in Docker, keys written to .env\n```\n\nThen every run is traced at [http://localhost:3000](http://localhost:3000). Details: [Observability & Tracing Guide](https://agboss.dev/guides/observability-and-tracing/) · [Wiki → Tracing](https://github.com/agbossjs/agboss/wiki/Tracing).\n\nTickets move across the GitHub Project board of the repo:\n\n| Stage | Skill | Result | \n|---|---|---|\n| Triage | `triage` | business side clarified through quizzes; issue(s) created or updated, split into sub-issues if needed | \n| Backlog | `spec-session` | spec, implementation plan, automated + manual acceptance criteria — in the issue | \n| In progress → Test | `implement` | independent issues in parallel; branch + PR per issue, merged to `main` ; manual-test handoff | \n| Done | a human | after manual acceptance | \n\nRules and the delivery pipeline: [`WORKFLOW.md`](https://github.com/agbossjs/agboss/blob/main/WORKFLOW.md) · [`QUALITY.md`](https://github.com/agbossjs/agboss/blob/main/QUALITY.md) · [`CLAUDE.md`](https://github.com/agbossjs/agboss/blob/main/CLAUDE.md). Full documentation lives at [https://agboss.dev](https://agboss.dev) and in the [GitHub Wiki](https://github.com/agbossjs/agboss/wiki).\n\n- [`CONTRIBUTING.md`](https://github.com/agbossjs/agboss/blob/main/CONTRIBUTING.md) — how a change\ngoes from an issue to a merged pull request.\n- [`SECURITY.md`](https://github.com/agbossjs/agboss/blob/main/SECURITY.md) — report a\nvulnerability privately to[security@agboss.dev](mailto:security@agboss.dev) ; supported versions and what to expect.\n- [`CHANGELOG.md`](https://github.com/agbossjs/agboss/blob/main/CHANGELOG.md) — what each release\nchanges; the per-pull-request log with every breaking change is the[Wiki's Changelog](https://github.com/agbossjs/agboss/wiki/Changelog) .", "url": "https://wpnews.pro/news/show-hn-agboss-dependency-injection-and-static-validation-for-ai-agents", "canonical_source": "https://github.com/agbossjs/agboss", "published_at": "2026-10-27 12:56:45+00:00", "updated_at": "2026-10-27 13:13:07.749687+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools", "artificial-intelligence"], "entities": ["AGBoss", "@agboss/core", "@agboss/cli", "LangGraph", "npm", "GitHub", "TypeScript"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/show-hn-agboss-dependency-injection-and-static-validation-for-ai-agents", "markdown": "https://wpnews.pro/news/show-hn-agboss-dependency-injection-and-static-validation-for-ai-agents.md", "text": "https://wpnews.pro/news/show-hn-agboss-dependency-injection-and-static-validation-for-ai-agents.txt", "jsonld": "https://wpnews.pro/news/show-hn-agboss-dependency-injection-and-static-validation-for-ai-agents.jsonld"}}