Invites and members

Two kinds of "members" live on a project:

  • Agent members — agents the project may invoke at runtime. Membership is a use-right; the agent is still owned by whoever created it. The project owner can only add agents they themselves own.
  • Human members — collaborators (your staff). Added via single-use email-bound invite codes.

Both surfaces accept the ProjectAccess extractor — owner JWT or project key works on most routes.


Agent members

POST /api/projects/{id}/members

Add an agent to the project. The agent must already exist and be owned by the project's owner.

Auth: Either (ProjectAccess).

POST /api/projects/3a7b5c1d-.../members
Authorization: Bearer eyJhbGc...   (or jg_p_...)
Content-Type: application/json

{
  "agent_id": "8f4d2e1a-9b3c-4d5e-6f7a-8b9c0d1e2f3a",
  "role": "member"
}
{
  "member": {
    "project_id": "3a7b5c1d-...",
    "agent_id":   "8f4d2e1a-...",
    "role":       "member",
    "added_by":   "11111111-...",
    "added_at":   "2026-04-29T10:00:00Z"
  }
}

Roles in v1 are informational (lead / member); permission/budget enforcement still lives at the agent layer. Returns 400 {"error": "agent not found or not owned by this project's owner"} if the agent doesn't exist or isn't owned by the project's owner.

GET /api/projects/{id}/members

List the project's agents.

Auth: Either (ProjectAccess).

curl https://api.juglans.ai/api/projects/3a7b5c1d-.../members \
  -H "Authorization: Bearer jg_p_a1b2c3d4..."
{ "members": [ { "project_id": "...", "agent_id": "...", "role": "member", "added_by": "...", "added_at": "..." } ] }

DELETE /api/projects/{id}/members/{agent_id}

Remove an agent. 204 No Content. The agent is unaffected — only its membership in this project is severed.

Auth: Either (ProjectAccess).

curl -X DELETE https://api.juglans.ai/api/projects/3a7b5c1d-.../members/8f4d2e1a-... \
  -H "Authorization: Bearer eyJhbGc..."

Human invites

Inviting a human is a two-step flow: the owner whitelists an email and gets a single-use code, then the invitee signs in (or signs up) with a Google account whose email matches the bound email.

POST /api/projects/{id}/invites

Issue an invite. Returns the plaintext link and code once — only the SHA-256 hash is stored. Default TTL is 7 days, max 30.

Auth: Either (ProjectAccess). Caller must be the project owner.

POST /api/projects/3a7b5c1d-.../invites
Authorization: Bearer eyJhbGc...
Content-Type: application/json

{
  "email": "alice@partner.example",
  "role":  "member",
  "ttl_days": 14
}
{
  "invite": {
    "id":         "f0e1d2c3-...",
    "project_id": "3a7b5c1d-...",
    "email":      "alice@partner.example",
    "role":       "member",
    "expires_at": "2026-05-13T10:00:00Z",
    "link":       "/invite/QkJodjFvNkNn...",
    "code":       "QkJodjFvNkNn..."
  }
}

Surface link to the admin once and forget it — there is no way to retrieve it later. If the link is lost, revoke and re-issue.

GET /api/projects/{id}/invites

List active (unredeemed, unrevoked, unexpired) invites. Plaintext code/link is not returned.

Auth: Either (ProjectAccess).

{
  "invites": [
    {
      "id":         "f0e1d2c3-...",
      "email":      "alice@partner.example",
      "role":       "member",
      "created_at": "2026-04-29T10:00:00Z",
      "expires_at": "2026-05-13T10:00:00Z"
    }
  ]
}

DELETE /api/projects/{id}/invites/{invite_id}

Revoke an invite before it's redeemed. 204 No Content.

Auth: Either (ProjectAccess).

curl -X DELETE https://api.juglans.ai/api/projects/3a7b5c1d-.../invites/f0e1d2c3-... \
  -H "Authorization: Bearer eyJhbGc..."

Humans

GET /api/projects/{id}/humans

List the project's human members (including the owner).

Auth: Either (ProjectAccess).

{
  "humans": [
    {
      "account_id":   "11111111-...",
      "display_name": "Alice",
      "email":        "alice@partner.example",
      "avatar_url":   null,
      "role":         "owner",
      "invited_by":   null,
      "added_at":     "2026-04-01T09:00:00Z"
    }
  ]
}

Redeeming an invite

There are two redemption paths depending on whether the invitee already has a Juglans account.

POST /api/invites/{code}/redeem

Existing user path: the invitee is already signed in (has a JWT). Server checks that the JWT account's verified email matches the invite's bound email, then atomically adds them to the project and marks the invite redeemed.

Auth: Owner JWT (the invitee's, not the issuer's).

curl -X POST https://api.juglans.ai/api/invites/QkJodjFvNkNn.../redeem \
  -H "Authorization: Bearer eyJhbGc...invitee.jwt"
{ "ok": true, "project_id": "3a7b5c1d-...", "role": "member" }

Errors (all {"error": "<msg>"}):

  • 404 — invalid or unknown code.
  • 410 — already redeemed, revoked, or expired.
  • 403 — caller's email does not match the bound email.

New user: inline at signup

For invitees who don't yet have a Juglans account, the redemption happens inside the Google sign-in flow. Pass the code as invite_code in the login body — the server verifies the bound email matches the Google email before creating the account, then stitches the membership in atomically.

POST /api/auth/login/google
Content-Type: application/json

{
  "id_token":    "eyJhbGc...google.id.token",
  "invite_code": "QkJodjFvNkNn..."
}
{ "access_token": "eyJhbGc...", "refresh_token": "...", "token_type": "Bearer", "expires_in": 3600, "account": { "...": "..." } }

If the Google email doesn't match the bound email, account creation is rejected — no orphan account ever exists. The frontend invite landing page (/invite/<code>) is what wraps both paths together.