Inspiration API
The API surface that creates and manages Inspirations — the static-asset entities you drop onto the grid: decks, docs, demo HTML, images. This is the single source of truth for any client that writes Inspirations: the web UI, the CLI (grid plug), and API automation.
1. Naming and verb
The user-facing verb is drop. The user-facing noun is Inspiration. The endpoints reflect this:
POST /api/v2/inspirations— creates an Inspiration (the act of dropping)GET /api/v2/inspirations/:id— reads an InspirationPOST /api/v2/entities/:id/reactions— leaves a reaction (works on any entity)
The CLI command is grid plug <path> — plug is the unified verb for putting any node on the grid.
2. Auth
All endpoints require:
- Header:
Authorization: Bearer <jwt> - The JWT must resolve to a user with an active membership in the grid the request scopes to.
- Active grid context is determined by the
X-CloudGrid-Gridheader (the legacyX-CloudGrid-Orgheader is still accepted as an alias; when both are sent,X-CloudGrid-Gridwins). Required on all writes; defaults to your primary grid on reads if omitted.
If the header resolves to a grid you have no membership in, the response is 403.
3. POST /api/v2/inspirations — create
Request is multipart/form-data:
| Field | Required | Type | Constraints | Notes |
|---|---|---|---|---|
file | yes | binary | max 50 MB | The Inspiration payload |
name | yes | string | 3–40 chars, ^[a-z][a-z0-9-]*$ | URL-safe slug |
display_name | no | string | 1–100 chars | Human-readable; defaults to name |
scope | yes | enum | personal | space | org | Where this Inspiration lives |
space_id | conditional | UUID | Required if scope: space; you must be a member of that space | |
visibility | yes | enum | private | space | org | link | Who can see it |
expires_in_days | no | integer | 1–365, or null for pinned | Default 30 |
pin_reason | conditional | string | 5–280 chars | Required if expires_in_days: null |
source_type | yes | enum | see the table below | How the payload was authored |
uploaded_via | yes | enum | web | cli | claude_extension | claude_mcp | api | Attribution |
inspired_by | no | UUID[] | Up to 10 entity ids | Inspirations this one builds on (see inspired_by) |
Slug format: the name you submit is the base slug. The server appends a 4-char hex suffix (-XXXX) to prevent URL enumeration and cross-user collisions. The response name and url fields reflect the full suffixed slug. Use the returned value when constructing links — don't reconstruct from your original input.
Scope and visibility — interaction rules
scope: personal+visibility: private— only the owner can see itscope: personal+visibility: org— visible to all grid members at the owner's user-pathed URLscope: personal+visibility: link— anyone with the URL can see it (logged in or not)scope: space+visibility: space— visible to space members onlyscope: space+visibility: org— visible to all grid members but pathed under the spacescope: org+visibility: org— visible to all grid members at the grid-pathed URLscope: org+visibility: private— invalid (returns 400)scope: org+visibility: space— invalid (no specific space defined)
Supported source_type values
| Value | File types accepted | Rendered as |
|---|---|---|
html | .html, .htm, .zip (zip must contain index.html at root; nested directories preserved) | Static site |
react_single_file | .jsx, .tsx, .js | Wrapped in minimal scaffold, served static |
markdown | .md | Styled doc render |
pdf | .pdf | In-browser viewer |
pptx | .pptx | Embedded viewer |
docx | .docx | Embedded viewer |
xlsx | .xlsx | Embedded viewer |
image | .png, .jpg, .jpeg, .svg, .webp | Gallery wrapper |
The server validates the extension matches the declared source_type. Mismatch returns 415.
Response — 201 Created
{
"entity_id": "7f8e2a4c-...",
"name": "q2-pricing-deck-f8a2",
"display_name": "Q2 Pricing Deck",
"realm": "inspiration",
"kind": "inspiration",
"url": "https://acme.cloudgrid.io/q2-pricing-deck-f8a2",
"scope": "personal",
"space_id": null,
"visibility": "org",
"expires_at": "2026-05-13T00:00:00Z",
"pinned": false,
"source_type": "pptx",
"status": "provisioning",
"view_count": 0,
"reactions": { "sparked": 0, "useful": 0, "build_on": 0, "impressive": 0, "discuss": 0 },
"owner": { "user_id": "a1b2c3d4-...", "name": "Marcus Chen", "slug": "marcus" },
"grid_slug": "acme",
"space": null,
"inspired_by": [],
"created_at": "2026-04-13T14:22:31Z",
"brain_status": "pending",
"caller_permissions": { "can_edit": true, "can_archive": true, "can_pin": true }
}
The reactions map may also carry historical keys (viewed, resonated, love) as read-only pass-throughs alongside the five pressable kinds — no key ever disappears from the wire shape, so don't reject unknown reaction keys. See Reactions.
caller_permissions is server-computed: what you may do on this Inspiration (can_edit / can_archive / can_pin are true for the owner and grid admins). Use these flags to render action controls rather than re-implementing the check. They are also present on list rows.
When scope: space, the space field is populated with { space_id, slug, name }.
Status semantics
provisioning— entity persisted, upload in progress, URL not yet servinglive— URL serving contentfailed— provisioning failed; see the error in the entity detailarchived— soft-deleted by owner or admin. Still readable by callers with visibility; reactions are refused.
Display the URL immediately on 201 but expect a brief 503 window. Poll GET /api/v2/inspirations/:id at 1-second intervals if you want to confirm live before showing an "Open" control.
brain_status semantics
pending— enrichment queuedenriched— description, icon, category populatedskipped— enrichment failed; the Inspiration is fully usable, it just lacks auto-generated metadata
Enrichment is fire-and-forget. The upload response does not block on it.
4. GET /api/v2/inspirations/:id — read
:id accepts either an entity_id (UUID) or a name (slug), resolved against your active grid. Anonymous callers can only look up by UUID; a name-shaped param from an anonymous caller returns the same 404 NOT_FOUND shape as an unknown id, so the endpoint does not leak whether a slug exists.
Access
You must have read access per the Inspiration's visibility. The four modes:
private— only the owner (and grid admins)authenticated— any signed-in caller, including cross-gridgrid— scoped byvisibility_spaces. Whole-grid (absent / empty /['everyone']) means any member of the Inspiration's grid. A custom space subset means members of any listed space.link— anyone with the URL (no auth required, but a JWT enables view-counter dedup)
Visibility requires a live-or-building state. Declared visibility only takes effect for viewers other than the owner or a grid admin while the entity is serving or on its way to serving (running, building, provisioning, paused, or a failed redeploy still serving its last good version). In any other state — draft, stopped, expired, archived, failed-never-live — effective visibility collapses to private: detail reads return 404 NOT_FOUND and list responses omit the row. The owner and grid admins keep seeing their rows. Stored visibility is never mutated.
Response
Same shape as the create response, with current values, plus:
description,icon,category,tags— enrichment output. Sparse: each field is present only when populated.visibility_spaces,link_indexed— the visibility sub-state. Sparse:visibility_spacesforgrid,link_indexedforlink. See Visibility.
Side effects
Calling this endpoint as an authenticated user counts as a view for that user (deduplicated by user-day). Anonymous calls (link visibility, no JWT) increment the counter using IP-day deduplication.
5. List endpoints
GET /api/v2/inspirations
Returns Inspirations visible to you. Visibility filtering is applied server-side — an Inspiration you cannot see is never returned, regardless of query parameters. Responses only include Inspirations in your active grid.
| Param | Type | Default | Notes |
|---|---|---|---|
owner | string (user slug, or me) | — | Only Inspirations owned by this user. me resolves to the caller. |
space | string (space slug) | — | Only Inspirations scoped to this space. You must be a member. |
sort | created_at_desc | view_count_desc | reactions_desc | created_at_desc | |
cursor | string | — | Opaque cursor from the previous response's next_cursor. |
limit | integer | 50 | 1–100. |
Response is { items, next_cursor, total_returned }. Each item is a summary row: entity_id, name, display_name, url, visibility, view_count, reactions, owner, sparse enrichment fields, created_at, caller_permissions, and three retention fields:
status— the effective serving state. A published Inspiration that is serving, unexpired, and unarchived readslive; one past itsexpires_atreadsexpired;archivedwins over both. Any other stored status passes through as-is.expires_at— the expiry instant;nullwhen pinned / never-expiring.expires_in_days— whole days until expiry, clamped to 0 once passed.nullmeans no expiry;0means already expired.0never means "no expiry".
The summary omits inspired_by — call GET /api/v2/inspirations/:id when you need the full shape.
GET /api/v2/runtimes
Returns Runtimes (Apps and Agents) visible to you. Runtimes are always grid-scoped; every runtime in your active grid is visible to every member.
| Param | Type | Default | Notes |
|---|---|---|---|
kind | app | agent | all | all | |
owner | string | — | User slug. |
space_id | string (space UUID) | — | Only runtimes tagged to this space. |
sort | created_at_desc | updated_at_desc | updated_at_desc | |
cursor, limit | — | 50 | Same as above. |
Each item carries entity_id, name, display_name, kind, url, status, owner, sparse enrichment fields, created_at, updated_at. A never-deployed draft additionally carries sparse expires_at + expires_in_days (drafts expire after a platform-set TTL, 14 days by default); live, paused, and failed entities carry no retention clock.
6. PATCH /api/v2/inspirations/:id — update
Only the owner or a grid admin can update. Non-owner non-admin gets 403; cross-grid gets 404.
Request body (all fields optional; pass at least one):
| Field | Type | Constraints |
|---|---|---|
display_name | string | 1–100 chars |
description | string | null | up to 280 chars; null clears it |
visibility | private | authenticated | grid | link | four-mode model; see Visibility. Legacy values are accepted for a transition window and mapped on store. |
visibility_spaces | string[] | honored only when the effective visibility is grid (space-slug list; ['everyone'] / omitted = whole grid) |
link_indexed | boolean | honored only when the effective visibility is link (omitted = false) |
scope | personal | space | org | scope is independent of visibility; unchanged unless explicitly set |
space_id | string | null | UUID; required when scope: space; you must be a member; cleared on move to personal/org |
Side effects of a scope change:
* → space:space_idis set (required); membership verifiedspace → *:space_idis cleared- Uniqueness re-check at the new scope —
409 NAME_COLLISIONif the slug already exists there - The URL slug never changes —
entity.namestays stable across PATCH. Rename would break the public URL.
Response: 200 with the refreshed full Inspiration shape, including caller_permissions.
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | empty body, invalid length, invalid enum |
| 400 | SCOPE_INVALID | scope and visibility combination not in the allowed set (legacy visibility values only) |
| 400 | SPACE_REQUIRED | scope: space without space_id |
| 403 | SPACE_NOT_MEMBER | not a member of the target space |
| 403 | FORBIDDEN | not owner, not admin |
| 404 | NOT_FOUND | entity missing, wrong realm, or cross-grid |
| 409 | NAME_COLLISION | slug conflicts at the target scope |
7. Visibility
Every entity — Inspiration or Runtime — carries one of four visibility modes. Two modes carry a sub-state:
| Mode | Who can see the entity | Sub-state |
|---|---|---|
private | only the creator | — |
authenticated | any logged-in CloudGrid user (network-wide) | — |
grid | grid members in at least one of the listed spaces | visibility_spaces: string[]. ['everyone'] (default) = all grid members. A custom subset = only members of the listed spaces. |
link | anyone with the URL | link_indexed: boolean. false (default) = not crawlable (noindex + sitemap exclusion). true = public and indexable. |
Read responses carry two sparse fields, each emitted only for the mode it belongs to: visibility_spaces (for grid, default ['everyone']) and link_indexed (for link, default false).
PATCH /api/v2/entities/:id/visibility
The write surface for the visibility model on any entity. :id accepts an entity_id (UUID) or a name/slug scoped to your active grid. You must own the entity or be a grid admin; otherwise 403 NOT_OWNER. Cross-grid access surfaces as 404.
| Field | Required | Type | Notes |
|---|---|---|---|
visibility | yes | private | authenticated | grid | link | The deprecated org value is rejected on this route (400 VALIDATION_FAILED, hint to use grid). |
visibility_spaces | no | string[] | Only valid when visibility: grid. Non-empty array of space slugs. Omitted means ['everyone'] (whole grid). An unknown slug returns 400 SPACE_NOT_FOUND. |
link_indexed | no | boolean | Only valid when visibility: link. Omitted means false. |
Supplying visibility_spaces for a non-grid mode, or link_indexed for a non-link mode, returns 400 VALIDATION_FAILED. Each mode clears the sub-state it does not own, so a transition between modes never leaves stale fields behind.
For runtime entities, a visibility change re-applies the entity's Ingress so the serving auth posture (gated vs public) tracks the new visibility. Inspirations are enforced at the serving read path, so there is no re-apply step.
Response — 200: { entity_id, name, visibility, visibility_spaces?, link_indexed?, updated_at }.
PATCH /api/v2/inspirations/:id/visibility
The Inspiration parallel of the entity route, so clients carry one visibility shape across both realms. Same body, same validation, same response shape. One difference: this route still accepts legacy visibility values (org, space, link-anonymous, link-indexed, public) for a transition window and maps them to the four-mode model on store. A legacy value combined with either sub-state field is rejected — use the canonical mode.
This route writes visibility only. It never touches scope or the scope-owned space_id — scope is where the Inspiration lives; visibility is who can see it.
8. Errors
All errors follow one shape:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields were not accepted.",
"details": [
{ "field": "name", "issue": "already_exists", "hint": "Try 'q2-pricing-deck-v2'." }
],
"request_id": "req_..."
}
}
| Status | When | Retryable |
|---|---|---|
| 400 | Validation failure (bad field, invalid scope+visibility combination, mismatched source_type) | No |
| 401 | Missing or invalid JWT | No |
| 403 | No access to the grid or space | No |
| 404 | Not found, or visibility hides it from the caller | No |
| 409 | Name collision within scope | No |
| 413 | File too large | No |
| 415 | Unsupported file type for the declared source_type | No |
| 422 | File contents malformed | No |
| 426 | CLI below the minimum supported version (CLI_UPGRADE_REQUIRED) — upgrade and retry | After upgrade |
| 429 | Rate limited (see rate limits) | Yes, after Retry-After |
| 500 | Server error | Yes, with backoff |
| 503 | Grid or scope resources temporarily unavailable | Yes, with backoff |
| Code | When |
|---|---|
SCOPE_INVALID | scope+visibility combination is invalid (e.g. scope: org, visibility: private) |
SPACE_REQUIRED | scope: space without space_id |
SPACE_NOT_MEMBER | scope: space for a space you aren't in |
INSPIRED_BY_INVALID | one or more inspired_by entity ids don't exist or aren't visible to you |
ORG_HEADER_REQUIRED | the active-grid header is missing on a write for a user with more than one grid membership. Single-grid users fall back to their only membership silently. |
9. POST /api/v2/entities/:id/reactions — toggle a reaction
Leave or toggle a reaction on any entity you can read — app, agent, or Inspiration. (POST /api/v2/inspirations/:id/reactions is a deprecated alias with the identical shape; new clients use the entities path.)
{ "kind": "sparked" }
kind must be one of the five pressable kinds: sparked | useful | build_on | impressive | discuss. Three historical kinds (viewed, resonated, love) are retired as pressable and return 400 VALIDATION_FAILED on a press — their historical counts are preserved and still surface in reactions. Page views are counted by the visit beacon, not a reaction press.
Response — 200 OK
{
"entity_id": "7f8e2a4c-...",
"reactions": { "viewed": 0, "resonated": 1, "love": 5, "sparked": 3, "useful": 2, "build_on": 0, "impressive": 4, "discuss": 0 },
"user_reactions": ["sparked", "impressive"]
}
reactionsis the global aggregate. It is eventually consistent — within a sub-second window the count reflects your toggle; the synchronous response carries the last-known total, which may not yet include the toggle you just made.user_reactionsis your own active reactions, read synchronously — authoritative (read-your-write). Trustuser_reactionsfor the toggle state and reconcile the displayed total against the next read.
Toggle behavior
POSTing the same kind twice toggles it off. The reaction state is strongly consistent; the count is derived asynchronously, so it never makes the toggle decision wrong.
Auth
You must have read access to the entity. Anonymous reactions (link visibility, no JWT) are not supported — only logged-in users can react. Reactions on private Inspirations are refused (403), and reactions on archived entities are refused.
10. POST /api/v2/views/:entity_id — visit beacon
Records one visit to a shared serving URL. The served public page is cached and byte-identical for every viewer, so a recipient opening a shared link never calls the detail endpoint — this beacon is the cache-safe signal. A tiny inline snippet on the served page fires it once on first paint. It is a thin wrapper over the same per-day-deduplicated view counter the detail read uses — not a separate counter.
- Auth: none required — a shared-link recipient is anonymous, and the view is deduplicated on a one-way IP hash. When a Bearer token is sent, dedup switches to the user id.
- Response: always
202 Accepted, empty body. Counting happens after the response, so the beacon never blocks the page. A bogus id, a filtered request, or a counting failure all still return 202. - Body: optional and reserved for future attribution; the shipped browser beacon sends no body.
Filtering (so the count means something)
- Crawlers, link-unfurlers, and naive HTTP clients are dropped server-side.
- Owner self-loads are not recorded — the owner viewing their own page does not inflate the count.
- A
cg-no-countcookie opts a browser out of counting. - Per-day dedup: a single viewer reloading the same day does not double-count.
11. Rate limits
| Limit | Window | Applies to |
|---|---|---|
| 10 Inspirations created per minute | per user | All clients |
| 100 Inspirations created per hour | per grid | All clients |
| 50 MB max file size | per request | Per tier — Starter is 50 MB; future tiers raise this |
| 60 reactions per minute | per user | All clients |
| 200 Inspiration reads per minute | per user | All clients |
Exceeding a limit returns 429 with a Retry-After header (seconds) and the specific limit hit in error.details.
12. Active grid context
All write endpoints require the X-CloudGrid-Grid header to specify which grid you are acting in. The value is the grid's slug (e.g. acme). The server resolves the slug and validates membership. If the slug is unknown or you have no membership, the response is 403 ORG_NOT_ACCESSIBLE. For reads, the header is optional and defaults to your primary grid.
13. The inspired_by relationship
When creating an Inspiration, you can pass inspired_by: [entity_id, ...] listing up to 10 other Inspirations that inspired this one. Each referenced id creates a link. You must have read access to each referenced source — you can only declare a source you can see; otherwise the request fails with INSPIRED_BY_INVALID.
Links are realm-agnostic: a Runtime can be inspired by an Inspiration, and an Inspiration can build on another Inspiration.
Link endpoints
POST /api/v2/runtimes/:id/inspirations— link a Runtime to an Inspiration source. Body:{ "inspiration_entity_id": "<uuid>", "note": "<optional, up to 280 chars>" }. You need read access to both sides. Returns 201 with the new link row.DELETE /api/v2/runtimes/:id/inspirations/:link_id— remove a link. Caller must be the user who noted it, or a grid admin.GET /api/v2/runtimes/:id/inspirations— list Inspirations that inspired this Runtime. Sources you cannot see are silently omitted.GET /api/v2/inspirations/:id/inspired-runtimes— list Runtimes inspired by this Inspiration. Each row carries alinked_runtimesummary; runtimes you cannot read are silently omitted.
14. What this contract does not cover
- Versioning or evolution UI
- Comments or threaded discussions
- Polls, scoring, or structured feedback
- Sharing notifications or invite-to-view flows
- Analytics beyond view count and reactions
- Runtime creation — separate contract
- Custom domain handling
- Pre-signed direct-to-storage uploads (uploads go through the API server)
- Bulk operations