Core Resources
View as Markdown

Space

A named container of Droplets with its own membership and join policy. The unit of team-level scope on Drop.

A Space groups related Droplets and answers the question "who is this group of work for?" Each Space has an owner, a roster of members with roles (OWNER / EDITOR / READ_ONLY, default EDITOR when added or joined), and a joinPolicy that controls whether outsiders can self-join. Droplets in a Space can use internalAccess: SPACE to bind their audience live to the current member list — add a member, they immediately see every SPACE-mode Droplet.

Asking to join an invite-only Space. INVITE_ONLY Spaces can't be self-joined, but anyone at Hi Marley can ask: request-access records the ask (with an optional note) and notifies every member with the OWNER role — in Drop, by email, and by Slack DM with Add / Dismiss buttons, per each owner's Access notification setting. Owners review with requests list / grant (EDITOR by default, or READ_ONLY) / dismiss, or from Space settings → Members. The requester is told when they're added; a dismissal is silent, and they can ask again after 24 hours. Someone added through any other path (the member picker, a User Group, auto-member) has their pending request closed automatically.

The Space object

Every Space carries these attributes.

Attributes

slug
string
URL-friendly identifier. Stable for the lifetime of the Space — appears in /spaces/{slug} and every API path. Lowercase alphanumeric + hyphens, min 2 characters, globally unique.
name
string
Human-readable display name. Unique across all Spaces.
description
string· nullable
Optional prose blurb shown on the Space landing page and in the Space directory.
joinPolicy
enum
INVITE_ONLYANYONE_HIMARLEYEVERYONE_AUTOMEMBER
Who can join. INVITE_ONLY — members are added explicitly; non-members see the row but cannot self-join (POST /join returns 403 CANNOT_SELF_JOIN). ANYONE_HIMARLEY — any @himarley.com user can self-join with one click. EVERYONE_AUTOMEMBER (admin-only) — every @himarley user is added at signup; an enable triggers a one-time backfill.
status
enum
ACTIVEARCHIVED
Hard-status. Distinct from archivedAt — ARCHIVED here means the Space is tombstoned; the lifecycle archive uses archivedAt.
archivedAt
timestamp· nullable
Non-destructive archive timestamp. When set, the Space is hidden from /spaces and from search by default; member Droplets cascade-archive too (029-freshness). Restore with Unarchive.
featuredTags
string[]
Admin-curated tag chips shown in the Space header. Clicking a chip narrows the Space landing to Droplets carrying that tag (tags @>). Lowercase-normalized, deduped, soft cap of 12; order preserved.
icon
string· nullable
Lucide React icon name (e.g. rocket, users, briefcase). Rendered in the sidebar + Space header.
linkedGroupId
string· nullable
Optional pointer to a paired User Group (e.g. the engineering Space links to the @engineering Group). Pure cross-reference — there is no membership sync between the two.
More attributes+ Show 7
id
string
CUID primary key. Use slug in API paths and URLs; the id shows up in audit events and the toSpaceId argument of `POST /move`.
createdById
string
The User who created the Space. Distinct from current owner — the Space owner role lives on the SpaceMember join row.
slackChannelId
string
If the Space is linked to a Slack channel for notifications, the channel id. Set during the (planned) Slack channel-binding flow.
slackChannelName
string
Cached channel name for display alongside slackChannelId. Re-synced lazily.
privateTagPrefixes
string[]
Tag prefixes (e.g. hr/, legal/) whose tagged Droplets render tags only to members. See feature 024-tags-ux. Empty by default.
createdAt
timestamp
ISO 8601 timestamp of creation.
updatedAt
timestamp
ISO 8601 timestamp of last metadata update.
THE SPACE OBJECT
{
  "id": "spc_platform_team",
  "slug": "platform-team",
  "name": "Platform Team",
  "description": "Roadmaps, retros, and decision logs for the platform team",
  "icon": "rocket",
  "joinPolicy": "INVITE_ONLY",
  "status": "ACTIVE",
  "featuredTags": [
    "roadmap",
    "retro"
  ],
  "privateTagPrefixes": [],
  "slackChannelId": null,
  "slackChannelName": null,
  "linkedGroupId": null,
  "archivedAt": null,
  "createdById": "usr_alex",
  "createdAt": "2026-04-20T10:00:00.000Z",
  "updatedAt": "2026-05-31T09:00:00.000Z"
}

