Add an operation
An operation is declared once, in the registry. The HTTP route (POST /v1/<op>), the OpenAPI schema, the MCP tool (loom_<op with dots as underscores>), the catalogue (/v1/operations, ops.capabilities), the SDK op tables and the operations reference derive from it. You write the models, handler, declaration and tests, and regenerate the rest.
flowchart LR
M["Input / Output models<br/>server/<domain>/api/models_<group>.py"] --> D["Operation(...) in OPERATIONS<br/>server/<domain>/api/ops_<group>.py"]
H["handler<br/>server/<domain>/app/<group>.py"] --> D
D --> R["ALL_OPERATIONS"]
R --> HTTP["HTTP /v1/<op>"]
R --> MCP["MCP tool + toolsets"]
R --> G["generated: sdk ops.py, ops.ts,<br/>reach manifest, reference/operations.md"]
The example is calibration.get, one of the smallest ops: server/memory/api/models_calibration.py, server/memory/api/ops_calibration.py and server/memory/app/calibration.py.
1. Define the input and output models
Put them in the group's models module, server/<domain>/api/models_<group>.py. A models module holds Pydantic wire models only: it imports no handler.
from pydantic import BaseModel, Field
class CalibrationGetInput(BaseModel):
org: str
namespace: str
class CalibrationGetOutput(BaseModel):
source: str # "namespace" / "cluster" / "scalar"
embedder_model: str = ""
noise_floor: float
...
similar_pairs_n: int = Field(default=0)
Rules for the models:
- Identity. A namespace-addressed op takes
organdnamespace. Ops that write rows take the rest of the identity tuple:agent,user_id,session_id,run_id(the last three default to'-'). Over MCP the server fills omitted identity fields from the connection's defaults, then authorizes the(org, namespace)the body names. - Server-stamped columns never come from the body. A write path sets
actor(server.memory.app.provenance.writer_actor) andsource_class(server.memory.domain.provenance.source_class_for). A request may only declare a downgrade toderived,externalorimported. - Hide agent-irrelevant fields from MCP. Mark a ranking knob or pipeline switch with
json_schema_extra=MCP_HIDDEN(server/operations/base.py). The MCP schema omits it; the op still accepts it. - No model-level "one of A or B" validators on read inputs. The transport validates the body before authorization, so a raising
@model_validatordescribes the op to a principal the namespace gate would refuse. Put the rule in the handler and raiseserver.core.errors.ValidationError.tests/unit/test_op_inputs_after_gate.pychecks every registered op. - To retire a field, delete it. A client that still sends it is served, with the field named in
ignored_args(unknown and removed fields).
2. Write the handler
One handler module per group, under server/<domain>/app/. A handler is async, takes the input model and returns the output model. It imports the wire models at module top; the models never import a handler, so the import is acyclic:
from server.memory.api.models_calibration import CalibrationGetInput, CalibrationGetOutput
async def handle_calibration_get(input: CalibrationGetInput) -> CalibrationGetOutput:
from server.memory.app.embedder_calibration import get_calibration, get_namespace_calibration
ns_cal = await get_namespace_calibration(input.org, input.namespace)
...
- Pure logic goes in the domain's
domain/, SQL in itsstore/. The handler composes them. - Refusals raise a
server.core.errors.LoomServerErrorsubclass (or anHTTPExceptionwith a{"code", "message"}detail). The transports turn it into the error envelope, and over MCP into anisErrorresult. A new error code appears in the generated errors reference. - A broad
exceptsays why:except Exception as e: # noqa: BLE001 — <what must not fail>. - The transport tags ClickHouse queries with
log_comment; a handler does nothing. - Read settings as
settings.<field>, nevergetattr(settings, ...). - If the op writes
loom.memoryorloom.shared, build rows with the builders inserver/memory/store/rows.py(adding a table).
Ops addressed by an org-wide id
An op that takes only (org, memory_id), such as memory.get_by_id or memory.update_by_id, gets no namespace from the transport. Declare it row_scoped=True, resolve the namespace the caller reaches before the read, and hide a foreign row:
import server.memory.store as memory_repo
from server.memory.app.id_namespace import namespace_for_id
namespace = await namespace_for_id(input.org, input.memory_id, strong=False)
if namespace is None:
return MemoryGetByIdOutput(found=False)
row = await memory_repo.get_by_id(input.memory_id, org=input.org, namespace=namespace, ...)
if row is None:
return MemoryGetByIdOutput(found=False)
Answer as for a missing row, never 403: a 403 confirms the id exists in another tenant. Pass Role.WRITER and strong=True to namespace_for_id for a write, so the holders are read current. An op that must read the row first, such as memory.redact, checks it with authz.row_in_scope after the fetch (row-scoped operations).
3. Declare the operation
from server.operations.base import Operation, Role
calibration_get = Operation(
name="calibration.get",
maturity="experimental",
input_model=CalibrationGetInput,
output_model=CalibrationGetOutput,
handler=handle_calibration_get,
idempotent=True,
required_role=Role.VIEWER,
audit=False,
)
OPERATIONS: list[Operation] = [calibration_get]
Its latency budget is one entry of OP_BUDGETS_P99_MS in server/settings/catalog.py, where every op's budget is set, "calibration.get": 60, (the op's design-target p99 in ms; a successful call over it emits a budget.violated event). Request shapes the handler notes at run time (server.observability.budget_shape) get their own budgets in OP_SHAPE_BUDGETS_P99_MS, and the catalogue lists them. tests/unit/test_op_budgets.py fails an op with no entry. A deployment tunes one op with LOOM_LATENCY_BUDGET_OVERRIDES and all of them with LOOM_LATENCY_BUDGET_MULTIPLIER.
The declaration lives in the group's ops module, server/<domain>/api/ops_<group>.py, which imports the models and the handler. Append it to the module's OPERATIONS list. A new group module is imported and appended to _OPERATION_MODULES in server/operations/registry.py.
| Field | Required | Meaning |
|---|---|---|
name |
yes | Dotted <group>.<verb>. HTTP path /v1/<name>; MCP tool loom_<name with . → _>. |
input_model, output_model |
yes | Pydantic models. |
handler |
yes | Async callable: input model → output model. |
idempotent |
yes | Safe to retry on a transient failure. The SDKs and clients rely on it. |
required_role |
yes | Role.VIEWER, WRITER, ADMIN, ORG_ADMIN or PLATFORM_ADMIN. |
audit |
no | True: success writes one row to loom.audit_log. Set it for anything that changes memory, grants, policy or configuration. |
writes |
no | The op changes stored state although a viewer may call it and a retry is safe (a feedback row, a seen-marker). A writer role or audit=True already says "writes". |
maturity |
no | stable (default) or experimental. See step 5. |
skip_namespace_authz |
no | Skip the transport's namespace check for ops that act on a namespace (join, leave, register) or whose target only the handler can resolve (memory.verify by id, erasure.request by scope, erasure.list by caller role); the handler enforces its own rules. The role check still runs. |
deployment_scoped |
no | The op reads facts about the deployment (its build, its registry), not any namespace's data; a scoped token passes with its best per-namespace role. |
row_scoped |
no | Addressed by an org-wide id; the handler authorizes the row's scope (namespace_for_id before the read, or row_in_scope after it) and hides a foreign row as not found. |
The op is read-only (MCP readOnlyHint, the readonly toolset) exactly when it is idempotent, required_role == Role.VIEWER, not audit and not writes (Operation.read_only). Clients that auto-approve read-only tools trust these flags.
4. Decide MCP visibility
server/operations/toolsets.py decides where the op is listed:
- A curated roster (
_MCP_CORE_OPS,_MCP_CODING_OPS,_MCP_ATOMIC_OPS,_MCP_TASK_OPS,_MCP_TRACKER_OPS,_MCP_CODE_CORE_OPS) lists it on that?toolset=. Every listed property costs tokens on every turn, so add only what that archetype calls.tests/unit/test_mcp_listing_budget.pyholds the listing under a token ceiling. - Unlisted (most ops): callable by name through
loom_opon any listing (reads only onreadonly, MCP toolsets).?toolset=alllists astableop, and anexperimentalop only when the core or atomic roster lists it (listed_under_allinserver/operations/toolsets.py). _MCP_HIDDEN_OPS: not reachable over MCP.tests/unit/test_hidden_op_says_why.pyrequires a refusal that names what to call instead.
A listed op needs its description in server/transport/mcp_descriptions.py
(TOOL_DESCRIPTIONS). Write it for the model: when to call the tool, what it returns, what not to use it for. A description about a changed fact interpolates revision_policy() from the same module instead of stating its own. Name another op by its dotted op name; each listing renames a listed op to its tool and marks an unlisted one (MCP reference). Never write an op as a call (op(args)), inside a longer code span, or with a hand-written "(via loom_op)". An op name given as a field's example value goes in single quotes ('memory.ask'), which the listing leaves as written.
5. Check reach and maturity
An op's reach is derived (Operation.reach, server/operations/reach.py): its MCP roster membership plus a committed scan of the SDKs, examples/, dev/ and scripts/, the plugin hooks, and the dashboard's "/v1/<op>" call sites. {"http_only"} means only a raw /v1/* POST reaches it.
Every op is on every surface or says why not in code. HTTP serves every op in the registry, and both SDKs are generated from it. MCP reaches it unless _MCP_HIDDEN_OPS keeps it for people and operators. The dashboard calls it when a person needs it: to administer, review, inspect, configure, or delete with a confirmation; a new op of that kind ships with its control. An op only an agent or a client program calls, or one the dashboard serves through another op, is named in NOT_IN_DASHBOARD (server/operations/dashboard.py) with the reason. The census's --check (tests/unit/test_op_reach_census.py) fails on an op with neither a dashboard call site nor an entry in server/operations/dashboard.py.
- An op hidden from MCP that has no other reach path (
server/operations/reach.pylists the kinds) declaresmaturity="experimental". - A
stableop is listed under the MCPalltoolset, or, if hidden from MCP, has another reach path, and is meant to have production callers;python scripts/codegen/op_reach_census.py --call-counts --days 30shows whether it has them. Declare a new opexperimentalunless it replaces a stable one. - Refresh the manifest:
python scripts/codegen/op_reach_census.py --write-manifest(writesserver/operations/reach_manifest.py).
6. Write the tests
| Test | What it asks of your op |
|---|---|
A unit test under tests/unit/ |
Covers the handler's refusals and its happy path. |
tests/integration/test_cross_principal_leakage.py |
Every read-shaped op is driven as another tenant (a writer and a viewer on another namespace, and another org) with canary nonces; no response may contain one, and every call ends 200 (empty), 403 or 404, never 500. Payloads are synthesized from your input model, so a new read op is probed the day it is registered. |
tests/integration/test_restricted_namespace_leakage.py |
The same for restricted namespaces. |
tests/unit/test_op_inputs_after_gate.py |
Your input model constructs without a model-level validation error. |
tests/unit/test_op_reach_census.py |
Reach and maturity agree, and the manifest is current. |
tests/unit/test_mcp_listing_budget.py |
A listed op keeps the listing under budget and its description points at no tool the client cannot call. |
If the leakage suite cannot synthesize a body for your op, it reports it; add a hint instead of skipping. An op that mutates the caller's own namespace goes in the suite's _SKIP_OPS, with the writer gate covered elsewhere. Integration tests need a running server (LOOM_BASE_URL) and skip without one. Mint tokens with the suite's helpers, not self-signup, which CI does not enable.
7. Regenerate and run the gates
python scripts/codegen/sdk_py_gen.py # sdk/python/loom/ops.py
python scripts/codegen/sdk_py_client_gen.py # sdk/python/loom/client.py
python scripts/codegen/sdk_ts_gen.py # sdk/typescript/src/generated/ops.ts
python scripts/codegen/op_reach_census.py --write-manifest # server/operations/reach_manifest.py
python scripts/codegen/docs_reference.py # generated regions of docs/reference/*.md
make format
make lint # ruff, gates, every generator with --check, the reach census --check, Biome's dashboard accessibility rules
make typecheck
make test-unit # reach census, MCP listing budget
CI fails when a generated file is stale. The generated SDK files give every client the op at once: agent.call("calibration.get", {...}) in Python and TypeScript is typed against the new TypedDicts and interfaces. A hand-written method on an SDK client (sdk/python/loom/<group>.py) is optional; add one for a common verb. It counts as a reach path.
python scripts/codegen/wire_changelog.py <rev_a> <rev_b> prints what changed for clients between two revisions. The release notes are built from it.
Before you open the PR
- Models, handler, declaration,
OPERATIONSentry (and_OPERATION_MODULESfor a new group). -
required_role,idempotent,audit,writes,maturitychosen deliberately. -
row_scopedops answer a foreign row as a missing one. - Toolset placement and, if listed, a description.
- Tests named above pass; generated files regenerated.
- The feature page that owns the op links its anchor in the operations reference.
Related
- Extending Loom
- System overview: the gate order every op passes
- HTTP API
- Code generation