# ops-engine

> CRUD API over a POF-formatted ops workspace (Roles, Skills, Connections, Users, Notebooks, Wikis, Artifacts, Channels, Routines, Logs, Policies) stored in Postgres, multi-tenant by URL path. Built to be fetched/written directly by AI agent runtimes for their own skills/instructions — not a human-browsing API. Field-level change history on every write. Also servable as a remote MCP server (see MCP section below) — same content, same auth.

Follows nicetry's **engine contract v1** (errors, pagination, idempotency, X-Actor, X-Request-Id, health — see Conventions below).

## Auth

Two separate credentials, never interchangeable:

- `x-admin-api-key`: only for `/admin/tenants` (create/list/delete tenants, rotate a tenant's key).
- `X-Engine-Key`: one per tenant, returned exactly once by `POST /admin/tenants` when that tenant is first created (only its sha256 is stored — a lost key can't be read back, `POST /admin/tenants/{slug}/rotate-key` issues a new one). Required on every `/{tenant}/...` call below, for both reads and writes. (The `/{tenant}/mcp` mount also still accepts the legacy header name `x-api-key` for this same key, for MCP clients configured before this engine standardized on `X-Engine-Key` — everywhere else, including internally, only `X-Engine-Key` works.)

Credential first, then tenant: without a valid credential every `/{tenant}/...` call is a `401`, whether or not that tenant exists. With a valid one, a different tenant's path is a `403` and a tenant that doesn't exist is a `404`. A `401` carries `WWW-Authenticate: Bearer resource_metadata="..."` pointing at the tenant's OAuth resource document.

Admin: `POST /admin/tenants {slug, enabledEntities?}` is an upsert -> `{ slug, apiKey, created, enabledEntities }` (`apiKey` null and `created: false` if it already existed). `DELETE /admin/tenants/{slug}` is a `409` while the tenant still has entities, unless the body is `{"confirm": "<slug>"}`. Without `ADMIN_API_KEY` configured, `/admin/tenants` answers `401`.

A tenant can also be restricted to a subset of entities (`enabledEntities` on `POST /admin/tenants`, `null` = every entity, the default) — a 403 on an entity means this tenant doesn't have it enabled, not a credentials problem. `GET /{tenant}` (with that tenant's own `X-Engine-Key`) returns `{ slug, enabledEntities }` to check.

If your client requires OAuth instead of a static header (some do, e.g. Gemini Spark), it's supported as an alternative — see the OAuth part of the MCP section below. An `Authorization: Bearer` token from that flow works anywhere `X-Engine-Key` does, REST included.

Rate limits, per minute (429 + `Retry-After`): 600 per credential, 120 per IP without one, 30 per IP on `/admin/tenants`, `/authorize`, `/register`, `/token` and `/revoke`.

`X-Actor: <system>:<id>` (`user:<email>`, `agent:<routine>`, `loops:<slug>`, `panel:<email>`, `admin:<email>`) on every write records who asked for it in that write's history entries — not authentication, just attribution. During adoption a missing or malformed `X-Actor` is still accepted (and logged): then the body's `actor` field is recorded, or `null`. An optional `reason` field (body) or `x-reason` header (DELETE and file uploads) records the *why* — e.g. on a Skill, "sacado el paso de retry x3 en 429, causaba cobros duplicados en prod" tells the next agent that touches it not to reintroduce that exact change.

## Conventions (engine contract v1)

- **Request id:** send `X-Request-Id` to correlate, or get a generated one; it's echoed in the response header, in every error body and in this engine's logs.
- **Errors:** always JSON, `{ error, code, details?, requestId }`. `error` is for people, `code` is stable: `invalid_json` and `validation_error` (400, `details` = the field issues), `unauthorized` (401), `forbidden` (403), `not_found` (404, unknown routes included), `conflict` / `idempotency_conflict` (409), `payload_too_large` (413, a file over the size limit), `unsupported_media_type` (415, a JSON route called with another content type), `rate_limited` (429, `Retry-After`), `unavailable` (503, the database didn't answer), `internal` (500).
- **Pagination:** every listing (including every `/history`) takes `?limit` (default 100, max 500) and `?cursor`. The body is still the plain array; when there's another page, its cursor is in the `X-Next-Cursor` response header — pass it back as `?cursor`. No header = last page. A bad `limit` or cursor is a `400`.
- **Idempotency:** any `POST` under a tenant accepts `Idempotency-Key`. The same key with the same request within 24 h replays the original response (`Idempotent-Replayed: true`) without writing again; with a different request, or while the first is still running, it's `409 idempotency_conflict`.
- **Health:** `GET /health` -> `{ status, engine: "ops", version, db }`, pinging the database; `503` if it doesn't answer.

## Endpoints

- `GET /{tenant}/roles?team=&person=` -> `[{ name, title, team, person?, markdown, path }]`
- `POST /{tenant}/roles` `{ team, title, person?, body?, actor?, reason? }` -> `201`, same shape as above
- `GET /{tenant}/roles/{team}/{slug}` -> `{ name, title, team, person?, markdown, path }`
- `PUT /{tenant}/roles/{team}/{slug}` `{ team?, title?, person?, body?, actor?, reason? }` -> may change the role's own path
- `DELETE /{tenant}/roles/{team}/{slug}` -> `204`
- `GET /{tenant}/roles/{team}/{slug}/history` -> `[{ field, old_value, new_value, actor, reason, changed_at }]`

- `GET /{tenant}/skills?role=&user=&domain=&category=` -> `[{ name, description, markdown, files?, path }]` — the exact shape eve's `defineSkill` resolvers already expect. `role=<team>/<slug>` matches a skill's `roles` relation (many-to-many toward Roles); `user=<slug>` matches its `users` relation (personal, independent of any role).
- `POST /{tenant}/skills` `{ domain, title, description, category?, body?, roles?: string[], users?: string[], actor?, reason? }` -> `201`
- `GET /{tenant}/skills/{domain}/{slug}` -> one skill in full
- `PUT /{tenant}/skills/{domain}/{slug}` -> `roles`/`users`, when given, each replace that entire relation (not a merge)
- `DELETE /{tenant}/skills/{domain}/{slug}` -> `204`
- `PUT /{tenant}/skills/{domain}/{slug}/files/{path}` (raw bytes body, `content-type` header) -> upserts an attached file, returns the skill
- `DELETE /{tenant}/skills/{domain}/{slug}/files/{path}` -> `204`
- `GET /{tenant}/skills/{domain}/{slug}/history`

- `GET /{tenant}/connections?role=&user=&routine=&protocol=` -> `[{ name, title, description, protocol, url, service, path }]` — MCP/OpenAPI connections an eve agent runtime resolves dynamically per caller. `role=<team>/<slug>` matches a connection's `roles` relation (shared, app-scoped); `user=<slug>` matches its `users` relation (personal, one real OAuth grant per user, independent of any role — both can apply to the same connection at once); `routine=<slug>` returns every connection that routine needs.
- `POST /{tenant}/connections` `{ title, description?, protocol, url, service, roles?: string[], users?: string[], actor?, reason? }` -> `201`
- `GET /{tenant}/connections/{slug}` -> one connection in full
- `PUT /{tenant}/connections/{slug}` -> `roles`/`users`, when given, each replace that entire relation (not a merge)
- `DELETE /{tenant}/connections/{slug}` -> `204`
- `GET /{tenant}/connections/{slug}/history`

- `GET /{tenant}/users?external_system=&external_id=` -> `[{ name, title, path, email?, externalRefs: [{ system, externalId }] }]` — real individuals a personal connection can be assigned to directly. `externalRefs` links the user to its identity in other systems (`admin` = nicetry's canonical person, `hub` = the Actividades user id); `?external_system=hub&external_id=<id>` finds the ops user for an Actividades user. `PUT/DELETE /{tenant}/users/{slug}/external-refs/{system}` `{ externalId }` manage them (409 if the id belongs to another user).
- `POST /{tenant}/users` `{ slug, title, actor?, reason? }` -> `201`. Unlike every other entity here, `slug` is NOT derived from `title` — it must match the value the calling app sends as `x-agent-user`.
- `GET /{tenant}/users/{slug}`, `PUT /{tenant}/users/{slug}`, `DELETE /{tenant}/users/{slug}`
- `GET /{tenant}/users/{slug}/history`

- `GET /{tenant}/notebooks?project=&domain=&role=` -> `[{ name, title, description?, markdown, notes: [{id, timestamp, markdown}], sources: [...], files?, path }]`
- `POST /{tenant}/notebooks` `{ project, title, description?, summary?, domains?: string[], roles?: string[], actor?, reason? }`
- `GET /{tenant}/notebooks/{project}/{slug}` -> one notebook in full — NOTEBOOK.md, notes, sources, attached files
- `PUT /{tenant}/notebooks/{project}/{slug}`, `DELETE /{tenant}/notebooks/{project}/{slug}`
- `POST /{tenant}/notebooks/{project}/{slug}/notes` `{ content, actor?, reason? }` -> immediate, append-only (not editable — delete + re-add to correct one)
- `DELETE /{tenant}/notebooks/{project}/{slug}/notes/{noteId}`
- `POST /{tenant}/notebooks/{project}/{slug}/sources` `{ label, url?, actor?, reason? }`, `DELETE .../sources/{sourceId}`
- `PUT|DELETE /{tenant}/notebooks/{project}/{slug}/files/{path}`
- `GET /{tenant}/notebooks/{project}/{slug}/history`

- `GET /{tenant}/wikis?wiki=&entity=` -> `[{ type, name, title, resource?, tags?, metadata?, markdown, wiki, entity, path }]` — flat list of wiki objects across every wiki/entity, or scoped down.
- `POST /{tenant}/wikis` `{ name, entities: string[], actor?, reason? }` -> creates a wiki with one or more entity types in one call
- `GET /{tenant}/wikis/{wiki}` -> that wiki's own info + its entity types
- `DELETE /{tenant}/wikis/{wiki}`
- `POST /{tenant}/wikis/{wiki}/entities` `{ name, actor?, reason? }` -> adds an entity type (never renamed — no update route, by design)
- `DELETE /{tenant}/wikis/{wiki}/{entity}`
- `POST /{tenant}/wikis/{wiki}/{entity}/attributes` `{ name, actor?, reason? }` -> defines an attribute ahead of time (optional — an unrecognized `metadata` key on a create/update auto-creates its own definition)
- `POST /{tenant}/wikis/{wiki}/{entity}` `{ identifier, content?, resource?, tags?: string[], metadata?: Record<string,string>, actor?, reason? }` -> creates an object
- `GET /{tenant}/wikis/{wiki}/{entity}/{slug}`, `PUT .../{slug}`, `DELETE .../{slug}`
- `GET /{tenant}/wikis/{wiki}/{entity}/{slug}/history`

- `GET /{tenant}/artifacts?skill=&collection=` -> `[{ name, title, description?, markdown, collection, files?, path }]` — a bundle of real code/asset files that constitutes something executable/deployable (a codebase). `skill=<domain>/<slug>` matches an artifact's `skills` relation (which skills document how to use/run it — the only relation this entity has). Storage-only: no render/execution here, by design (see Data model).
- `POST /{tenant}/artifacts` `{ collection, title, description?, body?, skills?: string[], actor?, reason? }` -> `201`
- `GET /{tenant}/artifacts/{collection}/{slug}` -> one artifact in full
- `PUT /{tenant}/artifacts/{collection}/{slug}` -> `skills`, when given, replaces the entire relation (not a merge)
- `DELETE /{tenant}/artifacts/{collection}/{slug}` -> `204`
- `PUT|DELETE /{tenant}/artifacts/{collection}/{slug}/files/{path}` — same upsert-by-path pattern as Skill/Notebook files
- `GET /{tenant}/artifacts/{collection}/{slug}/history`

- `GET /{tenant}/channels?role=` -> `[{ name, platform, platformOther?, identifier?, description, path }]` — a communication channel a role can use (Slack, WhatsApp/Twilio, email, ...). Documentation, not a live integration — no credentials/webhooks live here.
- `POST /{tenant}/channels` `{ platform, platformOther?, identifier?, description?, roles?: string[], actor?, reason? }` -> `201`. `platform` is a fixed enum (slack/discord/teams/telegram/twilio/github/linear/chat_sdk/mcp/linq/photon/http/custom/other); `platformOther` only means something when `platform` is `"other"`.
- `GET|PUT|DELETE /{tenant}/channels/{slug}`
- `GET /{tenant}/channels/{slug}/history`

- `GET /{tenant}/routines?role=` -> `[{ name, title, goal, steps, definitionOfDone, markdown, roles, connections, channels, path }]` — instructions for an agent to run.
- `POST /{tenant}/routines` `{ title, goal?, steps?, definitionOfDone?, roles?: string[], connections?: string[], channels?: string[], actor?, reason? }` -> `201`. `markdown` is assembled server-side from `goal`/`steps`/`definitionOfDone` (Objetivo/Procedimiento/Criterio de éxito) — convenience, not a separate source of truth.
- `GET|PUT|DELETE /{tenant}/routines/{slug}`
- `GET /{tenant}/routines/{slug}/history`

- `GET /{tenant}/logs?system=` -> `[{ name, title, description?, system, path }]` — no `threads` here on purpose (see Data model below for why).
- `POST /{tenant}/logs` `{ system, name, description?, actor?, reason? }` -> `201`
- `GET /{tenant}/logs/{system}/{slug}` -> the log in full, with every thread and every message in it
- `PUT|DELETE /{tenant}/logs/{system}/{slug}`
- `GET /{tenant}/logs/{system}/{slug}/history`
- `GET|POST /{tenant}/logs/{system}/{slug}/attributes` `{ name, actor?, reason? }` -> attribute definitions shared by every thread in this log (optional — an unrecognized `metadata` key on a thread create/update auto-creates its own definition, same as Wiki)
- `POST /{tenant}/logs/{system}/{slug}/threads` `{ identifier, summary?, tags?: string[], metadata?: Record<string,string>, actor?, reason? }` -> `201`
- `GET|PUT|DELETE /{tenant}/logs/{system}/{slug}/threads/{thread}`
- `GET /{tenant}/logs/{system}/{slug}/threads/{thread}/history`
- `POST /{tenant}/logs/{system}/{slug}/threads/{thread}/messages` `{ author, content (<=4000 chars), occurredAt?, actor?, reason? }` -> `201`, append-only — `occurredAt` lets you backfill a real historical timestamp (e.g. importing an old conversation)
- `DELETE /{tenant}/logs/{system}/{slug}/threads/{thread}/messages/{messageId}`

- `GET /{tenant}/policies` -> `[{ name, title, description, requireCatchAll, rules: [{ id, position, title, when, then }], path }]` — an ordered ladder of rules that decides a target for a case (e.g. which advisor gets a lead).
- `POST /{tenant}/policies` `{ title, description?, requireCatchAll?, rules?: [{ id?, title, when, then }], actor?, reason? }` -> `201`. `rules` is the whole ordered list; on `PUT` it replaces it — send a rule's `id` back to keep it (and its round-robin/rotation state), leave `id` out for a new rule, and any existing rule you don't list is deleted. Invalid rules are a `400` naming the exact field.
- `GET|PUT|DELETE /{tenant}/policies/{slug}`
- `GET /{tenant}/policies/{slug}/history`
- `POST /{tenant}/policies/{slug}/evaluate` `{ context, dryRun? }` -> `{ matched, rule?, target?, method?, distribution?, trace }`. `context` is the case plus whatever business data the rules read — the policy owns none of it. `dryRun` simulates without advancing any state. Ops doesn't record the outcome; whoever owns the case does.

## MCP

`POST /{tenant}/mcp` is a remote MCP server (Streamable HTTP transport, same `X-Engine-Key` auth as above — or the legacy `x-api-key` header name, accepted here only, for older MCP client configs — or OAuth, see below) over this same content — not one tool per REST operation: `help` (call it first — and again anytime as a refresher — full usage docs plus this file), `api` (calls any REST route of this tenant with the connection's own credential and X-Actor: `{ method, path, query?, body? }`, `path` relative to the tenant; answers the status, then `next-cursor: <c>` and `deprecation: …` lines when the response has them, then the body — on the tenant-wide mount only, never on a role-scoped one), and `fs`, which treats this tenant's content as a small git-like filesystem (`operation: pull|push|read|write|list|delete`). `pull` returns the whole POF tree as markdown+frontmatter files (same shape `GET /{tenant}/export` produces) plus a checkpoint; work against a local checkout with your own file tools, then `push` the files you changed back — each one applies independently, or comes back as a conflict (with the current remote version) if something changed remotely since your checkpoint ("local wins" otherwise, no automatic merge). No local disk? Use `read`/`write`/`delete` directly against one path at a time instead. Every operation calls back into this same REST API internally, so behavior and data are always identical between the two — same validation, same `enabledEntities`/role-scope restrictions. Stateless: a fresh server/session is built per HTTP request, never shared across requests or tenants. Roles/Wikis/Logs/Artifacts are read-only via `fs` (write via REST directly); Users aren't part of this tree at all (own small tool set instead, see `tools/list`).

**OAuth** (for clients that require it instead of a static header): standard OAuth 2.1 — `GET /.well-known/oauth-authorization-server` and `GET /.well-known/oauth-protected-resource/{tenant}/mcp` for discovery, `POST /register` for Dynamic Client Registration, `GET`/`POST /authorize` + `POST /token` for the authorization_code (PKCE, S256) and refresh_token grants, `POST /revoke` to revoke. There's no separate user system behind this — `/authorize`'s form just asks for the same tenant slug + `X-Engine-Key` used above, and the resulting token resolves back to that exact pair, so it's subject to the same `enabledEntities` restriction.

**Role-scoped connections**: `POST /{tenant}/mcp/{team}/{slug}` is the same server restricted to one Role, in the REST sense — a token this scoped 404s outside its Role on any surface, MCP included. `fs` itself still only ever shows/accepts Skills/Connections/Notebooks/Channels/Routines belonging to that role, plus that role's own record (read-only); Wikis/Logs have a real Role relation too (a role sees a Wiki/Log's object/thread if the whole container is granted to it — directly, or via the log's own `system` being covered by the role's `logSystems` — OR that object/thread is, directly — either is enough to read; container-level writes need the container grant specifically, and a `logSystems` grant counts as one) but stay outside `fs`'s tree for now, reachable only via REST directly. Skills/Notebooks are in scope if directly related OR the role's own `skillDomains`/`notebookProjects` covers their domain/project. Artifacts/Users still have no Role relation at all, so they're hidden entirely. The restriction lives in the OAuth token, not the URL — request it with `resource` set to this exact URL at `/authorize` — so it applies the same way whether that token is used here, at the bare `/{tenant}/mcp`, or against REST directly.

## Data model

Eleven entities, each tenant-scoped in Postgres (`tenant_slug`), each with its own field-level history in a shared `field_changes` table:

- **Role** — `team`, `title` (the position), `person`? (who holds it), `body` (free markdown: identity, operating context, purpose, rules — never parsed server-side). Addressed by `<team>/<slug>`, where `slug` is derived from `title` and unique within its team.
- **Skill** — `domain`, `title`, `description` (activation triggers), `category`?, `body`, `roles`/`users`: both many-to-many, independent of each other — a skill can be shared by role, personal to specific users, or both. May carry attached files. Addressed by `<domain>/<slug>`, unique per tenant.
- **Connection** — `title`, `description`, `protocol` (`mcp`/`openapi`), `url` (the MCP server or OpenAPI spec/base URL), `service` (the Vercel Connect service identifier, e.g. `slack`), `roles`/`users`: both many-to-many, independent of each other — a connection can be shared by role, personal to specific users, or both at once. No secrets/tokens live here; those live entirely in Vercel Connect (or an equivalent provider), keyed by `<service>/<slug>`. Addressed by its own `slug`, unique per tenant.
- **User** — `title`, `slug` (must equal the `x-agent-user` value the calling app sends — the one entity here whose slug is caller-chosen, not derived). No body/markdown of its own; exists purely to relate to Connections. Addressed by its own `slug`, unique per tenant.
- **Notebook** — `project`, `title`, `description`?, `summary` (its markdown is assembled as `# {title}\n\n{summary}`), `domains[]`, `roles`: many-to-many toward Role. Has dated, append-only notes and source references as separate immediate sub-resources, plus attached files. Addressed by `<project>/<slug>`, unique per tenant.
- **Wiki** — a container (`name`/`slug`) of one or more entity types you define (e.g. "Clientes"), each holding objects with a free EAV attribute schema. `resource`/`tags` are two reserved attribute names mapped to their own response fields; anything else lands in `metadata`. Read-only from every agent surface except this API's own object write routes — objects are addressed by `<wiki>/<entity>/<slug>`.
- **Artifact** — `collection` (grouping, same role as `domain` on Skill), `title`, `description`?, `body` (setup/usage instructions), `skills`: many-to-many toward Skill (the only relation this entity has, per its own POF schema). May carry attached files (the actual code). Addressed by `<collection>/<slug>`, unique per tenant. Deliberately storage-only — its own POF spec says ops doesn't need to render or run it, just define it and hold the code. Unrelated to the separate `engines/artifacts` service (single-file buildless html/markdown/react hosting) despite the shared name.
- **Channel** — a communication channel a role can use (`platform`: a fixed enum — slack/discord/teams/telegram/twilio/github/linear/chat_sdk/mcp/linq/photon/http/custom/other — plus `platformOther` when it's `"other"`, `identifier`? e.g. a phone number or Slack channel name, `description`), `roles`: many-to-many toward Role. Documentation only — no credentials/webhooks live here, same spirit Connections had before it needed a real `protocol`/`url`. Addressed by its own `slug`, unique per tenant.
- **Routine** — instructions for an agent to run: `title`, `goal`, `steps`, `definitionOfDone` (assembled server-side into one `markdown`), plus which `roles`/`connections`/`channels` (all many-to-many) it needs. Addressed by its own `slug`, unique per tenant. Formerly "Workflow", then "Task" — renamed on request, same shape.
- **Log** — System -> Log -> Thread (a free EAV attribute schema, same pattern as WikiObject) -> Message (append-only, up to 500 messages per thread, 4000 characters each). `system` is a pure grouping label (like `project` on Notebook), not its own table. `GET /{tenant}/logs` (list) deliberately omits `threads` — resolving every thread and message for every log of a tenant in one unpaginated response was the single biggest performance risk in this whole engine; `GET` of one specific log still returns everything, since there the volume is already bounded.
- **Policy** — `title`, `description`, `requireCatchAll` (the last rule must then be unconditional, so no case is left without a target), and an ordered list of Rules, each `{ title, when, then }`. Standalone: no relation to any other entity, targets are free text, and business data comes in each evaluation's `context`. `when` is `null` (always applies) or a condition tree — `{op:"exists",path}`, `{op:"equals",path,value}`, `{op:"in",path,values}`, `{op:"all"|"any",conditions}`, `{op:"not",condition}`. Any value is a literal or `{from:"a.b.c"}`, read from `context`. `then` picks a `method`: `fixed` (`target`), `weighted` (random, proportional to weights), `round_robin`/`weighted_rotation` (stateful turns, the latter with exact proportions; `partitionBy` keeps one cursor per value). Pool methods take `candidates` (strings or `{target, weight?, ...attrs}`) and an optional per-candidate `filter` over `{candidate, context}`. A rule catches a case only if its `when` holds AND its `then` yields a target — missing data or an empty pool just mean the case falls to the next rule, with the reason in the trace. Policies are deterministic on purpose: judgement over business data belongs in a loops `ai` step or an agent, which writes an attribute the policy then reads. Not role-scopable, not part of `fs`'s tree (reachable over MCP through `api`). Addressed by its own `slug`, unique per tenant.

None of this touches GitHub — a tenant is just a `slug` + a hashed api key (see `POST /admin/tenants`), nothing more. The GitHub-backed era of this API (agents/routines/skills read from a repo, plus a `projects`/`apps`/`files`/`memories` workspace for a human-facing UI and a `GET /{tenant}/version` tip-commit check) is gone entirely — there's no repo behind any tenant anymore.

## Full reference

- [OpenAPI spec](/openapi.json)
- [Swagger UI](/docs)