Methods

Endpoints to operate on a Space. The example switches between curl, drop CLI, and MCP via the dropdown — your choice persists across the page.

GET

List Spaces

/api/spaces

Return Spaces the caller belongs to. Pass ?include=all to browse the directory — every Space the caller can see (including INVITE_ONLY, with the member roster suppressed). Each row carries joinPolicy, memberCount, appCount, ownerEmail/ownerName, and a computed canSelfJoin flag.

Parameters

include
string· query
all to browse every Space (directory mode). Default: only Spaces the caller belongs to.
includeArchived
boolean· query
Include archived Spaces (default false).
GET/api/spaces
drop spaces list
Response200
[
  {
    "id": "spc_platform_team",
    "name": "Platform Team",
    "slug": "platform-team",
    "description": "Roadmaps, retros, and decision logs",
    "icon": "rocket",
    "joinPolicy": "INVITE_ONLY",
    "internalAccess": "PRIVATE",
    "status": "ACTIVE",
    "memberCount": 12,
    "appCount": 8,
    "isMember": true,
    "canSelfJoin": false,
    "role": "OWNER",
    "createdAt": "2026-04-20T10:00:00.000Z",
    "createdBy": {
      "name": "Alex Chen",
      "email": "alex@himarley.com"
    }
  }
]
POST

Create a Space

/api/spaces

Create a new Space. The caller becomes the owner. Slug rules match Droplets (lowercase alphanumeric + hyphens, min 2 chars, globally unique). joinPolicy sets the membership policy at create time. Setting joinPolicy: EVERYONE_AUTOMEMBER requires admin privilege and triggers a backfill of every @himarley user as a MEMBER. If the slug collides with an existing User Group slug, the response includes a non-blocking collisionWarning for the UI to suggest linking.

Parameters

name
string· body· required
Display name for the Space (1-100 characters).
slug
string· body
URL slug (auto-generated from name if omitted).
description
string· body
Brief description.
icon
string· body
Lucide React icon name.
joinPolicy
enum· body
INVITE_ONLY | ANYONE_HIMARLEY | EVERYONE_AUTOMEMBER. Default INVITE_ONLY.
POST/api/spaces
drop spaces create --name "Platform Team" --slug platform-team --description "Roadmaps, retros, and decision logs" --join-policy ANYONE_HIMARLEY
Response201
{
  "space": {
    "id": "spc_platform_team",
    "name": "Platform Team",
    "slug": "platform-team",
    "description": "Roadmaps, retros, and decision logs",
    "icon": null,
    "joinPolicy": "ANYONE_HIMARLEY",
    "internalAccess": "EVERYONE",
    "memberCount": 1,
    "appCount": 0,
    "isMember": true,
    "role": "OWNER",
    "createdAt": "2026-04-20T10:00:00.000Z",
    "createdBy": {
      "name": "Alex Chen",
      "email": "alex@himarley.com"
    }
  },
  "collisionWarning": null
}
GET

Retrieve a Space

/api/spaces/{slug}

Return full Space detail by slug — metadata, join policy, owner, current member roster. Members see the full roster; non-members of INVITE_ONLY Spaces get a 404 to hide existence. Open Spaces (ANYONE_HIMARLEY / EVERYONE_AUTOMEMBER) are visible to every @himarley user.

Parameters

slug
string· path· required
The Space slug.
GET/api/spaces/{slug}
drop spaces get platform-team
Response200
{
  "id": "spc_platform_team",
  "name": "Platform Team",
  "slug": "platform-team",
  "description": "Roadmaps, retros, and decision logs for the platform team",
  "icon": "rocket",
  "joinPolicy": "INVITE_ONLY",
  "internalAccess": "PRIVATE",
  "status": "ACTIVE",
  "slackChannelId": null,
  "slackChannelName": null,
  "featuredTags": [
    "roadmap",
    "retro"
  ],
  "linkedGroup": null,
  "memberCount": 12,
  "appCount": 8,
  "isMember": true,
  "role": "OWNER",
  "createdAt": "2026-04-20T10:00:00.000Z",
  "createdBy": {
    "name": "Alex Chen",
    "email": "alex@himarley.com"
  },
  "members": [
    {
      "id": "spm_alex",
      "userId": "usr_alex",
      "name": "Alex Chen",
      "email": "alex@himarley.com",
      "role": "OWNER",
      "createdAt": "2026-04-20T10:00:00.000Z"
    }
  ]
}
PATCH

