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.