This guide reflects the current codebase and is updated frequently while Noēsis is in active development.
How adapters work
An adapter is any execution target that Noēsis can invoke.The ns.solve() integration contract
ns.solve() invokes the resolved using= target in the act phase by calling invoke(), then run(), then __call__() (in that order). EpisodeRunner owns cognition, governance, and event emission.
If you’re already inside an event loop, use ns.solve_async(...), which preserves the same invocation order and semantics while allowing async adapters to be awaited.
The execute(...) adapter protocol (advanced)
The repo also defines an Adapter.execute(...) protocol in noesis.adapters.protocols.Adapter:
ns.solve() does not call execute() directly.
Protocol-first tool invocation contract (internal)
Noesis now includes an application-layer contract for side-effect tools innoesis.usecases.tool_invocation:
prepare_tool_invocation(...)execute_prepared_tool_invocation(...)
ns.run(...), ns.solve(...), ns.governed_act(...)) remain unchanged.
Intent
The contract separates reviewable intent from side effects:prepare_tool_invocation(...)may validate, normalize, authenticate, authorize, emit candidate evidence, compute preflight bindings, and persist aPreparedToolInvocation.prepare_tool_invocation(...)must not execute tool side effects.execute_prepared_tool_invocation(...)loads previously prepared intent by identity and dispatches only after approval/idempotency checks.
Prepare -> execute workflow
Use this runbook when building a tool protocol adapter:- Build
ToolInvocationInputfrom inbound protocol data. - Call
prepare_tool_invocation(...)with your ports and persist the returned draft. - If status is
pending_approval, collect approval externally and persistToolApprovalDecisionusing the samerun_id+draft_id. - Call
execute_prepared_tool_invocation(...)with thatrun_id+draft_id. - Return
ToolExecutionResultto the caller; handlereplayed/failedidempotency outcomes without re-dispatching.
- prepare:
ToolPayloadNormalizerPort,ToolAuthenticatorPort,ToolAuthorizerPort,ToolCandidateEmitterPort,ToolEventRecorderPort,PreparedInvocationRepositoryPort, optionalToolPreflightPort - execute:
PreparedInvocationRepositoryPort,ApprovalDecisionRepositoryPort,IdempotencyStorePort,ToolDispatchPort,ToolEventRecorderPort
Event and identity invariants
For write + approval-required flows, prepare emits:tool.requested -> tool.validated -> tool.authn.passed -> tool.authz.passed -> action.candidate_emitted -> tool.preflight.computed -> tool.draft_created -> tool.approval.pending
For approved execution, execute emits:
- new execution:
tool.approved -> tool.execution.started -> tool.execution.succeeded(ortool.execution.failed) - replay path:
tool.approved -> tool.replayed
- execute lookup key is
run_id + draft_id - missing prepared draft raises
PreparedToolInvocationNotFoundError - missing/non-approved decision raises
ApprovalDecisionRequiredError - mismatched
request_id, reviewed fingerprint, or impact hash raisesApprovalDecisionBindingError - idempotency
replayorconflictreturns without dispatching side effects
Common pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
PreparedToolInvocationNotFoundError | approval service called execute with wrong run_id/draft_id | persist and pass durable identity unchanged across systems |
ApprovalDecisionRequiredError | decision missing or not approved | write an approved ToolApprovalDecision before execute |
ApprovalDecisionBindingError | decision is not bound to reviewed prepared intent | store request_id, reviewed_fingerprint, and impact_hash from the prepared artifact and verify before save |
tool.replayed result with no dispatch | same idempotency key and fingerprint seen previously | treat as successful replay; do not retry with a new side effect |
summary.json / manifest.json missing while run is paused | expected non-terminal lifecycle state | inspect latest run.state_projection links; resume and terminate run to produce final artifacts |
Runtime bridge guardrails (current)
The runtime continuation bridge currently enforces the following:- only
ToolProtocol.SUBPROCESSis supported for prepare/resume bridging - resume lookup expects exactly one pending draft for the run (
load_pending_for_run) - multiple pending drafts for one run raise
AmbiguousPreparedToolInvocationError
UnsupportedToolProtocolError.
Subprocess payload contract
ForToolProtocol.SUBPROCESS, the normalized payload supports only:
argv: required, non-emptylist[str]cwd: optionalstrenv: optionaldict[str, str]timeout_ms: optionalint > 0(falls back to execution default when omitted)
Operational runbook: inspect and resume pending drafts
Prepared/approval/idempotency records are persisted under the run directory:tool_invocations/prepared/*.jsontool_invocations/approvals/*.jsontool_invocations/idempotency/*.json
- Confirm one pending draft exists in
tool_invocations/prepared/for the target run. - Persist an approved
ToolApprovalDecisionbound to the samerun_id + draft_id. - Resume the run (
ns.resume_run(...)) so continuation can execute the pending draft through the runtime bridge.
Runtime evidence checks (pause/continue)
When a run pauses before side effects, inspect runtime events to confirm bridge evidence is complete:- expect
phase="runtime",event_type="run.interrupt"andrun.checkpoint - expect a
phase="runtime",event_type="run.state_projection"event - on paused runs,
run.state_projection.payload.linksshould contain onlyeventsandlearn(nosummary/manifestyet) - on paused runs,
run.state_projection.payload.statusshould match the pause status (for exampleinterrupted)
ns.resume_run(...) completes terminally, the latest
run.state_projection should include terminal links (summary, manifest) and
state finalization artifacts will be present.
When you call ns.solve() with using=, Noēsis:
- Resolves the target via the graph loader (callable, object, or string source)
- Captures the task in the observe phase
- Runs the target in the act phase
- Records the result in the reflect phase
- Emits all events to the timeline
Resolution rules
using= accepts several shapes, resolved by the loader:
- Callable: used directly (or invoked if it is a zero-arg factory).
- Object: used directly if it exposes
invoke()or is callable. - String: can be a dotted factory (
pkg.mod:make), a filesystem path, or a short name resolved viaflows.<name>ornoesis_user.<name>.
invoke(), run(), or __call__() (in that order).
Objects that only define execute() are not compatible with ns.solve() unless wrapped.
If you need to map the input task string to a structured payload, you can:
- wrap the target and do the mapping inside your wrapper, or
- if supported by the invocation path you’re using, define a
__noesis_input_mapper__callable that transforms the task before invocation.
Plain functions
The simplest adapter is a plain Python function:LangGraph integration
Compiled LangGraph graphs exposeinvoke(), so you can pass them directly:
noesis.adapters.langgraph.LangGraphAdapter. It resolves awaitables with asyncio.run() when no event loop is running (and raises if a loop is already running). It also supports an optional input mapper and may fall back to __noesis_input_mapper__ if the wrapped graph provides one.
CrewAI integration
Wrap a CrewAI crew withnoesis.adapters.crewai.CrewAIAdapter:
{"task": task} unless the crew defines __noesis_input_mapper__.
If your crew expects a different input shape, provide __noesis_input_mapper__ on the crew or pass input_mapper= when constructing CrewAIAdapter.
Claude Agent SDK integration
When you’re already running inside an event loop, usens.solve_async(...) and pass an async adapter directly.
If you’re in synchronous code, wrap the SDK’s async loop with
asyncio.run(...) or move the call
to a worker thread before calling ns.solve(...).SDK surface shown above reflects the public
claude_agent_sdk examples; verify against your installed SDK version.Experimental Assistants adapter
noesis.adapters.assistant.AssistantsAdapter is experimental and writes its own events via write_event. It follows the execute(...) protocol but does not expose invoke() / run() / __call__(), so it is not compatible with ns.solve() without a wrapper. Use it only with a custom runner that explicitly calls execute().
Custom adapters with metadata
Adapters can return any object. Noēsis usesstr(result) when constructing the action summary. If you return a dict, the summary will include its string representation.
Error handling
Noēsis treats exceptions as failures. Raise to mark a failed action:Using string sources
You can also pass a stringusing= value that the loader resolves:
pkg.mod:factoryto import and call a zero-arg factory./path/to/module.pyto load a local modulenameto loadflows.nameornoesis_user.name
Adapter best practices
Keep adapters stateless. If you need state, use the Noēsis state artifact rather than adapter instance variables.
Testing adapters
Test adapters independently before integrating:Next steps
Export metrics
Send adapter metrics to your observability stack.
Python API reference
Full API documentation for adapters.

