---
title: "Kodama REST API and MCP reference"
description: "Call Kodama from code: authentication, the request and result envelope, error codes, idempotency keys, versions, every REST route, and the MCP endpoint."
canonical_url: https://optimi.com/en/docs/kodama/api
md_url: https://optimi.com/en/docs/kodama/api.md
language: en
alternates:
  - lang: fr
    url: https://optimi.com/fr/docs/kodama/api
    md_url: https://optimi.com/fr/docs/kodama/api.md
last_updated: 2026-11-14
---

# API and MCP

Kodama has **one operation router behind two edges**: REST under `/api/v1` and
MCP at `/mcp`. Both expose the same 58 operations with the same authorization.
`kodactl` is a REST client, and so is a pipeline container. Laptops, external
applications, CI and workloads all use this API.

## Authenticate

Send a bearer token over HTTPS:

```text
Authorization: Bearer <token>
```

| Caller | Token |
| --- | --- |
| A person | An OAuth access token issued for Kodama’s audience; `kodactl login` obtains and refreshes one |
| A service account | A `kodsvc.` token that an administrator issues, with an explicit expiry |
| A pipeline container | The `kodex.` execution token mounted at `/kodama/secrets/KODAMA_TOKEN` |

`GET /.well-known/kodama-configuration` describes the installation’s OAuth
configuration; `kodactl login` reads it. `/healthz`, `/readyz` and `/metrics`
need no token.

Keep tokens out of command lines and URLs. With `curl`, write a private header
file:

```sh
source ~/.kodama/instances/default/env.sh
header="$(mktemp)" && chmod 600 "$header"
printf 'Authorization: Bearer %s\nContent-Type: application/json\n' \
  "$(cat "$KODAMA_CREDENTIAL_FILE")" > "$header"

curl --silent --show-error --cacert "$KODAMA_CA_FILE" --header @"$header" \
  "$KODAMA_URL/api/v1/whoami"
```

## The envelope

Every operation takes the same request object and returns the same result
object. The only content type is `application/json`.

```json
{
  "input": { "title": "Fix the login bug", "pipeline_id": "…" },
  "expected_version": 3,
  "idempotency_key": "ticket-1843-start"
}
```

| Field | Meaning |
| --- | --- |
| `input` | The operation’s own fields. Identifiers that are in the path, such as the project or the execution, may be omitted |
| `expected_version` | The version you last read, for an operation that changes a versioned resource |
| `idempotency_key` | Your retry key for a mutation: up to 128 characters of `A-Z a-z 0-9 . _ : -` |

A successful call always answers **200** with:

```json
{
  "operation": "task.create_and_start",
  "body": { "task_id": "…", "execution_id": "…", "lifecycle_version": 2 }
}
```

`replayed: true` is added when the call was a retry that returned a recorded
result.

Rules worth knowing:

- **Reads are `GET` requests** whose input is a JSON body. An empty body is
  accepted, so `curl "$KODAMA_URL/api/v1/executions/<id>"` works.
- **Unknown fields are refused**, in the envelope and in the input.
- **YAML is base64.** The `yaml` field of `pipeline.apply` and `pipeline.start`
  carries the document as base64, at most 1 MiB. Log chunks are base64 too.
- **Lists are paged.** Pass `"page": {"limit": 50, "cursor": "…"}` in the input
  and continue with the `next_cursor` of the result. The default limit is 50
  and the maximum 200.
- A request body is at most 1 MiB, or 2 MiB when it carries YAML.

## Errors

An error answers with a stable code. The HTTP status follows the code, one to
one:

```json
{ "code": "version_conflict", "message": "…", "request_id": "…" }
```

| Code | Status | Meaning, and what to do |
| --- | --- | --- |
| `invalid_input` | 400 | The request shape or a value is refused. Fix the request |
| `unauthenticated` | 401 | The token is missing, expired or not verifiable |
| `forbidden` | 403 | The principal lacks authority for this operation |
| `not_found` | 404 | No such entity in your scope |
| `deadline_expired` | 408 | The work is at or past its deadline, for example an answer to a timed-out request |
| `version_conflict` | 409 | The expected version is stale. Read again, then decide |
| `changed_idempotency_content` | 422 | The key was already used with a different request |
| `internal_failure` | 500 | Unexpected failure; keep the `request_id` |
| `unsupported_capability` | 501 | The installation’s runtime cannot run what was asked |
| `uncertain_external_effect` | 502 | An effect’s outcome is unknown. Observe before you repeat it |
| `dependency_unavailable` | 503 | A dependency is down. Retry later with the same key |

## Idempotency and versions