Update a Space

/api/spaces/{slug}

Patch Space metadata — name, description, icon, joinPolicy, featuredTags, linkedGroupId. Owner-only for most fields; OWNER or EDITOR can patch featuredTags (curator-level). Setting joinPolicy: EVERYONE_AUTOMEMBER is admin-gated and triggers a member backfill. Reverting from auto-member never silently revokes members. The legacy internalAccess field is accepted as an alias by the server but SPECIFIC is rejected with 400 INVALID_POLICY.

Parameters

slug
string· path· required
The Space slug.
name
string· body
New display name (1-100 chars).
description
string | null· body
New description; null to clear.
icon
string | null· body
Lucide React icon; null to clear.
joinPolicy
enum· body
INVITE_ONLY | ANYONE_HIMARLEY | EVERYONE_AUTOMEMBER (admin-only).
status
enum· body
ACTIVE | ARCHIVED.
featuredTags
string[]· body
Admin-curated tag chips (max 5, lowercase-normalized).
linkedGroupId
string | null· body
Cross-reference User Group id, or null to clear.
PATCH/api/spaces/{slug}
drop spaces update platform-team --description "Engineering platform team — owns CI, infra, and Drop." --featured-tags dashboard,retro
Response200
{
  "id": "spc_platform_team",
  "name": "Platform Team",
  "slug": "platform-team",
  "description": "Engineering platform team — owns CI, infra, and Drop.",
  "icon": "rocket",
  "joinPolicy": "INVITE_ONLY",
  "internalAccess": "PRIVATE",
  "status": "ACTIVE",
  "featuredTags": [
    "dashboard",
    "retro"
  ],
  "linkedGroup": null,
  "memberCount": 12,
  "appCount": 8,
  "isMember": true,
  "role": "OWNER",
  "createdAt": "2026-04-20T10:00:00.000Z",
  "createdBy": {
    "name": "Alex Chen",
    "email": "alex@himarley.com"
  }
}
DELETE

Delete a Space

/api/spaces/{slug}

Irreversible hard-delete (029-freshness FR-031). Gated by the clean-slate rule: caller must be the sole OWNER, the Space must have zero member Droplets, and zero non-owner SpaceMember rows. On gate failure returns 409 NOT_CLEAN_SLATE with { code, reason, message }. Otherwise removes the Space row (SpaceMember cascade-deletes).

Parameters

slug
string· path· required
The Space slug.
DELETE/api/spaces/{slug}
drop spaces delete old-experimental-space
Response200
{
  "success": true,
  "hardDeleted": true
}
GET

List members

/api/spaces/{slug}/members

Return every member of the Space with role + join timestamp. Visible to members; for INVITE_ONLY Spaces non-members get 403.

Parameters

slug
string· path· required
The Space slug.
GET/api/spaces/{slug}/members
drop spaces members list platform-team
Response200
[
  {
    "id": "spm_alex",
    "userId": "usr_alex",
    "name": "Alex Chen",
    "email": "alex@himarley.com",
    "role": "OWNER",
    "createdAt": "2026-04-20T10:00:00.000Z"
  }
]
POST

Add member(s)

/api/spaces/{slug}/members

Add one or more people to a Space by email. Owner-only. Idempotent — already-members are returned in alreadyMembers; unknown emails are skipped silently. New members immediately inherit access to every SPACE-mode Droplet in the Space (edge cache resynced fire-and-forget). New members are added as EDITOR by default; pass READ_ONLY for view-only access. OWNER is not accepted via this endpoint — use update-member to promote.

Parameters

