# Entities

> The Entities operations of the Hub REST API (17 operations).

URL: https://docs.synap.live/docs/reference/hub-api/entities · Generated from Synap-core/backend@c31e261ab (main)

Paths are relative to `/api/hub` on a pod.

## GET /users/{userId}/entities

List entities for a user

Returns the caller's entities, optionally filtered by profileSlug and workspaceId. Without workspaceId it reads every workspace the caller can read plus pod-wide entities; with one, that workspace (an archived or non-member one is a 403). Pod-wide profiles are returned regardless of workspaceId. `userId` must be the caller's own id or `me`; any other id is a 403.

Parameters:

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `userId` | path | string | yes | The caller's own user id, or `me`. |
| `profileSlug` | query | string |  |  |
| `type` | query | string |  | Deprecated alias for profileSlug. |
| `workspaceId` | query | string |  |  |
| `projectId` | query | string |  | Project lens: only the project and what is filed into it. |
| `facetSlug` | query | string |  | Only entities carrying a live facet of this role (e.g. `client`). |
| `limit` | query | string |  |  |

Responses:

- `200`: Array of entities
- `400`: Bad request — the wrapped door refused the input
- `401`: Unauthorized
- `403`: Missing scope, a userId other than the caller's, or a workspace the caller cannot use
- `404`: Not found
- `409`: Conflict — a refusal the caller can act on
- `412`: Precondition failed
- `500`: Internal error

## GET /entities/without-project/counts

Count the things in no project, by kind

Counts the caller's visible things filed into NO project, grouped by kind (largest first). `total` is the sum of `byProfile` and equals the total of GET /entities?withoutProject=true with no lens.

Responses:

- `200`: Counts by kind
- `400`: Bad request — the wrapped door refused the input
- `401`: Unauthorized
- `403`: Missing scope
- `404`: Not found
- `409`: Conflict — a refusal the caller can act on
- `412`: Precondition failed
- `500`: Internal error

## GET /entities

Search/list entities

Canonical list/search endpoint. When `q` is set, performs a Typesense search; otherwise lists entities with normal scoping.

Parameters:

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `q` | query | string |  | Free-text search query. Refused (403 GRANT_DOOR_UNSUPPORTED) for a scoped key: search is not bounded by a grant. List without `q` instead. |
| `profileSlug` | query | string |  |  |
| `workspaceId` | query | string |  |  |
| `projectId` | query | string |  | Project lens — narrows results to a single project's data (the project entity + everything that belongs_to it). Pure-narrowing; cannot widen access past the user floor. |
| `withoutProject` | query | "true" \| "false" |  | `true` = only the things filed into NO project (the 'Not in a project' list). 400 with `projectId` or `q`. Counts by kind: GET /entities/without-project/counts. |
| `limit` | query | string |  |  |
| `offset` | query | string |  | Zero-based offset for cursor-free pagination (default 0). |
| `sort` | query | string |  | e.g. `updatedAt:desc`. |
| `scope` | query | "pod" \| "workspace" \| "all" |  | `pod` = pod-wide entities only (null workspaceId); `workspace` = single workspace (requires workspaceId); `all` = merge across all accessible workspaces. |
| `includePodWide` | query | string |  | When a workspaceId is supplied, include pod-wide (null workspaceId) globals in the result. Defaults to `true` so a workspace lens returns 'that workspace + pod-wide globals' — the behavior agents and the CRM expect. Set `false` to return only the exact workspace's rows. |
| `facetSlug` | query | string |  | Kind + Facets filter — only return entities carrying a live facet of this role-profile slug (e.g. `investor`). |
| `facetProfileId` | query | string |  | Same as facetSlug but by profile id. Wins if both are set. |
| `createdAfter` | query | string |  | Creation-date window, INCLUSIVE lower bound (ISO-8601). Together with createdBefore forms the half-open interval [createdAfter, createdBefore), so consecutive windows tile. Rejected with 400 when combined with `q` — see createdBefore. |
| `createdBefore` | query | string |  | Creation-date window, EXCLUSIVE upper bound (ISO-8601). NOT combinable with `q`: the `q` branch is a relevance-ranked top-N served by Typesense, and a date window is an exhaustive question, so silently truncating one to the other would under-report. Ask for the window without `q`. |

Responses:

- `200`: Array of entities
- `400`: Malformed or unsupported filter combination — an unparseable createdAfter/createdBefore, or a date window combined with `q`.
- `401`: Unauthorized
- `403`: Forbidden
- `404`: Not found
- `409`: Conflict — a refusal the caller can act on
- `412`: Precondition failed
- `500`: Internal error

