Skip to content

METIS API Playbook — /api/v1/

Authoritative reference for /api/v1/, the open API for external systems integrating with METIS. If you are building against METIS from outside — an AI client, a data integration, a partner system — this is the surface you want, and this file is where its contracts live.

Not the same surface as /api/ — despite the paths, /api/v1/ is not a versioned edition of /api/. They are independent, with different authentication and error shapes. See PLAYBOOK.md for /api/, and API.md for the full surface index if you're not sure which one you want.

Live schema: https://app.the-gathering.earth/api/v1/openapi.json Swagger UI: https://app.the-gathering.earth/api/v1/docs


What this API is

METIS models a network of people and the groups, events and communities they belong to. This API exposes that model directly: holons (the groups, events and other entities), the people in them, the memberships that place a person in a holon at a point in a journey, and the relationships between holons — plus search and the class and journey definitions that give those records their meaning.

It is read-mostly, and every call is authenticated as a real METIS account and attributed to the Person behind it, so an integration's writes stay auditable to a human rather than to a shared key. The write endpoints are POST /api/v1/relationships/{relationship_id}/update, POST /api/v1/memberships/{membership_id}/update, and POST /api/v1/holons/{holon_id}/update. Generic bounded Membership creation is available at POST /api/v1/holons/{holon_id}/memberships:bulk-add, and POST /api/v1/people creates a Person.

