@unseal-ai/unseal-space-sdk
v0.2.0
Published
Typed SDK for creating, iterating on, previewing, and publishing unseal-space projects.
Readme
@unseal-ai/unseal-space-sdk
Typed TypeScript SDK for unseal-space — create a website project, iterate on it with an AI agent, inspect its live preview, manage its environment variables and version history, publish it, and attach a domain. Everything an External Agent or your own backend needs, in one client.
The SDK is a thin, fully typed adapter over the public unseal-space API. Every method mirrors the API contract 1:1 and returns plain DTOs — no wrapper objects, no hidden state.
npm install @unseal-ai/unseal-space-sdkpnpm add @unseal-ai/unseal-space-sdk
yarn add @unseal-ai/unseal-space-sdk
bun add @unseal-ai/unseal-space-sdkRequirements — ESM only (import, no require). Any runtime with a global
fetch and async iterators: Node.js 18+, Bun, Deno, or an edge runtime. Pass
your own fetch if the global one is unsuitable.
Authentication
Create an API key on the API Keys page of the unseal-space dashboard, choosing a permission preset or explicit permissions. The key is shown once — store it as a secret. The SDK sends it on every request; you never construct headers yourself.
import { createUnsealSpaceClient } from "@unseal-ai/unseal-space-sdk";
const client = createUnsealSpaceClient({
baseUrl: "https://api.unseal.space",
apiKey: process.env.UNSEAL_SPACE_API_KEY!,
});| Option | Type | Required | Notes |
| --------- | -------------- | -------- | ----------------------------------------------------------------- |
| baseUrl | string | yes | Absolute http(s) origin. Production: https://api.unseal.space |
| apiKey | string | yes | Workspace API key |
| fetch | typeof fetch | no | Custom fetch (proxy, retry, instrumentation, tests) |
Keys are scoped to one workspace, and every call is authorized against the key's permissions — see Permissions.
Quick start
Create a project, drive one agent turn, then publish it:
import { createUnsealSpaceClient } from "@unseal-ai/unseal-space-sdk";
const client = createUnsealSpaceClient({
baseUrl: "https://api.unseal.space",
apiKey: process.env.UNSEAL_SPACE_API_KEY!,
});
// 1. Create a project. The initial prompt starts the first turn for you.
const project = await client.projects.create({
initialPrompt: "Build a landing page for a specialty coffee roaster",
});
// 2. Wait for that turn to reach a terminal state.
if (project.initialMessageId) {
const turn = await client.messages.wait(project.projectId, project.initialMessageId);
if (turn.status !== "completed") throw new Error(turn.error?.message ?? turn.status);
}
// 3. Look at the live preview.
const preview = await client.preview.get(project.projectId);
console.log(preview.status, preview.url); // "ready" https://…
// 4. Iterate.
const message = await client.messages.send(project.projectId, {
content: "Make the hero image full-bleed and add a newsletter form",
});
await client.messages.wait(project.projectId, message.messageId);
// 5. Publish to production and wait for the deployment.
const accepted = await client.deployments.create(project.projectId);
const deployment = await client.deployments.wait(project.projectId, accepted.deploymentId);
console.log(deployment.status, deployment.url);Every long-running operation follows the same accept → wait shape: the
mutation returns an id immediately, and a wait() helper polls or streams to a
terminal state. Persist the id — a lost wait() never cancels the remote work,
so you can always resume by id.
API
projects
await client.projects.create({ initialPrompt: "…", idempotencyKey: "…" }); // both optional
await client.projects.list({ filter: "starred" }); // "all" | "starred" | "created" | "shared"
await client.projects.get(projectId);create returns the project plus initialMessageId when initialPrompt was
given. A project created from a marketplace template starts with
provisioningState: "provisioning" — poll projects.get until it flips to
"ready" (or "failed", with provisioningError) before sending messages.
preview
await client.preview.get(projectId); // → { url, status }status is none | warming | ready | error. A warming preview means the
sandbox dev server is still starting; poll until ready.
messages — the agent turn
One turn per project at a time; a second send while one is in flight fails
with MESSAGE_IN_FLIGHT (the busy messageId is in error.data).
const { messageId } = await client.messages.send(projectId, {
content: "Add a pricing section",
// all optional:
model: "…", // from the platform's model allowlist
attachmentIds: [attachmentId], // see attachments
skillNames: ["…"],
idempotencyKey: crypto.randomUUID(), // replay guard
});Wait for the terminal state (the common case):
const result = await client.messages.wait(projectId, messageId, {
timeoutMs: 10 * 60 * 1000, // default 10 minutes
maxReconnectAttempts: 5, // default 5
cursor: savedCursor, // resume after a previous run
onEvent: ({ chunk, cursor }) => console.log(chunk.type),
});
result.status; // "completed" | "failed" | "interrupted"
result.version; // the Version this turn produced, when completed
result.error; // { code, status, message } when failed
result.cursor; // persist this to resume laterwait streams the event feed, reconnects with exponential backoff on transient
failures, and resumes from the last cursor — so a dropped connection does not
lose the turn. It throws SDK_TIMEOUT when the local deadline elapses; the
remote turn keeps running, so resume with the same messageId and cursor.
Stream the events yourself when you want to render progress. The chunks are
AI SDK v5 UI Message Stream chunks (text, reasoning,
dynamic tool calls, data-* parts), so they fold directly into a UIMessage:
for await (const { chunk, cursor } of client.messages.events(projectId, messageId, { cursor })) {
if (chunk.type === "text-delta") process.stdout.write(String(chunk.delta));
if (chunk.type === "finish" || chunk.type === "error" || chunk.type === "abort") break;
}attachments
Two steps: reserve a slot (returns a direct upload URL), then PUT the bytes.
Pass the returned attachmentId to messages.send.
const bytes = await readFile("hero.png");
const reservation = await client.attachments.reserve(projectId, {
filename: "hero.png",
contentType: "image/png", // png | jpeg | webp | gif
size: bytes.byteLength,
});
await client.attachments.upload(reservation, bytes);
await client.messages.send(projectId, {
content: "Use this as the hero image",
attachmentIds: [reservation.attachmentId],
});The byte length must match the reserved size exactly — a mismatch throws
SDK_INVALID_ARGUMENT before any upload happens.
environment — write-only values
Manages the project's environment variables (database URLs, third-party API keys). Values are write-only: you can create, replace, or delete a value, but the platform never returns one. Every response carries only metadata (name, kind, targets, version) plus runtime freshness.
const env = await client.environment.get(projectId);
await client.environment.create(projectId, {
name: "DATABASE_URL",
value: process.env.DATABASE_URL!, // write-only: cannot be read back later
kind: "secret", // "secret" | "plain"
preview: true,
production: true,
});
// Mutations take the variable id + optimistic-lock version from a fresh get().
const variable = env.variables.find((v) => v.name === "DATABASE_URL")!;
await client.environment.replace(projectId, {
eid: variable.id,
expectedVersion: variable.version,
value: "postgres://rotated",
kind: variable.kind,
});
await client.environment.updateTargets(projectId, {
eid: variable.id,
expectedVersion: variable.version + 1,
preview: true,
production: false,
});
await client.environment.delete(projectId, {
eid: variable.id,
expectedVersion: variable.version + 2,
});A stale expectedVersion fails with engine.ENVIRONMENT_CHANGED; re-read with
environment.get and retry. After a write, preview.stale === true until the
preview dev server restarts with the new variables, and
production.pending === true until the next production deployment ships them.
versions
const versions = await client.versions.list(projectId); // newest history first
await client.versions.restore(projectId, {
targetSha: versions[3].sha,
confirmedSha: versions[3].sha, // must equal targetSha — an explicit confirmation
idempotencyKey: crypto.randomUUID(),
});restore rewrites the working tree the project keeps building on. To ship a
historical version to production without touching the working tree, use
deployments.create(projectId, { versionId }) instead. Do not mix the two.
deployments
const accepted = await client.deployments.create(projectId, {
versionId: "…", // optional: deploy a historical version
idempotencyKey: "…", // optional replay guard
});
const deployment = await client.deployments.wait(projectId, accepted.deploymentId, {
timeoutMs: 10 * 60 * 1000, // default
pollIntervalMs: 1000, // default
});
deployment.status; // "queued" | "building" | "uploading" | "ready" | "failed"
deployment.url; // set once readyregisteredDomains — bought through unseal-space
Purchases are money movements, so they are gated by a short-lived price
challenge: quote first, then purchase against the returned nonce.
const suggestions = await client.registeredDomains.search({ query: "roastery", limit: 10 });
const [check] = await client.registeredDomains.check(["roastery.coffee"]);
const challenge = await client.registeredDomains.createPurchaseChallenge("roastery.coffee");
// challenge.maximumPriceMicroUsd / .currency / .expiresAt — show this before charging
await client.registeredDomains.purchase({
nonce: challenge.nonce,
idempotencyKey: crypto.randomUUID(),
autoRenew: true,
privacyMode: "redaction",
contact: { name, email, phone, street, city, state, postalCode, country },
});
const registration = await client.registeredDomains.wait("roastery.coffee");
if (registration.status === "succeeded") {
await client.registeredDomains.bind("roastery.coffee", projectId);
}Also: list(), get(name), registrationStatus(name), unbind(name). bind
and unbind accept { idempotencyKey } in their options argument.
connectedDomains — owned at another registrar
const connected = await client.connectedDomains.connect({ hostname: "example.com", projectId });
connected.instructions; // DNS records the owner must create (CNAME / TXT)
await client.connectedDomains.get("example.com"); // poll ownership/cert/routing status
await client.connectedDomains.assign("example.com", projectId); // or null to unassign
await client.connectedDomains.disconnect("example.com");
await client.connectedDomains.list();Classify before routing. A hostname purchased through unseal-space appears
in registeredDomains.list() and is routed with bind; a hostname you own
elsewhere appears in connectedDomains.list() and is routed with assign.
Check both inventories before changing routing.
Permissions
Each API key carries explicit permissions; a call outside them fails with
app.FORBIDDEN (403). Presets in the dashboard: build, publish,
domain.
| SDK method | Required permission |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| projects.create | projects:write |
| projects.list · projects.get · versions.list | projects:read |
| versions.restore | projects:restore |
| preview.get | preview:read |
| environment.get | environment:read |
| environment.create · replace · updateTargets · delete | environment:write |
| messages.send | messages:write |
| messages.events | messages:read |
| messages.wait | messages:read + projects:read |
| attachments.reserve | attachments:write |
| deployments.create | deployments:write |
| deployments.get · deployments.wait | deployments:read |
| registeredDomains.search · check · list · get · registrationStatus · wait | registeredDomains:read |
| registeredDomains.createPurchaseChallenge · getPurchaseChallenge · purchase | registeredDomains:purchase |
| registeredDomains.bind / unbind | registeredDomains:bind / :unbind |
| connectedDomains.list · get | connectedDomains:read |
| connectedDomains.connect / assign / disconnect | connectedDomains:connect / :assign / :disconnect |
messages.wait reads version history to report the version a turn produced, so
it needs projects:read on top of messages:read.
Idempotency
projects.create, messages.send, deployments.create, versions.restore,
registeredDomains.purchase, and the domain bind/assign mutations accept an
idempotencyKey. Reuse a key only when retrying the exact same mutation;
generate a new one whenever any argument changes. Replaying a key with
different input fails with engine.IDEMPOTENCY_CONFLICT, and replaying while
the original is still running fails with engine.IDEMPOTENCY_IN_PROGRESS.
Errors
Every failure throws UnsealSpaceError — never a bare Error, never an oRPC
internal.
import { UnsealSpaceError } from "@unseal-ai/unseal-space-sdk";
try {
await client.messages.send(projectId, { content: "…" });
} catch (error) {
if (error instanceof UnsealSpaceError) {
error.code; // "MESSAGE_IN_FLIGHT"
error.status; // 409
error.why; // machine-written cause, when the server provides one
error.fix; // the suggested recovery action
error.link; // documentation link, when there is one
error.data; // full structured payload (e.g. { messageId })
}
throw error;
}Most server codes are namespaced namespace.CODE — app.* (auth, validation,
rate limits), engine.* (project / turn / environment lifecycle), billing.*,
domain.*. A few errors declared directly on the API contract use a bare
UPPER_SNAKE_CASE code and carry a typed payload in data. Common ones:
| Code | Status | Meaning |
| -------------------------------- | ------ | --------------------------------------------------- |
| app.UNAUTHORIZED | 401 | Missing, revoked, or expired API key |
| app.FORBIDDEN | 403 | Key lacks the permission for this call |
| app.NOT_FOUND | 404 | Unknown project, message, deployment, or domain |
| app.VALIDATION_FAILED | 400 | Rejected input |
| app.RATE_LIMITED | 429 | Too many requests — back off before retrying |
| MESSAGE_IN_FLIGHT | 409 | A turn is already running (data.messageId) |
| engine.IDEMPOTENCY_CONFLICT | 409 | Key reused with different input |
| engine.IDEMPOTENCY_IN_PROGRESS | 409 | The original request with this key is still running |
| engine.ENVIRONMENT_CHANGED | 409 | Stale expectedVersion — re-read and retry |
| billing.INSUFFICIENT_CREDIT | 402 | Workspace is out of credit |
Failures raised locally by the SDK carry SDK_* codes and status: 0 (except
where noted): SDK_INVALID_CONFIG, SDK_INVALID_ARGUMENT,
SDK_REQUEST_FAILED (network), SDK_TIMEOUT (408), SDK_ABORTED (499),
SDK_RECONNECT_EXHAUSTED (504), SDK_UPLOAD_FAILED.
Cancellation and timeouts
Every method takes an options argument with an AbortSignal; the wait
helpers additionally take timeoutMs (and pollIntervalMs where they poll).
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
await client.projects.list({}, { signal: controller.signal });Aborting or timing out cancels your local wait only — the remote turn, deployment, or registration keeps running. Persist the id and resume.
Types
The root entry exports the client, UnsealSpaceError, and every DTO. Type-only
subpaths let you import DTOs without pulling in runtime code:
import type { UnsealSpaceProject } from "@unseal-ai/unseal-space-sdk/projects";
import type { MessageEvent } from "@unseal-ai/unseal-space-sdk/messages";Available subpaths: /projects, /preview, /messages, /attachments,
/environment, /versions, /deployments, /domains.
Types are exported under both UnsealSpace* and legacy Builder* names, and
createBuilderClient / BuilderError remain as deprecated aliases of
createUnsealSpaceClient / UnsealSpaceError. New code should use the
UnsealSpace* names.
Other ways in
- CLI —
unseal-spaceis a standalone binary built on this SDK, with no Node.js requirement. Install withcurl -fsSL https://assets.unseal.space/cli/install.sh | sh. - REST / OpenAPI — the same surface is documented at
https://api.unseal.space/api-reference(spec at/api-reference/spec.json) for non-JavaScript consumers.
Versioning
Semantic versioning. The SDK's DTOs are checked against the server contract in CI, so a published type shape and the live API cannot drift.
