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¶
- Client calls
POST /api/v1/auth/loginwith: X-Metis-Api-Key: <API_LOGIN_SECRET>header- JSON body:
{"email": "...", "password": "..."} - Server returns a 24-hour bearer token:
metis_agentic_<token_id>_<secret> - Subsequent calls send
Authorization: Bearer <token> - Logout:
POST /api/v1/auth/logoutrevokes the token server-side
Token format: metis_agentic_<session_key>_<secret>
session_key: 32-char lowercase-alphanumeric keysecret: 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:
- Set the new
API_LOGIN_SECRETon the server and restart. - Update all clients to send the new value in
X-Metis-Api-Key. - 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), orcan_edit_holon(the holon itself) — otherwise the call returns403 permission_denied.POST /holons/{id}/updateadditionally requires global edit access to setjourney_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_atis when the contact entered METIS, which is not when they joined any particular holon. AMembershipstores 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_fieldsall live in the holon'sinfosJSON column and are merged into one update.- The
changesobject 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_slugmust name a non-archived step on the relationship's existing journey.advance_step: truemoves to the next active step by(order, pk); the first active step when no current step is set.- The
changesobject 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_slugmust name a non-archived step — on the membership's existing journey, or onjourney_slug's journey when reassigning.advance_step: truemoves to the next active step by(order, pk); the first active step when no current step is set.journey_slugmust name one of the holon's MetisClass-allowed journeys for people (a journey'sallow_bulk_add: falseconfig 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
changesobject 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). GETendpoints do not create or modify records.