Every mutation requires an `idempotency_key`. Kodama records the effect and a
receipt together, scoped to the tenant, the project, the principal, the
operation and your key.

- **Retrying the identical request returns the recorded result.** Use this when
  a response is lost: repeat the call with the same key.
- **Reusing a key with different content is refused** with
  `changed_idempotency_content`.
- Receipts are kept until the project is deleted, so a key stays reserved.

Derive keys from something stable in your system, such as `erp:evt-1843:start`.
Never generate a new key to retry an ambiguous failure: that is how work gets
done twice.

Versions protect transitions. Task content, task lifecycle, executions,
actions, pipelines, memberships and bindings each carry a version. Send the
one you read; if someone else changed the resource first, you get
`version_conflict` and nothing is committed.

Authorization is never cached: every call, including a replayed one, is
checked against the caller’s current roles and state.

## Worked example

Start a pipeline file without storing it, then follow it:

```sh
project="$KODAMA_PROJECT"
jq -n --arg yaml "$(base64 -w0 hello.yaml)" \
  '{input: {yaml: $yaml}, idempotency_key: "hello-1"}' > start.json

curl --silent --show-error --cacert "$KODAMA_CA_FILE" --header @"$header" \
  --data-binary @start.json \
  "$KODAMA_URL/api/v1/projects/$project/executions" | tee started.json
execution="$(jq -r .body.execution_id started.json)"

curl --silent --show-error --cacert "$KODAMA_CA_FILE" --header @"$header" \
  "$KODAMA_URL/api/v1/executions/$execution" | jq '.body | {state, actions: [.actions[] | {name, state}]}'
```

The inspection result holds the execution’s `state` and `version`, its
`actions` (each with `action_id`, `name`, `state`, `version`, and its outputs,
subject or decision), a `failure` when there is one, and the `cleanup` state of
its workload and workspace.

Answer a waiting request with the action’s ID and version from that result:

```sh
jq -n --argjson version "$action_version" \
  '{input: {expected_action_version: $version, decision: "pass"}, idempotency_key: "review-1"}' |
  curl --silent --show-error --cacert "$KODAMA_CA_FILE" --header @"$header" \
    --data-binary @- "$KODAMA_URL/api/v1/executions/$execution/actions/$action_id/resolve"
```

## Routes

All paths are under `/api/v1`. The operation name is also the argument of
`kodactl call`.

**Executions and pipelines**

| Operation | Method and path |
| --- | --- |
| `pipeline.start` | `POST /projects/{project}/pipelines/{pipeline}/start`, or `POST /projects/{project}/executions` with inline YAML |
| `execution.inspect` | `GET /executions/{execution}` |
| `execution.list` | `GET /projects/{project}/executions` |
| `execution.cancel` | `POST /executions/{execution}/cancel` |
| `execution.logs.read` | `GET /executions/{execution}/logs` |
| `execution.action.outputs` | `PUT /executions/{execution}/actions/{action}/outputs` |
| `execution.request.resolve` | `POST /executions/{execution}/actions/{action}/resolve` |
| `pipeline.apply` | `PUT /projects/{project}/pipelines/{pipeline}` |
| `pipeline.read`, `pipeline.update`, `pipeline.delete` | `GET`, `PATCH`, `DELETE /projects/{project}/pipelines/{pipeline}` |
| `pipeline.list` | `GET /projects/{project}/pipelines` |

**Tasks**

| Operation | Method and path |
| --- | --- |
| `task.create` | `POST /projects/{project}/tasks` |
| `task.create_and_start` | `POST /projects/{project}/tasks/create-and-start` |
| `task.list` | `GET /projects/{project}/tasks` |
| `task.inbox` | `GET /projects/{project}/inbox` |
| `task.read`, `task.edit` | `GET`, `PATCH /tasks/{task}` |
| `task.comment` | `POST /tasks/{task}/comments` |
| `task.select_pipeline` | `PUT /tasks/{task}/pipeline` |
| `task.start` | `POST /tasks/{task}/start` |
| `task.ask` | `POST /tasks/{task}/ask` |
| `task.reply` | `POST /tasks/{task}/reply` |
| `task.cancel` | `POST /tasks/{task}/cancel` |
| `task.history` | `GET /tasks/{task}/history` |

**Administration**

