{ "openapi": "3.0.3", "info": { "title": "MaxGood.work Public BETA API", "version": "1.0.0-beta.1", "summary": "Public BETA API (Phase 1) for Enterprise Avatar Owners and Avatar Admins.", "description": "Machine-readable specification of the MaxGood.work Public BETA API\n(Phase 1). The full Stability & Use Contract \u2014 what is stable, how\nbreaking changes are announced, what an API key grants, how to get\nsupport \u2014 is the authoritative companion document and is hosted at\nhttps://blog.maxgood.work/api-documentation-beta.\n\nAll endpoints are authenticated with an org-scoped API key\n(`Authorization: Bearer mgak_.`) created\nfrom MaxGoodWeb's *Avatar Admin > Account* tab. Each key is bound\nto exactly one Avatar; calls authenticated with that key act only\non, and only on behalf of, that Avatar. The BETA API is intended\nfor server-to-server integrations; do not embed an API key in any\nclient-side code.\n\nThe BETA surface is published under the `/v1beta/` prefix. The\ninternal `/api/...` routes used by MaxGoodWeb and the messaging\nintegrations are NOT covered by this contract.\n", "contact": { "name": "MaxGood.work BETA API support", "url": "https://discord.gg/UMGkK4ES" }, "license": { "name": "Proprietary \u2014 MaxGood.work Stability & Use Contract", "url": "https://blog.maxgood.work/api-documentation-beta" } }, "servers": [ { "url": "https://api.maxgood.work/v1beta", "description": "Production" }, { "url": "https://stageapi.maxgood.work/v1beta", "description": "Stage" } ], "security": [ { "AvatarApiKey": [] } ], "tags": [ { "name": "Enterprise", "description": "Avatar identity, semantic retrieval over the Avatar's content,\nand tagging of Avatar members. These are the read-and-light-write\nendpoints that integrators reach for first \u2014 confirm the key is\ngood, look at what the Avatar can retrieve, and enrich member\ncontext.\n" }, { "name": "Content \u2014 titles", "description": "Titles are the unit of knowledge organisation in MaxGood.work \u2014 a\ntitle (for example \"Coaching for Scrum Masters\") groups a set of\nchunks. Chunks belong to a title, not directly to an Avatar; you\ncannot upload content without a title to put it under. These\nendpoints CRUD titles, list their documents and chunks, attach a\nfooter, and toggle a title's retrievability without deleting it.\n" }, { "name": "Content \u2014 chunks", "description": "Chunks are the atomic units of retrieval. Each chunk has its own\ntext and a usage count that tracks how often it has actually\nsurfaced in an Avatar reply. Editing a chunk re-embeds it so\nretrieval quality stays current; deleting a chunk is a\nsoft-delete that preserves the chunk-to-source-document linkage.\n" }, { "name": "Avatar reply", "description": "Get the Avatar's reply to a message on behalf of a named member\nof the Avatar. This is the same code path that powers the\nwebsite chat, Slack, Teams, Discord, and email integrations \u2014\nreply quality and personalisation are identical to a member\ninteracting through any first-party channel.\n" }, { "name": "Users", "description": "Manage the people in your Avatar. List members, invite new\nones (or attach existing MaxGood.work users to your Avatar),\nadjust per-Avatar role flags and per-Avatar metadata (notes,\nstructured data, license expiry), and soft-remove a user from\nyour Avatar without affecting any other Avatar they belong to.\n\nTwo policies apply to the invite path (`POST /users`):\na per-Avatar rate limit of 200 invites per rolling hour\n(returns `429 Retry-After`), and a security/abuse cap at 1000\nactive members per Avatar (returns `403` with a contact-support\nmessage). The org-type quota configured for your Avatar\n(Starter/Essential/Enterprise) is also enforced.\n\nUser identity (name, role, expertise, industry, goal, problem,\nlanguage) is set on `POST` and is intentionally NOT editable\nvia `PUT /users/{user_id}` \u2014 that surface manages only this\nAvatar's relationship to the user. If a user already exists on\nthe platform when you invite them, their identity fields are\npreserved (no-overwrite); only your Avatar's membership is\nadded. If they are already a member of your Avatar, the\nrequest returns `409`.\n" } ], "components": { "securitySchemes": { "AvatarApiKey": { "type": "http", "scheme": "bearer", "bearerFormat": "mgak_.", "description": "Org-scoped API key. The Avatar (`org_id`) is derived from the\ncredential; never include an `org_id` in the request.\n" } }, "schemas": { "SuccessEnvelope": { "type": "object", "required": [ "result", "response" ], "properties": { "result": { "type": "string", "enum": [ "success" ] }, "response": { "description": "Endpoint-specific payload (see each operation)." } } }, "FailureEnvelope": { "type": "object", "required": [ "result", "response" ], "properties": { "result": { "type": "string", "enum": [ "failure" ] }, "response": { "type": "string", "description": "Human-readable error message." } } }, "Title": { "type": "object", "properties": { "title_id": { "type": "string" }, "title_name": { "type": "string" }, "authors": { "type": "array", "items": { "type": "string" } }, "owner": { "type": "string" }, "description": { "type": "string" }, "is_enabled": { "type": "boolean" }, "count": { "type": "integer", "description": "Number of active chunks belonging to this title." } } }, "TitleList": { "type": "object", "properties": { "titles_count": { "type": "integer" }, "titles": { "type": "array", "items": { "$ref": "#/components/schemas/Title" } } } }, "Chunk": { "type": "object", "properties": { "text": { "type": "string" }, "title_id": { "type": "string" } } }, "ChunkSummary": { "type": "object", "properties": { "chunk_id": { "type": "string" }, "chunk_name": { "type": "string" }, "chunk_text": { "type": "string" }, "usage_count": { "type": "integer", "description": "How often this chunk has appeared as retrieval context in an Avatar reply." } } }, "ChunkList": { "type": "object", "properties": { "chunks": { "type": "array", "items": { "$ref": "#/components/schemas/ChunkSummary" } } } }, "UserMembership": { "type": "object", "description": "One user as seen from the perspective of the caller's Avatar:\nthe user's identity fields plus this Avatar's per-membership\nflags and metadata.\n", "properties": { "user_id": { "type": "string" }, "email": { "type": "string", "format": "email" }, "name": { "type": "string" }, "role": { "type": "string", "nullable": true }, "expertise": { "type": "string", "nullable": true }, "industry": { "type": "string", "nullable": true }, "goal": { "type": "string", "nullable": true }, "problem": { "type": "string", "nullable": true }, "language": { "type": "string", "nullable": true }, "is_admin": { "type": "boolean" }, "is_coach": { "type": "boolean", "description": "Label indicating the user provides coaching within this\nAvatar. No behavior change is currently associated with\nthis flag \u2014 it is shown as a \"Coach\" badge in MaxGoodWeb\nand MaxGoodAdmin and can be used to filter your own\nintegration views.\n" }, "is_owner": { "type": "boolean" }, "license_expiry_date": { "type": "string", "format": "date-time", "nullable": true }, "notes": { "type": "string", "nullable": true, "description": "This Avatar's free-form text note about the member." }, "structured_data": { "type": "object", "nullable": true, "description": "This Avatar's free-form JSON metadata about the member." }, "joined_at": { "type": "string", "format": "date-time" } } }, "Document": { "type": "object", "properties": { "id": { "type": "string" }, "org_id": { "type": "string" }, "title_id": { "type": "string" }, "s3_location": { "type": "string" }, "filename": { "type": "string" }, "filetype": { "type": "string" }, "mimetype": { "type": "string" }, "filesize": { "type": "integer", "format": "int64" }, "upload_date": { "type": "number", "format": "double" }, "active": { "type": "boolean" } } }, "DocumentList": { "type": "object", "properties": { "title_id": { "type": "string" }, "document_count": { "type": "integer" }, "documents": { "type": "array", "items": { "$ref": "#/components/schemas/Document" } } } }, "RagChunk": { "type": "object", "properties": { "text": { "type": "string" }, "score": { "type": "number" }, "chunk_id": { "type": "string" }, "title_id": { "type": "string" } } }, "Tag": { "type": "object", "required": [ "name", "description", "source" ], "properties": { "name": { "type": "string", "description": "Tag name (no `{}[]<>\"'` characters)." }, "description": { "type": "string" }, "source": { "type": "string" } } }, "AvatarReply": { "type": "object", "properties": { "response": { "type": "string", "description": "The Avatar's reply text. A reply that starts with `[ERROR]:` means the chat core declined to produce a response (e.g. the prompt exceeded the Avatar's input limit) and the text explains why.\n\nFoundation Content Title attribution is rendered into this text per the licensed Author's per-title preference (one line per cited Foundation chunk, in Markdown \u2014 link, link + author name, or title image + name + author image + name). Avatar Owners SHOULD NOT strip this attribution from the response shown to end users; it carries the Author's branding into every reply that cites their work." }, "interaction_id": { "type": "string", "description": "Server-minted id of the interaction row created for this call. Provided for reference only \u2014 do NOT send it back as input." }, "partner_content_ids": { "type": "array", "items": { "type": "string" }, "description": "Expertise chunk references (customer-uploaded content) cited by this reply. Foundation Content Title citations are NOT included here \u2014 they are rendered as Markdown attribution in `response.response` per the Author's per-title preference. Whether the Expertise references are displayed in the UI is controlled by the Avatar Owner's `display_references` toggle." } } } }, "responses": { "Unauthorized": { "description": "Missing, malformed, or revoked API key.", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" } } } } } }, "Forbidden": { "description": "Credential does not have the required scope.", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" } } } } } }, "NotFound": { "description": "Target resource was not found, or is not owned by the caller's Avatar (the two are intentionally indistinguishable).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } }, "ValidationError": { "description": "Request body or parameters failed validation.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } }, "PayloadTooLarge": { "description": "Request body exceeds the per-endpoint size limit.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } } } }, "paths": { "/organization": { "get": { "tags": [ "Enterprise" ], "operationId": "getOrganization", "summary": "Get the caller's Avatar identity.", "description": "Returns the Avatar identity (`org_id`) bound to your API key \u2014\nthe cheapest, simplest call on the BETA surface. A 200 means\nthe key is valid, not revoked, and bound to the Avatar you\nexpect; cache the returned `org_id` for the rest of your\nsession.\n\n**Why it exists.** The Avatar is derived from the credential\nrecord itself, never from the request body. A single\nround-trip to this endpoint is therefore both an\nauthentication check and an identity check \u2014 the API\nequivalent of a `whoami`.\n\n**Use cases.**\n- Boot-time health check before a long-running integration\n job.\n- Tag every outbound log line your integration emits with the\n connected Avatar's `org_id`.\n- Show \"Connected to *Avatar X*\" in your integration's\n settings UI so an admin reviewing a key knows exactly what\n it controls before configuring anything destructive.\n", "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "org_id": { "type": "string" }, "org_name": { "type": "string" } } } } } ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } } } }, "/rag-search": { "post": { "tags": [ "Enterprise" ], "operationId": "ragSearch", "summary": "Semantic retrieval over the Avatar's own content.", "description": "Runs an Intention-Driven RAG (ID-RAG) lookup \u2014 the same\nretrieval pipeline that backs every Avatar reply \u2014 over the\nAvatar's own Expertise content and returns the matching\nchunks with their similarity scores.\n\n**Scope.** Retrieval is restricted to the caller's own Avatar\n(`org-chunks-`). The collection target,\n`top_k`, and `min_score` are all derived server-side from\nthe Avatar and its `organization_type` \u2014 they are **not**\ncaller-supplied. This keeps Public BETA retrieval\nsemantically aligned with what the Avatar itself does\ninternally when it builds a reply.\n\nFoundation content retrieval (the platform-wide collection\nthat licensed expert authors contribute to) is intentionally\n**not** exposed in Phase 1 \u2014 opening it requires additional\ntechnical work and a separate commercial / contractual\nframework. If you have a use case that needs Foundation\nretrieval through the API, please get in touch on the support\nDiscord.\n\n**Why it matters.** A coaching Avatar's value is overwhelmingly\na function of *what content it can retrieve* for a member's\nquestion. Retrieval quality determines whether replies feel\ninsightful, generic, or off-base. Calling this directly lets\nyou reason about that quality for your own Expertise content\nwithout paying the cost of a full chat call.\n\n**Use cases.**\n- **Content-gap analysis.** Feed your ICP's common questions\n through this endpoint; queries that consistently fall below\n your `min_score` cutoff reveal where your Avatar's\n Expertise needs more material.\n- **Pre-flight \"does my Avatar already cover topic X?\"**\n check before redirecting a member to an external resource.\n- **External search UI.** Surface your Avatar's content in\n your own intranet search box with the same ranking the\n Avatar itself sees at reply time.\n- **Quality-of-retrieval regression detection.** Run the same\n probe queries on a schedule and alert when the top hit's\n similarity score drops below a baseline \u2014 early warning of\n content drift, mis-tagging, or accidental disabling of a\n high-value title.\n", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "content" ], "properties": { "content": { "type": "string", "description": "The query text to retrieve against." } } } } } }, "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "chunks": { "type": "array", "items": { "type": "object", "properties": { "chunk_id": { "type": "string" }, "score": { "type": "number" }, "text": { "type": "string" } } } } } } } } ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/ValidationError" } } } }, "/tags": { "post": { "tags": [ "Enterprise" ], "operationId": "addTagsToUser", "summary": "Add tags to a member of the Avatar.", "description": "Upserts one or more tags onto a member of the Avatar, keyed by\n`(org_id, user_id, tag_name)`. Sending the same tag name again\nupdates its description and source rather than duplicating it.\n\n**Why it matters.** Tags are first-class context for the\nAvatar \u2014 when a member interacts, the Avatar can see their\ntags and adjust its response emphasis, style, or\nrecommendations accordingly. Tagging a member is one of the\nlowest-effort, highest-leverage ways to make an Avatar's\nreplies feel personalised.\n\n**Use cases.**\n- **CRM sync.** Push HubSpot, Salesforce, or other CRM tags\n through so the Avatar's view of a member matches your\n account team's view.\n- **Behavioural enrichment.** Auto-tag based on external\n signals such as `completed-onboarding`,\n `scrum-master-cert`, or `industry:fintech` so the Avatar\n leans into the member's actual context.\n- **Learning-management bridge.** Sync completed-course or\n progress tags from an LMS so the Avatar references\n curriculum the member has actually finished.\n- **Lifecycle tags.** `trial-user`, `champion`, `at-risk` \u2014\n let the Avatar know what to emphasise (or de-emphasise)\n without writing a custom prompt.\n", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "user_email", "tags_to_add" ], "properties": { "user_email": { "type": "string", "format": "email" }, "tags_to_add": { "type": "array", "items": { "$ref": "#/components/schemas/Tag" } } } } } } }, "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessEnvelope" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/ValidationError" } } } }, "/content/titles": { "get": { "tags": [ "Content \u2014 titles" ], "operationId": "listContentTitles", "summary": "List the Avatar's Expertise titles.", "description": "Lists the Avatar's **Expertise** titles \u2014 the titles owned by\nthe Avatar itself. Foundation titles (the global licensed\nauthor collection) are not included.\n\nEach entry carries the title id, name, enabled flag, and the\ncount of active chunks under it. This is the roster of what\nyour Avatar \"knows.\"\n\n**Why it matters.** A title is a coherent body of knowledge\n\u2014 for example, \"Coaching for Scrum Masters\" \u2014 and what is\n*enabled* here is what the Avatar can actually retrieve from\nwhen it replies. Auditing this list regularly is the simplest\nway to keep an Avatar's Expertise aligned with what your\ncustomers care about.\n\n**Use cases.**\n- External content-management dashboard for users who do not\n use MaxGoodWeb.\n- Quarterly Expertise audit \u2014 every title with its chunk\n count and enabled state, in a single report.\n- Sync titles outward to a source-of-truth knowledge base or\n CMS that already manages your content lifecycle.\n", "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "titles_count": { "type": "integer" }, "titles": { "type": "array", "items": { "type": "object", "properties": { "title_id": { "type": "string" }, "title_name": { "type": "string" }, "is_enabled": { "type": "boolean" }, "count": { "type": "integer", "description": "Number of active chunks belonging to this title." } } } } } } } } ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } } }, "post": { "tags": [ "Content \u2014 titles" ], "operationId": "createContentTitle", "summary": "Create an Expertise title for the Avatar.", "description": "Creates a new Expertise title under the Avatar. The only input\nis the title's name; the new title is owned by the API\ncredential that created it.\n\n**Why it matters.** Titles are the unit of knowledge\norganisation in the platform \u2014 chunks belong to a title, not\ndirectly to an Avatar. You cannot upload content\n(`POST /content/titles/{id}/upload`) without a title to put\nit under. This endpoint is the entry point for growing an\nAvatar's Expertise in a new domain.\n\n**Use cases.**\n- One-time scaffolding when a new Avatar is created \u2014\n pre-create the titles your team intends to fill out so\n uploads can begin immediately.\n- Migration from another knowledge platform \u2014 recreate the\n source's category structure as titles.\n- Continuous content pipeline \u2014 a CMS webhook auto-creates a\n title the first time a new content category appears, then\n uploads chunks into it.\n", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "title" ], "properties": { "title": { "type": "string", "description": "The human-readable title name." } } } } } }, "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "id": { "type": "string" }, "message": { "type": "string" } } } } } ] } } } }, "400": { "$ref": "#/components/responses/ValidationError" }, "401": { "$ref": "#/components/responses/Unauthorized" } } } }, "/content/titles/{title_id}": { "parameters": [ { "in": "path", "name": "title_id", "required": true, "schema": { "type": "string" } } ], "get": { "tags": [ "Content \u2014 titles" ], "operationId": "getContentTitle", "summary": "Get one Expertise title owned by the Avatar.", "description": "Returns the metadata of a single Expertise title owned by the\nAvatar \u2014 its name, enabled flag, and active chunk count.\nReturns 404 if the title does not exist or is owned by a\ndifferent Avatar (the two cases are intentionally\nindistinguishable, so this endpoint never confirms another\nAvatar's title id).\n\n**Use cases.** Detail view in an external CMS; integration\nthat needs to validate a `title_id` exists before uploading\ncontent; sync-checking after a `PUT /content/titles/{id}`\nupdate has been applied.\n", "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "title_id": { "type": "string" }, "title_name": { "type": "string" }, "is_enabled": { "type": "boolean" }, "count": { "type": "integer", "description": "Number of active chunks belonging to this title." } } } } } ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } }, "put": { "tags": [ "Content \u2014 titles" ], "operationId": "updateContentTitle", "summary": "Rename an Expertise title owned by the Avatar.", "description": "Renames an Expertise title. The BETA accepts only\n`title_name`; owner, weight, and description are not part of\nthe Expertise-title surface in Phase 1 and are not changeable\nthrough this route. Cross-Avatar updates are rejected with\n404.\n\n**Why it matters.** Titles are referenced by `title_id`\neverywhere \u2014 in chunks, footers, retrieval results, the\ninternal MaxGoodWeb UI. Renaming a title here updates all\nthose surfaces atomically; no further work is needed to keep\nthe rest of the platform in sync.\n\n**Use cases.** Bulk-rename titles after a content reorg; fix\na typo in a title name without recreating its chunks; sync\ntitle-name changes from an external system of record.\n", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "title_name" ], "properties": { "title_name": { "type": "string" } } } } } }, "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "title_name": { "type": "string" } } } } } ] } } } }, "400": { "$ref": "#/components/responses/ValidationError" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } }, "delete": { "tags": [ "Content \u2014 titles" ], "operationId": "deleteContentTitle", "summary": "Soft-delete a title and cascade to its content.", "description": "Soft-deletes a title and cascades to its chunks, documents,\nand footer: rows are marked deleted, text fields are\noverwritten with placeholders, S3 objects are retained, and\nChromaDB vectors are removed so the title is no longer\nretrievable. **This is a one-way operation.**\n\n**Why it matters.** The cascade is intentional \u2014 leaving\ntombstoned chunks behind would surface garbage through\nretrieval. Because S3 objects are retained, the originating\ndocuments are recoverable if you ever need them.\n\n**Use cases.** Retire a title you no longer want the Avatar\nto draw on; clean up after a misconfigured upload; remove\nout-of-licence content programmatically when a licence ends.\n", "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessEnvelope" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } } }, "/content/titles/{title_id}/is_enabled": { "parameters": [ { "in": "path", "name": "title_id", "required": true, "schema": { "type": "string" } } ], "put": { "tags": [ "Content \u2014 titles" ], "operationId": "setContentTitleEnabled", "summary": "Enable or disable a title (reversible).", "description": "Sets a title's `is_enabled` flag. Disabled titles stay in the\ndatabase but are excluded from retrieval \u2014 the Avatar acts as\nif they don't exist.\n\n**Business rule.** Setting `is_enabled=true` on a title with\nzero active chunks is rejected with `409 Conflict` and the\nexplanatory message *\"Cannot enable a title with no active\nchunks. Upload content to the title before enabling it.\"* \u2014\nan empty title would silently mislead integrators into\nthinking the Avatar can draw on it. Disabling is always\npermitted.\n\n**Why it matters.** Disabled is reversible; `DELETE` is not.\nUse this endpoint for any temporary \"do not retrieve from\nthis\" state rather than deletion.\n\n**Use cases.**\n- **A/B-test what content the Avatar uses** \u2014 disable a title\n for a window and observe whether reply quality changes.\n- **Maintenance mode** while content is being rewritten \u2014\n disable, edit, re-upload, re-enable.\n- **Seasonal content** \u2014 disable an event-year-specific title\n after the event, without losing it for next year.\n", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "is_enabled" ], "properties": { "is_enabled": { "type": "boolean" } } } } } }, "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "title_id": { "type": "string" }, "is_enabled": { "type": "boolean" } } } } } ] } } } }, "400": { "$ref": "#/components/responses/ValidationError" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" }, "409": { "description": "Cannot enable a title that has no active chunks.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } } } } }, "/content/titles/{title_id}/upload": { "parameters": [ { "in": "path", "name": "title_id", "required": true, "schema": { "type": "string" } } ], "post": { "tags": [ "Content \u2014 titles" ], "operationId": "uploadContentToTitle", "summary": "Ingest a file or raw text into a title.", "description": "Submits a file or raw text to be ingested into a title. Files\ngo through Apache Tika for text extraction (PDF, DOCX, PPT,\nand other common formats are supported); the JSON path skips\nTika. In both paths the text is chunked, embedded with the\nplatform's local embedding model, and stored in PostgreSQL\nand ChromaDB. The request body is capped at 25 MB.\n\nAn optional `source_document_title` is recorded alongside the\nresulting chunks so they can be traced back to the document\nthey came from. Arbitrary per-title or per-chunk metadata is\nintentionally not in Phase 1 \u2014 it is on the roadmap with the\nwider IDRAG metadata improvements.\n\n**Why it matters.** This is the primary path for growing an\nAvatar's knowledge. Every chunk created here becomes\nretrievable on the next `/rag-search` or `/avatar/respond`\ncall against the Avatar.\n\n**Use cases.**\n- **Programmatic sync** of a customer's internal wiki or\n playbook into their Enterprise Avatar.\n- **Auto-ingest** meeting transcripts, support tickets, or\n post-mortems as they are generated, on a queue.\n- **Bulk import on Avatar setup** \u2014 feed a folder of PDFs\n through the endpoint to bootstrap a new Avatar.\n- **Continuous content pipeline** \u2014 webhook from a CMS\n triggers an upload whenever an article is published or\n updated.\n", "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "type": "object", "required": [ "file" ], "properties": { "file": { "type": "string", "format": "binary" }, "source_document_title": { "type": "string", "description": "Optional human-readable name for the document this upload represents; recorded on the resulting chunks for traceability." } } } }, "application/json": { "schema": { "type": "object", "required": [ "text" ], "properties": { "text": { "type": "string" }, "source_document_title": { "type": "string", "description": "Optional human-readable name for the document this text represents; recorded on the resulting chunks for traceability." } } } } } }, "responses": { "200": { "description": "OK \u2014 content ingested.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "message": { "type": "string" }, "chunk_ids": { "type": "array", "items": { "type": "string" } }, "chunk_count": { "type": "integer" } } } } } ] } } } }, "400": { "$ref": "#/components/responses/ValidationError" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" }, "413": { "$ref": "#/components/responses/PayloadTooLarge" } } } }, "/content/titles/{title_id}/documents": { "parameters": [ { "in": "path", "name": "title_id", "required": true, "schema": { "type": "string" } } ], "get": { "tags": [ "Content \u2014 titles" ], "operationId": "listContentTitleDocuments", "summary": "List the source documents of a title.", "description": "Lists the source documents (the original uploaded files) for a\ntitle \u2014 filename, mimetype, size, upload date, active flag,\nand a presigned `download_url` that lets you retrieve the\noriginal file directly. The presigned URL is valid for 15\nminutes; re-call this endpoint to refresh.\n\nBoth active and inactive (soft-deleted) documents are\nreturned, so a tombstoned title's references stay visible\n\u2014 `is_active=false` indicates a soft-deleted row whose S3\nobject is still retained.\n\n**Why it matters.** Documents are the *source* of chunks.\nKnowing which document a chunk came from \u2014 and being able to\nfetch the original file back \u2014 is essential for any audit,\nattribution, or retraction workflow.\n\n**Use cases.** External content-management dashboard showing\nper-title source files; an audit log of what was ingested\nwhen; resolving \"where did this chunk come from?\" while\nresponding to a content question from a customer; pulling\nthe original file back into a different system without\nkeeping a parallel copy.\n", "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "title_id": { "type": "string" }, "document_count": { "type": "integer" }, "documents": { "type": "array", "items": { "type": "object", "properties": { "document_id": { "type": "string" }, "filename": { "type": "string" }, "filetype": { "type": "string" }, "mimetype": { "type": "string" }, "filesize": { "type": "integer", "format": "int64" }, "upload_date": { "type": "number", "format": "double" }, "is_active": { "type": "boolean" }, "download_url": { "type": "string", "description": "Presigned download URL valid for 15 minutes; empty string if the underlying storage location is unset or unreachable." } } } } } } } } ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } } }, "/content/titles/{title_id}/chunks": { "parameters": [ { "in": "path", "name": "title_id", "required": true, "schema": { "type": "string" } } ], "get": { "tags": [ "Content \u2014 titles" ], "operationId": "listContentTitleChunks", "summary": "List the active content chunks of a title.", "description": "Lists the active chunks under a title \u2014 each chunk's id, name,\ntext, and per-chunk `usage_count` (how often this chunk has\nactually appeared as retrieval context in an Avatar reply).\nInactive (soft-deleted) chunks are excluded.\n\n**Why it matters.** Chunks are the atomic units of retrieval.\nLooking at them gives you the most accurate picture of what\nthe Avatar can actually surface for this title, and\n`usage_count` is a direct signal of which chunks are doing\nthe work.\n\n**Use cases.** External chunk-level CMS; usage-driven content\nprioritisation (high-usage chunks deserve quality investment;\nchunks that never get retrieved are candidates for editing or\nremoval); identifying low-quality chunks for editing\n(`PUT /content/chunks/{id}`) or deletion.\n", "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "chunk_count": { "type": "integer" }, "chunks": { "type": "array", "items": { "type": "object", "properties": { "chunk_id": { "type": "string" }, "chunk_name": { "type": "string" }, "chunk_text": { "type": "string" } } } } } } } } ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } } }, "/content/titles/{title_id}/footer": { "parameters": [ { "in": "path", "name": "title_id", "required": true, "schema": { "type": "string" } } ], "get": { "tags": [ "Content \u2014 titles" ], "operationId": "getContentTitleFooter", "summary": "Get the footer for a title.", "description": "Returns the footer that the Avatar will surface alongside\nretrieved chunks from this title. Footers are typically used\nfor attribution, source citations, or licensing notices.\n\nAlways returns a `footer_html` value: when an explicit footer\nis set for the title, its HTML is returned and `footer_id`\nis the footer row's id; when no footer is set, `footer_id`\nis `null` and `footer_html` falls back to the\norganization-type-wide default footer so the caller has\nsomething usable.\n\n**Why it matters.** Footers travel with retrieval results, so\nlegal or attribution requirements stay attached to the\ncontent they apply to without polluting the chunks\nthemselves.\n\n**Use cases.** Surface a licence statement next to every\nretrieved chunk from a licensed third-party title; show an\nauthor byline; insert a \"see original\" backlink to the\ncanonical source.\n", "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "footer_id": { "type": "string", "nullable": true, "description": "Footer row id, or null when no explicit footer is set for the title." }, "footer_html": { "type": "string", "description": "Footer HTML \u2014 the title's own footer when set, otherwise the organization-type default." } } } } } ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } }, "put": { "tags": [ "Content \u2014 titles" ], "operationId": "setContentTitleFooter", "summary": "Create or replace the footer for a title.", "description": "Upserts the footer for a title. Sending the request again for\nthe same title replaces the existing footer; the footer\napplies retroactively to every existing chunk under the\ntitle and prospectively to new ones. Paired with the GET at\nthe same URL.\n\n**Use cases.**\n- Bulk-update licensing language across all titles when a\n licence's terms change.\n- Insert per-title attribution when onboarding a new licensed\n author.\n- Set a \"source: \" footer on titles backed by a\n single source document so retrieval results carry their\n origin.\n", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "footer_html" ], "properties": { "footer_html": { "type": "string", "description": "Footer HTML content." } } } } } }, "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "footer_id": { "type": "string" }, "message": { "type": "string" } } } } } ] } } } }, "400": { "$ref": "#/components/responses/ValidationError" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } } }, "/content/chunks/{chunk_id}": { "parameters": [ { "in": "path", "name": "chunk_id", "required": true, "schema": { "type": "string" } } ], "get": { "tags": [ "Content \u2014 chunks" ], "operationId": "getContentChunk", "summary": "Get one content chunk owned by the Avatar.", "description": "Returns a chunk's text, name, parent `title_id`, and\nactive-state flag.\n\nSoft-deleted chunks are intentionally still readable here:\n`DELETE /content/chunks/{id}` overwrites the text with a\nplaceholder but leaves the row queryable so the chunk-to-\nsource-document linkage is preserved. A caller who already\nknows the chunk_id can still fetch it \u2014 `is_active=false`\nsignals the tombstoned state. The list-chunks endpoint\n(`GET /content/titles/{id}/chunks`) excludes soft-deleted\nchunks, so deleted chunks are not *discoverable* from the\nlist but remain *readable* directly.\n\n**Use cases.** Chunk detail view in an external CMS;\nresolving `partner_content_ids` returned by `/rag-search` or\n`/avatar/respond` to the underlying chunk content.\n", "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "chunk_text": { "type": "string" }, "chunk_name": { "type": "string" }, "title_id": { "type": "string" }, "is_active": { "type": "boolean", "description": "false when the chunk has been soft-deleted; the returned `chunk_text` will be a placeholder." } } } } } ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } }, "put": { "tags": [ "Content \u2014 chunks" ], "operationId": "editContentChunk", "summary": "Edit a chunk's text (and optionally its source attribution).", "description": "Edits a chunk's text and re-embeds it in ChromaDB so\nsubsequent retrievals reflect the change.\n\nEvery chunk's stored text starts with a canonical header that\nthe platform builds at upload time:\n\n```\n `Title in Avatar Expertise: `\n `Source Document Title: `\n```\n\nThat header is part of the embedding, so the title name and\nsource-document title both participate in retrieval matches\nfor every chunk. Supplying `source_document_title` here\nrebuilds the header with the new source-document title and\nupdates the chunk's stored metadata + auto-derived `name`;\nomitting it preserves the existing source attribution.\n\nCross-Avatar attempts and unknown chunk ids both return 404\n(the underlying `\"Forbidden.\"` is mapped so the endpoint\nnever confirms a chunk exists under another Avatar).\n\n**Why it matters.** Edits are surgical. You can fix a single\nbad chunk without re-ingesting the whole document.\nRe-embedding is automatic, so retrieval quality stays current\nwith the edit.\n\n**Use cases.**\n- Correct a fact in a chunk without re-uploading the source\n document.\n- Sharpen a chunk's wording \u2014 Tika extraction can produce\n slightly awkward English; a brief edit improves both the\n embedding (and therefore retrieval) and the eventual reply\n quality.\n- Re-attribute a chunk to a different source document (for\n example, after merging or renaming source documents) and\n have the new attribution flow through to retrieval.\n- Redact PII discovered post-ingest in a single chunk rather\n than rebuilding the whole title.\n", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "chunk_text" ], "properties": { "chunk_text": { "type": "string" }, "source_document_title": { "type": "string", "description": "Optional. When provided, rebuilds the chunk's canonical header (which is part of the embedding) with this new source-document title and updates the chunk's stored metadata + auto-derived name column." } } } } } }, "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessEnvelope" } } } }, "400": { "$ref": "#/components/responses/ValidationError" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } }, "delete": { "tags": [ "Content \u2014 chunks" ], "operationId": "deleteContentChunk", "summary": "Soft-delete a chunk.", "description": "Soft-deletes a chunk: text is replaced with a placeholder,\nthe `is_active` flag is flipped, the ChromaDB vector is\nremoved, and an audit record is written. The chunk's database\nrow is preserved so its linkage to the originating source\ndocument survives. Cross-Avatar attempts and unknown chunk\nids both return 404 (the underlying `\"Forbidden.\"` is mapped\nso the endpoint never confirms a chunk exists under another\nAvatar).\n\n**Use cases.** Drop a single bad chunk without affecting the\nrest of its title; one-off content takedown driven by an\nexternal review workflow; programmatic redaction tied to a\nprivacy report.\n", "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessEnvelope" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } } }, "/avatar/respond": { "post": { "tags": [ "Avatar reply" ], "operationId": "avatarRespond", "summary": "Get an Avatar reply on behalf of a member.", "description": "Asks the Avatar to reply to a message on behalf of one of its\nmembers. The caller names the member by `mg_user_id` (who\nMUST be a current member of the Avatar bound to the API key \u2014\n404 otherwise); per-thread context is built automatically\nfrom that member's stored interaction history, so the\nintegrator does NOT manage threads.\n\n**Why it matters.** This is the only BETA endpoint that\nactually *exercises* the Avatar's coaching behaviour from\noutside the first-party MaxGood.work channels. It runs the\nsame code path that powers the website chat, Slack, Teams,\nDiscord, and email integrations \u2014 reply quality, retrieval,\npersonalisation by member tags, and tone are identical to\nwhat the member would get through any of those.\n\n**The interaction id is server-minted.** The endpoint\ndeliberately does not accept a caller-supplied interaction or\nconversation id, because the underlying chat-core upserts by\nid with no ownership predicate \u2014 a caller-supplied id would\nbe a write-IDOR onto any interaction in the system.\n`interaction_id` in the response is for reference only.\n\n**Prompt-length limits (Phase 1).** Two hard caps are\nenforced *and* both must pass:\n\n- **10 000 characters** (`len(prompt)`) \u2014 matches the limit\n MaxGoodWeb's own chat input enforces.\n- **4 096 tokens** (counted with the same tokenizer the LLM\n uses) \u2014 the LLM context-window unit.\n\nFor typical English prose either cap leaves plenty of room\nand 10 k characters is the binding constraint; for unusually\ndense text (code blocks, CJK, lots of symbols) the token\ncap can bite first. **Either over-limit rejects the request\nwith `400`** and a message naming the cap that was hit, so\nintegrators are encouraged to surface *both* limits in their\nown UX (a character counter is cheap; an approximate\ntoken-count is a useful \"yellow zone\" indicator).\n\nBoth limits will become per-Avatar-type configuration in a\nfuture release (tracked under the per-Avatar-type-limits\nExpedite card linked from card #65).\n\n**Use cases.**\n- **Embed the Avatar in a customer's own product surface** \u2014\n their support widget, in-app coach, intranet chat \u2014\n without taking on the full first-party integration.\n- **Asynchronous coaching loops** \u2014 a daily job picks up\n trigger conditions for a member, drops a prompt to the\n Avatar, and emails the reply through your own mail system.\n- **Bridge to a private channel** that MaxGood.work itself\n does not have a first-party integration for yet (for\n example, a closed enterprise messenger).\n- **A/B-test reply presentation** \u2014 wrap `/avatar/respond` in\n your own UI and try formatting variants (with or without\n references, summarised, etc.) while the Avatar's logic\n stays constant.\n", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "mg_user_id", "prompt" ], "properties": { "mg_user_id": { "type": "string", "description": "Internal MaxGood user id of the end user this request is on behalf of. Must be a member of the Avatar bound to the API key." }, "prompt": { "type": "string", "description": "The user's message (non-empty after trim)." } } } } } }, "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "$ref": "#/components/schemas/AvatarReply" } } } ] } } } }, "400": { "$ref": "#/components/responses/ValidationError" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "description": "API credential is missing the required `enterprise_api` scope.", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" } } } } } }, "404": { "description": "User is not a member of this Avatar.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } }, "502": { "description": "The chat core did not return a response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } } } } }, "/users": { "get": { "tags": [ "Users" ], "operationId": "listUsers", "summary": "List the Avatar's active members, paginated.", "description": "Lists every user with an active membership in the caller's\nAvatar. Soft-removed members are excluded. Each entry carries\nthe user's identity (name, role, expertise, industry, goal,\nproblem, language) plus this Avatar's per-member flags\n(`is_admin`, `is_coach`, `is_owner`) and per-Avatar metadata\n(`license_expiry_date`, `notes`, `structured_data`).\n\n**Pagination.** `page` defaults to 1, `page_size` defaults to\n50 and is clamped to a maximum of 200. Ordering is\nmost-recently-joined first, with `user_id` as a stable\ntiebreaker.\n\n**Use cases.**\n- Sync your Avatar's roster outward to an HR system, IdP, or\n analytics warehouse.\n- Show \"who is in the Avatar?\" inside your own internal tool\n without round-tripping through MaxGoodWeb.\n- Quarterly seat audit against your active membership count.\n", "parameters": [ { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "default": 1 } }, { "name": "page_size", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } } ], "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "page": { "type": "integer" }, "page_size": { "type": "integer" }, "users_count": { "type": "integer" }, "users": { "type": "array", "items": { "$ref": "#/components/schemas/UserMembership" } } } } } } ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } } }, "post": { "tags": [ "Users" ], "operationId": "createUser", "summary": "Invite a user into the caller's Avatar.", "description": "Adds a user to your Avatar. Behavior depends on whether the\nemail already maps to a MaxGood.work user:\n\n- **New user** \u2014 creates the user record with the provided\n identity fields and adds your Avatar's membership. An\n invitation email is sent to the user.\n- **Existing user, not yet in your Avatar** \u2014 adds your\n Avatar's membership without touching the user's identity\n fields (no-overwrite). The user is notified that they have\n been added to your Avatar.\n- **Existing user, already a member of your Avatar** \u2014 returns\n `409`. Use `PUT /users/{user_id}` to update their per-Avatar\n flags or metadata instead.\n\n**Policies enforced (in order).**\n1. *Rate limit.* 200 invites per rolling hour per Avatar.\n Returns `429` with a `Retry-After` header.\n2. *Org-type quota.* Your Avatar's\n `organization_types.user_limit` (configured per Avatar type)\n is honored \u2014 the same ceiling MaxGoodWeb's Add User flow\n enforces. Returns `403` when full.\n3. *Security/abuse cap.* 1000 active members per Avatar.\n Returns `403` with a \"contact support\" message. Support\n can lift this cap on a per-Avatar basis.\n\n**No-overwrite scope.** When an existing user is invited,\nthese fields are silently ignored from the request body:\n`name`, `role`, `expertise`, `industry`, `goal`, `problem`,\n`language`. Per-Avatar fields (`is_admin`, `is_coach`,\n`license_expiry_date`, `notes`, `structured_data`) ARE\napplied to the new membership.\n\n`is_owner` is NOT acceptable on this endpoint \u2014 owner transfer\nis a staff-side operation.\n", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "email", "name" ], "properties": { "email": { "type": "string", "format": "email" }, "name": { "type": "string" }, "is_admin": { "type": "boolean" }, "is_coach": { "type": "boolean" }, "role": { "type": "string" }, "expertise": { "type": "string" }, "industry": { "type": "string" }, "goal": { "type": "string" }, "problem": { "type": "string" }, "language": { "type": "string" }, "license_expiry_date": { "type": "string", "format": "date", "description": "ISO-8601 date (YYYY-MM-DD) or full datetime." }, "notes": { "type": "string" }, "structured_data": { "type": "object", "description": "Free-form JSON metadata your Avatar owns about this member." } } } } } }, "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "user_id": { "type": "string" }, "email": { "type": "string" }, "name": { "type": "string" }, "existing_user": { "type": "boolean" }, "invitation_sent": { "type": "boolean" } } } } } ] } } } }, "400": { "$ref": "#/components/responses/ValidationError" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "description": "Avatar quota reached. Body's `response` field names the\nspecific limit hit (org-type user limit OR the 1000-member\nsecurity cap).\n", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } }, "409": { "description": "User is already an active member of this Avatar.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } }, "429": { "description": "Per-Avatar invite rate limit (200/hour) reached. The\n`Retry-After` response header gives seconds until you may\nretry.\n", "headers": { "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds until the rate-limit window opens." } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } } } } }, "/users/{user_id}": { "parameters": [ { "name": "user_id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "MaxGood.work user id." } ], "get": { "tags": [ "Users" ], "operationId": "getUser", "summary": "Get one user with this Avatar's membership.", "description": "Returns the user's identity fields and the single membership\nrow for the caller's Avatar.\n\nIDOR-safe: a user that does not exist AND a user that exists\nbut has no active membership in your Avatar are reported\nidentically as `404`. The endpoint never confirms whether\nanother Avatar's user exists.\n\n`context` and `private_data` are intentionally NOT exposed\nvia the Public BETA.\n", "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "$ref": "#/components/schemas/UserMembership" } } } ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "description": "User not found or not a member of this Avatar.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } } } }, "put": { "tags": [ "Users" ], "operationId": "updateUser", "summary": "Update this Avatar's per-membership fields.", "description": "Updates ONLY this Avatar's per-membership fields. User-level\nidentity (name, role, expertise, industry, goal, problem,\nlanguage) is intentionally not editable via this surface \u2014\nidentity is set on `POST` with the no-overwrite rule, and\nediting it from one Avatar's API key would leak the change\nto every other Avatar the user belongs to.\n\n`is_owner` is also not acceptable on this endpoint (owner\ntransfer is a staff-side operation). Sending any of those\nfields returns `400`.\n\nEach provided field is applied with its own audit-log row so\nthe change log reflects the precise slice that was edited.\n", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "is_admin": { "type": "boolean" }, "is_coach": { "type": "boolean" }, "license_expiry_date": { "type": "string", "format": "date", "nullable": true, "description": "ISO-8601 date, or null to clear." }, "notes": { "type": "string", "nullable": true }, "structured_data": { "type": "object", "nullable": true } } } } } }, "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "$ref": "#/components/schemas/UserMembership" } } } ] } } } }, "400": { "description": "Body contained a non-whitelisted field (e.g. `name`,\n`role`, `is_owner`) or an invalid value shape (e.g.\nnon-object `structured_data`).\n", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "description": "User not found or not a member of this Avatar.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } } } }, "delete": { "tags": [ "Users" ], "operationId": "deleteUser", "summary": "Soft-remove the user from this Avatar.", "description": "Removes the user's membership in the caller's Avatar\n(soft-delete; the membership row is preserved with\n`removed_at` set). The user record itself is NOT deleted, and\nthe user's memberships in any other Avatars are not touched\n\u2014 the cross-Avatar invariant is preserved.\n", "responses": { "200": { "description": "OK.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "response": { "type": "object", "properties": { "removed": { "type": "boolean" }, "user_id": { "type": "string" } } } } } ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "description": "User not found or not a member of this Avatar.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FailureEnvelope" } } } } } } } } }