write(operation, data) is the only tool that changes anything in Opik;
schema(operation) returns an operation’s input. This doc answers what a write
promises, what comes back on success and failure, and where to change or add an operation.
operation is an entity and verb pair such as thread.close. An unknown
name gets the unknown_operation error.data is an object for one write, or an array for a batch where the
operation allows it (supports_batch). dataset_item.upsert and
experiment_item.create carry their records inside an envelope object.idempotency_key is sent as the Idempotency-Key header. If an item’s own
id differs, the tool-level key wins and a warning is logged.dry_run validates and checks scope, then returns the request unsent.WRITE_OPERATIONS in src/opik_mcp/writes/registry.py lists the operations.
None deletes anything.{"ok": true, "operation": "thread.close", "method": "PUT", "path": "/v1/private/traces/threads/close",
"status": 204, "batch": false, "item_count": 1, "backend_body": "...", "url": "..."}
item_count counts the records sent, inside the envelope where there is one.url is one UI link per call on observability and thread writes: the row
for a single write, the Logs view for a batch. It needs a known project id;
a project_name alone gives no link.{"dry_run": true, "would_call": {...}} with method, path,
body, item count and a note where the preview cannot be exact.dispatch.run_write raises every failure as a WriteError;
write_tool.run_write turns it into a ToolError whose text is compact JSON.
{"error": "validation_failed", "operation": "thread.close",
"message": "data does not fit 'thread.close'; retry write('thread.close', data=…) shaped like example, and schema('thread.close') returns the full schema.",
"issues": [{"field": "", "message": "thread_project_missing: ...", "code": "thread_project_missing"}],
"example": {"thread_id": "...", "project_name": "..."}}
field is a dotted path, prefixed with the index in a batch ([3].name).
Validation stops at the first failing item.code is the Pydantic error type, unless a validator message starts with
<code>: , which then becomes the code (thread_project_missing).example is a working payload. The JSON Schema is not inlined; message
names schema(operation), which returns it. A check made before sending,
such as a thread that is not found, returns the same validation_failed
shape (refuse in wire.py), but its message is the check’s own sentence
and fix rather than the schema-mismatch one, and its issue carries only
field and code, so the sentence is said once.{"error": "backend_error", "operation": "trace.create",
"message": "Opik rejected the data for 'trace.create' (400); fix it and retry write('trace.create', data=…).",
"backend_error": {"status": 400}, "backend_message": "project not found"}
message is one sentence per status and the call to retry
(_backend_sentence); a 409 says the write conflicts with the record’s
current state and to check its ids and project, since the backend answers
409 both for an existing id and for a trace updated under the wrong
project. backend_error holds only the status, which
analytics buckets on; the body, method and path are not carried.backend_message appears on a 400, 409 or 422 only: the strings under the
body’s errors or message, cut at _BACKEND_REASON_CHARS
(backend_reason in src/opik_mcp/client/base.py). A non-JSON body or any other status has none.
A 400 or 422 while a comment resolves its thread carries it too.note_backend_401), as the
main write path does.Each code is a plain exception class in src/opik_mcp/writes/errors.py,
whose args rebuild it when pickled or copied. Other codes: unknown_operation (with
valid_operations, did_you_mean), batch_too_large (over BATCH_LIMIT) and
authorization_denied (required_scope). The OAuth 401 hint is in
hosted-auth.
Each operation declares a scope, and the dispatcher compares it with the
scopes it is given. The write tool in src/opik_mcp/server/tools/write.py gives none, so
the default ALL_WRITE_SCOPES applies and every call passes today.
server.write src/opik_mcp/server/tools/write.py
-> write_tool.run_write src/opik_mcp/writes/write_tool.py (WriteError -> ToolError)
-> dispatch.run_write src/opik_mcp/writes/dispatch.py
lookup, validate, authorize, prepare, build, send, retry, finalize, decorate
-> OpikClient.write_json src/opik_mcp/client/base.py
finalize: a non-2xx status becomes backend_error, a 2xx the success result.
src/opik_mcp/writes/registry.py. Each operation’s model and example sit
beside its builder in src/opik_mcp/writes/operations/, and the registry
entry points at both. The thread lifecycle models are in observability.py
instead of threads.py: mypy reads a Pydantic model as explicit Any, and
threads.py is outside the Any baseline. src/opik_mcp/writes/models.py
keeps only the shared mixins, field types and example helpers.src/opik_mcp/writes/operations/. Hook types are in
src/opik_mcp/writes/wire.py. Without a build_fn the request is the
endpoint plus items[0].decorate_with_page and
_LOGS_VIEW in src/opik_mcp/writes/operations/observability.py; the URLs
are built by trace_page_url and thread_page_url in the trace and thread
entities, over src/opik_mcp/read_list/ui_links.py
(tool-surface).Evaluation hooks: experiment-flows.
Diagnostics hooks: diagnostics. write_json:
runtime. Error kinds:
analytics. Every write against a real Opik:
live-e2e.
Add a model and an example in its writes/operations/ module, and a
registry entry that names both; the enum,
description and schema follow. Also:
write input snapshot with UPDATE_SNAPSHOTS=1
(tests/conformance/test_schema_snapshots.py) and say why in the PR.SURFACE_BUDGET_BYTES in
tests/conformance/test_tool_inventory.py.decorate_fn set and its name
in the decorate_with_page branches and _LOGS_VIEW, or it gets no link or
only the bare Logs page. No test catches a missing entry.write tool over a registry replaced the per-verb tools, so a new
operation is a registry entry (ADR 0003, #99).
Its logic lives in hooks, so the dispatcher stays generic
(ADR 0004, #186).operation is advertised as an enum but typed str, so a wrong name reaches
the dispatcher and gets the valid names back (#99).schema(operation) for the schema. Inlining the schema made a failed write
cost up to 3,219 characters (OPIK-8496; it was inlined from #99).backend_message on a 400, 409 or 422 is the
exception, in its own field so it is never read as ours (OPIK-8496).thread_id string plus a project, and the comment
hook resolves the model UUID, so callers use one identifier (#152).experiment_item.create has supports_batch=True and no build_fn, so for a
top-level array of envelopes the default builder sends only items[0] and
drops the rest without an error. Known bug, not fixed here.BatchPartialFailureError is defined but never raised; each batch is one
request. Its error kind is validation, with no HTTP status.trace.update batch goes out as a POST to the batch route, overriding
the registry’s PATCH (test_trace_update_batch_coerces_patch_to_post).resolve_comment_thread_id replaces target_id before the link is
built. test_commenting_on_a_thread_links_to_that_thread skips that step.tests/writes/test_dispatch.py; the envelope shapes,
test_a_validation_failure_points_at_schema_instead_of_inlining_it,
test_a_backend_rejection_is_one_sentence_and_the_retry_call,
test_a_failed_follow_up_after_409_reports_its_own_status,
test_a_409_write_says_it_conflicts_and_quotes_why,
test_a_401_on_the_follow_up_drops_the_cached_oauth_validation,
test_a_thread_resolve_400_carries_the_backends_reason,
test_a_thread_with_no_model_id_names_the_retry,
test_a_write_backend_message_is_one_line_without_double_quotes,
test_a_write_backend_message_is_capped, test_a_500_write_has_no_backend_message. Models and their issue
codes: tests/writes/test_models.py, tests/writes/test_data_rules.py.tests/writes/test_registry.py,
and over a real MCP session tests/conformance/test_write_tool_surface.py.tests/writes/test_dispatch_stays_generic.py.tests/writes/test_write_links.py. OAuth 401 hint: tests/writes/test_backend_error_oauth_hint.py.tests/writes/test_recovery_envelope.py.writes/operations/: tests/hermetic/writes/; the case tables share
tests/hermetic/writes/surface.py. A backend failure and rejection:
tests/hermetic/writes/test_dispatch.py
(test_a_backend_rejection_quotes_only_its_error_strings).schema key: tests/writes/test_schema_tool.py.schema(op), backend_error is {status}, and a 400/409/422 adds a capped backend_message (OPIK-8496).models.py into writes/operations/; write errors became plain classes (OPIK-8496).item_count counts records inside an envelope; a large upsert had reported 1 (#199).test_suite.* writes became dataset.create and dataset_item.upsert; a suite is a dataset (#190).write tool and schema replaced the score and comment tools (#99).