| Operation | Method and path |
| --- | --- |
| `principal.whoami` | `GET /whoami` |
| `tenant.create` | `POST /tenants` |
| `tenant.rotate_key` | `POST /tenants/{tenant}/rotate-key` |
| `project.create`, `project.list` | `POST`, `GET /tenants/{tenant}/projects` |
| `project.update` | `PATCH /projects/{project}` |
| `membership.grant`, `membership.revoke` | `PUT`, `DELETE /projects/{project}/memberships/{principal}` |
| `membership.list` | `GET /projects/{project}/memberships` |
| `secret.write`, `secret.delete`, `secret.metadata.read` | `PUT`, `DELETE`, `GET /projects/{project}/secrets/{secret}` |
| `secret.metadata.list` | `GET /projects/{project}/secrets` |
| `service.create`, `service.list` | `POST`, `GET /projects/{project}/services` |
| `service.update` | `PATCH /services/{service}` |
| `service.enable`, `service.disable` | `POST /services/{service}/enable`, `POST /services/{service}/disable` |
| `service.credential.issue`, `service.credential.revoke` | `POST`, `DELETE /services/{service}/credentials` |
| `service.credential.rotate` | `POST /services/{service}/credentials/rotate` |

**Resources, events, models and tools**

| Operation | Method and path |
| --- | --- |
| `resource.binding.register`, `resource.binding.list` | `POST`, `GET /projects/{project}/bindings` |
| `resource.binding.read`, `resource.binding.update`, `resource.binding.delete` | `GET`, `PATCH`, `DELETE /bindings/{binding}` |
| `actor.verify` | `POST /verify` |
| `resource.report` | `POST /reports` |
| `event.publish`, `event.read` | `POST`, `GET /projects/{project}/events` |
| `event.subscribe` | `POST /projects/{project}/events/subscribe` |
| `model.invoke` | `POST /projects/{project}/models/{model}/invoke` |
| `tool.invoke` | `POST /projects/{project}/tools/{tool}/invoke` |

The Go types of every input and result are in the
[`api` package](https://github.com/optimiware/kodama/tree/master/api), and a Go
REST client is in the
[`client` package](https://github.com/optimiware/kodama/tree/master/client).

## MCP

The MCP edge is at `<server URL>/mcp`, over Streamable HTTP. It accepts `POST`
only and uses the same bearer tokens as REST.

- **Tools are the operations.** Each operation is one tool whose name replaces
  the dots with underscores, such as `task_create_and_start`; `tools/call` also
  accepts the dotted name. A tool takes the same envelope as REST, and returns
  the result as `content` and `structuredContent`.
- **Clients.** `kodactl mcp install claude`, `cursor` or `codex` writes the
  client’s configuration. It registers `kodactl mcp stdio`, a bridge that adds
  your credential and the installation’s CA, so the client needs no token of
  its own.
- **Direct HTTP.** A client that speaks Streamable HTTP can connect to `/mcp`
  with a bearer token. An unauthenticated tool call answers 401 with the
  resource metadata that OAuth-capable clients expect.
- **Origin check.** A request whose `Origin` header is not the installation’s
  own is refused. Command-line clients send no `Origin` and are unaffected.

In 0.2.1 an unauthenticated `tools/list` answers 200; only tool calls answer
401.

This endpoint exposes Kodama’s operations to a client. The separate, optional
**MCP proxy** does the reverse: it lets a pipeline call external MCP tools that
an operator registered, through `tool.invoke`.

## Events and logs

Every domain change is an event with a stable ID, a type, a source, a
timestamp, its tenant and project, and bounded data.

- **Read events** with `event.read`, filtered by `types` and paged. This is the
  way for people and for most integrations.
- **Publish custom observations** with `event.publish`. A custom event never
  settles an action or counts as a decision.
- **Read logs** with `execution.logs.read`, per action and stream. Chunks are
  available when the action’s workload has ended.
- **Subscribe over NATS** as a service account, for event-driven services.
  JetStream delivers at least once: deduplicate by event ID and tolerate
  reordering. In 0.2.1, subscriptions by people and browsers are refused; read
  over HTTP or MCP instead.

NATS carries events only. There is no way to submit an operation over NATS.

## Build an integration

A Slack, email, webhook or timer adapter is an ordinary API client with a
service account:

1. An administrator creates the service account (`service.create`), gives it
   roles, and issues a credential (`service.credential.issue`).
2. The adapter handles its external protocol and calls Kodama with stable
   idempotency keys: `task.create_and_start` for new work, `task.reply` or
   `execution.request.resolve` for the questions and requests assigned to
   `service/<its id>`.
3. To react to Kodama, it reads the inbox and the events. Scan current state as
   well as new events, so that an event missed earlier cannot strand a request.

A service that guards its own resource, as the Git service does, also uses
`actor.verify` before each effect and `resource.report` after it. See
**[Architecture](/en/docs/kodama/architecture)**.