App-owned parts of this surface are documented by their owning app: outreach-PLAYBOOK.md for /api/v1/outreach/*.


Authentication

The API uses a two-step auth flow. Standard API_TOKEN bearer tokens and browser session cookies do not authenticate /api/v1/ endpoints.

Login flow

  1. Client calls POST /api/v1/auth/login with:
  2. X-Metis-Api-Key: <API_LOGIN_SECRET> header
  3. JSON body: {"email": "...", "password": "..."}
  4. Server returns a 24-hour bearer token: metis_agentic_<token_id>_<secret>
  5. Subsequent calls send Authorization: Bearer <token>
  6. Logout: POST /api/v1/auth/logout revokes the token server-side

Token format: metis_agentic_<session_key>_<secret>

  • session_key: 32-char lowercase-alphanumeric key
  • secret: 43-char base64url random value (only a hash is stored server-side)

Invalidation triggers:

  • Explicit logout
  • Token expiry (24 hours)
  • Password change
  • Account deactivation

Settings

Setting Description
API_LOGIN_SECRET Shared login gate secret. Required on every login call.

Secret rotation procedure:

  1. Set the new API_LOGIN_SECRET on the server and restart.
  2. Update all clients to send the new value in X-Metis-Api-Key.
  3. Existing tokens remain valid until they expire or are logged out.

Error shape

All /api/v1/ error responses use:

{"code": "unauthenticated", "message": "Authentication required.", "retryable": false}
code HTTP status retryable
unauthenticated 401 false
permission_denied 403 false
not_found 404 false
validation_error 400 false
server_error 500 true

Exception — request validation (422): a missing required param or out-of-range value returns HTTP 422 with a different shape:

{"detail": [{"type": "...", "loc": ["query", "limit"], "msg": "..."}]}

Treat both 400 and 422 as non-retryable bad input.


Access model

Most directory reads remain authentication-level, with an object-level privacy exception for Holon classes explicitly configured as private:

  • Reads: a valid token has shared-directory Person access. Ordinary Holons remain directory-readable. A private-class Holon, its Membership/workflow state, related notes, and relationships are visible only to a global editor or a caller whose team-active authority covers that Holon. Direct reads of an inaccessible private Holon return 404, and collection/worklist endpoints filter it out.
  • Writes (POST /relationships/{id}/update, POST /memberships/{id}/update, POST /holons/{id}/update) are object-scoped: the caller must be able to edit the relevant holon — can_update_relationship (either side of the relationship), can_update_membership (the membership's holon), or can_edit_holon (the holon itself) — otherwise the call returns 403 permission_denied. POST /holons/{id}/update additionally requires global edit access to set journey_ids.
  • Creating a Person (POST /people) requires global edit access — the broadest gate on this API, and one most tokens do not carry. Adding to the shared directory is treated as a wider act than editing one record in it, because everyone else sees the result.

Do not use ordinary Person fields for private address-book data: Person projections remain shared-directory data even when one of their Memberships is on a private Holon.


Endpoints

POST /api/v1/people — auth: tokenBearer

Create a Person, optionally with an initial Membership and a note. Requires global edit access.

Duplicates are refused, never merged: if contact.email or contact.linkedin matches an existing Person, the call returns 409 with match_field, match_value, and existing_person_ids, and writes nothing — the caller decides whether to reuse that Person, correct the input, or merge in the web app. Names are not treated as identity, so two people may share one.

membership and note are independent and optional. With both, the note lands on the membership and so appears on the person's and the holon's feeds; with a note alone it attaches to the Person. The whole call is one transaction — an invalid membership means no Person is created either.

See the live schema for the full request and response shapes.


POST /api/v1/auth/login — auth: X-Metis-Api-Key + credentials

Exchange METIS email/password for a 24-hour bearer token. The account must have a linked METIS Person or login returns 403.

Request body:

{"email": "user@example.com", "password": "..."}

Response 200:

{
  "token": "metis_agentic_<id>_<secret>",
  "token_type": "Bearer",
  "expires_at": "2026-06-18T12:00:00+00:00",
  "expires_in_seconds": 86400,
  "person": {"id": 42, "name": "Alice", "description": "...", "photo_url": null, "actor_kind": "person", "contact": {}}
}


POST /api/v1/auth/logout — auth: tokenBearer

Revoke the current read token immediately.

Response 200: {"revoked": true}


GET /api/v1/auth/whoami — auth: tokenBearer

Return the logged-in METIS Person for the current token.

Response 200:

{"authenticated": true, "person": {"id": 42, "name": "Alice", ...}}


GET /api/v1/search — auth: tokenBearer

Search public Person and Holon fields together.

Param In Required Default Description
q query yes Search query (min 2 chars)
types query no person,holon Comma-separated subset
limit_per_type query no 20 Max 50 per type

Returns ranked results (name/channel hits ranked above description-only hits).


GET /api/v1/people — auth: tokenBearer

Search people by name substring.

Param In Required Default Description
q query yes Case-insensitive name substring
sort query no name name, latest (created desc), or updated (updated desc); all deterministic with a PK tie-breaker
created_after / updated_after query no ISO-8601 timestamps; strictly-after filters for polling / incremental sync
limit query no 100 Max 100
offset query no 0 Page offset — increment by limit until has_more is false

Response 200: {query, limit, offset, count, has_more, items: [PersonPublic]}PersonPublic includes created_at and updated_at. Invalid sort values return a validation error rather than being ignored.

These are the Person record's timestamps, not membership timestamps. created_at is when the contact entered METIS, which is not when they joined any particular holon. A Membership stores no creation timestamp at all, so join order is not retrievable from this API. Do not reconstruct it by scraping "Membership created: …" notes: that body is only a default, replaced whenever the creating caller supplied its own note, so the reconstruction is silently incomplete.


GET /api/v1/people/{person_id} — auth: tokenBearer

Retrieve one person by integer PK. Returns 404 if not found.


GET /api/v1/holons — auth: tokenBearer

Generic holon discovery. At least one of q or class must be provided.

Param In Required Description
q query no Case-insensitive name or description substring
class query no Active holon class slug, e.g. organisation, camp, experience. Matches that class and its whole subtree (e.g. class=camp also reaches camp_pt2026, camp_mx2026, etc.).
parent query no Filter by parent Holon PK (e.g. the owning Camp for experiences)
sort query no name (default), latest (created desc), or updated (updated desc); all deterministic with a PK tie-breaker
created_after / updated_after query no ISO-8601 timestamps; strictly-after filters for polling / incremental sync
limit query no Default 100, max 100
offset query no Page offset — increment by limit until has_more is false

Response 200: {query, class, parent, limit, offset, count, has_more, items: [HolonPublic]}HolonPublic now includes created_at and updated_at. Invalid class slugs or sort values return a validation error rather than being ignored. The live schema at /api/v1/openapi.json remains authoritative.


GET /api/v1/holons/{holon_id} — auth: tokenBearer

Retrieve one holon by integer PK. Returns 404 if not found.


GET /api/v1/holons/by-slug/{slug} — auth: tokenBearer

Retrieve one holon by slug. Returns 404 if not found.


POST /api/v1/holons/{holon_id}/update — auth: tokenBearer

Edit a holon's core fields, locations/spheres, per-class custom fields (info_field_groups), and/or journey assignments. Partial update: only fields present in the request body are touched.

Param In Required Description
holon_id path yes Holon PK

Request body (all fields optional):

Field Type Notes
name string Rejected if the holon's type marks name read-only (e.g. domain, event), or if empty after trimming.
description string Sanitized as rich-text HTML (same allowlist as the web editor).
links object (string→string) Full replace. Empty/whitespace-only values are dropped.
locations array of strings ISO country codes. 400 if any code is invalid.
spheres array of integers Sphere PKs. Must be active spheres. 400 if any id is invalid or inactive.
info_fields object (string→any) Keyed by an info_field_groups field key for the holon's class (discoverable via GET /classes). 400 on an unknown key, a slideshow-type key (not settable via this API), or a malformed select/video value. A select-type field is multi-value: its value must be a JSON array of strings (e.g. ["Dancing and music", "Inner Development"]), even to set a single tag — there is no single-value select. Any submitted value not in the field's options list is silently dropped rather than rejected, so double-check spelling against GET /classes.
journey_ids array of integers Full replace of the holon's Journey assignments. Requires global edit access (see Permissions). 400 if any id is invalid.

Behavior:

  • locations/spheres/info_fields all live in the holon's infos JSON column and are merged into one update.
  • The changes object in the response reports only fields that actually changed, {old, new} per field.

Response 200: {holon: HolonPublic, changes}.

Permissions: the caller must be able to edit the holon (can_edit_holon) for any field. journey_ids additionally requires global edit access — journeys are a class-catalog concern, not a per-holon one, matching the web UI's stricter gate on the Journeys field.

Errors: 400 (validation failures per field above), 403 permission denied, 404 not found.


GET /api/v1/classes — auth: tokenBearer

Discover active object classes and API-safe capability config. Use object_kind=holon to list Holon classes. Existing Holon payloads still return the class slug as type; they do not embed class config.

Only classes with is_active: true are listed here. A class can be retired (is_active: false) while objects already assigned to it remain on it — their type will still show the retired slug, but that slug will not appear in this list and GET /classes/{object_kind}/{slug} will 404 for it. Treat an unknown type as "retired class", not an error.

For holon classes, config also includes info_field_groups — the schema of custom per-class fields (grouped, each with key/type/label/options/etc.) that POST /holons/{holon_id}/update's info_fields accepts, keyed by key.

journeys is the class's effective journey catalog (own plus inherited, in catalog order) — the same set POST /holons/{holon_id}/memberships:bulk-add validates a journey slug against. Each entry is a JourneyListItem (see GET /journeys below); use GET /journeys/{slug} for a journey's steps.

Param In Required Description
object_kind query no Optional class scope, e.g. holon

Response 200: [MetisClassPublic]

{
  "object_kind": "holon",
  "slug": "event",
  "label": "Event",
  "plural_label": "Events",
  "description": "",
  "sort_order": 30,
  "is_system": true,
  "is_active": true,
  "icon_url": null,
  "config": {"css_class": "holon-type-event", "hasAdditionalFields": true},
  "journeys": [{"slug": "sponsorship", "name": "Sponsorship", "object_kind": "holon", "...": "..."}]
}

GET /api/v1/classes/{object_kind}/{slug} — auth: tokenBearer

Retrieve one active object class. Returns 404 if not found or inactive.


GET /api/v1/journeys — auth: tokenBearer

List every Journey with aggregate usage counts, for auditing the journey catalog (e.g. spotting unused or redundant journeys). Each item reports step_count and a usage object (holon_direct_count, membership_count, relationship_count) — aggregate totals only, not the underlying rows. To inspect the actual Membership/HolonRelationship records, use GET /people/{person_id}/memberships, GET /holons/{holon_id}/memberships, or GET /holons/{holon_id}/relationships.

Param In Required Description
metis_app query no Filter by owning MetisApp slug
object_kind query no Filter by applies_to object kind, e.g. holon
applies_to query no Filter by applies_to MetisClass slug
is_conversation query no true/false — conversation vs. non-conversation journeys
q query no Case-insensitive substring match on name or slug
limit query no Default 50, max 200
offset query no Default 0

Response 200: {count, limit, offset, has_more, items: [JourneyListItem]}

{
  "slug": "sponsorship",
  "name": "Sponsorship",
  "description": "",
  "metis_app_slug": "metis",
  "applies_to_slug": "camp",
  "applies_to_label": "Camp",
  "object_kind": "holon",
  "is_conversation": false,
  "public_visible": false,
  "config_flags": [{"key": "public-visible", "value": false, "is_set": false}],
  "step_count": 3,
  "usage": {"holon_direct_count": 1, "membership_count": 0, "relationship_count": 4}
}

GET /api/v1/journeys/{slug} — auth: tokenBearer

Retrieve one journey, including every step (ordered, archived steps included) with per-step usage counts (membership_count, relationship_count) — the signal for spotting orphaned or stale steps. Returns 404 if not found.

Response 200: JourneyListItem fields plus steps: [JourneyStepItem], each step shaped as:

{
  "slug": "proposal",
  "order": 1,
  "title": "Proposal",
  "goal": "",
  "success_criteria": "",
  "starter_message": "",
  "is_archived": false,
  "config_flags": [],
  "usage": {"membership_count": 0, "relationship_count": 1}
}

POST /api/v1/experiences — auth: tokenBearer

Create an Experience (gathering-owned) under an owning holon — a Camp or a Gathering.

Request body:

Field Type Notes
parent_id integer PK of the owning holon. Must allow an experience-subtree class as a child, or 400.
name string Required, non-empty after trimming.
description string Required, non-empty after trimming.
metis_class string, optional An experience-subtree class slug allowed by parent_id. Omit to use the parent's default (first allowed experience class); 400 if the slug given isn't one of the parent's allowed classes.
info_fields object (string→any), optional Same validation as POST /holons/{holon_id}/update's info_fields above — in particular, select-type fields (e.g. a tags field) are multi-value: submit a JSON array of strings even for a single tag. A value that isn't in the field's options is silently dropped rather than rejected.

Response 201: {experience: HolonPublic}.

Permissions: the caller must be able to edit the parent holon's content (can_edit_holon_content).

Errors: 400 (empty name/description, disallowed/unknown metis_class, parent config allows no experience class, invalid info_fields), 403 permission denied on parent, 404 parent not found.


POST /api/v1/experiences/{experience_id}/logo — auth: tokenBearer

Upload and set (or replace) an Experience's logo image. The logo is a display override: it takes precedence over the background image auto-assigned from the owning camp/gathering's pool, so this is how a caller pins a specific image instead of accepting the automatic draw.

Send multipart/form-data with a single logo file part.

Field In Type Notes
experience_id path integer PK of the Experience holon.
logo form file Required. image/jpeg, image/png, image/gif or image/webp, at most 5 MB.

Response 200: {experience: HolonPublic}logo_url reflects the new image.

Permissions: the caller must be able to edit the Experience's content (can_edit_holon_content).

Errors: 400 (unsupported content type, larger than 5 MB), 403 permission denied, 404 experience not found.


GET /api/v1/responsible — auth: tokenBearer

Unified worklist across Membership (person-side) and HolonRelationship (holon-side) follow-up assignments, ordered by follow_up_after ascending (items with no date sort last).

The two kinds are not forced into one shape: kind="person" items carry person+holon; kind="holon" items carry from_holon+to_holon.

Param In Required Default Description
responsible query no Filter by responsible Person PK
type query no all Comma-separated subset of person and registered holon class slugs. person selects Membership items; each holon class selects HolonRelationship items involving that class or any descendant class.
when query no none Comma-separated subset of overdue, today, future. Omitted = no date filter (includes undated). When set, undated items are excluded.
limit query no 100 Max 100
offset query no 0 Page offset

Response 200:

{
  "count": 2, "limit": 100, "offset": 0, "has_more": false,
  "items": [
    {
      "kind": "person", "id": 34, "follow_up_after": "2025-03-24",
      "journey_name": "Contact", "step_title": "Invited",
      "responsible_person": {"id": 7, "name": "Victor", ...},
      "person": {"id": 32, "name": "Alice", ...},
      "holon": {"id": 1, "name": "Global", ...}
    },
    {
      "kind": "holon", "id": 9, "follow_up_after": "2025-03-26",
      "journey_name": "Partnership", "step_title": "Negotiating",
      "responsible_person": null,
      "from_holon": {"id": 4, "name": "Summit Camp", ...},
      "to_holon": {"id": 11, "name": "Beta Inc", ...}
    }
  ]
}

journey_name, step_title, follow_up_after, and responsible_person are null when not set.

Errors: 400 if type or when contains an unrecognised value.


GET /api/v1/people/{person_id}/memberships — auth: tokenBearer

List all memberships for a person, ordered by journey name then holon name.

Param In Required Default Description
person_id path yes Person PK
limit query no 50 Max 200
offset query no 0 Page offset

Response 200: {count, limit, offset, has_more, items: [{membership_id, holon, journey_name, journey_slug, step_title, step_slug, follow_up_after, responsible_person}]}

responsible_person is a full PersonPublic object or null.

Errors: 404 if person not found.


GET /api/v1/people/{person_id}/notes — auth: tokenBearer

List notes on a person's memberships, newest first.

Param In Required Default Description
person_id path yes Person PK
limit query no 50 Max 200

Response 200: {count, limit, items: [{id, body, note_type, created_at, author_person}]}

author_person is a full PersonPublic object or null.

Errors: 404 if person not found.


GET /api/v1/holons/{holon_id}/memberships — auth: tokenBearer

List all memberships in a holon, ordered by journey name then person name.

Param In Required Default Description
holon_id path yes Holon PK
q query no Person name, description, or contact substring
journey query no Exact Journey slug
step_slug query no Exact current step slug
responsible_person_id query no Exact responsible Person PK
follow_up query no overdue, today, future, or none
sort query no name name, -name, follow_up_after, -follow_up_after, person_created, or -person_created
limit query no 50 Max 200
offset query no 0 Page offset

person_created orders by the Person's created_at (oldest first; prefix - for newest first) — when the contact entered METIS, not when they joined this holon. See the note under GET /api/v1/people: memberships carry no timestamp, so join order is not available here.

Response 200: {count, limit, offset, has_more, items: [{membership_id, person, journey_name, journey_slug, step_title, step_slug, follow_up_after, responsible_person}]}

Errors: 404 if holon not found.

POST /api/v1/holons/{holon_id}/memberships:bulk-add — auth: tokenBearer

Create 1–500 exact (person, holon, journey) Memberships with per-item outcomes. The Journey must be available in the Holon's effective class catalog and permit bulk addition. A later exact retry returns already_present; clients should not issue overlapping bulk writes for the same Holon. See the Outreach API playbook for the primary client use case; the live schema defines all fields and errors.


GET /api/v1/holons/{holon_id}/relationships — auth: tokenBearer

List holon relationships where the holon appears as from_holon or to_holon.

Param In Required Default Description
holon_id path yes Holon PK
limit query no 50 Max 200

Response 200: {count, items: [{relationship_id, from_holon, to_holon, journey_name, step_title, follow_up_after, responsible_person}]}

Errors: 404 if holon not found.


GET /api/v1/holons/{holon_id}/notes — auth: tokenBearer

List notes referencing a holon (directly, via its memberships, or via its relationships), newest first.

Param In Required Default Description
holon_id path yes Holon PK
limit query no 50 Max 200

Response 200: {count, limit, items: [{id, body, note_type, created_at, author_person}]}

Errors: 404 if holon not found.


POST /api/v1/relationships/{relationship_id}/update — auth: tokenBearer

Record an update on an existing holon relationship: a required note, plus an optional follow-up date change and an optional journey step move. All changes are applied atomically.

Param In Required Description
relationship_id path yes HolonRelationship PK

Request body:

Field Type Required Notes
note string yes Stored verbatim (trimmed). Must be non-empty.
follow_up_after date / null no ISO YYYY-MM-DD to set, null to clear. Omit to leave unchanged.
step_slug string no Active step slug on the relationship's current journey.
advance_step boolean no Move to the next active step in the current journey.

step_slug and advance_step: true are mutually exclusive.

Behavior:

  • step_slug must name a non-archived step on the relationship's existing journey.
  • advance_step: true moves to the next active step by (order, pk); the first active step when no current step is set.
  • The changes object in the response reports what actually changed (never augments the note text).

Response 200: {relationship, note, changes} where changes reports current_step (by slug) and/or follow_up_after (ISO dates) as {old, new}. Empty when nothing changed.

Permissions: a caller who can edit either holon side may update the relationship.

Errors: 400 (empty note, malformed date, invalid step_slug, both step controls supplied, no next step), 403 permission denied, 404 not found.


POST /api/v1/memberships/{membership_id}/update — auth: tokenBearer

Record an update on an existing person membership: a required note, plus an optional follow-up date change, an optional journey step move, and an optional journey reassignment. All changes are applied atomically. This is also the only way to move a membership onto a different Journey — there is no separate transfer endpoint and memberships:bulk-add only creates new memberships; set journey_slug (with step_slug) here to reassign an existing one.

Param In Required Description
membership_id path yes Membership PK

Request body:

Field Type Required Notes
note string yes Stored verbatim (trimmed). Must be non-empty.
follow_up_after date / null no ISO YYYY-MM-DD to set, null to clear. Omit to leave unchanged.
step_slug string no Active step slug. Resolved against the membership's current journey, or against journey_slug if that's also given. Required when journey_slug is given.
advance_step boolean no Move to the next active step in the current journey. Mutually exclusive with journey_slug.
responsible_person_id integer / null no Person with a user account; null clears responsibility.
journey_slug string no Move the membership to a different Journey on the same Holon. Must be paired with step_slug; mutually exclusive with advance_step. Omit to leave the membership on its current Journey.

step_slug and advance_step: true are mutually exclusive. journey_slug requires step_slug and is mutually exclusive with advance_step.

Behavior:

  • step_slug must name a non-archived step — on the membership's existing journey, or on journey_slug's journey when reassigning.
  • advance_step: true moves to the next active step by (order, pk); the first active step when no current step is set.
  • journey_slug must name one of the holon's MetisClass-allowed journeys for people (a journey's allow_bulk_add: false config does not block reassignment, unlike bulk-add). Reassignment is rejected if the person already has another membership on that journey for the same holon.
  • The changes object in the response reports what actually changed (never augments the note text).
  • The note is attached to both the person's and the holon's note feeds.

Response 200: {membership, note, changes} where changes reports journey (by slug), current_step (by slug), and/or follow_up_after (ISO dates) as {old, new}. Empty when nothing changed.

Permissions: a caller who can edit the membership's holon may update it.

Errors: 400 (empty note, malformed date, invalid step_slug, both step controls supplied, no next step, journey_slug without step_slug, journey_slug not allowed on this holon, duplicate membership on target journey), 403 permission denied, 404 not found.


Public field projections

PersonPublic: id, name, description, photo_url, actor_kind, contact

Private fields (infos, config, memberships, journey state, notes) are excluded. contact is returned to authenticated read-token holders.

HolonPublic: id, name, slug, type, description, parent_id, logo_url, links

type is always the Holon's class slug string, not a nested class object. Slugs are database-backed; active ones are discoverable at /api/v1/classes?object_kind=holon, but a Holon can carry a retired (is_active: false) class slug that is not present in that list — see GET /api/v1/classes above.

logo_url is computed as holon.logo.url if holon.logo else null — it is not a model field, and is null when no logo is set. links is returned to authenticated read-token holders.

Private fields (infos, config, memberships, relationships, journey state, notes) are excluded.

Not the same shape as /public/'s projections. /public/ (see public-PLAYBOOK.md) serves a fully anonymous caller and deliberately uses narrower, differently-named schemas built from data that's already been public_only-filtered — it does not reuse PersonPublic/HolonPublic above, since those assume a trusted, authenticated caller (e.g. PersonPublic.contact dumps the raw contact dict). This divergence is intentional; don't try to unify them.


Scope notes

  • No CORS — designed for server-side AI clients, not browser JS.
  • List endpoints return [] when nothing matches (never 404).
  • GET endpoints do not create or modify records.