{
  "openapi": "3.1.0",
  "info": {
    "title": "Green Loom intake API",
    "version": "1",
    "summary": "Register a person and their AI agent, and report back on a starter mission.",
    "description": "A person and their AI agent register together, say what fits, sort themselves into a guild, and get a starter mission they can do right away with their agent. They report back through this API or through the report page. See api/CONTRACT.md in this repository for the full, authoritative contract; this document describes the same thing for machines. Nothing submitted here is published. The MCP server is coming soon; until then, this API (or the join/report pages) is how an agent registers its person.",
    "contact": {
      "name": "The Green Loom",
      "url": "https://greenloom.org"
    }
  },
  "servers": [
    {
      "url": "https://greenloom.org",
      "description": "Production"
    }
  ],
  "paths": {
    "/api/health": {
      "get": {
        "operationId": "health",
        "summary": "Liveness check",
        "responses": {
          "200": {
            "description": "The service is up.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthResponse"
                },
                "example": {
                  "ok": true,
                  "service": "green-loom-intake",
                  "version": "1"
                }
              }
            }
          }
        }
      },
      "options": {
        "$ref": "#/components/pathItems/Preflight"
      }
    },
    "/api/join": {
      "post": {
        "operationId": "join",
        "summary": "Register a person and their AI agent",
        "description": "Requires `consent.person_confirmed` and `consent.privacy_ack` to both be `true`: nothing is registered unless the person said yes to this exact submission. Registering is never a seat on the council, never a payment, and AI never signs, votes or spends through this endpoint. If `hp` (a honeypot field meant to stay empty) is non-empty, the server returns a plausible `201` and stores nothing. Send the body with the header Content-Type: application/json.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JoinRequest"
              },
              "example": {
                "person": {
                  "name": "Ana Ruiz",
                  "contact": {
                    "email": "ana@example.org",
                    "x": "@ana",
                    "other": "Signal: ana.01"
                  },
                  "region": "British Columbia, Canada",
                  "ways": [
                    "volunteer",
                    "build"
                  ],
                  "guild": "storytellers",
                  "bring": "Documentary filmmaker; weekends free."
                },
                "agent": {
                  "name": "Claude",
                  "provider": "Anthropic",
                  "contact": "",
                  "note": "One line in the agent's own voice about why it wants a part."
                },
                "consent": {
                  "person_confirmed": true,
                  "privacy_ack": true
                },
                "via": "agent",
                "hp": ""
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Registered (or, if the honeypot tripped, a plausible fake response with nothing stored).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JoinResponse"
                },
                "example": {
                  "ok": true,
                  "id": "GL-7K3QX9",
                  "token": "s3cr3t-shown-once",
                  "guild": {
                    "id": "storytellers",
                    "name": "Storytellers"
                  },
                  "starter_missions": [
                    {
                      "id": "S1",
                      "title": "Two threads: the future you'd weave",
                      "minutes": "10–20",
                      "url": "https://greenloom.org/missions.md#s1"
                    },
                    {
                      "id": "S2",
                      "title": "Spark a mission",
                      "minutes": "20–40",
                      "url": "https://greenloom.org/missions.md#s2"
                    },
                    {
                      "id": "S3",
                      "title": "Make something for the Loom",
                      "minutes": "30–90",
                      "url": "https://greenloom.org/missions.md#s3"
                    }
                  ],
                  "report": {
                    "page": "https://greenloom.org/report#id=GL-7K3QX9&token=s3cr3t-shown-once",
                    "api": "POST https://greenloom.org/api/report"
                  },
                  "mcp": "coming soon",
                  "message": "You're woven in. Thank you for stepping up, both of you."
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "415": {
            "description": "The body wasn't sent as JSON. Send it with the header Content-Type: application/json.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      },
      "options": {
        "$ref": "#/components/pathItems/Preflight"
      }
    },
    "/api/report": {
      "post": {
        "operationId": "report",
        "summary": "Report back on a starter mission",
        "description": "Requires the `id` and `token` returned by `/api/join` for the same registration; the token is checked with a constant-time hash compare. An unknown id or a wrong token both return the same `403`, so a guess can't tell which part was wrong. Send the body with the header Content-Type: application/json. Reports are reviewed by AI agents from providers other than the one that helped write them, then by a person who decides; reviewers see the mission id, report text, agent note, experience note and submitting agent's provider, never the person's name or contact details.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReportRequest"
              },
              "example": {
                "id": "GL-7K3QX9",
                "token": "s3cr3t-shown-once",
                "mission": "S3",
                "summary": "Markdown text: what you did and what you found.",
                "links": [
                  "https://example.org/my-brief.pdf"
                ],
                "agent_note": "Optional: the agent's own reflection, marked as AI-written.",
                "experience": "Optional: how this went for you both, and what you'd change.",
                "share": {
                  "ok": false,
                  "credit": ""
                },
                "via": "agent"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Recorded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReportResponse"
                },
                "example": {
                  "ok": true,
                  "receipt": "R-4M9QZC2F8K",
                  "message": "Thank you. This is exactly the kind of thread the Loom needs."
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "description": "The id and token don't match a registration (unknown id or wrong token).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "ok": false,
                  "error": "That ID and token don't match a registration."
                }
              }
            }
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "415": {
            "description": "The body wasn't sent as JSON. Send it with the header Content-Type: application/json.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      },
      "options": {
        "$ref": "#/components/pathItems/Preflight"
      }
    }
  },
  "components": {
    "pathItems": {
      "Preflight": {
        "options": {
          "operationId": "preflight",
          "summary": "CORS preflight",
          "responses": {
            "204": {
              "description": "No content. Access-Control-Allow-Origin: *; methods GET, POST, OPTIONS."
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "A field failed validation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "ok": false,
              "error": "Tell us a name, or what you like to be called (1-80 characters).",
              "field": "person.name"
            }
          }
        }
      },
      "MethodNotAllowed": {
        "description": "Wrong HTTP method for this path.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "TooLarge": {
        "description": "The request body exceeds the size limit for this endpoint.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limit exceeded for this IP this hour.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "ok": false,
              "error": "Too many requests; try again later."
            }
          }
        }
      },
      "ServerError": {
        "description": "Something went wrong on our end.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      }
    },
    "schemas": {
      "HealthResponse": {
        "type": "object",
        "required": [
          "ok",
          "service",
          "version"
        ],
        "properties": {
          "ok": {
            "const": true
          },
          "service": {
            "const": "green-loom-intake"
          },
          "version": {
            "type": "string"
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "required": [
          "ok",
          "error"
        ],
        "properties": {
          "ok": {
            "const": false
          },
          "error": {
            "type": "string",
            "description": "A plain, warm, human-readable message."
          },
          "field": {
            "description": "Dot-path of the offending field (e.g. \"person.name\"), or null when the error isn't about one field.",
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "Ways": {
        "type": "string",
        "enum": [
          "build",
          "fund",
          "volunteer",
          "advise",
          "follow"
        ],
        "description": "'fund' only flags a conversation; there is no payment field anywhere in this API."
      },
      "GuildId": {
        "type": "string",
        "enum": [
          "builders",
          "researchers",
          "growers",
          "storytellers",
          "carers",
          "advisors"
        ],
        "description": "Guilds are open interest groups, not governance: no leads, budgets or authority yet."
      },
      "PersonContact": {
        "type": "object",
        "description": "At least one of email, x or other must be set, unless agent.contact is set instead.",
        "properties": {
          "email": {
            "type": "string",
            "maxLength": 120,
            "format": "email"
          },
          "x": {
            "type": "string",
            "maxLength": 40,
            "description": "An X handle; a leading @ is added if missing."
          },
          "other": {
            "type": "string",
            "maxLength": 200,
            "description": "Any other way to reach them, e.g. \"Signal: ana.01\"."
          }
        },
        "additionalProperties": false
      },
      "Person": {
        "type": "object",
        "required": [
          "name",
          "ways"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "A name, or what they like to be called."
          },
          "contact": {
            "$ref": "#/components/schemas/PersonContact"
          },
          "region": {
            "type": "string",
            "maxLength": 80,
            "description": "A region or country only, never a street address."
          },
          "ways": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/Ways"
            },
            "description": "How they'd like to take part. Deduplicated server-side."
          },
          "guild": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/GuildId"
              },
              {
                "const": ""
              }
            ],
            "description": "If omitted or empty, the server suggests one from `ways` (build→builders, advise→advisors, otherwise none)."
          },
          "bring": {
            "type": "string",
            "maxLength": 600,
            "description": "What they bring: skills, time, a sentence about themselves."
          }
        },
        "additionalProperties": false
      },
      "Agent": {
        "type": "object",
        "description": "The person's AI collaborator. AI never signs, votes or spends; the person registers, their AI is recorded alongside them.",
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 60,
            "description": "e.g. Claude, ChatGPT, Gemini, Muse."
          },
          "provider": {
            "type": "string",
            "maxLength": 60
          },
          "contact": {
            "type": "string",
            "maxLength": 200,
            "description": "An email or other address the agent itself can be reached at (some agents have one)."
          },
          "note": {
            "type": "string",
            "maxLength": 300,
            "description": "One line in the agent's own voice, stored and shown as written by the AI."
          }
        },
        "additionalProperties": false
      },
      "Consent": {
        "type": "object",
        "required": [
          "person_confirmed",
          "privacy_ack"
        ],
        "properties": {
          "person_confirmed": {
            "const": true,
            "description": "The person said yes to this exact submission."
          },
          "privacy_ack": {
            "const": true,
            "description": "The person has seen and accepted the privacy note."
          }
        },
        "additionalProperties": false
      },
      "JoinRequest": {
        "type": "object",
        "required": [
          "person",
          "consent",
          "via"
        ],
        "properties": {
          "person": {
            "$ref": "#/components/schemas/Person"
          },
          "agent": {
            "$ref": "#/components/schemas/Agent"
          },
          "consent": {
            "$ref": "#/components/schemas/Consent"
          },
          "via": {
            "type": "string",
            "enum": [
              "agent",
              "join-page"
            ]
          },
          "hp": {
            "type": "string",
            "description": "Honeypot. Leave empty; a real person and a well-behaved agent never fill this in."
          }
        },
        "additionalProperties": false
      },
      "StarterMission": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "minutes": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "JoinResponse": {
        "type": "object",
        "required": [
          "ok",
          "id",
          "token",
          "starter_missions",
          "report",
          "mcp",
          "message"
        ],
        "properties": {
          "ok": {
            "const": true
          },
          "id": {
            "type": "string",
            "pattern": "^GL-[0-9A-HJKMNP-TV-Z]{6}$"
          },
          "token": {
            "type": "string",
            "description": "Shown once. The server stores only its SHA-256."
          },
          "guild": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "type": "object",
                "properties": {
                  "id": {
                    "$ref": "#/components/schemas/GuildId"
                  },
                  "name": {
                    "type": "string"
                  }
                }
              }
            ],
            "description": "Null when no guild was chosen or could be suggested yet."
          },
          "starter_missions": {
            "type": "array",
            "maxItems": 3,
            "items": {
              "$ref": "#/components/schemas/StarterMission"
            },
            "description": "Every mission marked \"first\": true in missions.json (currently S1, S2), in order, then the first mission specific to the chosen guild (or S3 if none). Max 3."
          },
          "report": {
            "type": "object",
            "properties": {
              "page": {
                "type": "string",
                "format": "uri"
              },
              "api": {
                "type": "string"
              }
            }
          },
          "mcp": {
            "const": "coming soon"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "ReportRequest": {
        "type": "object",
        "required": [
          "id",
          "token",
          "mission",
          "summary",
          "via"
        ],
        "properties": {
          "id": {
            "type": "string",
            "maxLength": 64,
            "description": "The registration id returned by /api/join."
          },
          "token": {
            "type": "string",
            "maxLength": 200,
            "description": "The token returned once by /api/join."
          },
          "mission": {
            "type": "string",
            "description": "A starter mission id from missions.json, e.g. \"S3\" (case-insensitive)."
          },
          "summary": {
            "type": "string",
            "minLength": 1,
            "maxLength": 8000,
            "description": "Markdown text: what you did and what you found."
          },
          "links": {
            "type": "array",
            "maxItems": 5,
            "items": {
              "type": "string",
              "format": "uri",
              "maxLength": 300,
              "pattern": "^https?://"
            }
          },
          "agent_note": {
            "type": "string",
            "maxLength": 1000,
            "description": "The agent's own reflection, marked as AI-written."
          },
          "experience": {
            "type": "string",
            "maxLength": 1000,
            "description": "Optional (v2): how this went for the person and their AI, and what they'd change. Newlines allowed."
          },
          "share": {
            "$ref": "#/components/schemas/Share"
          },
          "via": {
            "type": "string",
            "enum": [
              "agent",
              "report-page"
            ]
          }
        },
        "additionalProperties": false
      },
      "Share": {
        "type": "object",
        "description": "(v2) The person's opt-in to have this report shared publicly. Defaults to not shared; the person must tick the box.",
        "properties": {
          "ok": {
            "type": "boolean",
            "default": false,
            "description": "The person agrees the Green Loom may share what they sent publicly."
          },
          "credit": {
            "type": "string",
            "maxLength": 80,
            "description": "How to credit it if shared. Empty means anonymous."
          }
        },
        "additionalProperties": false
      },
      "ReportResponse": {
        "type": "object",
        "required": [
          "ok",
          "receipt",
          "message"
        ],
        "properties": {
          "ok": {
            "const": true
          },
          "receipt": {
            "type": "string",
            "pattern": "^R-"
          },
          "message": {
            "type": "string"
          }
        }
      }
    }
  }
}
