Python SDK reference.
cosmonapse Python package - verified against packages/python-sdk. If something here disagrees with the code, the code wins.
Effector - tools & side effects
Neurons think, Engrams remember, Effectors act. An Effector is the synapse-side participant that services TOOL_CALL and replies with TOOL_RESULT. It is modelled on Engram deliberately - same addressing (effector_id or effector_kind), same mounting (attach_effector), same rule that a failure rides the reply instead of raising a separate ERROR.
This is where MCP servers and every other tool now live. A tool is not a Neuron: a Neuron decides, an Effector does. Effectors do not think either - choosing which tool to call, and reacting to the outcome, is Neuron-side work.
Cosmonapse does not build your tools. There is no @tool registry and no per-tool routing: you get exactly the signal pair, and dispatch tables, MCP sessions, subprocesses, and sandboxing stay in your code.
Effector.serve(*, effector_id, effector_kind='effector', version=None)Build an Effector from one decorator. @on_tool_call receives (tool, args) - plus call_id / deadline_ms / trace_id if declared as keyword parameters - and its return value IS the TOOL_RESULT. Handlers run in registration order; the first non-None answers, None falls through, a raise becomes error on the reply.
from cosmonapse import Effector, EffectorBinding, Axon, Dendrite # ── The Effector side: what actually runs ────────────────────────── FX = Effector.serve(effector_id="fs-effector", effector_kind="filesystem") @FX.on_tool_call async def handle(tool, args): # Return value IS the TOOL_RESULT - no manual publish. if tool == "read": return {"content": open(args["path"]).read()} return None # fall through to the next handler worker = Dendrite(synapse=synapse, role="worker") worker.attach_effector(FX) # ── The Neuron side: binding + native dialect ────────────────────── # THE GATE: effectors= without tool_standard= raises ValueError. brain = Axon( neuron_id="brain", neuron_fn=Neuron(source="openai", model="gpt-4o"), effectors=[EffectorBinding(name="fs", directed_id="fs-effector")], tool_standard="codex", ) # A Neuron that declares call_tool= gets the helper injected, already # scoped to the running trace - the action-side twin of recall/imprint. async def think(inp, ctx, *, call_tool): out = await call_tool("fs", tool="read", args={"path": "/etc/hosts"}) return {"response": out.result if out.error is None else out.error}
cosmonapse.EffectorThe low-level path: subclass and implement connect / close / invoke when the backend owns real resources. A served Effector is just a concrete subclass with the handler wired for you.
class Effector(ABC): effector_id: str effector_kind: str capabilities: list[str] # tool names; [] means "everything" version: str | None async def connect(self) -> None: ... # open subprocess / HTTP pool async def close(self) -> None: ... # release them async def can_serve(self, tool: str) -> bool: ... # Default: tool in capabilities (empty list = serve everything). async def invoke( self, tool: str, args: dict, *, call_id: str | None = None, deadline_ms: int | None = None, trace_id: str | None = None, ) -> ToolOutcome: ... # Tool-level failure (bad args, missing file, non-zero exit) is # ToolOutcome(error=...) - NOT a raise. Raise only for a backend # fault; the Dendrite maps it onto TOOL_RESULT error either way. @property def host(self) -> _EffectorHostProxy: ... # @fx.host.on_<signal> queues a Dendrite decorator at module level, # replayed onto the HOSTING Dendrite once it connects this Effector. @dataclass(frozen=True) class ToolOutcome: tool: str result: Any = None error: str | None = None call_id: str | None = None took_ms: int | None = None effector_id: str | None = None
Wiring an MCP server
Neuron(source="mcp") is still the stdio transport - it spawns the server subprocess and speaks the Model Context Protocol. What changed is where it sits: instead of being attached as a Neuron, it is wrapped in an Effector and attached with attach_effector, so the server answers TOOL_CALLs on the trace rather than producing AGENT_OUTPUT of its own.
# An MCP server is an Effector. Neuron(source="mcp") stays as the stdio # transport underneath - the Effector is what goes on the bus. from cosmonapse import Effector, Neuron, ToolOutcome class MCPEffector(Effector): def __init__(self, *, effector_id, effector_kind, mcp, shape=None): self.effector_id = effector_id self.effector_kind = effector_kind self.capabilities = [] # empty = proxy whatever the server takes self._mcp, self._shape = mcp, shape async def connect(self): pass # the Neuron spawns lazily on first call async def close(self): await self._mcp.aclose() async def invoke(self, tool, args, *, call_id=None, **kw): # shape() maps a model-facing tool name onto the server's own name name, payload = (self._shape(tool, args) if self._shape else (tool, args)) out = await self._mcp({"tool": name, "arguments": payload}, []) if out.get("is_error"): return ToolOutcome(tool=tool, call_id=call_id, error=str(out.get("response"))) return ToolOutcome(tool=tool, call_id=call_id, result=out) files = MCPEffector( effector_id="files-effector", effector_kind="filesystem", mcp=Neuron(source="mcp", server="filesystem", args=["/data"]), ) worker.attach_effector(files)
Native tool-call dialects
Models emit tool calls in their own format. tool_standard= tells the Axon which dialect to recognise in the raw output; TOOL_STANDARDS holds the parsers. They are pure text parsers with an anti-misfire rule - ordinary JSON in a reply never registers as a call, and the first call found wins.
| tool_standard= | Recognises |
|---|---|
| "hermes" | Nous/Hermes <tool_call>{"name", "arguments"}</tool_call> tags. |
| "claude" | Anthropic {"type": "tool_use", "name", "input"} blocks. |
| "codex" | OpenAI function calling - a tool_calls array, legacy function_call, or a bare exact-keys {"name", "arguments"}. A top-level "type": "function" marker also licenses parameters as the args key, which is how Llama-class models actually reply. |
The gate: effectors= requires tool_standard=, or the Axon raises ValueError at construction - a misconfiguration fails at import, not silently at runtime. tool_standard= on its own is pure translation: the recognised call is surfaced on AGENT_OUTPUT as {tool, args} and your host chain executes it. A model’s native call always takes precedence over the Axon’s own recognisers.
Calling a tool
dendrite.call_tool(*, effector_id=None, effector_kind=None, tool, args=None, call_id=None, deadline_ms=None, trace_id=None, parent_id=None, meta=None) -> ToolOutcomeEmit TOOL_CALL and await the correlated TOOL_RESULT. Trace attribution resolves explicit ids first, then the ambient task context, then a fresh trace. With no deadline_ms - and none on the binding - the call waits until the trace terminates; pass a deadline if it must not hang.
call_tool(name, *, tool, args=None, call_id=None, deadline_ms=None, meta=None) -> ToolOutcomeA Neuron that declares a call_tool= keyword parameter gets this injected by its Axon, already scoped to the running trace. name is the local EffectorBinding name, not the deployment's effector_id - the action-side twin of the injected recall / imprint helpers. The 30s DEFAULT_TOOL_DEADLINE_MS applies to Axon-dispatched native tool calls, not to this helper.
cosmonapse.EffectorBinding(name, directed_id=None, directed_type=None, default_deadline_ms=None, tools=None)Declarative wiring stored on the Axon. name is what the Neuron addresses; directed_id (effector_id) or directed_type (effector_kind) determines routing on the wire. Binding resolution goes tools-list, then name match, then the sole binding.
dendrite.effector_client -> EffectorClientCaller-side correlation table, the action-side twin of engram_client. TOOL_RESULT is always subscribed, replies are correlated by parent_id and call_id.
Errors never terminate the TASK. A tool-level failure comes back as error on the TOOL_RESULT, so the Neuron can read it and try something else. That is also why TOOL_CALL stays in PATHWAY_TYPES: the servicing branch does not consume it, so trace observers and your own on_tool_call handlers still see every call go past. The SDK-level exceptions (EffectorTimeout, EffectorCancelled, EffectorNotBound, EffectorOverloaded) are raised caller-side, not on the wire.
Have a feature in mind?
The protocol, SDKs, and CLI are still pre-1.0. If something here is missing, ambiguous, or wrong - open an issue and propose a change. Every breaking change is debated in DECISIONS.md first.