## POST /entities

Create an entity

Creates an entity on behalf of a user. Honors profile.entityScope: pod-wide profiles ignore workspaceId. Supports `Idempotency-Key`.

Request body (`application/json`): CreateEntityRequest

| Field | Type | Required | Description |
|---|---|---|---|
| `userId` | string |  | Owner of the entity. Defaults to the authenticated user. |
| `agentUserId` | string |  | Agent user that performed the write. Used for proposal authorship. |
| `workspaceId` | string |  | Target workspace. Falls back to profile.entityScope and the user's first accessible workspace. |
| `projectId` | string |  | File the new entity into a project (its entity id). Stamps a belongs_to_project edge at materialization, so project-scoped recall (ask with projectId) finds it. Orthogonal to workspaceId. |
| `profileSlug` | string |  | Profile slug. Provide profileSlug, profileId, or the deprecated `type` alias. |
| `profileId` | string |  | Profile UUID. Resolved to the profile's slug server-side; takes precedence over profileSlug/type. |
| `type` | string |  | Deprecated alias for profileSlug. Prefer profileSlug. |
| `title` | string | yes |  |
| `description` | string |  |  |
| `properties` | object |  |  |
| `content` | string |  | Long-form markdown body. When set, a linked, versioned document is created (MinIO-stored) instead of inlining into properties. |
| `reasoning` | string |  | The proposing agent's rationale for this action, surfaced in the proposal inbox. |
| `source` | "intelligence" \| "agent" \| "openwebui-pipeline" \| "extension" \| "cli" \| "n8n" \| "raycast" |  | Origin signal for downstream attribution. |
| `sourceMessageId` | string |  | Optional message ID that triggered this write (for event chain causality). |
| `sessionId` | string |  | Optional session ID to link proposals created by this write to an active focus session. |
| `facets` | array<object> |  | Kind + Facets: role-profiles to attach to the new entity in the same call (each via the governed attachFacet door). Applied only when the entity materializes. `slug` is canonical; `profileSlug` is accepted as an alias for parity with the /structure and tRPC facet shapes. |
| `forceCreate` | boolean |  | Bypass the weak same-name create gate when a same-profile entity with this title already exists. Prefer reusing the existing id. Does not bypass strong-signal auto-merge (email/phone/url). |
| `externalId` | string |  | Strong cross-source identity anchor for this subject, as `provider:id` (e.g. `discord:123456789012345678`). Registered as an `external_id` identity signal so a later create with the SAME value auto-resolves to the same entity (dedup) instead of minting a duplicate — the strong atom that the weak email/phone/url auto-extraction cannot express for opaque connector ids. Opaque and case-sensitive; the provider prefix namespaces the id. |
| `expectedLabel` | string |  | The declared session-output slot this entity fulfils, exactly as declared on the session's expected outputs. Left unset when the create doesn't fulfil a specific declared slot. |

Responses:

- `200`: Created entity (with effectiveWorkspaceId echo)
- `202`: Proposed for review — `{ status: 'proposed', proposalId, reviewUrl }`
- `400`: Bad request
- `403`: Missing scope
- `404`: Referenced profile not found
- `409`: Weak same-name conflict — a same-profile entity with this title already exists (pass forceCreate or reuse the candidate id)
- `500`: Internal error

## GET /entities/{id}/connections

Get a single entity's relations

Returns up to `limit` relations (default 50, max 200) connected to the entity. Workspace-scoped on shared pods.

Parameters:

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | yes |  |
| `userId` | query | string |  |  |
| `workspaceId` | query | string |  |  |
| `limit` | query | string |  |  |

Responses:

- `200`: Relations list with summary counts
- `400`: Bad request
- `403`: Forbidden
- `404`: Not found
- `409`: Conflict — a refusal the caller can act on
- `412`: Precondition failed
- `500`: Internal error

## GET /entities/{id}

Fetch a single entity

Returns the entity row by ID. Verifies workspace access on shared pods.

Parameters:

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | yes |  |
| `userId` | query | string |  |  |

Responses:

- `200`: Canonical entity wire record
- `400`: Bad request — the wrapped door refused the input
- `403`: Forbidden
- `404`: Not found
- `409`: Conflict — a refusal the caller can act on
- `412`: Precondition failed
- `500`: Internal error

## POST /entities/recall

Hybrid semantic recall

Combines Typesense keyword search and pgvector cosine similarity search, merged via Reciprocal Rank Fusion (RRF k=60). Falls back to Typesense-only when the embedding service is unavailable.

