Error Model
Noetic uses structured NoeticError objects with error codes and recovery hints.
Quick Example
import { AgentHarness, isNoeticError } from '@noetic-tools/core';
declare const harness: AgentHarness;
try {
const result = await harness.execute('Do something');
} catch (e) {
if (isNoeticError(e)) {
console.log(e.noeticError.kind); // e.g. 'step_failed'
console.log(e.message); // Human-readable description
}
}Noetic wraps every framework-level failure in a structured NoeticError discriminated union. Instead of catching generic exceptions and guessing what went wrong, you can match on the kind field and access typed metadata for each error variant.
NoeticError Type
NoeticError is a discriminated union with a kind field. Each variant carries context-specific data:
kind | Key Fields | When it occurs |
|---|---|---|
step_failed | stepId, cause: Error, retriesExhausted | A step throws after exhausting retries. |
model_refused | stepId, refusal: string | The model returns a refusal instead of content. |
model_parse_error | stepId, raw: string, schema: StandardSchemaV1, zodError: ZodError | Structured output fails schema validation. schema is the Standard Schema v1 validator; zodError stays populated for backward compatibility — the real ZodError for Zod schemas, a synthetic custom-issue ZodError adapted from the vendor's Standard Schema issues otherwise. |
model_rate_limit | stepId, retryAfter?: number | The LLM provider returns a rate-limit response. |
fork_partial | stepId, succeeded[], failed[] | Thrown by inParallel mode 'all' when any path fails, and by 'race' when every path fails. (Mode 'settle' never throws this — it reports failures in its SettleResult array instead.) |
spawn_summary_failed | stepId, childOutput: unknown, summaryCause: Error | A spawn's child succeeded but a context layer's onReturn hook (such as a summarizer) failed. Never thrown: the spawn returns the child's unsummarized output, and this error is reported as a layer diagnostic and a spawn.summary_failed span event. |
channel_timeout | channelName, timeout: number | A recv() call — or a back-pressured send() parked on a full queue — exceeded its timeout. |
channel_closed | channelName | Attempted to send on a closed external channel. |
cancelled | reason?: string | The execution was cancelled via ctx.abort() or harness.cancel() — including a parent's cancellation cascading into an inParallel path or spawn child. Pending channel recv calls (and parked back-pressure send calls) on the aborted context reject with this kind immediately, and an in-flight model call or ACP agent turn is cut short. Under a graceful stop (cancel(ctx, { drainMs })), the steps, rounds and tool calls it held back throw this kind once the drain ends. |
budget_exceeded | field: 'cost' | 'steps' | 'duration', limit, actual | A budget guard detected the run exceeded its limits. |
steering_denied | guidance?: string | A context layer's steering hook (beforeToolCall / afterModelCall) returned a deny decision and the runtime blocked the action, or the harness approveToolCall approver refused an invokeTool call to a tool with needsApproval. guidance carries the corrective feedback, if any. |
item_schema_mismatch | category: 'items' | 'developerMessages' | 'toolCalls' | 'toolResults', itemType?: string | An item failed extension-schema validation in the ItemSchemaRegistry: a category item (e.g. a tool result) matched none of the schemas registered for its category, or an unknown item type matched no registered schema under strict mode. itemType is set for the unknown-type case. |
connection_unavailable | connection, reason | A connection is disconnected, its pool stayed full past waitMs, or an event stream a step was waiting on ended. |
connection_failed | connection, op, error | The server refused or failed the operation. error.code is one of the codes connection profiles define, such as malformed, guarded or ambiguous. |
connection_ref_invalid | connection, reason | A ref was not issued by this harness, belongs to a different connection than the call's target, was used from another thread, or is stale. |
connection_secret_refused | connection, op | A secret() was used outside a field the operation marks sensitive, or by the model. |
connection_input_required | connection, request | A long-running operation asked a question nobody could answer. |
connection_resume_refused | connection, reason | A resumed run's connection was replaced and cannot safely be driven again. |
verify_act_failed | stepId, cause: Error, run?: BlobRef | A step.verify's act threw. The verification run is recorded first, with the failure as evidence, and run points at it. |
Full Type Definition
type NoeticError =
| { kind: 'step_failed'; stepId: string; cause: Error; retriesExhausted: boolean }
| { kind: 'model_refused'; stepId: string; refusal: string }
| { kind: 'model_parse_error'; stepId: string; raw: string; schema: StandardSchemaV1; zodError: ZodError }
| { kind: 'model_rate_limit'; stepId: string; retryAfter?: number }
| { kind: 'fork_partial'; stepId: string; succeeded: Array<{ stepId: string; value: unknown }>; failed: Array<{ stepId: string; error: NoeticError }> }
| { kind: 'spawn_summary_failed'; stepId: string; childOutput: unknown; summaryCause: Error }
| { kind: 'channel_timeout'; channelName: string; timeout: number }
| { kind: 'channel_closed'; channelName: string }
| { kind: 'cancelled'; reason?: string }
| { kind: 'budget_exceeded'; field: 'cost' | 'steps' | 'duration'; limit: number; actual: number }
| { kind: 'steering_denied'; guidance?: string }
| { kind: 'item_schema_mismatch'; category: 'items' | 'developerMessages' | 'toolCalls' | 'toolResults'; itemType?: string }
| { kind: 'connection_unavailable'; connection: string; reason: string }
| { kind: 'connection_failed'; connection: string; op: string; error: { code: ConnectionErrorCode; message: string; details?: Record<string, unknown> } }
| { kind: 'connection_ref_invalid'; connection: string; reason: string }
| { kind: 'connection_secret_refused'; connection: string; op: string }
| { kind: 'connection_input_required'; connection: string; request: { id: string; message: string; schema?: JsonSchema } }
| { kind: 'connection_resume_refused'; connection: string; reason: string }
| { kind: 'verify_act_failed'; stepId: string; cause: Error; run?: BlobRef };Model provider failures
A failing provider call in a callModel or decide step never reaches you as a raw Error. A 429 becomes model_rate_limit, a failure caused by cancellation becomes cancelled, and anything else becomes step_failed with the provider's error on cause.
NoeticErrorImpl Class
NoeticErrorImpl extends the standard Error class, so it works with try/catch and stack traces. The structured error data lives on the .noeticError property:
class NoeticErrorImpl extends Error {
readonly noeticError: NoeticError;
constructor(error: NoeticError);
}The message string is auto-generated from the error kind and its fields. For example, a step_failed error produces:
Step 'fetch-data' failed: Connection refusedisNoeticError Type Guard
Use isNoeticError() to narrow an unknown catch value:
import { isNoeticError } from '@noetic-tools/core';
function isNoeticError(e: unknown): e is NoeticErrorImpl;This is an instanceof check against NoeticErrorImpl with a structural fallback: any Error carrying a noeticError object whose kind is a string also passes. The fallback keeps the guard reliable when more than one copy of the framework is loaded (mixed src/dist resolution, duplicated node_modules), where a plain instanceof would fail across module copies. The inner kind is not validated against the closed union, so errors minted by a newer framework version still pass.
Handling Errors in Loops
Loops accept an onError callback that receives the structured error and returns a recovery action:
import { callModel, loop, until } from '@noetic-tools/core';
const resilientLoop = loop({
id: 'retry-loop',
steps: [callModel({ id: 'generate', model: 'openai/gpt-4o' })],
until: until.noToolCalls(),
onError(error, ctx) {
if (error.kind === 'model_rate_limit') {
return 'retry'; // Try the same iteration again
}
if (error.kind === 'step_failed' && !error.retriesExhausted) {
return 'skip'; // Move to the next iteration
}
return 'abort'; // Re-throw the error
},
});| Return value | Behavior |
|---|---|
'retry' | Re-execute the same iteration immediately. |
'skip' | Skip this iteration and continue with the previous output. |
'abort' | Re-throw the error, ending the loop. |
Matching on Error Kind
Because NoeticError is a discriminated union, TypeScript narrows the type after checking kind:
declare const e: unknown;
if (isNoeticError(e)) {
const err = e.noeticError;
switch (err.kind) {
case 'model_parse_error':
// TypeScript knows: err.raw, err.schema, err.zodError
console.log('Validation errors:', err.zodError.issues);
break;
case 'fork_partial':
// TypeScript knows: err.succeeded, err.failed
console.log(`${err.succeeded.length} paths ok, ${err.failed.length} failed`);
break;
case 'budget_exceeded':
// TypeScript knows: err.field, err.limit, err.actual
console.log(`${err.field} limit: ${err.limit}, used: ${err.actual}`);
break;
}
}NoeticConfigError
NoeticConfigError is a separate class from NoeticError. It reports a setup mistake — an invalid builder option, missing credentials, a workflow that references something unregistered — rather than a failure while running. Builders throw it when you construct a step; AgentHarness.run() and execute() check what they can before the first step runs.
class NoeticConfigError extends Error {
readonly code: string; // SCREAMING_SNAKE_CASE, e.g. 'MISSING_API_KEY'
readonly hint: string; // a sentence saying what to do next
readonly docsUrl: string; // this page, at the section for `code`
}
function isNoeticConfigError(e: unknown): e is NoeticConfigError;docsUrl defaults to https://noetic.tools/docs/framework/errors#<code in lower case> — the matching section below. For example, MISSING_API_KEY links to #missing_api_key.
import { isNoeticConfigError, loop, until } from '@noetic-tools/core';
try {
loop({ id: '', steps: [], until: until.maxSteps(10) });
} catch (e) {
if (isNoeticConfigError(e)) {
console.error(`${e.code}: ${e.message}\n${e.hint}\nSee ${e.docsUrl}`);
}
}Configuration Error Codes
Every code a NoeticConfigError can carry, with its cause and fix.
Credentials and Harness Setup
NO_LLM_PROVIDER
Cause: A callModel or decide step needs a model, but the harness has no LLM credentials. AgentHarness.run() and execute() raise this before any step runs when the model step is visible in the step tree; a model step chosen at run time raises it when it is reached.
Fix: Pass callModelDefaults: { apiKey: '...' } to AgentHarness (it defaults to the Noetic platform) or set NOETIC_API_KEY. For OpenRouter directly, pass callModelDefaults: { provider: 'openrouter', apiKey: '...' } or set OPENROUTER_API_KEY.
MISSING_API_KEY
Cause: createOpenRouterEmbed() was called without an OpenRouter API key.
Fix: Pass the key explicitly, for example createOpenRouterEmbed(process.env.OPENROUTER_API_KEY).
MISSING_CALL_MODEL
Cause: A steering() rule is evaluated by an LLM, but no model call is available to the layer.
Fix: Give the harness LLM credentials (see NO_LLM_PROVIDER), or use a rule that does not need a model.
NO_STEP_CONFIGURED
Cause: harness.execute() (or the session API) was called on a harness built without an agentGraph.
Fix: Pass agentGraph to the AgentHarness constructor, or drive steps directly with harness.run(step, input, ctx).
MISSING_HARNESS_CONTEXT
Cause: A workflow() or dynamicWorkflow() step ran on a context with no harness attached.
Fix: Run the step through AgentHarness.run() (or execute()), which attaches the harness to the context.
STEP_LEDGER_RETENTION_INVALID
Cause: environment.storage.stepLedgerRetention has a cap that is not a positive number or Infinity.
Fix: Omit the field to take the default, pass a positive number, or pass Infinity to remove the cap.
CHECKPOINT_SCHEMA_MISMATCH
Cause: harness.restore() found a checkpoint written by a runtime version whose snapshot schema this version does not recognize.
Fix: Clear the snapshot with CheckpointStore.clear() and restart the execution.
Step Builders
EMPTY_STEP_ID
Cause: A step builder (runCode, callModel, invokeTool, loop, spawn, withContext, inParallel, conditional, schedule, decide, routeSwitch, workflow, step.acpAgent) or registerStep() got an empty id.
Fix: Give every step a unique, non-empty id, for example runCode({ id: 'fetch-data', ... }).
EMPTY_STEP_ARRAY
Cause: An empty array was given where a step goes (harness.run, harness.detachedSpawn, agentGraph, a spawn or withContext child, a schedule body, an inParallel path, a conditional route's pick, or a when/otherwise clause). An array of steps runs in sequence and needs at least one step.
Fix: Pass the steps to run, or a single step.
INVALID_STEP_IN_ARRAY
Cause: An element of an array of steps is not a step: a nested array, null or another non-object, or an object without a known step kind and a string id. The array is checked where it becomes a step (the builder, when/otherwise, harness.run, detachedSpawn, the harness constructor for agentGraph, or when an inParallel path or a conditional route's pick is resolved), so it fails there instead of as a TypeError mid-run. Nested arrays are rejected, not flattened. TypeScript rejects these arrays at compile time; the check covers untyped callers.
Fix: Put only steps in the array. Spread a nested array into its parent ([a, ...rest]) rather than nesting it.
MISSING_EXECUTE_FUNCTION
Cause: runCode() or tool() was called without an execute function.
Fix: Pass an async execute function — execute: async (input, ctx) => result for runCode, execute: async (args, toolCtx) => result for tool.
EMPTY_TOOL_NAME
Cause: tool() got an empty name.
Fix: Pass a unique name, for example tool({ name: 'greet', ... }).
MISSING_TOOL
Cause: invokeTool() was called without a tool.
Fix: Pass a tool built with tool().
MISSING_MODEL
Cause: callModel() got an empty model, a lazy model getter returned an empty string at run time, or a decide() step has neither its own model nor a harness decisionModel.
Fix: Pass a non-empty model id such as 'anthropic/claude-sonnet-4-20250514'. For decide, set model on the step or decisionModel on the harness.
UNKNOWN_TOOL_CHOICE
Cause: A callModel step's toolChoice: { tool } names a tool the step cannot call. callModel() (and the JSON hydrator) throws it at build time when tools is an eager array without that client tool; the harness throws it at request time, before anything is sent, when the tool is outside the unified tool set or the step's restriction.
Fix: Force a client tool the step offers — add it to tools, or register it through a context layer — or use toolChoice: 'required' to make the model call any allowed tool. Server tools cannot be forced.
MISSING_JSON_SCHEMA
Cause: A tool input schema or a structured-output schema cannot be turned into JSON Schema for the model.
Fix: Use a schema that implements StandardJSONSchemaV1 (Zod does), or pass inputJsonSchema on tool() / outputJsonSchema on the model step.
MISSING_LOOP_BODY
Cause: loop() got an empty steps array.
Fix: Add at least one step to steps.
MISSING_UNTIL_PREDICATE
Cause: loop() was called without an until predicate.
Fix: Pass one, for example until: until.maxSteps(10). Use until.never() for a loop that only an error or cancellation ends.
MISSING_CHILD_STEP
Cause: spawn() or withContext() was called without a child step.
Fix: Pass the step to run inside the child context as child.
MISSING_CONTEXT_LAYERS
Cause: withContext() was called without context layers.
Fix: Pass the layers to provide, for example context: [scratchpad()].
MISSING_MERGE_FUNCTION
Cause: inParallel() in 'all' or 'settle' mode was called without merge.
Fix: Pass a merge function that combines the path results into one output.
MISSING_ROUTE_FUNCTION
Cause: conditional() was called without a route function.
Fix: Pass a route function that picks the next step at run time.
MISSING_BODY_STEP
Cause: schedule() was called without a body step.
Fix: Pass the step to run on each scheduled iteration.
INVALID_INTERVAL_MS
Cause: schedule() got an interval that is negative, NaN, or infinite.
Fix: Pass a non-negative number of milliseconds.
INVALID_JITTER
Cause: schedule() got a jitter that is negative, NaN, or infinite.
Fix: Pass a non-negative number of milliseconds, or omit jitter.
Channels
INVALID_CHANNEL_TIMEOUT
Cause: A channel send() or recv() got a negative or NaN timeout. The call rejects with this error instead of waiting.
Fix: Pass a positive timeout in milliseconds, or 0 to wait with no timeout.
Decide and routeSwitch
INVALID_DECIDE_QUESTIONS
Cause: decide() got an invalid question map: no questions, a boolean question with neither instructions nor criteria, a choice question with fewer than 1 or more than 255 values or duplicates, or a score question with fewer than 2 or more than 10 levels or duplicates.
Fix: Correct the question named in the error message.
INVALID_UNTIL_DECIDER
Cause: A decide step used as a loop's until (directly, or inside any/all) does not ask exactly one question, or its question is not boolean. Raised where the loop or combinator is built. Also raised if a decider predicate is called outside a loop.
Fix: Give the decider one boolean question whose yes means stop, e.g. questions: { done: { type: 'boolean', instructions: 'Is the task finished?' } }.
INVALID_DECIDE_SAMPLES
Cause: decide() got a samples value that is not a positive integer.
Fix: Omit samples, or pass a whole number of runs to average, for example samples: 3.
UNKNOWN_ROUTE_QUESTION
Cause: routeSwitch() routes on a question its decide step does not ask.
Fix: Set on to one of the decide step's question ids.
INCOMPLETE_ROUTE_CASES
Cause: routeSwitch() has no case for one or more outcomes of the question it routes on.
Fix: Give a case for every outcome named in the error message.
INVALID_MIN_CONFIDENCE
Cause: routeSwitch() got a minConfidence outside [0, 1].
Fix: Pass a confidence between 0 and 1, for example minConfidence: 0.6.
MISSING_UNCERTAIN_STEP
Cause: routeSwitch() was called without an uncertain step.
Fix: Pass uncertain: the step to run when the model refuses or is not confident enough.
Context Layers
INVALID_HISTORY_MAX_ITEMS
Cause: history() got a maxItems that is not an integer in the allowed range.
Fix: Pass a sensible cap such as { maxItems: 40 }, or leave the layer out to keep history uncapped.
INVALID_BUDGET_INPUT
Cause: allocateBudgets() got NaN for a budget input (a layer budget, the total budget, or a reserve).
Fix: Pass a real number. Infinity is allowed and means uncapped.
JSON Workflows
WORKFLOW_VALIDATION_FAILED
Cause: A workflow document does not match WorkflowDocumentSchema, one of its expressions does not parse as JSONata, or dynamicWorkflow()'s planner failed to produce a valid document within its revision budget.
Fix: Correct the document against the published JSON Schema, and fix any expression syntax the message names (it gives the JSONata error code and position). For dynamicWorkflow, give the planner clearer instructions or a more capable model, or raise maxRevisions.
INVALID_WORKFLOW_SOURCE
Cause: workflow() got both or neither of document and ref.
Fix: Pass exactly one: document: { version: 1, root: ... } for an inline workflow, or ref: '<name>' with a workflows registry.
UNKNOWN_WORKFLOW_REFERENCE
Cause: A workflow() step's ref, or a subflow node, names a workflow that is not registered.
Fix: Register the workflow under that name in the workflows option (or HydrationContext.workflows).
WORKFLOW_RECURSION_LIMIT
Cause: Named sub-workflows recursed deeper than maxRecursionDepth (default 100) on one execution path. Raised at execution time, when the ref one level too deep would resolve.
Fix: Give the recursion a base case, such as a conditional that stops calling the subflow, or raise maxRecursionDepth (Infinity removes the limit).
INVALID_MAX_RECURSION_DEPTH
Cause: maxRecursionDepth is not a positive integer or Infinity. Raised when a named ref first resolves.
Fix: Pass a positive integer such as 100, or Infinity.
EXPRESSION_FAILED
Cause: An expression failed at execution time: JSONata raised an error (such as arithmetic on a string), a compute expression gave no value or gave a function, or a test gave something other than a boolean. The message names the node and quotes JSONata's error code.
Fix: Fix the expression or the input it reads. A path that matches nothing gives no value, so check field names against the node's input.
EXPRESSION_LIMIT_EXCEEDED
Cause: An expression ran past one of its limits: its step budget, its time budget, its nesting depth, or the size of a sequence it built.
Fix: Bound the recursion or data size in the expression, or raise the limits through expressionLimits on parseAndRunWorkflow, dynamicWorkflow, workflow(), or HydrationContext.
INVALID_EXPRESSION_LIMITS
Cause: An expressionLimits value is not a positive integer or Infinity.
Fix: Pass a positive integer for each limit you set, Infinity to lift one, or leave it unset to keep its default.
UNKNOWN_NODE_KIND
Cause: A workflow node has a kind the hydrator does not know.
Fix: Use one of the node kinds listed in the error's hint.
UNKNOWN_UNTIL_PREDICATE
Cause: A workflow loop's until predicate has an unknown kind.
Fix: Use one of maxSteps, maxCost, maxDuration, noToolCalls, never, outputContains, outputEquals, converged, expr, any, all, or decide.
UNKNOWN_TOOL_REFERENCE
Cause: A workflow node names a tool that is not in HydrationContext.tools.
Fix: Register the tool under that name in HydrationContext.tools before hydrating. The hint lists the tools that are registered.
UNKNOWN_LAYER_REFERENCE
Cause: A workflow node names a context layer that is not in HydrationContext.layers.
Fix: Register the layer under that name in HydrationContext.layers.
UNKNOWN_UI_LIBRARY_REFERENCE
Cause: A workflow node's output names a UI library that is not in HydrationContext.uiLibraries.
Fix: Register an output codec under that name, for example uiLibraries: new Map([['my-lib', openUi(myLibrary)]]) with openUi from @noetic-tools/openui.
UNKNOWN_ACP_AGENT_REFERENCE
Cause: An acp-agent node names an agent that is not in HydrationContext.acpAgents.
Fix: Pass agent adapters via HydrationContext.acpAgents, for example createAcpAgentRegistry(claudeCode()) from @noetic-tools/acp.
UNKNOWN_SUBPROCESS_REFERENCE
Cause: A run node names a subprocess adapter that HydrationContext.resolveSubprocess cannot resolve.
Fix: Map the reference in resolveSubprocess(ref), or omit subprocess to use the harness default.
INVALID_FORK_INPUT
Cause: A dynamic inParallel node (one with each) got input that is not JSON, or JSON that does not hold an array (at the over key, when set).
Fix: Feed the node a JSON array, or a JSON object with an array at the over key.
INVALID_REPLAY_KEY_INPUT
Cause: A node with "replayKey": { "from": [...] } got input that is not a JSON object, so the named properties cannot be read. You see it as a warning, not a thrown error: the node runs without replay or recording.
Fix: Feed the node a JSON object holding the from properties, or use "replayKey": true to key on the whole input.
INVALID_CUSTOM_ITEM_TYPE
Cause: A custom item was declared with a type that has no prefix: part.
Fix: Name it prefix:name after the Open Responses convention for extension items, e.g. myapp:progress.
INVALID_CUSTOM_ITEM_SHAPE
Cause: A custom item's JSON Schema does not load, or its output does not describe an object, or declares id or type.
Fix: Give an object schema for output and leave out id and type: the runtime adds both to every final item.
CUSTOM_ITEM_MISMATCH
Cause: workflow({ customItems }) and its document declare one item name with different types.
Fix: Give both declarations the same type, or rename one of them.
UNKNOWN_CUSTOM_ITEM
Cause: A step's emitItems.item names a custom item that is not in its scope: not on the step and, for a JSON node, not at its document's root.
Fix: Declare the item on the step (customItems) or at the workflow root.
CUSTOM_ITEM_HAS_NO_EVENTS
Cause: A hook or a generator runCode yielded an event, but the step's emitItems names no item, or names one declared without an event schema.
Fix: Name an item with an event schema in emitItems: { item }, or return the value instead of yielding it.
CUSTOM_ITEM_ALREADY_FINAL
Cause: One step dispatch gave a custom item a second final item, for example both onStart and onDone returned one.
Fix: Return the final fields from one hook only.
INVALID_CUSTOM_ITEM
Cause: An event or a final item does not match the schema its custom item declares. The message names the item type, the part (event or output), and the schema's issues.
Fix: Make the value match the declared event or output schema.
ACP Agents
MISSING_ACP_AGENT
Cause: step.acpAgent() or acpAgentTool() has no agent adapter, or its lazy agent resolved to nothing at run time.
Fix: Pass an agent factory result, for example agent: claudeCode() from @noetic-tools/acp.
MISSING_PROMPT
Cause: step.acpAgent() was built with neither prompt nor content, or its prompt resolved to an empty string at run time.
Fix: Pass a non-empty prompt for plain text, content for image, audio, or resource blocks, or give the step a string input.
ACP_REUSE_WITHOUT_KEEPALIVE
Cause: A step sets session.reuse but leaves keepAlive at 'step', so the connection closes before another step can share it.
Fix: Add keepAlive: 'run' to share the connection for the rest of the run, or keepAlive: 'harness' to keep it past the run and close it yourself with harness.closeAcpSessions().
ACP_SESSION_AGENT_CONFLICT
Cause: A step reuses a session key that another step opened with a different agent.
Fix: Give every step that shares a session.reuse key the same agent, or use separate keys.
ACP_SESSION_CAPABILITY_CONFLICT
Cause: A step reuses a session but asks for different clientCapabilities than the connection was opened with. ACP negotiates capabilities once per connection.
Fix: Give every step that shares a session.reuse key the same clientCapabilities, or use a separate session.
ACP_SESSION_SETTING_CONFLICT
Cause: A step reuses a session but asks for a different cwd or different MCP servers than the session was created with, or serves a tool the session's tool list lacks (the error names it).
Fix: Give every step that shares a session.reuse key the same cwd and tools, or use a separate session.
ACP_TRANSPORT_CANNOT_REACH
Cause: step.acpAgent() gives tools to an agent attached through a custom transport. Noetic did not start that agent, and the transport has no reach() to carry the tool bridge to wherever it runs. With tools left out, such an agent simply gets none.
Fix: Give the agent a command and a machine instead — localMachine() for this machine, or webSocketMachine() / commandMachine() from @noetic-tools/acp/remote for a remote runner (noetic-acp serve) — or leave tools off the step.
ACP_TRANSPORT_CANNOT_LAUNCH
Cause: As ACP_TRANSPORT_CANNOT_REACH, for an agent whose wiring has launch settings (Claude Code, Codex, opencode, pi): attached to an agent someone else started, those settings cannot be applied, so its tools would not match callModel.
Fix: Give the preset a machine instead of a transport; the machine's launch() starts the agent with those settings.
ACP_AGENT_CANNOT_TAKE_TOOLS
Cause: step.acpAgent() gives tools to gemini() without trustWorkspace: true. Gemini CLI starts no MCP server, the tool bridge included, in a folder it does not trust, so the agent would run with none of the tools callModel would offer.
Fix: Pass gemini({ trustWorkspace: true }) to trust the session's folder for the agent process. Trusting it also loads the folder's own .gemini/settings.json, GEMINI.md and .env, so do this only for a repository you wrote; otherwise leave tools off the step.
ACP_SERVER_TOOL
Cause: step.acpAgent() was given a server tool such as openrouter:web_search. Only a model provider can run server tools.
Fix: Pass only client tools built with tool().
ACP_TOOL_NAME_COLLISION
Cause: Two of a step's tools reduce to the same wire name (names keep only letters, digits, _, and -), so the agent could not tell them apart.
Fix: Rename one of the tools.
ACP_SERVE_KIND_RULES_WITHOUT_TOOLS
Cause: serveAcp() got a kind-based permission rule (such as { kind: 'execute' }) but no tools to look kinds up from.
Fix: Pass the harness's tools in the serve options: serveAcp(harness, { tools, permissions }).
Connections
CONNECTION_UNKNOWN
Cause: A handle, workflow node, trigger or allowlist names a connection the harness does not have, or a connectionTools('<name>') pick (or a JSON { "connection" } pick) names one neither the harness nor the turn attaches, found when the turn's tool set is built.
Fix: Add the connection under that name in new AgentHarness({ connections }), or correct the name. The hint lists the connections the harness has.
CONNECTION_UNSATISFIED
Cause: A connection does not offer what is required of it. The error's missing field says which: a profile or extension version, an operation, an event, or a sense a verify step needs.
Fix: Point the handle or manifest entry at a connection whose server offers what is missing, or relax the requirement (a wider version range, fewer operations).
CONNECTION_NOT_ALLOWED
Cause: A JSON workflow asks for more than its connections allowlist grants, or sets a setup field the profile reserves to the host (such as the command or path of an app). Or an acp-agent node asks for more than the acp allowlist grants its agent (permissions, mode, model, cwd, clientCapabilities, session), or names an MCP server, header or environment value the mcp allowlist does not grant, or names one header or variable twice.
Fix: Widen the allowlist passed to parseAndRunWorkflow or dynamicWorkflow, or move the host-only field into the pool's own setup in the harness configuration. For an MCP server URL, write it as the message shows.
CONNECTION_INVALID
Cause: A connection manifest entry, handle or provider configuration is malformed: for example a bad version range, a fresh handle without a pool, a secret() or an MCP server $secret used with no environment.secrets resolver, an mcp allowlist URL pattern with * in the host other than a leading *. followed by at least two labels, a listed entry with no name, a record key that disagrees with mcp({ name }), a turn or step entry that sets pool, a function-form tools that returns a connection the turn never attached, an mcp() config that is not ACP's McpServer shape (a command line string, protocol, headers or environment as a record), a header or environment variable given twice, auth together with an Authorization header, a connectionTools() name that is not a connection name, or a document { "mcp" } connection whose key disagrees with its name or that sets from, fresh or setup.
Fix: Correct the entry the message names. The hint says which field is wrong.
CONNECTION_TOOL_COLLISION
Cause: Two connection operations would reach the model under the same tool name.
Fix: Rename one of the connections, or set expose: 'call' or 'none' on one of them.
CONNECTION_NAME_TAKEN
Cause: An entry names a connection that its scope or an outer one already attaches with a different provider or options. The scopes are the harness, then the turn (execute(input, { connections })), then a step's tools. Two entries in one list or record with the same name and different providers fail the same way.
Fix: Pass the same entry object the outer scope uses, or give this one another name.
Verification
VERIFY_NOT_CONFIGURED
Cause: A step.verify (or a verify workflow node) ran, but the harness has no verifier.
Fix: Register one: new AgentHarness({ verification: verifier() }), with verifier from @noetic-tools/verify.
VERIFY_INVALID
Cause: A verify step is malformed: its act contains nothing that acts, a matcher cannot be parsed by its sense's grammar, or a witness is asked to run an operation with effects.
Fix: Give act at least one call with effects on the subject, fix the matcher text the message quotes, and let witnesses only run operations without effects.
Related Pages
- Context & Event Log --
ctx.abort()produces acancellederror. - Channels --
channel_timeoutandchannel_closederrors. - Loop & Until -- the
onErrorrecovery callback. - AgentHarness --
harness.cancel()triggers cancellation errors. - Expressions -- the JSONata expressions behind
EXPRESSION_FAILEDandEXPRESSION_LIMIT_EXCEEDED. - Connections -- the
CONNECTION_*codes andconnection_*kinds. - Connection Profiles -- the server error codes
connection_failedcarries. - Verification --
VERIFY_*codes andverify_act_failed.