slug
string· path· required
The Space slug.
emails
string[]· body· required
1–100 emails to add.
role
enum· body
EDITOR (default) or READ_ONLY.
POST/api/spaces/{slug}/members
drop spaces members add platform-team --emails kai@himarley.com,jane@himarley.com
Response200
{
  "added": [
    {
      "email": "kai@himarley.com",
      "role": "EDITOR"
    },
    {
      "email": "jane@himarley.com",
      "role": "EDITOR"
    }
  ],
  "alreadyMembers": []
}
PATCH

Change a member's role

/api/spaces/{slug}/members/{userId}

Owner-only. Promote or demote a Space member. Promoting to OWNER does not transfer ownership exclusively — multiple OWNERs are allowed. Demoting the last OWNER is blocked via the leave / remove-member last-owner guard.

Parameters

slug
string· path· required
The Space slug.
userId
string· path· required
The user id whose role to change.
role
enum· body· required
OWNER | EDITOR | READ_ONLY.
PATCH/api/spaces/{slug}/members/{userId}
drop spaces members set-role platform-team usr_kai --role EDITOR
Response200
{
  "success": true,
  "role": "EDITOR"
}
DELETE

Remove member

/api/spaces/{slug}/members/{userId}

Remove a single member. OWNER can remove anyone; members can only remove themselves (self-removal == leave). Cannot remove the last OWNER — 400 returned.

Parameters

slug
string· path· required
The Space slug.
userId
string· path· required
The user id to remove.
DELETE/api/spaces/{slug}/members/{userId}
drop spaces members remove platform-team usr_kai
Response200
{
  "success": true
}
POST

Self-join a Space

/api/spaces/{slug}/join

Self-join a Space. Returns 201 with the new MEMBER row for ANYONE_HIMARLEY. For EVERYONE_AUTOMEMBER users are added at signup; calling join when already a member returns 409 (the client treats it as idempotent success). For INVITE_ONLY returns 403 CANNOT_SELF_JOIN with ownerEmail / ownerName so the client can render "ask the owner for an invite".

Parameters

slug
string· path· required
The Space slug.
POST/api/spaces/{slug}/join
drop spaces join platform-team
Response201
{
  "membership": {
    "id": "spm_kai",
    "spaceId": "spc_platform_team",
    "userId": "usr_kai",
    "role": "EDITOR",
    "createdAt": "2026-05-31T09:15:00.000Z"
  }
}
POST

Leave a Space

/api/spaces/{slug}/leave

Leave a Space. Your Droplets in the Space stay in the Space. Cannot leave as the last OWNER — transfer ownership first (400 LAST_OWNER).

Parameters

slug
string· path· required
The Space slug.
POST/api/spaces/{slug}/leave
drop spaces leave platform-team
Response200
{
  "success": true
}
GET

Get audit log

/api/spaces/{slug}/audit

Return the Space-scoped audit log — membership adds/removes, role changes, join-policy changes, automember toggles, bulk backfills, featured-tag changes, and Droplet moves into / between this Space. Caller must be a MEMBER; non-members get 404 to avoid leaking existence. Cursor-paginated, newest-first.

Parameters

slug
string· path· required
The Space slug.
cursor
string· query
Audit-event id from previous response's nextCursor.
limit
number· query
Page size (default 50, max 100).
event
string· query
Optional event-name prefix filter.
GET/api/spaces/{slug}/audit
drop spaces audit platform-team
Response200
{
  "events": [
    {
      "id": "ae_1",
      "event": "space.member_added",
      "actorEmail": "alex@himarley.com",
      "actorName": "Alex Chen",
      "details": {
        "memberEmail": "kai@himarley.com",
        "role": "EDITOR"
      },
      "ipAddress": null,
      "createdAt": "2026-05-31T09:15:00.000Z"
    }
  ],
  "nextCursor": null,
  "hasMore": false,
  "pagination": {
    "limit": 50,
    "total": 1
  }
}
POST

Archive

/api/spaces/{slug}/archive

Non-destructive archive. Sets archivedAt, hides the Space from default lists, and cascade-archives every Droplet in the Space (029-freshness). Returns the count of cascaded Droplets + notified owners + a correlationId shared by all the cascaded audit events. Owner-only. Idempotent — calling on an already-archived Space returns 204.