Request body (`application/json`): object

| Field | Type | Required | Description |
|---|---|---|---|
| `query` | string | yes |  |
| `profileSlug` | string |  |  |
| `workspaceId` | string |  |  |
| `limit` | integer |  |  |

Responses:

- `200`: Merged entity results
- `400`: Bad request
- `403`: Forbidden
- `404`: Not found
- `409`: Conflict — a refusal the caller can act on
- `412`: Precondition failed
- `500`: Internal error

## POST /entities/retrieve

Type-aware hybrid retrieval (Synap Retrieval Engine)

DEPRECATED — prefer POST /knowledge/ask, whose semantic lane calls the exact same retrieve() SRE. Still functional for existing integrations. Infers entity type(s) + property hints from the query, fuses unscoped + type-scoped recall (RRF), and returns ranked entities plus the inferred understanding. Phase 1 of the SRE.

Deprecated.

Request body (`application/json`): object

| Field | Type | Required | Description |
|---|---|---|---|
| `query` | string | yes |  |
| `workspaceId` | string |  |  |
| `limit` | integer |  |  |

Responses:

- `200`: Ranked entities + retrieval understanding
- `400`: Bad request — the wrapped door refused the input
- `403`: Forbidden
- `404`: Not found
- `409`: Conflict — a refusal the caller can act on
- `412`: Precondition failed
- `500`: Internal error

## POST /entities/{entityId}/attachments

Attach an image (uploaded or pre-stored) to an entity

