Skip to main content
Custom content lets you create fully interactive applications that run inside entity pages. Your app runs in a sandboxed iframe and can read/write entity data via the REST API.

How it works

Seal injects window.SEAL_CONTEXT into your HTML with credentials to access the API.

Context

Your app receives this context automatically:
interface SealContext {
  // Entity data (snapshot at load time)
  entityId: string;
  version: string | null;
  entity: EntityData;        // Full entity - render instantly, no fetch needed

  // User context
  workspaceId: string;       // Current entity's system
  userId: string;
  actorRoleId: string | null;
  displayName: string;
  email: string;
  orgSlug: string;

  // Systems the user has access to
  systems: Array<{
    id: string;
    slug: string;
    name: string;
    permissionLevel: 'VIEWER' | 'OPERATOR' | 'BUILDER' | 'WORKSPACE_ADMIN' | null;
    access: 'direct' | 'transitive';  // transitive = inherited via hierarchy
  }>;

  // API access
  apiToken: string;
  apiUrl: string;
}

API examples

// Entity is included in context - no fetch needed for initial render
const { entity, userId, displayName } = window.SEAL_CONTEXT;

document.getElementById('title').textContent = entity.title;
document.getElementById('user').textContent = `Viewed by ${displayName}`;

Development

1

Start local dev server

Run your app on port 5347:
bun dev
2

Enter development mode

Open a custom content entity in Seal and click Start Development Mode.
3

Handle context

In dev mode, context is sent via postMessage:
let ctx = window.SEAL_CONTEXT;

window.addEventListener('message', (e) => {
  if (e.data?.type === 'SEAL_CONTEXT') ctx = e.data.payload;
});

if (!ctx) window.parent.postMessage({ type: 'REQUEST_CONTEXT' }, '*');
Your code works in both dev mode (postMessage) and production (injected context) with the pattern above.

Deploy

1

Build your app

bun build
2

Create zip

zip -r build.zip dist/
The zip must contain index.html at the root.
3

Deploy

curl -X POST "$SEAL_URL/api/custom-content/$ENTITY_ID/deploy" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/zip" \
  --data-binary @build.zip

Complete example

An editable notes field that saves on blur:
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Custom Content</title>
    <style>
      body { font-family: system-ui, sans-serif; padding: 1rem; }
      textarea { width: 100%; height: 200px; font-size: 14px; }
      .meta { color: #666; font-size: 12px; margin-bottom: 0.5rem; }
      .saving { opacity: 0.6; }
    </style>
  </head>
  <body>
    <div class="meta">Loading...</div>
    <textarea disabled>Loading...</textarea>

    <script>
      let ctx = window.SEAL_CONTEXT;
      let currentEntity = null;

      // Dev mode: receive context via postMessage
      window.addEventListener('message', (e) => {
        if (e.data?.type === 'SEAL_CONTEXT') {
          ctx = e.data.payload;
          init();
        }
      });

      if (ctx) {
        init();
      } else {
        window.parent.postMessage({ type: 'REQUEST_CONTEXT' }, '*');
      }

      function init() {
        currentEntity = ctx.entity;  // Instant - no fetch needed
        render();
      }

      function render() {
        const notes = currentEntity.fields.notes?.value ?? '';
        document.querySelector('.meta').textContent =
          `Editing as ${ctx.displayName} (${ctx.email})`;
        const textarea = document.querySelector('textarea');
        textarea.value = notes;
        textarea.disabled = false;
        textarea.onblur = () => saveNotes(textarea.value);
      }

      async function saveNotes(value) {
        const textarea = document.querySelector('textarea');
        textarea.classList.add('saving');

        await fetch(`${ctx.apiUrl}/entities/${ctx.entityId}/fields/notes`, {
          method: 'PATCH',
          headers: {
            Authorization: `Bearer ${ctx.apiToken}`,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ value })
        });

        // Refresh to get server state
        currentEntity = await fetch(
          `${ctx.apiUrl}/entities/${ctx.entityId}`,
          { headers: { Authorization: `Bearer ${ctx.apiToken}` } }
        ).then(r => r.json());

        textarea.classList.remove('saving');
        render();
      }
    </script>
  </body>
</html>

API reference

Full REST API documentation
The SDK uses postMessage instead of REST API. Both work, but REST API is recommended for new apps.