Parameters

slug
string· path· required
The Space slug.
POST/api/spaces/{slug}/archive
drop spaces archive platform-team
Response200
{
  "archivedAt": "2026-05-31T09:15:00.000Z",
  "cascadedDropletCount": 7,
  "notifiedOwnerCount": 3,
  "correlationId": "space-archive-clx7"
}
GET

Preview archive cascade

/api/spaces/{slug}/archive/preview

Return the blast radius of archiving the Space — total Droplet count, how many are yours, how many belong to other owners, plus up to 5 of those owners' emails. Visible to anyone who can GET the Space (non-members of INVITE_ONLY Spaces get 404).

Parameters

slug
string· path· required
The Space slug.
GET/api/spaces/{slug}/archive/preview
drop spaces archive-preview platform-team
Response200
{
  "totalDropletCount": 7,
  "ownedDropletCount": 5,
  "otherDropletCount": 2,
  "affectedOwnerCount": 2,
  "otherOwnerEmails": [
    "kai@himarley.com",
    "jane@himarley.com"
  ]
}
POST

Unarchive

/api/spaces/{slug}/unarchive

Clear archivedAt and return the Space to the active directory. Cascaded Droplets stay archived — owners restore them individually with drop unarchive. Owner-only. Idempotent — calling on an active Space returns 204.

Parameters

slug
string· path· required
The Space slug.
POST/api/spaces/{slug}/unarchive
drop spaces unarchive platform-team
Response200
{
  "slug": "platform-team",
  "archivedAt": null
}
POST

Bootstrap a User Group

/api/spaces/{slug}/bootstrap-group

Convenience: create a new User Group seeded from this Space's current roster, then set linkedSpaceId / linkedGroupId on both sides so the cross-reference is in place. People Admin only. No ongoing membership sync — the seed is one-shot.

Parameters

slug
string· path· required
The Space slug.
POST/api/spaces/{slug}/bootstrap-group
drop spaces bootstrap-group platform-team
Response201
{
  "groupSlug": "platform-team",
  "memberCount": 12
}
POST

Add a User Group's roster

/api/spaces/{slug}/members/from-group

Copy a User Group's current roster into an existing Space. One-time copy — future changes to the Group's roster do NOT propagate. Authorization: Space owner OR People Admin OR site admin.

Parameters

slug
string· path· required
The Space slug.
groupSlug
string· body· required
The User Group slug to copy from.
POST/api/spaces/{slug}/members/from-group
drop spaces members add-from-group platform-team --group-slug engineering
Response200
{
  "added": 8,
  "alreadyMembers": 2,
  "totalGroupMembers": 10
}
POST

Mark a Space visit

/api/spaces/{slug}/visits

Internal endpoint — upsert the viewer's UserSpaceVisit row with lastVisitAt = now() for this Space and return the PREVIOUS value (null on first visit). The Space Agent panel POSTs this on open; the returned timestamp becomes the since cursor on the immediately-following GET /api/changes?subject=space:S&since=… so the "what's new since I was here" pane renders in one round-trip. Caller must be a Space member (non-members get 404 to hide existence on INVITE_ONLY).

Parameters

slug
string· path· required
The Space slug.
POST/api/spaces/{slug}/visits
drop spaces _visit platform-team
Response200
{
  "lastVisitAt": "2026-06-07T09:15:00.000Z"
}
POST

Star a Space

/api/spaces/{slug}/star

Star a Space to pin it to the top of your sidebar. Personal to you — other members are unaffected. Idempotent: starring an already-starred Space returns the existing starredAt. Caller must be a member (404 SPACE_NOT_FOUND otherwise).

Parameters

slug
string· path· required
The Space slug.
POST/api/spaces/{slug}/star
drop spaces star platform-team
Response200
{
  "success": true,
  "starredAt": "2026-06-19T09:15:00.000Z"
}
DELETE

Unstar a Space

/api/spaces/{slug}/star

Remove your star from a Space. Personal to you. Idempotent: unstarring a Space that is not starred returns success with starredAt: null. Caller must be a member (404 SPACE_NOT_FOUND otherwise).