Service (Hub API-key) route. Two modes: (1) upload+link — base64-decode an image, store it as a file entity (document + snapshot); (2) link-only — pass `fileEntityId` to link a previously-stored orphan file. Either way the file is linked to :entityId via a `references` relation. Upload mode: max 10MB, image/* only. Requires scope hub-protocol.write.

Parameters:

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `entityId` | path | string | yes |  |

Request body (`application/json`): object

| Field | Type | Required | Description |
|---|---|---|---|
| `userId` | string |  |  |
| `workspaceId` | string |  |  |
| `filename` | string |  |  |
| `mimeType` | string |  |  |
| `contentBase64` | string |  |  |
| `fileEntityId` | string |  |  |

Responses:

- `200`: Created file entity + link result
- `400`: Bad request
- `403`: Forbidden
- `404`: Target entity not found
- `409`: Conflict — a refusal the caller can act on
- `412`: Precondition failed
- `413`: File too large
- `415`: Unsupported media type
- `500`: Internal error

## POST /entities/batch-update

Update many entities in one governed call

Up to 500 items `{id, patch, expected?}`. Each item is written now (when governance would auto-approve it), collected into ONE review proposal, denied, or skipped. `validate: true` returns the per-item verdict and writes nothing. `idempotencyKey` (or the `Idempotency-Key` header) replays the first call's receipt.

Request body (`application/json`): object

| Field | Type | Required | Description |
|---|---|---|---|
| `userId` | string |  |  |
| `agentUserId` | string |  |  |
| `workspaceId` | string |  |  |
| `sessionId` | string |  |  |
| `items` | array<object> | yes |  |
| `idempotencyKey` | string |  |  |
| `validate` | boolean |  |  |
| `reasoning` | string |  |  |

Responses:

- `200`: Per-item receipt: applied / proposed (one pack) / denied / skipped
- `400`: Bad request — the wrapped door refused the input
- `403`: Forbidden
- `404`: Not found
- `409`: Conflict — a refusal the caller can act on
- `412`: Precondition failed
- `500`: Internal error

## PATCH /entities/{entityId}

Update an entity

Patch entity title / preview / metadata. Supports `Idempotency-Key`.

Parameters:

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `entityId` | path | string | yes |  |

Request body (`application/json`): UpdateEntityRequest

| Field | Type | Required | Description |
|---|---|---|---|
| `userId` | string |  |  |
| `agentUserId` | string |  |  |
| `workspaceId` | string \| null |  |  |
| `title` | string |  |  |
| `preview` | string |  |  |
| `metadata` | object |  |  |
| `deleteProperties` | array<string> |  |  |
| `reasoning` | string |  | The proposing agent's rationale for this update, surfaced in the proposal inbox. |
| `sourceMessageId` | string |  |  |
| `sessionId` | string |  |  |
| `expectedStatus` | string \| null |  | Compare-and-set on the status: the status you SAW. If it moved meanwhile the call answers 409 with the current status and writes nothing. |

Responses:

- `200`: Updated entity
- `400`: Bad request
- `403`: Forbidden
- `404`: Entity not found
- `409`: Stale status (`expectedStatus` no longer holds) — nothing written
- `500`: Internal error

## DELETE /entities/{entityId}

Delete an entity

Deletes an entity by id via the canonical entities.delete procedure. For agent keys this is proposal-gated (returns { status: 'proposed', proposalId }); auto-approved contexts complete inline. Acting identity is bound to the principal.

Parameters:

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `entityId` | path | string | yes |  |
| `workspaceId` | query | string |  |  |
| `userId` | query | string |  |  |
| `reasoning` | query | string |  |  |
| `agentUserId` | query | string |  |  |

Responses:

- `200`: Deletion result (completed or proposed)
- `400`: Bad request
- `403`: Forbidden
- `404`: Not found
- `409`: Conflict — a refusal the caller can act on
- `412`: Precondition failed
- `500`: Internal error

## GET /entities/{entityId}/facets

List an entity's facets (role-profiles)

Returns the entity's live facets joined with their role-profile + that profile's effective properties (workspace-lensed). Requires hub-protocol.read.

Parameters:

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `entityId` | path | string | yes |  |

Responses:

- `200`: Effective facets
- `403`: Forbidden
- `404`: Not found
- `500`: Internal error

## POST /entities/{entityId}/facets

Attach a facet (role-profile) to an entity

Attach a role-profile as an additive facet. Thin wrapper over the governed entities.attachFacet door (proposal-gated for agents). Requires hub-protocol.write.

Parameters:

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `entityId` | path | string | yes |  |

Request body (`application/json`): object

| Field | Type | Required | Description |
|---|---|---|---|
| `userId` | string |  |  |
| `profileSlug` | string |  |  |
| `profileId` | string |  |  |
| `workspaceId` | string \| null |  |  |
| `contextEntityId` | string \| null |  |  |
| `status` | string |  |  |
| `properties` | object |  |  |
| `agentUserId` | string |  |  |
| `reasoning` | string |  |  |

Responses:

- `200`: Attach result (or proposal envelope)
- `400`: Bad request
- `403`: Forbidden
- `500`: Internal error

## PATCH /facets/{facetId}

Update a facet's status / properties

Thin wrapper over the governed entities.updateFacet door. Requires hub-protocol.write.

Parameters:

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `facetId` | path | string | yes |  |

Request body (`application/json`): object

| Field | Type | Required | Description |
|---|---|---|---|
| `userId` | string |  |  |
| `status` | string |  |  |
| `properties` | object |  |  |
| `workspaceId` | string \| null |  |  |
| `agentUserId` | string |  |  |
| `reasoning` | string |  |  |
| `expectedStatus` | string \| null |  | Compare-and-set on the role's status: the status you SAW. If it moved meanwhile the call answers 409 with the current status and writes nothing. |

Responses:

- `200`: Update result (or proposal envelope)
- `400`: Bad request
- `403`: Forbidden
- `409`: Stale status (`expectedStatus` no longer holds) — nothing written
- `500`: Internal error

## DELETE /facets/{facetId}

Detach a facet (soft-delete)

Thin wrapper over the governed entities.detachFacet door. Requires hub-protocol.write.

Parameters:

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `facetId` | path | string | yes |  |
| `userId` | query | string |  |  |
| `agentUserId` | query | string |  |  |
| `reasoning` | query | string |  |  |

Responses:

- `200`: Detach result (or proposal envelope)
- `403`: Forbidden
- `500`: Internal error

## POST /identity/resolve

Resolve identity (read-only) — does this subject already exist?

Call BEFORE create to decide create-vs-enrich-vs-attach_facet. Returns `match:'strong'` (globally-unique signal hit — link/attach, don't create), `'weak'` (same-name candidates — advisory), or `'none'`. Read-only; never writes.

Request body (`application/json`): ResolveIdentityRequest

| Field | Type | Required | Description |
|---|---|---|---|
| `kindSlug` | string |  | The entity kind (profile slug) being created. A weak (same-name) match must be this kind to become `match:'weak'`; cross-kind rows still ride in `candidates`. |
| `title` | string |  | Name/title to weak-match. Blank → strong-signal lookup only. |
| `workspaceId` | string |  |  |
| `signals` | object |  | Explicit strong identity atoms. |
| `properties` | object |  | Draft property bag — strong signals are also extracted from it (merged with `signals`). |

Responses:

- `200`: Identity resolution result
- `400`: Bad request
- `403`: Forbidden
- `500`: Internal error
