This document is the normative, versioned contract for third-party extension servers that interoperate with odek. An extension server is an MCP server (see MCP.md) that additionally follows the conventions below.
- Contract version:
odek-extension/v1 - MCP protocol version:
2025-03-26
Everything in this document is additive over plain MCP. A server that only
implements MCP 2025-03-26 still works with odek; the extension contract only
adds optional capabilities (artifact references, bounded results, structured
events).
All schemas in this contract are additive. Producers may add new fields in
future versions; consumers MUST ignore fields they do not recognize and
MUST NOT fail on them. Fields are never removed or re-typed within a /v1
schema. A breaking change ships as a new schema name (e.g.
odek.artifact-ref/v2).
Extension servers use the standard odek MCP client transport: JSON-RPC 2.0 over stdio, one message per line:
initialize— handshake; odek sendsprotocolVersion: "2025-03-26", the server should answer with the same or a compatible versiontools/list— discover toolstools/call— invoke a toolping— health check
Logging goes to stderr; stdin/stdout are reserved for the protocol.
Tool names returned by tools/list must be:
- 1–64 characters
- ASCII letters, digits,
_,-only - free of the substring
__(reserved as odek's<server>__<tool>qualifier separator)
odek rejects a server whose tools/list contains an invalid name. Servers
must also tolerate unknown arguments in tools/call params (same additive
rule).
Extension servers are declared in mcp_servers in ~/.odek/config.json
(operator-trusted) or ./odek.json (project-level; per-tool approval
required — see MCP.md):
{
"mcp_servers": {
"log-analyzer": {
"command": "/usr/local/bin/log-analyzer-mcp",
"args": ["--serve"],
"env": { "LOG_LEVEL": "info" },
"timeout_seconds": 120,
"max_response_bytes": 2097152,
"max_result_chars": 100000,
"artifact_roots": ["/var/ci-artifacts"]
}
}
}| Field | Type | Default | Since | Meaning |
|---|---|---|---|---|
command |
string | — (required) | MCP base | Executable to spawn |
args |
string[] | [] |
MCP base | Command-line arguments |
env |
object | {} |
MCP base | Env overrides; secret-looking keys are stripped |
timeout_seconds |
int | 30 |
odek-extension/v1 | Per-request timeout; clamped to a hard cap of 3600 |
max_response_bytes |
int | 10485760 (10 MiB) |
odek-extension/v1 | Max bytes for a single JSON-RPC response line; absolute ceiling 64 MiB |
max_result_chars |
int | 200000 |
odek-extension/v1 | Max characters of tool result text forwarded to the model; hard cap 1000000 |
artifact_roots |
string[] | [] |
odek-extension/v1 | Directories under which file:// artifact refs are accepted. Empty ⇒ every artifact ref is rejected (fail closed). |
Unknown fields in the server config are ignored (additive rule).
-
A call that exceeds
timeout_secondsfails with a deadline error; the server process is left running and may be reused. -
A single response line larger than
max_response_bytesfails closed: the response is dropped and the connection is closed. odek never buffers an unbounded line in memory. -
Result text longer than
max_result_charsis not silently mangled: malformed JSON is rejected outright, and valid-but-oversized structured results get a structured truncation notice that retains any artifact references. The notice has the exact form (intentionally free of angle brackets so it cannot be confused with the untrusted-content wrapper):[odek: result truncated — server "<server>" tool "<tool>" produced <observed> chars, exceeding the configured max_result_chars=<limit>; the full result is available via the retained artifact references, if any]For
odek.tool-result/v1envelopes the notice applies to the envelopetext; artifact refs are validated first and rendered as metadata lines regardless. -
Errors name the server, the tool, the configured limit, and the observed size.
A tool whose full output is too large or too binary for the model context
returns a compact text summary plus artifact references by emitting this
envelope as the JSON text of a single content item:
{
"schema": "odek.tool-result/v1",
"text": "Analyzed 1284 test cases: 1280 passed, 4 failed. Full report attached as artifact report-1.",
"artifacts": [
{
"schema": "odek.artifact-ref/v1",
"id": "report-1",
"uri": "file:///var/ci-artifacts/run-4821/junit.xml",
"media_type": "application/xml",
"sha256": "9f2c…",
"size_bytes": 21453211,
"summary": "Full CI test results (JUnit XML)"
}
]
}The envelope is detected by its schema field. Unknown fields are ignored.
A plain (non-envelope) text result is always valid and needs no schema field.
| Field | Required | Meaning |
|---|---|---|
schema |
yes | Exactly odek.artifact-ref/v1 |
id |
yes | Ref identifier, unique within the envelope |
uri |
yes | file:// URI only; absolute, clean path |
media_type |
yes | MIME type of the artifact |
sha256 |
optional | Lowercase hex digest; verified when present |
size_bytes |
optional | Verified against the real file when present |
summary |
optional | One-line human description shown to the model |
Validation fails closed on any violation: the resolved path (after symlink
evaluation) must lie inside a configured artifact_roots entry; traversal
(..), symlink escapes, non-file:// URIs, hash/size mismatches, and missing
files are all rejected. Artifact content is never auto-read into the model
context — the model sees only the compact text plus per-artifact metadata
lines (id, media type, size, short hash, summary).
odek can emit a structured runtime event stream: one JSON object per line
(JSONL), e.g. via odek run --events-jsonl <path>:
{"schema":"odek.event/v1","type":"tool_call_completed","run_id":"…","session_id":"…","iteration":3,"tool":"log-analyzer__summarize","timestamp":"2026-08-07T12:00:00Z","data":{"duration_ms":412,"result_bytes":812,"artifact_count":1}}typeis one of:run_started,iteration_completed,tool_call_started,tool_call_completed,tool_call_failed,session_saved,context_trimmed,budget_exceeded,run_completed,run_failed,plan_created,plan_updated,subagent_denied,subagent_spawned,subagent_completed,subagent_concurrency_wait.run_idis a random 128-bit hex identifier generated per agent run and stamped on every event of that run.session_idappears once the session is known; earlier events omit it.iterationis the 1-based loop iteration;toolis the tool name for tool-call events.datacarries per-type fields (below). Tool arguments are never logged raw by default — only a SHA-256 hash, sizes, and a structuredargs_summary. Human-readable fields pass through odek's secret redaction. No environment variables or credentials are ever included. As an explicit opt-in for incident review,odek run --events-include-args(or Go APIConfig.EventsIncludeArgs) adds the raw, still-redactedargstotool_call_startedevents — without it, the stream stays secret-safe.- Unknown
typevalues and unknown fields must be ignored by consumers.
Per-type data fields:
type |
data fields |
|---|---|
run_started |
model, sandbox (bool), max_iterations |
iteration_completed |
input_tokens, output_tokens (cumulative), tools_called |
tool_call_started |
call_id, args_sha256, args_bytes, args_summary, args (opt-in) |
tool_call_completed |
call_id, duration_ms, result_bytes, artifact_count |
tool_call_failed |
call_id, duration_ms, error_class |
session_saved |
message_count |
context_trimmed |
mode (proactive/survival), dropped_groups, truncated_results |
budget_exceeded |
limit_name (runtime/tool_calls/input_tokens/output_tokens/cost_usd), observed, limit |
run_completed |
duration_ms, input_tokens, output_tokens (run totals) |
run_failed |
duration_ms, error_class |
plan_created |
steps (total count), version |
plan_updated |
steps, done, in_progress, blocked, pending, version |
subagent_denied |
task_index, class, reason (emitted by delegate_tasks for each policy denial a child reports) |
call_id is the stable correlation key between a tool_call_started and
its matching tool_call_completed/tool_call_failed event: the provider's
tool-call ID when present, else a deterministic it<iteration>-call<index>
synthetic ID. Batched parallel calls MUST be paired via call_id — never
by positional order. args_summary is structured, low-cardinality audit
metadata extracted from the arguments — for shell tools the program name
(argv0, leading env assignments skipped) plus the danger class; for
path tools the target path/path list and class; for browser/http
tools the URL host only (full URLs can embed credentials). It is always
present for recognized tools; values pass through secret redaction like
every other string field.
args_sha256 correlates a tool_call_started with the model call that
produced it; pair it with call_id to match a
completion. error_class is a stable low-cardinality string
(context_canceled, deadline_exceeded, tool_error, error) — raw error
text is never emitted. On budget exhaustion budget_exceeded is always
emitted before the closing run_failed. session_saved is emitted by
surfaces that persist sessions per completed step (currently the odek run --session persist callback, with message_count). The plan events are
emitted once per effective version-bumping mutation of the built-in plan
tool (see PLANNING.md): create — including wholesale
replace — maps to plan_created, every other bumping mutation to
plan_updated. Payloads carry aggregate counts and the version ONLY — never
step titles or notes. A note-only update bumps the version and therefore
emits plan_updated with unchanged counts (the version stream stays
gapless). Plan events carry no iteration: mutations fire inside parallel
tool goroutines, so consumers correlate via the surrounding
tool_call_started/tool_call_completed pair for the plan tool.
Sink behavior (--events-jsonl): the file is created (and hardened) with
0600 permissions, the parent directory must already exist, a symlink at the
target path is refused (and the subsequent open itself passes O_NOFOLLOW,
so a symlink swapped in between the check and the open cannot redirect the
stream), and every event is flushed to stable storage before the write
returns. Event dispatch from the agent loop is non-blocking: events flow over
a buffered channel drained by a dedicated goroutine — when the buffer fills,
new events are dropped (never blocking the loop), and a panicking consumer
cannot crash the agent.
Sessions may carry operator-supplied pointers to state that lives outside odek (CI runs, dashboards, object stores). odek stores and transports these refs but never dereferences them:
{
"kind": "ci-run",
"uri": "https://ci.example.test/runs/4821",
"created_by": "ci-orchestrator",
"read_only": true,
"created_at": "2026-08-07T12:00:00Z"
}kind: 1–64 chars,[a-z0-9_-]uri: 1–2048 chars, no control characterscreated_by: 1–128 chars- Refs are deduplicated on
(kind, uri, created_by).
When a configured execution budget (runtime, tool calls, tokens, or cost) is
exhausted, odek emits a budget_exceeded event, persists the latest safe
session state, and exits with a distinguishable status:
| Exit code | Meaning |
|---|---|
0 |
success |
1 |
task/model/tool error |
2 |
overall timeout (currently used by odek subagent) |
3 |
setup/contract error (currently used by odek subagent) |
4 |
execution budget exhausted (odek-extension/v1; odek run, odek subagent) |
Budget errors are surfaced as typed errors naming the limit, the observed
value, and the configured maximum, so orchestrators can tell a budget stop
apart from a model or tool failure. Cost estimation uses operator-configured
per-million prices: an exact model_prices key match for the run's model ID
overrides the flat input/output_cost_per_million_usd pair (each missing
price in the entry falls back individually); unknown models use the flat
pair. Budget enforcement is wired into odek run and odek subagent — the
sub-agent enforces the operator limits (clamped by any parent-inherited
budget when subagent.budget_inherit is "share") and reports
status: "budget_exhausted" with exit code 4. In share mode the task
file's budget block carries the parent's remaining max_runtime_seconds,
max_tool_calls, and max_cost_usd plus explicit runtime_exhausted,
tool_calls_exhausted, and cost_exhausted flags — a remaining of 0 is
wire-ambiguous with an unconfigured limit, the flags are not. An exhausted
parent dimension is a hard cap of 0 for the child: the spawn fails fast
with the typed budget error and exit code 4 instead of starting an
unbounded child. continue, REPL, serve, and
Telegram do not yet enforce limits, and there is no ODEK_* env-var layer
for limits — sources are the limits config section (project configs may
only lower global values, and project-set prices — flat or per-model — are
rejected outright) and the --max-* CLI flags.
A minimal compatible server only needs: stdio JSON-RPC, initialize /
tools/list / tools/call, valid tool names, and text results. Everything
else in this document is opt-in. A reference mock implementing every fixture
tool (echo, large_result, artifact_result, bad_artifact, slow,
error_result) lives at internal/mcpclient/testdata/artifact_server.go and
is exercised by internal/mcpclient/contract_test.go.
The same artifact schemas power the delegate_tasks result channel (no MCP
server involved). The task envelope carries an artifact_root naming the
per-task directory the parent created; the child runner relocates staged
workspace files there, measures sha256/size_bytes itself, and returns
odek.artifact-ref/v1 references in subagentResult.artifacts. The parent
validates every ref fail-closed against the per-task root before rendering —
metadata-only lines in the model context, content inlined for text artifacts
≤ 32 KiB, everything else readable by the parent via the artifact_read
tool (id-keyed; paths never enter the model context). See
docs/SUBAGENTS.md — Result artifacts and docs/SECURITY.md for the
invariants; SUBAGENT_RESULT_ARTIFACTS_PLAN.md documents the design.