Parameters

slug
string· path· required
The Space slug.
DELETE/api/spaces/{slug}/star
drop spaces unstar platform-team
Response200
{
  "success": true,
  "starredAt": null
}
POST

Hide a Space

/api/spaces/{slug}/mute

Hide a Space so it drops out of your active sidebar (it moves to Inactive). Personal to you — other members are unaffected; the Space is NOT archived, and your notifications are unchanged. Idempotent. Caller must be a member (404 SPACE_NOT_FOUND otherwise).

Parameters

slug
string· path· required
The Space slug.
POST/api/spaces/{slug}/mute
drop spaces hide platform-team
Response200
{
  "success": true,
  "mutedAt": "2026-06-19T09:15:00.000Z"
}
DELETE

Unhide a Space

/api/spaces/{slug}/mute

Unhide a Space so it returns to its relationship group in your sidebar. Personal to you. Idempotent: unhiding a Space that is not hidden returns success with mutedAt: null. Caller must be a member (404 SPACE_NOT_FOUND otherwise).

Parameters

slug
string· path· required
The Space slug.
DELETE/api/spaces/{slug}/mute
drop spaces unhide platform-team
Response200
{
  "success": true,
  "mutedAt": null
}
POST

Request access

/api/spaces/{slug}/access-requests

Ask to join an invite-only Space. Every Space owner is notified on the channels they have on for "Access" (bell, email, Slack DM). Repeat requests are de-duplicated — the response reports the existing request (outcome: EXISTING) and nobody is notified again. A request an owner turned down also reads as EXISTING for 24 hours (dismissals are never announced); after that, asking again re-opens it. Errors: 409 ALREADY_MEMBER, 409 OPEN_SPACE (join it instead), 404 SPACE_NOT_FOUND (missing or archived).

Parameters

slug
string· path· required
The Space slug.
message
string· body
Optional note for the owners (≤500 chars).
POST/api/spaces/{slug}/access-requests
drop spaces request-access brand-hub --message "Picking up the logo audit from Mia this sprint."
Response201
{
  "request": {
    "id": "sar_123",
    "status": "PENDING",
    "createdAt": "2026-10-15T14:12:00.000Z"
  },
  "outcome": "CREATED"
}
GET

List access requests

/api/spaces/{slug}/access-requests

Owner-only. Pending requests to join the Space, most recent first.

Parameters

slug
string· path· required
The Space slug.
GET/api/spaces/{slug}/access-requests
drop spaces requests list brand-hub
Response200
[
  {
    "id": "sar_123",
    "message": "Picking up the logo audit from Mia this sprint.",
    "createdAt": "2026-10-15T14:12:00.000Z",
    "requester": {
      "id": "usr_sam",
      "name": "Sam Chen",
      "email": "sam@himarley.com"
    }
  }
]
POST

Grant an access request

/api/spaces/{slug}/access-requests/{requestId}/grant

Owner-only. Adds the requester to the Space (EDITOR by default, or READ_ONLY) and tells them they're in. If they already joined another way their existing role is kept. Idempotent: a request another owner already handled returns outcome: ALREADY_RESOLVED with its current status.

Parameters

slug
string· path· required
The Space slug.
requestId
string· path· required
The request id (from requests list).
role
enum· body
EDITOR (default) or READ_ONLY.
POST/api/spaces/{slug}/access-requests/{requestId}/grant
drop spaces requests grant brand-hub sar_123 --role EDITOR
Response200
{
  "outcome": "GRANTED",
  "status": "GRANTED",
  "role": "EDITOR"
}
POST

Dismiss an access request

/api/spaces/{slug}/access-requests/{requestId}/dismiss

Owner-only. Turns the request down without adding the requester. They aren't told; they can ask again after 24 hours. Idempotent like grant.

Parameters

slug
string· path· required
The Space slug.
requestId
string· path· required
The request id (from requests list).
POST/api/spaces/{slug}/access-requests/{requestId}/dismiss
drop spaces requests dismiss brand-hub sar_123
Response200
{
  "outcome": "DISMISSED",
  "status": "DISMISSED"
}